diff --git a/.gitignore b/.gitignore index 978c90c..e871bce 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,6 @@ CLAUDE.md /.mimocode/ /.agents/ /reference_project/ -/.zcode/ \ No newline at end of file +/.zcode/ +# superpowers brainstorm mockups +.superpowers/ diff --git a/app.json b/app.json index 2111b08..34eadae 100644 --- a/app.json +++ b/app.json @@ -1,8 +1,9 @@ { "expo": { - "name": "Bean Mobile", - "slug": "bean-mobile", - "scheme": "beanmobile", + "name": "浮记", + "slug": "drift-ledger", + "scheme": "driftledger", + "icon": "./assets/icon/direction_a_feather.png", "plugins": [ [ "expo-sqlite", @@ -25,10 +26,15 @@ "typedRoutes": true }, "ios": { - "bundleIdentifier": "com.example.beanmobile" + "bundleIdentifier": "com.example.driftledger", + "icon": "./assets/icon/direction_a_feather.png" }, "android": { - "package": "com.example.beanmobile" + "package": "com.example.driftledger", + "adaptiveIcon": { + "foregroundImage": "./assets/icon/direction_a_feather.png", + "backgroundColor": "#1B1F20" + } } } } diff --git a/assets/icon/direction_a_feather.png b/assets/icon/direction_a_feather.png new file mode 100644 index 0000000..6d2cc9e Binary files /dev/null and b/assets/icon/direction_a_feather.png differ diff --git a/package.json b/package.json index 38f9a55..1f7bed6 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "beancount-mobile", + "name": "drift-ledger", "version": "0.1.0", "private": true, "main": "expo-router/entry", diff --git a/plugins/accessibility/android/AccessibilityBridgeModule.kt b/plugins/accessibility/android/AccessibilityBridgeModule.kt index 4662bb4..feda700 100644 --- a/plugins/accessibility/android/AccessibilityBridgeModule.kt +++ b/plugins/accessibility/android/AccessibilityBridgeModule.kt @@ -230,4 +230,21 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex } } } + + /** 设置悬浮球开关状态。 */ + @ReactMethod + fun setFloatingBallEnabled(enabled: Boolean, promise: Promise) { + BillingAccessibilityService.floatingBallEnabled = enabled + val service = BillingAccessibilityService.instance + if (service == null) { + promise.resolve(true) + return + } + if (!enabled) { + service.dismissFloatingHelper() + } else { + service.refreshFloatingHelper() + } + promise.resolve(true) + } } diff --git a/plugins/accessibility/android/BillingAccessibilityService.kt b/plugins/accessibility/android/BillingAccessibilityService.kt index 543d56c..938230e 100644 --- a/plugins/accessibility/android/BillingAccessibilityService.kt +++ b/plugins/accessibility/android/BillingAccessibilityService.kt @@ -49,7 +49,14 @@ class BillingAccessibilityService : AccessibilityService() { var instance: BillingAccessibilityService? = null private set - /** 支付 App 白名单。 */ + /** 悬浮球全局开关(由 JS 层通过 AccessibilityBridge 控制)。 */ + @Volatile + var floatingBallEnabled = true + + /** + * 支付 App 包名(自动生成,来源:src/domain/constants.ts PAYMENT_PACKAGES)。 + * 修改时只需编辑 constants.ts,运行 expo prebuild 即可同步。 + */ val PAYMENT_PACKAGES = setOf( "com.eg.android.AlipayGphone", // 支付宝 "com.tencent.mm", // 微信 @@ -79,6 +86,7 @@ class BillingAccessibilityService : AccessibilityService() { } private var ocrDoing = false + private var ocrDoingTimestamp = 0L private val handler = Handler(Looper.getMainLooper()) private val debounceRunnable = Runnable { processContentChange() } @Volatile private var topPackage: String? = null @@ -93,6 +101,14 @@ class BillingAccessibilityService : AccessibilityService() { Log.i(TAG, "无障碍账单识别服务已连接") loadPageSignatures() configureService() + // 修复:检查当前前台 App,恢复悬浮球 + val root = rootInActiveWindow + val pkg = root?.packageName?.toString() + root?.recycle() + if (pkg != null) { + topPackage = pkg + updateFloatingHelperVisibility(pkg) + } } /** 动态配置服务能力(截图 + 页面变化监听)。 */ @@ -110,7 +126,15 @@ class BillingAccessibilityService : AccessibilityService() { } override fun onAccessibilityEvent(event: AccessibilityEvent?) { - if (ocrDoing) return // 处理中,跳过 + // ocrDoing 超时兜底:5秒后强制重置 + if (ocrDoing) { + if (System.currentTimeMillis() - ocrDoingTimestamp > 5000) { + Log.w(TAG, "ocrDoing 超时重置") + ocrDoing = false + } else { + return + } + } val eventPackage = event?.packageName?.toString() ?: return when (event.eventType) { @@ -120,13 +144,12 @@ class BillingAccessibilityService : AccessibilityService() { topPackage = eventPackage topActivity = activityName Log.d(TAG, "页面切换: $eventPackage / $activityName") - + // 更新悬浮窗助手状态 updateFloatingHelperVisibility(eventPackage) - if (PAYMENT_PACKAGES.contains(eventPackage)) { - scheduleContentChange() - } + // 页面切换时检查是否有已记住的签名需要触发文本提取 + scheduleContentChange() // 调试:如果是微信或支付宝,延迟 800ms 抓取并打印全屏无障碍文本内容 if (eventPackage == "com.tencent.mm" || eventPackage == "com.eg.android.AlipayGphone") { @@ -159,16 +182,20 @@ class BillingAccessibilityService : AccessibilityService() { } } AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> { - if (PAYMENT_PACKAGES.contains(eventPackage)) { - scheduleContentChange() - } + // 内容变化时也检查(防抖合并) + scheduleContentChange() } } } private fun updateFloatingHelperVisibility(pkg: String?) { handler.post { - if (pkg != null && PAYMENT_PACKAGES.contains(pkg)) { + val shouldShow = floatingBallEnabled + && pkg != null + && !filterPackage(pkg, "") + && !isLandscape() + + if (shouldShow) { if (floatingHelper == null) { floatingHelper = FloatingHelper(this) floatingHelper?.show() @@ -236,6 +263,7 @@ class BillingAccessibilityService : AccessibilityService() { private fun takeScreenshotAndProcess(packageName: String) { if (ocrDoing) return ocrDoing = true + ocrDoingTimestamp = System.currentTimeMillis() if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { ocrDoing = false return @@ -451,6 +479,21 @@ class BillingAccessibilityService : AccessibilityService() { } } + /** 关闭悬浮球(由 JS 层 setFloatingBallEnabled(false) 调用)。 */ + fun dismissFloatingHelper() { + handler.post { + floatingHelper?.dismiss() + floatingHelper = null + } + } + + /** 刷新悬浮球状态(由 JS 层 setFloatingBallEnabled(true) 调用)。 */ + fun refreshFloatingHelper() { + handler.post { + updateFloatingHelperVisibility(topPackage) + } + } + /** 横屏检测(plan.md「3.10 横屏免打扰」)。 */ private fun isLandscape(): Boolean { val dm = getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY) @@ -459,17 +502,17 @@ class BillingAccessibilityService : AccessibilityService() { return rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270 } - /** 过滤系统组件/桌面(不触发 OCR)。 */ + /** 过滤不应处理的系统组件(桌面启动器、SystemUI、输入法等)。 */ private fun filterPackage(pkg: String, className: String): Boolean { val p = pkg.lowercase() - // 针对自身应用的特殊过滤:只允许 MainActivity 通过以触发隐藏悬浮窗; - // 其他自身组件(如悬浮球容器 LinearLayout)一律过滤,避免自毁式关闭。 + // 自身 App:只放行 MainActivity,避免悬浮球容器触发自毁式关闭 if (pkg == packageName) { - return className != "com.example.beanmobile.MainActivity" + return !className.endsWith(".MainActivity") } - if (p == "android" || p.startsWith("com.android.") || p.startsWith("com.google.android.")) return true - if (p.contains("systemui") || p.contains("settings") || p.contains("inputmethod") || p.contains("keyboard") || p.contains("input")) return true + // 桌面启动器(悬浮球在桌面无意义) if (LAUNCHER_PACKAGES.any { p.contains(it) }) return true + // SystemUI / 输入法(悬浮球在这些组件上会遮挡系统 UI) + if (p.contains("systemui") || p.contains("inputmethod") || p.contains("keyboard")) return true return false } diff --git a/plugins/accessibility/android/FloatingHelper.kt b/plugins/accessibility/android/FloatingHelper.kt index 3cb2145..06b0ea8 100644 --- a/plugins/accessibility/android/FloatingHelper.kt +++ b/plugins/accessibility/android/FloatingHelper.kt @@ -86,7 +86,7 @@ class FloatingHelper( setColor(0xB05E6AD2.toInt()) // 70% 高透靛蓝色,无边框 } layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply { - gravity = Gravity.CENTER + gravity = Gravity.CENTER_VERTICAL or Gravity.START // 贴左边缘 } } bubbleLayout.addView(indicatorView) @@ -255,11 +255,11 @@ class FloatingHelper( if (!isMoving) { toggleMenu() } else { - // 拖动抬起时自动吸附到屏幕边缘 + // 拖动抬起时自动吸附到屏幕边缘(指示器中心对齐边缘) if (params.x < service.resources.displayMetrics.widthPixels / 2) { - params.x = 0 + params.x = -dp(1.5f) // 指示器 3dp 宽,中心对齐边缘 } else { - params.x = service.resources.displayMetrics.widthPixels - dp(24f) + params.x = service.resources.displayMetrics.widthPixels - dp(24f) + dp(1.5f) } containerView?.let { windowManager.updateViewLayout(it, params) } @@ -276,6 +276,10 @@ class FloatingHelper( Log.i(TAG, "记账助手悬浮窗显示成功") } catch (e: Exception) { Log.e(TAG, "记账助手悬浮窗创建失败: ${e.message}", e) + // 修复:失败时清理状态,允许下次重试 + containerView = null + bubbleView = null + menuView = null } } diff --git a/plugins/accessibility/android/OcrTileService.kt b/plugins/accessibility/android/OcrTileService.kt index 70dd8bc..d55f5f6 100644 --- a/plugins/accessibility/android/OcrTileService.kt +++ b/plugins/accessibility/android/OcrTileService.kt @@ -50,8 +50,8 @@ class OcrTileService : TileService() { val pi = PendingIntent.getActivity( this, 0, Intent().apply { - setClassName(packageName, "com.beancount.mobile.MainActivity") - action = "com.beancount.mobile.TRIGGER_OCR" + setClassName(packageName, "$packageName.MainActivity") + action = "$packageName.TRIGGER_OCR" flags = Intent.FLAG_ACTIVITY_NEW_TASK }, PendingIntent.FLAG_IMMUTABLE, diff --git a/plugins/accessibility/app.plugin.js b/plugins/accessibility/app.plugin.js index 221fbbb..78f412c 100644 --- a/plugins/accessibility/app.plugin.js +++ b/plugins/accessibility/app.plugin.js @@ -12,7 +12,33 @@ const { withAndroidManifest, withDangerousMod, withMainApplication } = require(' const fs = require('fs'); const path = require('path'); -const PACKAGE = 'com.beancount.mobile.accessibility'; +/** + * 从 src/domain/constants.ts 的 PAYMENT_PACKAGES 中提取包名列表。 + * 这是唯一的真相来源,Kotlin 端通过此函数自动同步。 + */ +function readPaymentPackagesFromConstants() { + const constantsPath = path.resolve(__dirname, '../../src/domain/constants.ts'); + if (!fs.existsSync(constantsPath)) return null; + const content = fs.readFileSync(constantsPath, 'utf8'); + // 匹配 PAYMENT_PACKAGES = { ... } 中的 key(冒号前的字符串) + const match = content.match(/PAYMENT_PACKAGES:\s*Record\s*=\s*\{([\s\S]*?)\n\};/); + if (!match) return null; + const keys = []; + const keyRegex = /^(\s*)'([^']+)'\s*:/gm; + let m; + while ((m = keyRegex.exec(match[1])) !== null) { + keys.push(m[2]); + } + return keys.length > 0 ? keys : null; +} + +/** + * 生成 Kotlin setOf("...", "...", ...) 代码。 + */ +function generateKotlinPaymentPackages(packages) { + const items = packages.map(pkg => ` "${pkg}"`).join(',\n'); + return ` val PAYMENT_PACKAGES = setOf(\n${items}\n )`; +} /** 递归复制目录。 */ function copyDir(src, dest) { @@ -26,19 +52,30 @@ function copyDir(src, dest) { } } +function getAppId(config) { + return config.android?.package || 'com.example.driftledger'; +} + function withAccessibilityService(config) { + const appId = getAppId(config); + const PACKAGE = `${appId}.accessibility`; + // 1. 复制 Kotlin 源码 + res/xml 资源到原生工程 config = withDangerousMod(config, [ 'android', async (modConfig) => { const projectRoot = modConfig.modRequest.platformProjectRoot; // Kotlin 源码(只复制 .kt 文件,不含 res/ 子目录) - const ktDest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/accessibility'); + const pkgPath = appId.replace(/\./g, '/'); + const ktDest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'accessibility'); fs.mkdirSync(ktDest, { recursive: true }); const androidDir = path.join(__dirname, 'android'); for (const f of fs.readdirSync(androidDir)) { if (f.endsWith('.kt')) { - fs.copyFileSync(path.join(androidDir, f), path.join(ktDest, f)); + let content = fs.readFileSync(path.join(androidDir, f), 'utf8'); + content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`); + content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`); + fs.writeFileSync(path.join(ktDest, f), content, 'utf8'); } } // res/xml 资源 @@ -46,6 +83,23 @@ function withAccessibilityService(config) { if (fs.existsSync(resSrc)) { copyDir(resSrc, path.join(projectRoot, 'app/src/main/res')); } + + // 从 constants.ts 读取包名列表,自动同步到 Kotlin + const packages = readPaymentPackagesFromConstants(); + if (packages) { + const ktFile = path.join(ktDest, 'BillingAccessibilityService.kt'); + if (fs.existsSync(ktFile)) { + let ktContent = fs.readFileSync(ktFile, 'utf8'); + // 替换 PAYMENT_PACKAGES 定义(匹配 val PAYMENT_PACKAGES = setOf( ... )) + const regex = /val PAYMENT_PACKAGES = setOf\([\s\S]*?\)/; + if (regex.test(ktContent)) { + ktContent = ktContent.replace(regex, generateKotlinPaymentPackages(packages)); + fs.writeFileSync(ktFile, ktContent, 'utf8'); + console.log(`[accessibility plugin] 已同步 ${packages.length} 个支付 App 包名到 Kotlin`); + } + } + } + // 确保有 accessibility_service_description 字符串资源 const stringsXmlPath = path.join(projectRoot, 'app/src/main/res/values/strings.xml'); if (fs.existsSync(stringsXmlPath)) { @@ -93,14 +147,24 @@ function withAccessibilityService(config) { return modConfig; }); - // 3. 注册服务到 AndroidManifest + // 3. 注册权限 + 服务到 AndroidManifest config = withAndroidManifest(config, (modConfig) => { const manifest = modConfig.modResults.manifest; + // 0. 添加 SYSTEM_ALERT_WINDOW 权限(悬浮窗必需) + if (!manifest['uses-permission']) { + manifest['uses-permission'] = []; + } + const overlayPerm = 'android.permission.SYSTEM_ALERT_WINDOW'; + const overlayPermExists = manifest['uses-permission'].some(p => p.$['android:name'] === overlayPerm); + if (!overlayPermExists) { + manifest['uses-permission'].push({ $: { 'android:name': overlayPerm } }); + } + // 1. 添加无障碍服务声明 const serviceNode = { $: { - 'android:name': 'com.beancount.mobile.accessibility.BillingAccessibilityService', + 'android:name': `${PACKAGE}.BillingAccessibilityService`, 'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE', 'android:label': '账单识别', 'android:exported': 'false', @@ -125,7 +189,7 @@ function withAccessibilityService(config) { app.service = []; } const exists = app.service.some( - s => s.$['android:name'] === 'com.beancount.mobile.accessibility.BillingAccessibilityService' + s => s.$['android:name'] === `${PACKAGE}.BillingAccessibilityService` ); if (!exists) { app.service.push(serviceNode); @@ -134,7 +198,7 @@ function withAccessibilityService(config) { // 3. 添加 OcrTileService 声明(快速设置磁贴,plan.md「3.11」) const tileServiceNode = { $: { - 'android:name': 'com.beancount.mobile.accessibility.OcrTileService', + 'android:name': `${PACKAGE}.OcrTileService`, 'android:label': 'OCR 记账', 'android:icon': '@android:drawable/ic_menu_camera', 'android:permission': 'android.permission.BIND_QUICK_SETTINGS_TILE', @@ -145,7 +209,7 @@ function withAccessibilityService(config) { }], }; const tileExists = app.service.some( - s => s.$['android:name'] === 'com.beancount.mobile.accessibility.OcrTileService' + s => s.$['android:name'] === `${PACKAGE}.OcrTileService` ); if (!tileExists) { app.service.push(tileServiceNode); diff --git a/plugins/accessibility/package.json b/plugins/accessibility/package.json index 9888a5e..9d2a26e 100644 --- a/plugins/accessibility/package.json +++ b/plugins/accessibility/package.json @@ -1,5 +1,5 @@ { - "name": "beancount-mobile-plugin-accessibility", + "name": "drift-ledger-plugin-accessibility", "version": "0.1.0", "private": true, "main": "app.plugin.js" diff --git a/plugins/notification-listener/android/BillingNotificationListenerService.kt b/plugins/notification-listener/android/BillingNotificationListenerService.kt index 5497c49..552a690 100644 --- a/plugins/notification-listener/android/BillingNotificationListenerService.kt +++ b/plugins/notification-listener/android/BillingNotificationListenerService.kt @@ -8,6 +8,7 @@ import android.util.Log import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.modules.core.DeviceEventManagerModule +import com.beancount.mobile.accessibility.BillingAccessibilityService import com.beancount.mobile.accessibility.ReactContextHolder /** @@ -15,7 +16,7 @@ import com.beancount.mobile.accessibility.ReactContextHolder * * 参考 AutoAccounting 的 NotificationListenerService: * - 提取支付 App 通知的 title/text - * - 白名单过滤(仅支付类 App) + * - 白名单过滤(复用 BillingAccessibilityService.PAYMENT_PACKAGES,单一数据源) * - 关键词黑白名单(JS 层 keywordFilter 进一步过滤) * - MD5 去重(JS 层 NotificationChannel 处理,避免原生持有状态) * - onListenerDisconnected 时 requestRebind 自动重连 @@ -26,26 +27,14 @@ class BillingNotificationListenerService : NotificationListenerService() { companion object { private const val TAG = "BillingNotification" - - /** 支付 App 白名单(与 JS 层 DEFAULT_PAYMENT_PACKAGES 一致)。 */ - private val PAYMENT_PACKAGES = setOf( - "com.eg.android.AlipayGphone", - "com.tencent.mm", - "com.unionpay", - "com.cmbchina", - "com.icbc", - "com.chinamworld.main", - "com.ccbrcb", - "com.bankcomm.Bankcomm", - ) } override fun onNotificationPosted(sbn: StatusBarNotification?) { super.onNotificationPosted(sbn) runCatching { val packageName = sbn?.packageName?.toString() ?: return - // 白名单过滤 - if (!PAYMENT_PACKAGES.contains(packageName)) return + // 白名单过滤(复用无障碍服务的 PAYMENT_PACKAGES) + if (!BillingAccessibilityService.PAYMENT_PACKAGES.contains(packageName)) return val notification = sbn.notification val extras = notification.extras diff --git a/plugins/notification-listener/app.plugin.js b/plugins/notification-listener/app.plugin.js index c67ce22..56c16f2 100644 --- a/plugins/notification-listener/app.plugin.js +++ b/plugins/notification-listener/app.plugin.js @@ -11,18 +11,29 @@ const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins' const fs = require('fs'); const path = require('path'); +function getAppId(config) { + return config.android?.package || 'com.example.driftledger'; +} + function withNotificationListener(config) { + const appId = getAppId(config); + const PACKAGE = `${appId}.notification`; + // 1. 复制 Kotlin 源码到原生工程 config = withDangerousMod(config, [ 'android', async (modConfig) => { const projectRoot = modConfig.modRequest.platformProjectRoot; - const dest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/notification'); + const pkgPath = appId.replace(/\./g, '/'); + const dest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'notification'); fs.mkdirSync(dest, { recursive: true }); const srcDir = path.join(__dirname, 'android'); for (const f of fs.readdirSync(srcDir)) { if (f.endsWith('.kt')) { - fs.copyFileSync(path.join(srcDir, f), path.join(dest, f)); + let content = fs.readFileSync(path.join(srcDir, f), 'utf8'); + content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`); + content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`); + fs.writeFileSync(path.join(dest, f), content, 'utf8'); } } return modConfig; @@ -36,9 +47,9 @@ function withNotificationListener(config) { // 1. 添加通知监听服务 const serviceNode = { $: { - 'android:name': 'com.beancount.mobile.notification.BillingNotificationListenerService', + 'android:name': `${PACKAGE}.BillingNotificationListenerService`, 'android:permission': 'android.permission.BIND_NOTIFICATION_LISTENER_SERVICE', - 'android:exported': 'false', + 'android:exported': 'true', }, 'intent-filter': [{ action: [{ $: { 'android:name': 'android.service.notification.NotificationListenerService' } }], @@ -54,7 +65,7 @@ function withNotificationListener(config) { app.service = []; } const exists = app.service.some( - s => s.$['android:name'] === 'com.beancount.mobile.notification.BillingNotificationListenerService' + s => s.$['android:name'] === `${PACKAGE}.BillingNotificationListenerService` ); if (!exists) { app.service.push(serviceNode); diff --git a/plugins/notification-listener/package.json b/plugins/notification-listener/package.json index cc0a4aa..82a20ee 100644 --- a/plugins/notification-listener/package.json +++ b/plugins/notification-listener/package.json @@ -1,5 +1,5 @@ { - "name": "beancount-mobile-plugin-notification-listener", + "name": "drift-ledger-plugin-notification-listener", "version": "0.1.0", "private": true, "main": "app.plugin.js" diff --git a/plugins/ppocr/app.plugin.js b/plugins/ppocr/app.plugin.js index 004684b..957d447 100644 --- a/plugins/ppocr/app.plugin.js +++ b/plugins/ppocr/app.plugin.js @@ -22,9 +22,33 @@ const { const fs = require('fs'); const path = require('path'); -const PACKAGE = 'com.beancount.mobile.ppocr'; +function getAppId(config) { + return config.android?.package || 'com.example.driftledger'; +} -/** 递归复制目录,prebuild 阶段执行一次。 */ +/** 递归复制目录,prebuild 阶段执行并替换包名。 */ +function copyAndReplaceDir(src, dest, appId) { + if (!fs.existsSync(src)) return; + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src)) { + const s = path.join(src, entry); + const d = path.join(dest, entry); + if (fs.statSync(s).isDirectory()) { + copyAndReplaceDir(s, d, appId); + } else { + if (entry.endsWith('.kt') || entry.endsWith('.java')) { + let content = fs.readFileSync(s, 'utf8'); + content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`); + content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`); + fs.writeFileSync(d, content, 'utf8'); + } else { + fs.copyFileSync(s, d); + } + } + } +} + +/** 递归复制目录(仅用于模型资源复制,不替换包名)。 */ function copyDir(src, dest) { if (!fs.existsSync(src)) return; fs.mkdirSync(dest, { recursive: true }); @@ -37,17 +61,24 @@ function copyDir(src, dest) { } function withPpOcr(config) { + const appId = getAppId(config); + const PACKAGE = `${appId}.ppocr`; + // 1+2. 复制 Kotlin 源码与 ONNX 模型/字典到原生工程 config = withDangerousMod(config, [ 'android', async (modConfig) => { - // platformProjectRoot 在 modRequest 里(项目根的 android/ 子目录) const projectRoot = modConfig.modRequest.platformProjectRoot; - // Kotlin 源码(OcrModule.kt + OcrPackage.kt) - copyDir( + const pkgPath = appId.replace(/\./g, '/'); + const ktDest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'ppocr'); + + // Kotlin 源码并自动重命名包名 + copyAndReplaceDir( path.join(__dirname, 'android'), - path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/ppocr'), + ktDest, + appId, ); + // ONNX 模型 + 字典(若已下载) const assetsSrc = path.join(__dirname, 'assets'); if (fs.existsSync(assetsSrc)) { diff --git a/plugins/ppocr/package.json b/plugins/ppocr/package.json index 5a558c1..ec138a9 100644 --- a/plugins/ppocr/package.json +++ b/plugins/ppocr/package.json @@ -1,5 +1,5 @@ { - "name": "beancount-mobile-plugin-ppocr", + "name": "drift-ledger-plugin-ppocr", "version": "0.1.0", "private": true, "main": "app.plugin.js" diff --git a/plugins/screenshot-monitor/app.plugin.js b/plugins/screenshot-monitor/app.plugin.js index 3f0ad68..d3daacf 100644 --- a/plugins/screenshot-monitor/app.plugin.js +++ b/plugins/screenshot-monitor/app.plugin.js @@ -12,27 +12,36 @@ const { withAndroidManifest, withDangerousMod, withMainApplication } = require(' const fs = require('fs'); const path = require('path'); -const PACKAGE = 'com.beancount.mobile.screenshot'; +function getAppId(config) { + return config.android?.package || 'com.example.driftledger'; +} function withScreenshotMonitor(config) { + const appId = getAppId(config); + const PACKAGE = `${appId}.screenshot`; + // 1. 复制 Kotlin 源码到原生工程 config = withDangerousMod(config, [ 'android', async (modConfig) => { const projectRoot = modConfig.modRequest.platformProjectRoot; - const dest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/screenshot'); + const pkgPath = appId.replace(/\./g, '/'); + const dest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'screenshot'); fs.mkdirSync(dest, { recursive: true }); const srcDir = path.join(__dirname, 'android'); for (const f of fs.readdirSync(srcDir)) { if (f.endsWith('.kt')) { - fs.copyFileSync(path.join(srcDir, f), path.join(dest, f)); + let content = fs.readFileSync(path.join(srcDir, f), 'utf8'); + content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`); + content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`); + fs.writeFileSync(path.join(dest, f), content, 'utf8'); } } return modConfig; }, ]); - // 2. 注册 ScreenshotPackage 到 MainApplication(复制 ppocr 模式) + // 2. 注册 ScreenshotPackage 到 MainApplication config = withMainApplication(config, (modConfig) => { let content = modConfig.modResults.contents; diff --git a/plugins/screenshot-monitor/package.json b/plugins/screenshot-monitor/package.json index 2c6f126..8392a03 100644 --- a/plugins/screenshot-monitor/package.json +++ b/plugins/screenshot-monitor/package.json @@ -1,5 +1,5 @@ { - "name": "beancount-mobile-plugin-screenshot-monitor", + "name": "drift-ledger-plugin-screenshot-monitor", "version": "0.1.0", "private": true, "main": "app.plugin.js" diff --git a/plugins/size-optimization/app.plugin.js b/plugins/size-optimization/app.plugin.js index 5bd54cd..feaea2e 100644 --- a/plugins/size-optimization/app.plugin.js +++ b/plugins/size-optimization/app.plugin.js @@ -42,6 +42,40 @@ function withSizeOptimization(config) { } ]); + // 3. 在 prebuild 时修改 root build.gradle 强制指定所有模块的 NDK 版本以匹配 27.1.12297006 + config = withDangerousMod(config, [ + 'android', + async (modConfig) => { + const projectRoot = modConfig.modRequest.platformProjectRoot; + const rootBuildGradlePath = path.join(projectRoot, 'build.gradle'); + if (fs.existsSync(rootBuildGradlePath)) { + let content = fs.readFileSync(rootBuildGradlePath, 'utf8'); + if (!content.includes('ndkVersion = "27.1.12297006"')) { + content += ` +subprojects { project -> + def configureProject = { proj -> + if (proj.hasProperty("android")) { + proj.android { + ndkVersion = "27.1.12297006" + } + } + } + if (project.state.executed) { + configureProject(project) + } else { + project.afterEvaluate { + configureProject(project) + } + } +} +`; + fs.writeFileSync(rootBuildGradlePath, content, 'utf8'); + } + } + return modConfig; + } + ]); + return config; } diff --git a/plugins/size-optimization/package.json b/plugins/size-optimization/package.json index 151b415..c4ca846 100644 --- a/plugins/size-optimization/package.json +++ b/plugins/size-optimization/package.json @@ -1,4 +1,4 @@ { - "name": "size-optimization", + "name": "drift-ledger-plugin-size-optimization", "main": "app.plugin.js" } diff --git a/plugins/sms-receiver/app.plugin.js b/plugins/sms-receiver/app.plugin.js index cba5f15..68db5a7 100644 --- a/plugins/sms-receiver/app.plugin.js +++ b/plugins/sms-receiver/app.plugin.js @@ -11,18 +11,29 @@ const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins' const fs = require('fs'); const path = require('path'); +function getAppId(config) { + return config.android?.package || 'com.example.driftledger'; +} + function withSmsReceiver(config) { + const appId = getAppId(config); + const PACKAGE = `${appId}.sms`; + // 1. 复制 Kotlin 源码到原生工程 config = withDangerousMod(config, [ 'android', async (modConfig) => { const projectRoot = modConfig.modRequest.platformProjectRoot; - const dest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/sms'); + const pkgPath = appId.replace(/\./g, '/'); + const dest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'sms'); fs.mkdirSync(dest, { recursive: true }); const srcDir = path.join(__dirname, 'android'); for (const f of fs.readdirSync(srcDir)) { if (f.endsWith('.kt')) { - fs.copyFileSync(path.join(srcDir, f), path.join(dest, f)); + let content = fs.readFileSync(path.join(srcDir, f), 'utf8'); + content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`); + content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`); + fs.writeFileSync(path.join(dest, f), content, 'utf8'); } } return modConfig; @@ -48,7 +59,7 @@ function withSmsReceiver(config) { // 2. 添加短信 Receiver const receiverNode = { $: { - 'android:name': 'com.beancount.mobile.sms.BillingSmsReceiver', + 'android:name': `${PACKAGE}.BillingSmsReceiver`, 'android:exported': 'true', }, 'intent-filter': [{ @@ -65,7 +76,7 @@ function withSmsReceiver(config) { app.receiver = []; } const exists = app.receiver.some( - r => r.$['android:name'] === 'com.beancount.mobile.sms.BillingSmsReceiver' + r => r.$['android:name'] === `${PACKAGE}.BillingSmsReceiver` ); if (!exists) { app.receiver.push(receiverNode); diff --git a/plugins/sms-receiver/package.json b/plugins/sms-receiver/package.json index b8dad36..0b12589 100644 --- a/plugins/sms-receiver/package.json +++ b/plugins/sms-receiver/package.json @@ -1,5 +1,5 @@ { - "name": "beancount-mobile-plugin-sms-receiver", + "name": "drift-ledger-plugin-sms-receiver", "version": "0.1.0", "private": true, "main": "app.plugin.js" diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index e659643..556dd21 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { Pressable, ScrollView, StyleSheet, Text, View, Alert } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; @@ -41,7 +41,7 @@ export default function HomeScreen() { }, [ledger, transactions]); // 2. 周期性记账到期计算 - const todayStr = useMemo(() => new Date().toISOString().slice(0, 10), []); + const todayStr = useMemo(() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }, []); const dueRecurring = useMemo(() => { return getDueRecurring(recurringTransactions, todayStr); }, [recurringTransactions, todayStr]); @@ -229,3 +229,4 @@ const styles = StyleSheet.create({ confirmBtnMini: { paddingVertical: 4, paddingHorizontal: 8, borderWidth: StyleSheet.hairlineWidth }, bentoStats: { gap: 8 }, }); + diff --git a/src/app/(tabs)/report.tsx b/src/app/(tabs)/report.tsx index 2bd8c6d..e74ce76 100644 --- a/src/app/(tabs)/report.tsx +++ b/src/app/(tabs)/report.tsx @@ -1,4 +1,4 @@ -/** +/** * 报表页(plan.md「5.2 图表与可视化」+「5.3 年度报告」)。 * * 功能: @@ -28,10 +28,11 @@ import { NetWorthChart } from '../../components/charts/NetWorthChart'; import { AnnualReport } from '../../components/charts/AnnualReport'; import { calculateMonthlyStats, generatePlainTextSummary, generateMonthlySummary } from '../../ai/monthlySummary'; import { BaseOpenAIProvider } from '../../domain/ai'; +import { addDecimals, negateDecimal } from '../../domain/decimal'; export default function ReportScreen() { const { theme } = useTheme(); - const commonStyles = createCommonStyles(theme); + const commonStyles = useMemo(() => createCommonStyles(theme), [theme]); const t = useT(); const router = useRouter(); const ledger = useLedgerStore(s => s.ledger); @@ -135,31 +136,33 @@ export default function ReportScreen() { // 周收支统计 const weeklyStats = useMemo(() => { - let income = 0; - let expense = 0; + const incomeAmounts: string[] = []; + const expenseAmounts: string[] = []; for (const t of weeklyTxs) { for (const p of t.postings) { if (!p.amount) continue; - const amt = parseFloat(p.amount); - if (p.account.startsWith('Income')) income += -amt; - if (p.account.startsWith('Expenses')) expense += amt; + if (p.account.startsWith('Income')) incomeAmounts.push(negateDecimal(p.amount)); + if (p.account.startsWith('Expenses')) expenseAmounts.push(p.amount); } } + const income = parseFloat(addDecimals(incomeAmounts.length ? incomeAmounts : ['0'])); + const expense = parseFloat(addDecimals(expenseAmounts.length ? expenseAmounts : ['0'])); return { income, expense, net: income - expense, count: weeklyTxs.length }; }, [weeklyTxs]); // 月度收支统计 const monthlyStats = useMemo(() => { - let income = 0; - let expense = 0; + const incomeAmounts: string[] = []; + const expenseAmounts: string[] = []; for (const t of monthlyTxs) { for (const p of t.postings) { if (!p.amount) continue; - const amt = parseFloat(p.amount); - if (p.account.startsWith('Income')) income += -amt; - if (p.account.startsWith('Expenses')) expense += amt; + if (p.account.startsWith('Income')) incomeAmounts.push(negateDecimal(p.amount)); + if (p.account.startsWith('Expenses')) expenseAmounts.push(p.amount); } } + const income = parseFloat(addDecimals(incomeAmounts.length ? incomeAmounts : ['0'])); + const expense = parseFloat(addDecimals(expenseAmounts.length ? expenseAmounts : ['0'])); return { income, expense, net: income - expense, count: monthlyTxs.length }; }, [monthlyTxs]); @@ -332,7 +335,7 @@ export default function ReportScreen() { {t('report.weeklyExpense')} - -{fmtAmount(weeklyStats.expense)} + {fmtAmount(-weeklyStats.expense)} {t('report.weeklyNet')} @@ -359,7 +362,7 @@ export default function ReportScreen() { {t('report.monthlyExpense')} - -{fmtAmount(monthlyStats.expense)} + {fmtAmount(-monthlyStats.expense)} {t('report.monthlyNet')} @@ -444,3 +447,4 @@ const styles = StyleSheet.create({ statsRow: { flexDirection: 'row', justifyContent: 'space-around', marginVertical: 8, width: '100%' }, statItem: { alignItems: 'center', gap: 4 }, }); + diff --git a/src/app/(tabs)/transactions.tsx b/src/app/(tabs)/transactions.tsx index ad2551d..dd0c9af 100644 --- a/src/app/(tabs)/transactions.tsx +++ b/src/app/(tabs)/transactions.tsx @@ -25,7 +25,7 @@ type DirectionFilter = 'all' | 'expense' | 'income' | 'transfer'; export default function TransactionsScreen() { const { theme } = useTheme(); - const commonStyles = createCommonStyles(theme); + const commonStyles = useMemo(() => createCommonStyles(theme), [theme]); const t = useT(); const router = useRouter(); const ledger = useLedgerStore(s => s.ledger); diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 76c8306..414b16c 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -8,18 +8,20 @@ import { useLedgerStore } from '../store/ledgerStore'; import { useImportStore } from '../store/importStore'; import { useSettingsStore } from '../store/settingsStore'; import { useMetadataStore } from '../store/metadataStore'; +import { parseDecimal } from '../domain/decimal'; import { useAutomationStore } from '../store/automationStore'; import { FileSystemBackend } from '../services/fileSystemBackend'; import { useT } from '../i18n'; import { LockScreen } from '../components/LockScreen'; import { OnboardingScreen } from './_onboarding'; import { setupDeepLinking } from '../services/deepLink'; -import { initPersistence } from '../store/storePersistence'; +import { initPersistence, flushPersistence } from '../store/storePersistence'; +import { buildPostings } from '../domain/transactionBuilder'; import * as FileSystem from 'expo-file-system/legacy'; import { useRouter } from 'expo-router'; import { parseNotification } from '../services/notification'; import { parseSms } from '../services/sms'; -import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts } from '../services/automationPipeline'; +import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts, loadProcessedTxKeys, pendingDrafts } from '../services/automationPipeline'; import { getAccessibilityBridge } from '../services/accessibilityBridge'; import { logger } from '../utils/logger'; import { @@ -39,11 +41,23 @@ import { let lastSignature = ''; let lastProcessedTime = 0; +/** 原生悬浮窗跳转 App 事件类型 */ +interface NativeOpenAppEvent { + draftId?: string; + confirmed?: boolean; + account?: string; + category?: string; + amount?: string; + time?: string; + direction?: string; + merchant?: string; + narration?: string; +} + /** 示例账本(后续由文件选择器导入,当前 demo 用内置)。 */ const SAMPLE_LEDGER = { path: 'main.bean', content: `option "operating_currency" "CNY" -include "mobile.bean" `, }; @@ -72,8 +86,15 @@ function AppShell() { const [privacyOverlay, setPrivacyOverlay] = useState(false); const lastMainFileRef = useRef<{ modificationTime?: number; size?: number } | null>(null); + const persistenceInitRef = useRef(false); + const lastCheckTimeRef = useRef(0); const checkAndReloadLedger = useCallback(async () => { + // 节流:2 秒内不重复检查 + const now = Date.now(); + if (now - lastCheckTimeRef.current < 2000) return; + lastCheckTimeRef.current = now; + const mainPath = FileSystem.documentDirectory + 'main.bean'; try { const info = await FileSystem.getInfoAsync(mainPath); @@ -105,31 +126,17 @@ function AppShell() { }, [loadLedger, setContext]); useEffect(() => { + if (persistenceInitRef.current) return; + persistenceInitRef.current = true; // 1. 初始化持久化并还原数据 initPersistence().then(async () => { - const mainPath = FileSystem.documentDirectory + 'main.bean'; - const mobilePath = FileSystem.documentDirectory + 'mobile.bean'; + // 加载已处理的交易键(跨会话去重) + await loadProcessedTxKeys(); - try { - // 一次性合并迁移:将旧的 mobile.bean 内容追加到 main.bean 并物理删除它 - const mobileInfo = await FileSystem.getInfoAsync(mobilePath); - if (mobileInfo.exists) { - const mobileContent = await FileSystem.readAsStringAsync(mobilePath); - if (mobileContent.trim()) { - const mainInfo = await FileSystem.getInfoAsync(mainPath); - let mainContent = ''; - if (mainInfo.exists) { - mainContent = await FileSystem.readAsStringAsync(mainPath); - } - const mergedContent = `${mainContent.trimEnd()}\n\n${mobileContent.trim()}\n`.trim(); - await FileSystem.writeAsStringAsync(mainPath, mergedContent); - logger.info('layout', '已成功将旧 mobile.bean 交易追加合并至 main.bean'); - } - await FileSystem.deleteAsync(mobilePath, { idempotent: true }); - } - } catch (e) { - logger.error('layout', '合并旧 mobile.bean 数据失败', e); - } + const mainPath = FileSystem.documentDirectory + 'main.bean'; + + // 架构说明:main.bean 作为主账本文件,包含所有 Beancount 指令(open/close/option/include 等), + // App 直接对其进行读写与解析。 // 2. 检查并读取本地真实主账本 FileSystem.getInfoAsync(mainPath).then(async (info) => { @@ -156,7 +163,7 @@ function AppShell() { } }).catch((e) => { // 加载失败时仍进入引导流程(而非跳过),让用户有机会重新导入账本 - console.warn('[layout] Ledger load failed:', e); + logger.warn('layout', `Ledger load failed: ${e instanceof Error ? e.message : String(e)}`); setPhase('onboarding'); }); }); @@ -233,36 +240,34 @@ function AppShell() { logger.error('layout', '截图 OCR 处理失败', e); }); }), - DeviceEventEmitter.addListener('billingOpenApp', (res) => { + DeviceEventEmitter.addListener('billingOpenApp', (res: NativeOpenAppEvent) => { try { logger.info('layout', `收到原生悬浮窗跳转 App 请求: ${JSON.stringify(res)}`); if (res.draftId) { try { - const { pendingDrafts } = require('../services/automationPipeline'); pendingDrafts.delete(res.draftId); } catch (err) { // 忽略 } } const amt = res.amount || '0'; - const currency = 'CNY'; - let postings: any[] = []; - if (res.direction === 'expense') { - postings = [ - { account: res.account, amount: `-${amt}`, currency }, - { account: res.category, amount: amt, currency } - ]; - } else if (res.direction === 'income') { - postings = [ - { account: res.account, amount: amt, currency }, - { account: res.category, amount: `-${amt}`, currency } - ]; - } else { - postings = [ - { account: res.account, amount: `-${amt}`, currency }, - { account: res.category, amount: amt, currency } - ]; + if (!res.account || !res.category || !amt || amt === '0') { + logger.warn('layout', 'billingOpenApp 数据不完整,跳过', JSON.stringify(res)); + return; } + try { + const parsed = parseDecimal(amt); + if (parsed.coefficient <= 0n) { + logger.warn('layout', 'billingOpenApp 金额无效', amt); + return; + } + } catch { + logger.warn('layout', 'billingOpenApp 金额格式错误', amt); + return; + } + const currency = 'CNY'; + const dir = (res.direction || 'expense') as 'income' | 'expense' | 'transfer'; + const postings = buildPostings(dir, res.account, res.category, amt, currency); const draft = { date: res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0], payee: res.merchant || undefined, @@ -349,8 +354,11 @@ function AppShell() { // 5. 隐私模糊(plan.md「0.4 隐私模糊」):App 进入后台/非活跃时遮盖内容 useEffect(() => { const sub = AppState.addEventListener('change', (state) => { - // 切到后台/非活跃时立即遮盖,回到前台时移除 + // 切到后台/非活跃时立即遮盖并同步持久化,回到前台时移除 setPrivacyOverlay(state !== 'active'); + if (state !== 'active') { + flushPersistence().catch(e => logger.error('layout', 'Failed to flush persistence on background', e)); + } if (state === 'active' && phase === 'ready') { checkAndReloadLedger(); } @@ -407,9 +415,13 @@ function AppShell() { - {/* 隐私遮罩(plan.md「0.4」):App 切后台时遮盖内容 */} + {/* 隐私遮罩(plan.md「0.4」):App 切后台时遮盖内容并拦截所有手势 */} {privacyOverlay && ( - + )} ); diff --git a/src/app/automation/index.tsx b/src/app/automation/index.tsx index 7ae6f5f..d613abf 100644 --- a/src/app/automation/index.tsx +++ b/src/app/automation/index.tsx @@ -5,7 +5,7 @@ * - 显示 5 个通道(通知/短信/截图/OCR/手动)的检测统计 * - 展示检测到的事件列表 * - "处理全部" → BillPipeline → 草稿 - * - 逐条确认/拒绝草稿 → 写入 mobile.bean + * - 逐条确认/拒绝草稿 → 写入 main.bean * - 截图监控开关(调原生 ScreenshotMonitor 模块) * - 无障碍服务控制: * - 服务状态(已连接/未启用) @@ -16,7 +16,7 @@ * - 支付 App 白名单展示 */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Alert, AppState, FlatList, Linking, NativeModules, Platform, Pressable, StyleSheet, Text, View } from 'react-native'; +import { Alert, AppState, FlatList, Linking, NativeModules, Platform, Pressable, StyleSheet, Text, View, Switch } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; @@ -25,6 +25,7 @@ import { useT } from '../../i18n'; import { useAutomationStore } from '../../store/automationStore'; import { useLedgerStore } from '../../store/ledgerStore'; import { useMetadataStore } from '../../store/metadataStore'; +import { useSettingsStore } from '../../store/settingsStore'; import { Card } from '../../components/Card'; import { Button } from '../../components/Button'; import type { AutomationSource } from '../../store/automationStore'; @@ -57,6 +58,9 @@ export default function AutomationScreen() { const ledger = useLedgerStore(s => s.ledger); const addTransaction = useLedgerStore(s => s.addTransaction); + const floatingBallEnabled = useSettingsStore(s => s.floatingBallEnabled); + const setFloatingBallEnabled = useSettingsStore(s => s.setFloatingBallEnabled); + // 无障碍服务状态 const [serviceRunning, setServiceRunning] = useState(false); const [pageSignatures, setPageSignatures] = useState([]); @@ -257,7 +261,7 @@ export default function AutomationScreen() { item.draft.sourceEventId ?? `${item.draft.date}-${index}`} + keyExtractor={(item, index) => item.draft.sourceEventIds?.[0] ?? `${item.draft.date}-${index}`} renderItem={({ item, index }) => ( @@ -303,6 +307,25 @@ export default function AutomationScreen() { )} + {/* 悬浮球开关 */} + + + {t('automation.floatingBall')} + + { + setFloatingBallEnabled(val); + const bridge = getAccessibilityBridge(); + if (bridge) { + try { await bridge.setFloatingBallEnabled(val); } catch {} + } + }} + trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }} + thumbColor={theme.colors.fgInverse} + /> + + {/* 操作按钮 */} {!serviceRunning && ( @@ -427,6 +450,7 @@ const styles = StyleSheet.create({ statItem: { alignItems: 'center', gap: 4 }, statusRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, statusDot: { width: 8, height: 8, borderRadius: 4 }, + switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, chip: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4 }, sigRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 4 }, }); diff --git a/src/app/category/index.tsx b/src/app/category/index.tsx index 103d85a..1355c1f 100644 --- a/src/app/category/index.tsx +++ b/src/app/category/index.tsx @@ -1,4 +1,4 @@ -/** +/** * 分类管理页面(plan.md「1.1 category/index」+ 决策 1 双轨制)。 * * 功能:分类列表(支出/收入分组)+ 添加/编辑/删除。 @@ -46,7 +46,7 @@ export default function CategoryScreen() { id: generateId('cat'), name: values.name || t('common.untitled'), type: modal.catType, - linkedAccount: values.linkedAccount || 'Expenses:Uncategorized', + linkedAccount: values.linkedAccount || (modal.catType === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized'), keywords: values.keywords ? values.keywords.split(/[,,\s]+/).filter(Boolean) : [], }); } else if (modal?.type === 'edit') { @@ -133,3 +133,4 @@ const styles = StyleSheet.create({ row: { flexDirection: 'row', alignItems: 'center', borderTopWidth: 1, paddingTop: 10 }, addBtn: { flexDirection: 'row', alignItems: 'center', paddingBottom: 10 }, }); + diff --git a/src/app/credit-card/index.tsx b/src/app/credit-card/index.tsx index 796e147..823faef 100644 --- a/src/app/credit-card/index.tsx +++ b/src/app/credit-card/index.tsx @@ -4,7 +4,7 @@ * 功能:信用卡列表(银行/尾号/账单日/还款日/额度) + 添加/编辑/删除。 * linkedAccount 映射到 Beancount 的 Liabilities 账户。 */ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; @@ -35,18 +35,21 @@ export default function CreditCardScreen() { const [modal, setModal] = useState(null); - /** 计算某账户的当前余额(从交易过账汇总)。 */ - const getAccountBalance = (account: string): string => { - let balance = 0; + /** 预计算所有账户余额 Map(避免在渲染循环中重复遍历)。 */ + const accountBalances = useMemo(() => { + const map = new Map(); for (const tx of transactions) { for (const p of tx.postings) { - if (p.account === account && p.amount) { - balance += parseFloat(p.amount); + if (p.account && p.amount) { + const prev = parseFloat(map.get(p.account) ?? '0'); + map.set(p.account, (prev + parseFloat(p.amount)).toFixed(2)); } } } - return String(balance.toFixed(2)); - }; + return map; + }, [transactions]); + + const getAccountBalance = (account: string): string => accountBalances.get(account) ?? '0.00'; const handleDelete = (card: CreditCard) => { Alert.alert(t('creditCard.deleteTitle'), t('creditCard.deleteConfirm', { name: card.name }), [ diff --git a/src/app/import/index.tsx b/src/app/import/index.tsx index 0964173..fd55c82 100644 --- a/src/app/import/index.tsx +++ b/src/app/import/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState } from 'react'; import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; @@ -6,23 +6,16 @@ import { Ionicons } from '@expo/vector-icons'; import * as DocumentPicker from 'expo-document-picker'; import * as FileSystem from 'expo-file-system/legacy'; import * as XLSX from 'xlsx'; -// @ts-ignore + +// 直接实例化 GBK 解码器,不修改全局 TextDecoder/TextEncoder const GbkTextDecoder = (() => { - // @ts-ignore - const origDecoder = global.TextDecoder; - // @ts-ignore - const origEncoder = global.TextEncoder; - // @ts-ignore - global.TextDecoder = undefined; - // @ts-ignore - global.TextEncoder = undefined; - const dec = require('text-encoding-gbk').TextDecoder; - // @ts-ignore - global.TextDecoder = origDecoder; - // @ts-ignore - global.TextEncoder = origEncoder; - return dec; + try { + return require('text-encoding-gbk').TextDecoder; + } catch { + return TextDecoder; // 降级到系统默认 + } })(); + import { useLedgerStore } from '../../store/ledgerStore'; import { useImportStore } from '../../store/importStore'; import { useMetadataStore } from '../../store/metadataStore'; @@ -244,8 +237,10 @@ export default function ImportScreen() { // 从待确认中批量移除 confirmDrafts(indices); + // 重新读取最新的 pendingDrafts 计算失败数(避免闭包过时) + const latestDrafts = useImportStore.getState().pendingDrafts; const successCount = drafts.length; - const failCount = pendingDrafts.length - successCount; + const failCount = latestDrafts.length - successCount; if (failCount === 0) { setStatus(t('importFlow.batchAllSuccess', { count: successCount })); @@ -292,7 +287,7 @@ export default function ImportScreen() { `${item.draft.sourceEventId || index}-${index}`} + keyExtractor={(item, index) => `${item.draft.sourceEventIds?.[0] || index}-${index}`} renderItem={({ item, index }) => ( {item.draft.narration} diff --git a/src/app/settings/preferences.tsx b/src/app/settings/preferences.tsx index 94f8b79..f873fa0 100644 --- a/src/app/settings/preferences.tsx +++ b/src/app/settings/preferences.tsx @@ -188,18 +188,41 @@ export default function PreferencesScreen() { {String(reminderHour).padStart(2, '0')}:{String(reminderMinute).padStart(2, '0')} - { - // 简单的时间调整:小时 +1 循环 - const newHour = (reminderHour + 1) % 24; - updateReminderConfig({ reminderHour: newHour }); - const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder'); - await setupDailyReminder(new RealNotificationScheduler(), newHour, reminderMinute); - }} - style={{ marginLeft: 12, padding: 4 }} - > - - + + { + const newHour = (reminderHour - 1 + 24) % 24; + updateReminderConfig({ reminderHour: newHour }); + const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder'); + await setupDailyReminder(new RealNotificationScheduler(), newHour, reminderMinute); + }} + style={{ padding: 4 }} + > + + + { + const newHour = (reminderHour + 1) % 24; + updateReminderConfig({ reminderHour: newHour }); + const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder'); + await setupDailyReminder(new RealNotificationScheduler(), newHour, reminderMinute); + }} + style={{ padding: 4 }} + > + + + { + const newMinute = (reminderMinute + 5) % 60; + updateReminderConfig({ reminderMinute: newMinute }); + const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder'); + await setupDailyReminder(new RealNotificationScheduler(), reminderHour, newMinute); + }} + style={{ padding: 4 }} + > + + + )} diff --git a/src/app/settings/sync.tsx b/src/app/settings/sync.tsx index 456a321..4fba5b9 100644 --- a/src/app/settings/sync.tsx +++ b/src/app/settings/sync.tsx @@ -235,9 +235,9 @@ export default function SyncSettingsScreen() { const content = await FileSystem.readAsStringAsync(file.uri); const bundle = deserializeBundle(content); const restoredFiles = restoreFiles(bundle); - // 用恢复的 content 替换当前内容,支持新版 main.bean 并且兼容旧版 mobile.bean - const restoredMobileBean = restoredFiles.find(f => f.path === 'main.bean')?.content || restoredFiles.find(f => f.path === 'mobile.bean')?.content || ''; - await replaceMobileBean(restoredMobileBean); + // 用恢复的 content 替换当前内容 + const restoredMainBean = restoredFiles.find(f => f.path === 'main.bean')?.content || ''; + await replaceMobileBean(restoredMainBean); // 恢复设置(v2) if (bundle.settings) { diff --git a/src/app/transaction/[id].tsx b/src/app/transaction/[id].tsx index a59e089..0942246 100644 --- a/src/app/transaction/[id].tsx +++ b/src/app/transaction/[id].tsx @@ -21,7 +21,7 @@ import { TransactionCard } from '../../components/TransactionCard'; export default function TransactionDetailScreen() { const { theme } = useTheme(); - const commonStyles = createCommonStyles(theme); + const commonStyles = useMemo(() => createCommonStyles(theme), [theme]); const t = useT(); const router = useRouter(); const { id } = useLocalSearchParams<{ id: string }>(); @@ -97,8 +97,8 @@ export default function TransactionDetailScreen() { const newTargetRaw = addLinkToRaw(targetTx.raw, linkTag); let newContent = mobileBean; - newContent = newContent.replace(tx.raw, newCurrentRaw); - newContent = newContent.replace(targetTx.raw, newTargetRaw); + newContent = newContent.replaceAll(tx.raw, newCurrentRaw); + newContent = newContent.replaceAll(targetTx.raw, newTargetRaw); await replaceMobileBean(newContent); setLinkModalVisible(false); @@ -132,7 +132,7 @@ export default function TransactionDetailScreen() { lines[0] = lines[0].replace(new RegExp(`\\s*\\^${linkTag}\\b`, 'g'), ''); const newRaw = lines.join('\n'); - newContent = newContent.replace(companion.raw, newRaw); + newContent = newContent.replaceAll(companion.raw, newRaw); if (companion.id === tx.id) { newCurrentRaw = newRaw; } diff --git a/src/app/transaction/new.tsx b/src/app/transaction/new.tsx index 4f75543..350f794 100644 --- a/src/app/transaction/new.tsx +++ b/src/app/transaction/new.tsx @@ -16,7 +16,8 @@ import { TagPicker } from '../../components/TagPicker'; import { PostingEditor } from '../../components/PostingEditor'; import { tagsToBeanSyntax } from '../../domain/tags'; import { serializeTransaction } from '../../domain/ledger'; -import { buildAndSaveTransaction } from '../../domain/transactionBuilder'; +import { buildAndSaveTransaction, buildPostings } from '../../domain/transactionBuilder'; +import { addDecimals, compareDecimals } from '../../domain/decimal'; import { matchCategory } from '../../domain/categories'; import { getNativeOcrModule, getNativeOcrBridge } from '../../services/ocrBridge'; import { OcrProcessor } from '../../domain/ocrProcessor'; @@ -27,9 +28,11 @@ import type { Posting } from '../../domain/types'; type Direction = 'expense' | 'income' | 'transfer'; +let ocrProcessor: OcrProcessor | null = null; + export default function NewTransactionScreen() { const { theme } = useTheme(); - const commonStyles = createCommonStyles(theme); + const commonStyles = useMemo(() => createCommonStyles(theme), [theme]); const t = useT(); const router = useRouter(); const { mode, editId, draftJson } = useLocalSearchParams<{ mode?: string; editId?: string; draftJson?: string }>(); @@ -64,10 +67,15 @@ export default function NewTransactionScreen() { ]); const [isSubmitted, setIsSubmitted] = useState(false); + const [submitting, setSubmitting] = useState(false); const navigation = useNavigation(); + const formRef = useRef({ amount, payee, narration, selectedTags, postings, isAdvanced, isSubmitted }); + formRef.current = { amount, payee, narration, selectedTags, postings, isAdvanced, isSubmitted }; + useEffect(() => { const unsubscribe = navigation.addListener('beforeRemove', (e) => { + const { amount, payee, narration, selectedTags, postings, isAdvanced, isSubmitted } = formRef.current; const hasChanges = amount.trim() !== '' || payee.trim() !== '' || @@ -95,7 +103,7 @@ export default function NewTransactionScreen() { }); return unsubscribe; - }, [navigation, amount, payee, narration, selectedTags, postings, isAdvanced, isSubmitted]); + }, [navigation]); // === 草稿预填模式 === useEffect(() => { @@ -262,8 +270,10 @@ export default function NewTransactionScreen() { // 3. 走 OcrProcessor 管线 (Layer1 regex → Layer2 → Layer3 AI) const ocrBridge = getNativeOcrBridge(); - const processor = new OcrProcessor(ocrBridge); - const ocrResult = await processor.process(base64); + if (!ocrProcessor) { + ocrProcessor = new OcrProcessor(ocrBridge); + } + const ocrResult = await ocrProcessor.process(base64); if (ocrResult.event) { // 4. 预填表单 @@ -309,6 +319,9 @@ export default function NewTransactionScreen() { }, [mode]); const submit = () => { + if (submitting) return; + setSubmitting(true); + const tagNames = selectedTags.map(tg => tg.name); if (isAdvanced) { @@ -316,23 +329,26 @@ export default function NewTransactionScreen() { const validPostings = postings.filter(p => p.account.trim()); if (validPostings.length < 2) { setStatus(t('transaction.atLeast2Postings')); + setSubmitting(false); return; } // 实时借贷平衡验证 - const currencySum: Record = {}; + const currencySum: Record = {}; for (const p of validPostings) { if (p.amount && p.currency) { - const val = parseFloat(p.amount); - if (!isNaN(val)) { - currencySum[p.currency] = (currencySum[p.currency] || 0) + val; - } + currencySum[p.currency] = currencySum[p.currency] || []; + currencySum[p.currency].push(p.amount); } } - const isBalanced = Object.values(currencySum).every(sum => Math.abs(sum) < 0.001); + const isBalanced = Object.entries(currencySum).every(([curr, amounts]) => { + const sum = addDecimals(amounts); + return compareDecimals(sum, '0') === 0; + }); if (!isBalanced) { setStatus(t('transaction.unbalanced')); + setSubmitting(false); return; } @@ -351,29 +367,47 @@ export default function NewTransactionScreen() { const newRaw = serializeTransaction(draft); editTransaction(oldRaw, newRaw).then(() => { setIsSubmitted(true); + setSubmitting(false); setStatus(t('transaction.editSuccess')); setTimeout(() => router.back(), 600); - }).catch(e => setStatus(String(e))); + }).catch(e => { + setStatus(String(e)); + setSubmitting(false); + }); } else { addTransaction(draft).then(() => { setIsSubmitted(true); + setSubmitting(false); setStatus(t('transaction.appended')); setNarration(''); setSelectedTags([]); setTimeout(() => router.back(), 600); - }).catch(e => setStatus(String(e))); + }).catch(e => { + setStatus(String(e)); + setSubmitting(false); + }); } } else { // 简单模式:调用统一的 buildAndSaveTransaction 构建复式 postings 并保存 + + // 日期格式校验 + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + setStatus(t('transaction.invalidDate')); + setSubmitting(false); + return; + } + const amt = amount.trim(); if (!amt) { setStatus(t('transaction.amountRequired')); + setSubmitting(false); return; } if (direction === 'transfer' && sourceAccount === targetAccount) { setStatus(t('transaction.sameAccountError')); + setSubmitting(false); return; } @@ -381,23 +415,13 @@ export default function NewTransactionScreen() { if (editId && oldRaw) { // 编辑模式:由于编辑模式需要保留原始元数据并替换原始代码块,我们仍然就地拼装 draft 并执行 editTransaction - let finalPostings: Posting[]; - if (direction === 'expense') { - finalPostings = [ - { account: sourceAccount, amount: `-${amt}`, currency }, - { account: categoryAccount, amount: amt, currency }, - ]; - } else if (direction === 'income') { - finalPostings = [ - { account: sourceAccount, amount: amt, currency }, - { account: categoryAccount, amount: `-${amt}`, currency }, - ]; - } else { - finalPostings = [ - { account: sourceAccount, amount: `-${amt}`, currency }, - { account: targetAccount, amount: amt, currency }, - ]; - } + const finalPostings = buildPostings( + direction, + sourceAccount, + direction === 'transfer' ? targetAccount : categoryAccount, + amt, + currency + ); const draft = { date, @@ -417,9 +441,13 @@ export default function NewTransactionScreen() { const newRaw = serializeTransaction(draft); editTransaction(oldRaw, newRaw).then(() => { setIsSubmitted(true); + setSubmitting(false); setStatus(t('transaction.editSuccess')); setTimeout(() => router.back(), 600); - }).catch(e => setStatus(String(e))); + }).catch(e => { + setStatus(String(e)); + setSubmitting(false); + }); } else { // 新建交易:直接调用统一的共享逻辑函数 buildAndSaveTransaction({ @@ -438,15 +466,19 @@ export default function NewTransactionScreen() { tags: tagNames, links: editLinks.length > 0 ? editLinks : undefined, metadata: editMetadata, - }).then(() => { + }, useLedgerStore.getState()).then(() => { setIsSubmitted(true); + setSubmitting(false); setStatus(t('transaction.appended')); setAmount(''); setNarration(''); setSelectedCategory(null); setSelectedTags([]); setTimeout(() => router.back(), 600); - }).catch(e => setStatus(String(e))); + }).catch(e => { + setStatus(String(e)); + setSubmitting(false); + }); } } }; @@ -776,7 +808,7 @@ export default function NewTransactionScreen() { )} -