Add transfer tracking and secure admin access
This commit is contained in:
@@ -151,13 +151,20 @@ class MainActivity : FlutterActivity() {
|
||||
)
|
||||
result.success(response?.getBoolean("success") == true)
|
||||
}
|
||||
"drainRecognitionCandidates" -> {
|
||||
"drainRecognitionCandidates" -> {
|
||||
val response = RecognitionBridge.call(
|
||||
this,
|
||||
RecognitionBridgeProvider.METHOD_DRAIN,
|
||||
)
|
||||
result.success(response?.getStringArrayList("candidates") ?: arrayListOf<String>())
|
||||
}
|
||||
result.success(response?.getStringArrayList("candidates") ?: arrayListOf<String>())
|
||||
}
|
||||
"listRecognitionCandidates" -> {
|
||||
val response = RecognitionBridge.call(
|
||||
this,
|
||||
RecognitionBridgeProvider.METHOD_CANDIDATES,
|
||||
)
|
||||
result.success(response?.getStringArrayList("candidates") ?: arrayListOf<String>())
|
||||
}
|
||||
"ackRecognitionCandidate" -> acknowledgeRecognition(call, result)
|
||||
"listRecognitionBatches" -> {
|
||||
val response = RecognitionBridge.call(
|
||||
|
||||
@@ -22,6 +22,9 @@ data class PaymentSignal(
|
||||
val categoryHint: String? = null,
|
||||
val amountSource: String = "result",
|
||||
val resultFingerprint: String? = null,
|
||||
val identityConfidence: String = "strong",
|
||||
val transferDirection: String? = null,
|
||||
val counterparty: String? = null,
|
||||
)
|
||||
|
||||
enum class PaymentStatusStrength(val wireValue: String) {
|
||||
@@ -189,11 +192,14 @@ object PaymentParser {
|
||||
val kind = flowKind ?: recognitionKind(text, direction)
|
||||
val merchant = extractMerchant(text)
|
||||
val orderId = extractOrderId(text)
|
||||
val transferDirection = direction.takeIf { kind == "transfer" }?.let {
|
||||
if (it == "income") "in" else "out"
|
||||
}
|
||||
return PaymentSignal(
|
||||
packageName = packageName,
|
||||
channel = "accessibility",
|
||||
amountCents = amountCents,
|
||||
type = direction,
|
||||
type = if (kind == "transfer") "transfer" else direction,
|
||||
merchant = merchant,
|
||||
orderId = orderId,
|
||||
occurredAtEpochMs = System.currentTimeMillis(),
|
||||
@@ -214,6 +220,8 @@ object PaymentParser {
|
||||
orderId,
|
||||
sha256(normalize(text)),
|
||||
),
|
||||
transferDirection = transferDirection,
|
||||
counterparty = merchant.takeIf { kind == "transfer" },
|
||||
)
|
||||
}
|
||||
fun fromNotification(notification: StatusBarNotification): PaymentSignal? {
|
||||
@@ -443,18 +451,21 @@ object PaymentParser {
|
||||
if (packageName !in supportedPackages) return null
|
||||
val normalized = normalize(text)
|
||||
if (containsBlockedStatus(normalized) || isHistoryPage(normalized)) return null
|
||||
val type = detectDirection(normalized) ?: return null
|
||||
val direction = detectDirection(normalized) ?: return null
|
||||
val amount = extractAmount(normalized) ?: return null
|
||||
if (!amount.isFinite() || amount <= 0 || amount > 100_000_000) return null
|
||||
|
||||
val orderId = extractOrderId(normalized)
|
||||
val merchant = extractMerchant(normalized)
|
||||
val kind = recognitionKind(normalized, type)
|
||||
val kind = recognitionKind(normalized, direction)
|
||||
val transferDirection = direction.takeIf { kind == "transfer" }?.let {
|
||||
if (it == "income") "in" else "out"
|
||||
}
|
||||
return PaymentSignal(
|
||||
packageName = packageName,
|
||||
channel = channel,
|
||||
amountCents = (amount * 100).roundToLong(),
|
||||
type = type,
|
||||
type = if (kind == "transfer") "transfer" else direction,
|
||||
merchant = merchant,
|
||||
orderId = orderId,
|
||||
occurredAtEpochMs = occurredAt,
|
||||
@@ -464,17 +475,19 @@ object PaymentParser {
|
||||
flowSessionId = flowSessionId,
|
||||
evidenceConfidence = evidenceConfidence,
|
||||
recognitionKind = kind,
|
||||
categoryHint = categoryHint(kind, type),
|
||||
categoryHint = categoryHint(kind, direction),
|
||||
amountSource = "result",
|
||||
resultFingerprint = resultFingerprint(
|
||||
packageName,
|
||||
kind,
|
||||
type,
|
||||
direction,
|
||||
(amount * 100).roundToLong(),
|
||||
merchant,
|
||||
orderId,
|
||||
sha256(normalized),
|
||||
),
|
||||
transferDirection = transferDirection,
|
||||
counterparty = merchant.takeIf { kind == "transfer" },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -538,4 +551,4 @@ object PaymentParser {
|
||||
private const val MAX_SUCCESS_HEADING_CHARS = 48
|
||||
private const val MAX_PAYMENT_SCAN_LINES = 48
|
||||
private const val MAX_HISTORY_TITLE_LINES = 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,12 @@ class RecognitionBridgeProvider : ContentProvider() {
|
||||
),
|
||||
)
|
||||
}
|
||||
METHOD_CANDIDATES -> Bundle().apply {
|
||||
putStringArrayList(
|
||||
"candidates",
|
||||
ArrayList(RecognitionCoordinator.get(appContext).recentCandidates()),
|
||||
)
|
||||
}
|
||||
METHOD_ACK -> Bundle().apply {
|
||||
val candidate = RecognitionCoordinator.get(appContext).acknowledge(
|
||||
extras?.getString("id").orEmpty(),
|
||||
@@ -144,6 +150,7 @@ class RecognitionBridgeProvider : ContentProvider() {
|
||||
const val METHOD_REQUEST_SCREENSHOT = "requestScreenshot"
|
||||
const val METHOD_SCREENSHOT_RESULT = "screenshotResult"
|
||||
const val METHOD_DRAIN = "drain"
|
||||
const val METHOD_CANDIDATES = "candidates"
|
||||
const val METHOD_ACK = "ack"
|
||||
const val METHOD_BATCHES = "batches"
|
||||
const val METHOD_RESTORE_DROPPED = "restoreDropped"
|
||||
|
||||
@@ -114,6 +114,8 @@ class RecognitionCoordinator private constructor(private val context: Context) {
|
||||
|
||||
fun recentBatches(): List<String> = store.recentBatches()
|
||||
|
||||
fun recentCandidates(): List<String> = store.recentCandidates()
|
||||
|
||||
fun restoreDropped(candidateId: String): StoredCandidate? {
|
||||
return store.restoreDropped(candidateId)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import org.json.JSONObject
|
||||
import org.json.JSONArray
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import kotlin.math.abs
|
||||
|
||||
data class StoredCandidate(
|
||||
val id: String,
|
||||
@@ -169,8 +168,9 @@ class RecognitionStore(context: Context) :
|
||||
val strongKey = orderStrongKey ?: flowStrongKey
|
||||
val clientRequestId = clientRequestIdFor(signal)
|
||||
val signalHigh = signal.channel in setOf("accessibility", "local_ocr") &&
|
||||
signal.evidenceConfidence == "high"
|
||||
val existing = findMergeCandidate(signal, channelBit, merchantHash, strongKey, now)
|
||||
signal.evidenceConfidence == "high" &&
|
||||
signal.identityConfidence == "strong"
|
||||
val existing = findMergeCandidate(strongKey)
|
||||
?: findByClientRequestId(clientRequestId)
|
||||
val batchId = existing?.batchId ?: if (batchMode) activeBatch(now) else null
|
||||
val id: String
|
||||
@@ -458,6 +458,12 @@ class RecognitionStore(context: Context) :
|
||||
.put("categoryHint", payload.optString("categoryHint").takeIf(String::isNotBlank))
|
||||
.put("confidence", if (cursor.getInt(cursor.getColumnIndexOrThrow("high_confidence")) == 1) "auto" else "confirm")
|
||||
.put("sourceText", payload.optString("sourceText").takeIf(String::isNotBlank))
|
||||
.put("transferDirection", payload.optString("transferDirection").takeIf(String::isNotBlank))
|
||||
.put("counterparty", payload.optString("counterparty").takeIf(String::isNotBlank))
|
||||
.put("provider", payload.optString("provider").takeIf(String::isNotBlank))
|
||||
.put("providerTransactionId", payload.optString("providerTransactionId").takeIf(String::isNotBlank))
|
||||
.put("recognitionOccurrenceId", payload.optString("recognitionOccurrenceId").takeIf(String::isNotBlank))
|
||||
.put("identityConfidence", payload.optString("identityConfidence").takeIf(String::isNotBlank))
|
||||
.put(
|
||||
"evidenceIds",
|
||||
JSONArray(evidenceByCandidate[cursor.getString(cursor.getColumnIndexOrThrow("id"))]
|
||||
@@ -564,13 +570,18 @@ class RecognitionStore(context: Context) :
|
||||
} ?: continue
|
||||
val amount = action.optDouble("amount", 0.0)
|
||||
val type = action.optString("type")
|
||||
if (amount <= 0 || type !in setOf("income", "expense")) continue
|
||||
val transferDirection = action.optionalString("transferDirection")
|
||||
?.takeIf { it in setOf("in", "out") }
|
||||
if (amount <= 0 || type !in setOf("income", "expense", "transfer") ||
|
||||
type == "transfer" && transferDirection == null) continue
|
||||
val candidateId = UUID.randomUUID().toString()
|
||||
val requestId = "recognition-" + PaymentParser.sha256("$batchId|create|$evidenceId").take(52)
|
||||
val payload = JSONObject()
|
||||
.put("packageName", image.first)
|
||||
.put("appName", PaymentParser.appName(image.first))
|
||||
.put("type", type)
|
||||
.put("transferDirection", transferDirection)
|
||||
.put("counterparty", action.optionalString("counterparty"))
|
||||
.put("amount", amount)
|
||||
.put("merchant", action.optionalString("note"))
|
||||
.put("orderId", JSONObject.NULL)
|
||||
@@ -578,11 +589,13 @@ class RecognitionStore(context: Context) :
|
||||
.put("sourceText", "AI 批次补全 · ${PaymentParser.appName(image.first)}")
|
||||
.put("flowSessionId", image.third)
|
||||
.put("evidenceConfidence", "high")
|
||||
.put("recognitionKind", "payment")
|
||||
.put("recognitionKind", if (type == "transfer") "transfer" else "payment")
|
||||
.put("categoryHint", action.optionalString("categoryName"))
|
||||
.put("categoryId", action.optLong("categoryId").takeIf { it > 0 })
|
||||
.put("amountSource", "ai_batch")
|
||||
.put("resultFingerprint", PaymentParser.sha256("$batchId|$evidenceId"))
|
||||
.put("recognitionOccurrenceId", image.third ?: candidateId)
|
||||
.put("identityConfidence", if (image.third == null) "weak" else "strong")
|
||||
.put("note", action.optionalString("note") ?: PaymentParser.appName(image.first))
|
||||
.put("paymentMethod", action.optionalString("paymentMethod"))
|
||||
.put("sourceOverride", "recognition_ai")
|
||||
@@ -704,9 +717,13 @@ class RecognitionStore(context: Context) :
|
||||
}
|
||||
|
||||
private fun applyActionFields(payload: JSONObject, action: JSONObject) {
|
||||
action.optionalString("type")?.takeIf { it in setOf("income", "expense") }?.let {
|
||||
action.optionalString("type")?.takeIf { it in setOf("income", "expense", "transfer") }?.let {
|
||||
payload.put("type", it)
|
||||
}
|
||||
action.optionalString("transferDirection")?.takeIf { it in setOf("in", "out") }?.let {
|
||||
payload.put("transferDirection", it)
|
||||
}
|
||||
action.optionalString("counterparty")?.let { payload.put("counterparty", it.take(100)) }
|
||||
action.optDouble("amount", 0.0).takeIf { it > 0 }?.let { payload.put("amount", it) }
|
||||
action.optionalString("note")?.let {
|
||||
payload.put("merchant", it.take(40))
|
||||
@@ -750,6 +767,7 @@ class RecognitionStore(context: Context) :
|
||||
.put("action", action)
|
||||
.put("reason", if (candidates.isNull(reasonIndex)) "" else candidates.getString(reasonIndex))
|
||||
.put("type", payload.optString("type"))
|
||||
.put("transferDirection", payload.optString("transferDirection").takeIf(String::isNotBlank))
|
||||
.put("amount", payload.optDouble("amount"))
|
||||
.put("merchant", payload.optString("merchant").takeIf(String::isNotBlank))
|
||||
.put("state", candidates.getString(candidates.getColumnIndexOrThrow("state")))
|
||||
@@ -892,13 +910,20 @@ class RecognitionStore(context: Context) :
|
||||
.toString()
|
||||
}
|
||||
|
||||
private fun findMergeCandidate(
|
||||
signal: PaymentSignal,
|
||||
channelBit: Int,
|
||||
merchantHash: String,
|
||||
strongKey: String?,
|
||||
now: Long,
|
||||
): CandidateRow? {
|
||||
@Synchronized
|
||||
fun recentCandidates(): List<String> {
|
||||
expireOld(System.currentTimeMillis())
|
||||
return readableDatabase.rawQuery(
|
||||
"SELECT * FROM candidates ORDER BY updated_at DESC LIMIT 100",
|
||||
null,
|
||||
).use { cursor ->
|
||||
buildList {
|
||||
while (cursor.moveToNext()) decode(cursor)?.json?.let(::add)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findMergeCandidate(strongKey: String?): CandidateRow? {
|
||||
if (strongKey != null) {
|
||||
readableDatabase.rawQuery(
|
||||
"SELECT * FROM candidates WHERE strong_key = ? AND state NOT IN ('undone','expired') LIMIT 1",
|
||||
@@ -910,39 +935,8 @@ class RecognitionStore(context: Context) :
|
||||
// counterparty are identical to another transaction in the same window.
|
||||
return null
|
||||
}
|
||||
val since = signal.occurredAtEpochMs - NO_ORDER_WINDOW_MS
|
||||
val until = signal.occurredAtEpochMs + NO_ORDER_WINDOW_MS
|
||||
readableDatabase.rawQuery(
|
||||
"""
|
||||
SELECT * FROM candidates
|
||||
WHERE package_name = ? AND amount_cents = ? AND direction = ?
|
||||
AND merchant_hash = ? AND occurred_at BETWEEN ? AND ?
|
||||
AND state IN ('pending_merge','auto_ready','pending_confirm','imported')
|
||||
ORDER BY ABS(occurred_at - ?) LIMIT 4
|
||||
""".trimIndent(),
|
||||
arrayOf(
|
||||
signal.packageName,
|
||||
signal.amountCents.toString(),
|
||||
signal.type,
|
||||
merchantHash,
|
||||
since.toString(),
|
||||
until.toString(),
|
||||
signal.occurredAtEpochMs.toString(),
|
||||
),
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val candidate = row(cursor)
|
||||
val hasSameChannel = candidate.channelMask and channelBit != 0
|
||||
val sameResult = signal.resultFingerprint != null &&
|
||||
signal.resultFingerprint == candidate.resultFingerprint
|
||||
if (sameResult ||
|
||||
!hasSameChannel ||
|
||||
now - candidate.updatedAt <= SAME_CHANNEL_DEBOUNCE_MS
|
||||
) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
// Amount, merchant and time are correlation hints, not transaction identity.
|
||||
// Updated notifications are already collapsed by their source-event hash.
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1018,6 +1012,21 @@ class RecognitionStore(context: Context) :
|
||||
.put("categoryHint", signal.categoryHint)
|
||||
.put("amountSource", signal.amountSource)
|
||||
.put("resultFingerprint", signal.resultFingerprint)
|
||||
.put("identityConfidence", signal.identityConfidence)
|
||||
.put("transferDirection", signal.transferDirection)
|
||||
.put("counterparty", signal.counterparty)
|
||||
.put(
|
||||
"provider",
|
||||
when (signal.packageName) {
|
||||
PaymentParser.WECHAT -> "wechat"
|
||||
PaymentParser.ALIPAY -> "alipay"
|
||||
else -> null
|
||||
},
|
||||
)
|
||||
.put("providerTransactionId", signal.orderId)
|
||||
.put("recognitionOccurrenceId", signal.flowSessionId)
|
||||
.put("evidenceFingerprint", signal.resultFingerprint)
|
||||
.put("recognitionConfidence", signal.identityConfidence)
|
||||
.put(
|
||||
"note",
|
||||
signal.merchant?.take(40) ?: when (signal.recognitionKind) {
|
||||
@@ -1038,6 +1047,12 @@ class RecognitionStore(context: Context) :
|
||||
if (existing.isNull("resultFingerprint") && signal.resultFingerprint != null) {
|
||||
existing.put("resultFingerprint", signal.resultFingerprint)
|
||||
}
|
||||
if (existing.isNull("transferDirection") && signal.transferDirection != null) {
|
||||
existing.put("transferDirection", signal.transferDirection)
|
||||
}
|
||||
if (existing.isNull("counterparty") && signal.counterparty != null) {
|
||||
existing.put("counterparty", signal.counterparty)
|
||||
}
|
||||
if (existing.optString("note").isBlank() && signal.merchant != null) existing.put("note", signal.merchant.take(40))
|
||||
existing.put("occurredAtEpochMs", minOf(existing.optLong("occurredAtEpochMs"), signal.occurredAtEpochMs))
|
||||
return existing
|
||||
@@ -1089,8 +1104,6 @@ class RecognitionStore(context: Context) :
|
||||
companion object {
|
||||
private const val ACCESSIBILITY_NOTIFICATION_MASK = 3
|
||||
private const val MERGE_DELAY_MS = 1_500L
|
||||
private const val NO_ORDER_WINDOW_MS = 90_000L
|
||||
private const val SAME_CHANNEL_DEBOUNCE_MS = 10_000L
|
||||
private const val EXPIRE_MS = 7L * 24L * 60L * 60L * 1000L
|
||||
private const val BATCH_IDLE_MS = 30_000L
|
||||
private const val BATCH_HARD_LIMIT_MS = 120_000L
|
||||
|
||||
+87
-1
@@ -50,6 +50,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
var expectedAmountCents: Long? = null,
|
||||
var expectedType: String? = null,
|
||||
var committedAt: Long? = null,
|
||||
var completedAt: Long? = null,
|
||||
var resultTransitionObserved: Boolean = false,
|
||||
var resultPageHash: String? = null,
|
||||
var resultFingerprint: String? = null,
|
||||
@@ -142,6 +143,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
val clickedPaymentAction =
|
||||
currentEvent.eventType == AccessibilityEvent.TYPE_VIEW_CLICKED &&
|
||||
PaymentParser.hasPaymentAction(eventText.ifBlank { combined })
|
||||
val majorWindowChange = currentEvent.eventType in MAJOR_WINDOW_EVENTS
|
||||
val inferredKind = PaymentParser.detectFlowKind(combined)
|
||||
?: existingFlow?.kind
|
||||
?: "payment"
|
||||
@@ -165,6 +167,71 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
val sameOutgoingResult =
|
||||
currentKind in setOf("payment", "transfer") &&
|
||||
existingFlow.kind in setOf("payment", "transfer")
|
||||
if (clickedPaymentAction) {
|
||||
val nextFlow = armPaymentFlow(
|
||||
recognizedPackage,
|
||||
currentEvent.windowId,
|
||||
now,
|
||||
forceNew = true,
|
||||
kind = inferredKind,
|
||||
)
|
||||
nextFlow.committedAt = now
|
||||
nextFlow.expectedType = "expense"
|
||||
RecognitionDiagnostics.record(
|
||||
this,
|
||||
recognizedPackage,
|
||||
stage = "event",
|
||||
result = "armed",
|
||||
nodeCount = page.nodeCount,
|
||||
reason = "new_payment_action",
|
||||
recognitionKind = inferredKind,
|
||||
)
|
||||
return
|
||||
}
|
||||
val observed = PaymentParser.fromAccessibility(
|
||||
packageName = recognizedPackage,
|
||||
text = combined,
|
||||
eventTime = currentEvent.eventTime,
|
||||
windowId = currentEvent.windowId,
|
||||
expectedType = status.direction ?: existingFlow.expectedType,
|
||||
flowKind = currentKind,
|
||||
)
|
||||
val resultChanged = observed?.resultFingerprint != null &&
|
||||
existingFlow.resultFingerprint != null &&
|
||||
observed.resultFingerprint != existingFlow.resultFingerprint
|
||||
val elapsedSinceCompletion = now - (existingFlow.completedAt ?: now)
|
||||
val startReason = completedResultStartReason(
|
||||
hasObservedResult = observed != null,
|
||||
resultFingerprintChanged = resultChanged,
|
||||
majorWindowChange = majorWindowChange,
|
||||
elapsedSinceCompletionMs = elapsedSinceCompletion,
|
||||
)
|
||||
val ambiguousRepeat = startReason == CompletedResultStartReason.AMBIGUOUS_REPEAT
|
||||
if (startReason != null) {
|
||||
val nextFlow = armPaymentFlow(
|
||||
recognizedPackage,
|
||||
currentEvent.windowId,
|
||||
now,
|
||||
forceNew = true,
|
||||
trusted = resultChanged,
|
||||
kind = currentKind,
|
||||
).apply { resultTransitionObserved = true }
|
||||
submitOnce(
|
||||
requireNotNull(observed).copy(
|
||||
flowSessionId = nextFlow.id,
|
||||
evidenceConfidence = "confirm",
|
||||
identityConfidence = if (ambiguousRepeat) {
|
||||
"ambiguous_repeat"
|
||||
} else {
|
||||
"strong"
|
||||
},
|
||||
),
|
||||
nextFlow,
|
||||
page.nodeCount,
|
||||
"tree",
|
||||
)
|
||||
return
|
||||
}
|
||||
if ((currentKind == existingFlow.kind || sameOutgoingResult) &&
|
||||
shouldSuppressCompletedResult(existingFlow.resultSurfaceExited)
|
||||
) {
|
||||
@@ -250,7 +317,6 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
return
|
||||
}
|
||||
|
||||
val majorWindowChange = currentEvent.eventType in MAJOR_WINDOW_EVENTS
|
||||
val flow = paymentFlow?.takeIf { !it.completed } ?: return
|
||||
val shouldProbe = flow.committedAt != null &&
|
||||
(majorWindowChange ||
|
||||
@@ -408,6 +474,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
return
|
||||
}
|
||||
flow.completed = true
|
||||
flow.completedAt = System.currentTimeMillis()
|
||||
flow.resultFingerprint = signal.resultFingerprint
|
||||
val coordinator = RecognitionCoordinator.get(this)
|
||||
val settings = RecognitionSettings.snapshot(this)
|
||||
@@ -1105,6 +1172,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
private const val CONTENT_DEBOUNCE_MS = 2_000L
|
||||
private const val PAYMENT_FLOW_TTL_MS = 90_000L
|
||||
private const val MIN_RESULT_TRANSITION_DELAY_MS = 250L
|
||||
private const val AMBIGUOUS_REPEAT_GAP_MS = 3_000L
|
||||
private const val VISUAL_STABILITY_DELAY_MS = 700L
|
||||
private const val VISUAL_RETRY_DELAY_MS = 850L
|
||||
private const val VISUAL_CAPTURE_THROTTLE_MS = 2_500L
|
||||
@@ -1137,6 +1205,19 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
internal fun shouldSuppressCompletedResult(resultSurfaceExited: Boolean): Boolean =
|
||||
!resultSurfaceExited
|
||||
|
||||
internal fun completedResultStartReason(
|
||||
hasObservedResult: Boolean,
|
||||
resultFingerprintChanged: Boolean,
|
||||
majorWindowChange: Boolean,
|
||||
elapsedSinceCompletionMs: Long,
|
||||
): CompletedResultStartReason? = when {
|
||||
!hasObservedResult -> null
|
||||
resultFingerprintChanged -> CompletedResultStartReason.CHANGED_RESULT
|
||||
majorWindowChange && elapsedSinceCompletionMs >= AMBIGUOUS_REPEAT_GAP_MS ->
|
||||
CompletedResultStartReason.AMBIGUOUS_REPEAT
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var isConnected = false
|
||||
private set
|
||||
@@ -1156,3 +1237,8 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class CompletedResultStartReason {
|
||||
CHANGED_RESULT,
|
||||
AMBIGUOUS_REPEAT,
|
||||
}
|
||||
|
||||
@@ -342,6 +342,36 @@ class PaymentParserTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completedTransferStartsANewFlowForChangedOrAmbiguousRepeatedResults() {
|
||||
assertEquals(
|
||||
CompletedResultStartReason.CHANGED_RESULT,
|
||||
ScreenshotAccessibilityService.completedResultStartReason(
|
||||
hasObservedResult = true,
|
||||
resultFingerprintChanged = true,
|
||||
majorWindowChange = false,
|
||||
elapsedSinceCompletionMs = 100L,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
CompletedResultStartReason.AMBIGUOUS_REPEAT,
|
||||
ScreenshotAccessibilityService.completedResultStartReason(
|
||||
hasObservedResult = true,
|
||||
resultFingerprintChanged = false,
|
||||
majorWindowChange = true,
|
||||
elapsedSinceCompletionMs = 3_000L,
|
||||
),
|
||||
)
|
||||
assertNull(
|
||||
ScreenshotAccessibilityService.completedResultStartReason(
|
||||
hasObservedResult = true,
|
||||
resultFingerprintChanged = false,
|
||||
majorWindowChange = false,
|
||||
elapsedSinceCompletionMs = 30_000L,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun paymentSignal(
|
||||
channel: String,
|
||||
sourceEventId: String,
|
||||
|
||||
@@ -18,7 +18,9 @@ class AddPage extends StatefulWidget {
|
||||
|
||||
class _AddPageState extends State<AddPage> {
|
||||
final _noteCtrl = TextEditingController();
|
||||
final _counterpartyCtrl = TextEditingController();
|
||||
String _tab = 'expense';
|
||||
String _transferDirection = 'out';
|
||||
final Map<String, List<CategoryItem>> _categoriesByType = {
|
||||
'expense': <CategoryItem>[],
|
||||
'income': <CategoryItem>[],
|
||||
@@ -26,18 +28,34 @@ class _AddPageState extends State<AddPage> {
|
||||
final Map<String, CategoryItem?> _selectedByType = {
|
||||
'expense': null,
|
||||
'income': null,
|
||||
'transfer_out': null,
|
||||
'transfer_in': null,
|
||||
};
|
||||
String _amount = '0';
|
||||
String? _paymentMethod;
|
||||
DateTime _occurredAt = ShanghaiTime.now;
|
||||
bool _saving = false;
|
||||
bool _loadingCategories = true;
|
||||
String get _categoryType => _tab == 'transfer'
|
||||
? _transferDirection == 'in'
|
||||
? 'income'
|
||||
: 'expense'
|
||||
: _tab;
|
||||
|
||||
String get _selectionKey =>
|
||||
_tab == 'transfer' ? 'transfer_$_transferDirection' : _tab;
|
||||
|
||||
List<CategoryItem> get _categories =>
|
||||
_categoriesByType[_tab] ?? const <CategoryItem>[];
|
||||
_categoriesByType[_categoryType] ?? const <CategoryItem>[];
|
||||
|
||||
CategoryItem? get _selected => _selectedByType[_tab];
|
||||
CategoryItem? get _selected => _selectedByType[_selectionKey];
|
||||
|
||||
Color get _activeColor => _tab == 'income' ? AppTheme.primary : AppTheme.red;
|
||||
Color get _activeColor =>
|
||||
_tab == 'income' || _tab == 'transfer' && _transferDirection == 'in'
|
||||
? AppTheme.primary
|
||||
: _tab == 'transfer'
|
||||
? AppTheme.orange
|
||||
: AppTheme.red;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -48,6 +66,7 @@ class _AddPageState extends State<AddPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
_noteCtrl.dispose();
|
||||
_counterpartyCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -68,6 +87,12 @@ class _AddPageState extends State<AddPage> {
|
||||
if (_selectedByType['income'] == null && results[1].isNotEmpty) {
|
||||
_selectedByType['income'] = results[1].first;
|
||||
}
|
||||
_selectedByType['transfer_out'] ??= results[0].isEmpty
|
||||
? null
|
||||
: results[0].first;
|
||||
_selectedByType['transfer_in'] ??= results[1].isEmpty
|
||||
? null
|
||||
: results[1].first;
|
||||
});
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
@@ -85,6 +110,11 @@ class _AddPageState extends State<AddPage> {
|
||||
setState(() => _tab = tab);
|
||||
}
|
||||
|
||||
void _switchTransferDirection(String direction) {
|
||||
if (_transferDirection == direction) return;
|
||||
setState(() => _transferDirection = direction);
|
||||
}
|
||||
|
||||
void _pressKey(String key) {
|
||||
setState(() {
|
||||
if (key == 'delete') {
|
||||
@@ -123,6 +153,11 @@ class _AddPageState extends State<AddPage> {
|
||||
await TxApi.create(
|
||||
categoryId: _selected!.id,
|
||||
type: _tab,
|
||||
transferDirection: _tab == 'transfer' ? _transferDirection : null,
|
||||
counterparty:
|
||||
_tab == 'transfer' && _counterpartyCtrl.text.trim().isNotEmpty
|
||||
? _counterpartyCtrl.text.trim()
|
||||
: null,
|
||||
amount: amount,
|
||||
note: _noteCtrl.text.trim().isEmpty ? null : _noteCtrl.text.trim(),
|
||||
paymentMethod: _paymentMethod,
|
||||
@@ -152,6 +187,17 @@ class _AddPageState extends State<AddPage> {
|
||||
if (value != null) setState(() => _noteCtrl.text = value);
|
||||
}
|
||||
|
||||
Future<void> _editCounterparty() async {
|
||||
final value = await showJzTextInputSheet(
|
||||
context,
|
||||
title: '转账对方',
|
||||
label: '姓名或备注名',
|
||||
initialValue: _counterpartyCtrl.text,
|
||||
maxLength: 40,
|
||||
);
|
||||
if (value != null) setState(() => _counterpartyCtrl.text = value);
|
||||
}
|
||||
|
||||
Future<void> _pickOccurredAt() async {
|
||||
final value = await showJzDateTimeSheet(
|
||||
context,
|
||||
@@ -201,6 +247,20 @@ class _AddPageState extends State<AddPage> {
|
||||
return Column(
|
||||
children: [
|
||||
_buildTypeSelector(),
|
||||
if (_tab == 'transfer') ...[
|
||||
const SizedBox(height: 6),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 72),
|
||||
child: JzSegmentedControl<String>(
|
||||
value: _transferDirection,
|
||||
options: const [
|
||||
JzOption(value: 'out', label: '转出'),
|
||||
JzOption(value: 'in', label: '转入'),
|
||||
],
|
||||
onChanged: _switchTransferDirection,
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 4),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
@@ -210,7 +270,9 @@ class _AddPageState extends State<AddPage> {
|
||||
transitionBuilder: (child, animation) {
|
||||
final offset = _tab == 'expense'
|
||||
? const Offset(-0.04, 0)
|
||||
: const Offset(0.04, 0);
|
||||
: _tab == 'income'
|
||||
? const Offset(0.04, 0)
|
||||
: Offset.zero;
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
@@ -244,7 +306,7 @@ class _AddPageState extends State<AddPage> {
|
||||
|
||||
Widget _buildTypeSelector() {
|
||||
return Container(
|
||||
width: 220,
|
||||
width: 300,
|
||||
height: 38,
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
padding: const EdgeInsets.all(3),
|
||||
@@ -258,11 +320,16 @@ class _AddPageState extends State<AddPage> {
|
||||
AnimatedAlign(
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: _tab == 'expense'
|
||||
? Alignment.centerLeft
|
||||
: Alignment.centerRight,
|
||||
alignment: Alignment(
|
||||
_tab == 'expense'
|
||||
? -1
|
||||
: _tab == 'income'
|
||||
? 1
|
||||
: 0,
|
||||
0,
|
||||
),
|
||||
child: Container(
|
||||
width: constraints.maxWidth / 2,
|
||||
width: constraints.maxWidth / 3,
|
||||
height: constraints.maxHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: _activeColor,
|
||||
@@ -271,7 +338,11 @@ class _AddPageState extends State<AddPage> {
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [_segment('支出', 'expense'), _segment('收入', 'income')],
|
||||
children: [
|
||||
_segment('支出', 'expense'),
|
||||
_segment('转账', 'transfer'),
|
||||
_segment('收入', 'income'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -315,7 +386,8 @@ class _AddPageState extends State<AddPage> {
|
||||
final selected = category.id == _selected?.id;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () => setState(() => _selectedByType[_tab] = category),
|
||||
onTap: () =>
|
||||
setState(() => _selectedByType[_selectionKey] = category),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -423,6 +495,14 @@ class _AddPageState extends State<AddPage> {
|
||||
label: _noteCtrl.text.isEmpty ? '备注' : _noteCtrl.text,
|
||||
onTap: _editNote,
|
||||
),
|
||||
if (_tab == 'transfer')
|
||||
_metaChip(
|
||||
icon: Icons.person_outline_rounded,
|
||||
label: _counterpartyCtrl.text.isEmpty
|
||||
? '转账对方'
|
||||
: _counterpartyCtrl.text,
|
||||
onTap: _editCounterparty,
|
||||
),
|
||||
_metaChip(
|
||||
icon: Icons.schedule_rounded,
|
||||
label: dateLabel,
|
||||
|
||||
@@ -174,7 +174,7 @@ class _ParseSheetState extends State<_ParseSheet> {
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: d.type == 'income'
|
||||
color: d.isIncome
|
||||
? AppTheme.primary
|
||||
: AppTheme.red,
|
||||
),
|
||||
@@ -189,18 +189,18 @@ class _ParseSheetState extends State<_ParseSheet> {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
(d.type == 'income'
|
||||
(d.isIncome
|
||||
? AppTheme.primary
|
||||
: AppTheme.red)
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: Text(
|
||||
d.type == 'income' ? '收入' : '支出',
|
||||
d.typeLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: d.type == 'income'
|
||||
color: d.isIncome
|
||||
? AppTheme.primary
|
||||
: AppTheme.red,
|
||||
),
|
||||
|
||||
@@ -135,12 +135,18 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
final type = switch (json['type']?.toString().toLowerCase()) {
|
||||
'income' => 'income',
|
||||
'expense' => 'expense',
|
||||
'transfer' => 'transfer',
|
||||
_ => 'unknown',
|
||||
};
|
||||
final rawTransferDirection = json['transferDirection']?.toString();
|
||||
final transferDirection =
|
||||
rawTransferDirection == 'in' || rawTransferDirection == 'out'
|
||||
? rawTransferDirection!
|
||||
: 'out';
|
||||
final categoryId = (json['categoryId'] as num?)?.toInt();
|
||||
final categoryName = json['categoryName']?.toString() ?? '其他';
|
||||
final categoryIcon = json['categoryIcon']?.toString() ?? 'tag';
|
||||
final categories = _categoriesFor(type);
|
||||
final categories = _categoriesFor(type, transferDirection);
|
||||
final exists = categories.any((category) => category.id == categoryId);
|
||||
if (type != 'unknown' && !exists && categoryId != null) {
|
||||
categories.add(
|
||||
@@ -148,7 +154,11 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
'id': categoryId,
|
||||
'name': categoryName,
|
||||
'iconKey': categoryIcon,
|
||||
'type': type,
|
||||
'type': type == 'transfer'
|
||||
? transferDirection == 'in'
|
||||
? 'income'
|
||||
: 'expense'
|
||||
: type,
|
||||
'sortOrder': 999,
|
||||
'isCustom': false,
|
||||
}),
|
||||
@@ -172,6 +182,8 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
occurredAt: parsedOccurredAt == null
|
||||
? ShanghaiTime.now
|
||||
: ShanghaiTime.toCivil(parsedOccurredAt),
|
||||
transferDirection: transferDirection,
|
||||
counterparty: json['counterparty']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,10 +201,11 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
List<CategoryItem> _categoriesFor(String type) {
|
||||
List<CategoryItem> _categoriesFor(String type, [String direction = 'out']) {
|
||||
return switch (type) {
|
||||
'income' => _incomeCategories,
|
||||
'expense' => _expenseCategories,
|
||||
'transfer' => direction == 'in' ? _incomeCategories : _expenseCategories,
|
||||
_ => <CategoryItem>[],
|
||||
};
|
||||
}
|
||||
@@ -203,7 +216,7 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
|
||||
void _changeType(_ScreenshotDraft draft, String type) {
|
||||
if (draft.type == type) return;
|
||||
final categories = _categoriesFor(type);
|
||||
final categories = _categoriesFor(type, draft.transferDirection);
|
||||
setState(() {
|
||||
draft.type = type;
|
||||
draft.included = true;
|
||||
@@ -211,6 +224,15 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
});
|
||||
}
|
||||
|
||||
void _changeTransferDirection(_ScreenshotDraft draft, String direction) {
|
||||
if (draft.transferDirection == direction) return;
|
||||
final categories = _categoriesFor('transfer', direction);
|
||||
setState(() {
|
||||
draft.transferDirection = direction;
|
||||
draft.categoryId = categories.isEmpty ? null : categories.first.id;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickOccurredAt(_ScreenshotDraft draft) async {
|
||||
FocusScope.of(context).unfocus();
|
||||
final value = await showJzDateTimeSheet(
|
||||
@@ -226,7 +248,7 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
}
|
||||
|
||||
Future<void> _selectCategory(_ScreenshotDraft draft, int billIndex) async {
|
||||
final categories = _categoriesFor(draft.type);
|
||||
final categories = _categoriesFor(draft.type, draft.transferDirection);
|
||||
if (categories.isEmpty) {
|
||||
_showMessage('当前收支类型暂无可选分类');
|
||||
return;
|
||||
@@ -308,8 +330,10 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
|
||||
for (var index = 0; index < selected.length; index++) {
|
||||
final draft = selected[index];
|
||||
if (draft.type != 'income' && draft.type != 'expense') {
|
||||
_showMessage('第 ${_drafts.indexOf(draft) + 1} 笔请先确认收入或支出');
|
||||
if (draft.type != 'income' &&
|
||||
draft.type != 'expense' &&
|
||||
draft.type != 'transfer') {
|
||||
_showMessage('第 ${_drafts.indexOf(draft) + 1} 笔请先确认账单类型');
|
||||
return;
|
||||
}
|
||||
if ((double.tryParse(draft.amountController.text) ?? 0) <= 0) {
|
||||
@@ -343,6 +367,14 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
source: 'screenshot',
|
||||
sourceText: '截屏识别',
|
||||
occurredAt: draft.occurredAt,
|
||||
transferDirection: draft.type == 'transfer'
|
||||
? draft.transferDirection
|
||||
: null,
|
||||
counterparty:
|
||||
draft.type == 'transfer' &&
|
||||
draft.counterpartyController.text.trim().isNotEmpty
|
||||
? draft.counterpartyController.text.trim()
|
||||
: null,
|
||||
);
|
||||
draft.saved = true;
|
||||
savedAny = true;
|
||||
@@ -540,7 +572,7 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
}
|
||||
|
||||
Widget _buildDraftCard(_ScreenshotDraft draft, int index) {
|
||||
final categories = _categoriesFor(draft.type);
|
||||
final categories = _categoriesFor(draft.type, draft.transferDirection);
|
||||
final selectedCategory = categories
|
||||
.where((category) => category.id == draft.categoryId)
|
||||
.firstOrNull;
|
||||
@@ -583,6 +615,8 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
label: switch (draft.type) {
|
||||
'income' => '收入',
|
||||
'expense' => '支出',
|
||||
'transfer' =>
|
||||
draft.transferDirection == 'in' ? '转入' : '转出',
|
||||
_ => '待确认',
|
||||
},
|
||||
color: draft.type == 'unknown'
|
||||
@@ -629,6 +663,15 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _TypeButton(
|
||||
label: '转账',
|
||||
selected: draft.type == 'transfer',
|
||||
color: AppTheme.orange,
|
||||
onTap: () => _changeType(draft, 'transfer'),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _TypeButton(
|
||||
label: '收入',
|
||||
@@ -639,6 +682,37 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (draft.type == 'transfer') ...[
|
||||
SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TypeButton(
|
||||
label: '转出',
|
||||
selected: draft.transferDirection == 'out',
|
||||
color: AppTheme.orange,
|
||||
onTap: () =>
|
||||
_changeTransferDirection(draft, 'out'),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _TypeButton(
|
||||
label: '转入',
|
||||
selected: draft.transferDirection == 'in',
|
||||
color: AppTheme.primary,
|
||||
onTap: () =>
|
||||
_changeTransferDirection(draft, 'in'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: draft.counterpartyController,
|
||||
decoration: InputDecoration(labelText: '转账对方'),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 10),
|
||||
_CategoryField(
|
||||
category: selectedCategory,
|
||||
@@ -715,6 +789,7 @@ class _ScreenshotParseSheetState extends State<ScreenshotParseSheet> {
|
||||
|
||||
class _ScreenshotDraft {
|
||||
String type;
|
||||
String transferDirection;
|
||||
int? categoryId;
|
||||
bool included;
|
||||
bool saved = false;
|
||||
@@ -722,6 +797,7 @@ class _ScreenshotDraft {
|
||||
final TextEditingController amountController;
|
||||
final TextEditingController noteController;
|
||||
final TextEditingController paymentController;
|
||||
final TextEditingController counterpartyController;
|
||||
|
||||
_ScreenshotDraft({
|
||||
required this.type,
|
||||
@@ -731,15 +807,19 @@ class _ScreenshotDraft {
|
||||
required String note,
|
||||
required String paymentMethod,
|
||||
required this.occurredAt,
|
||||
this.transferDirection = 'out',
|
||||
String counterparty = '',
|
||||
}) : included = included,
|
||||
amountController = TextEditingController(text: amount),
|
||||
noteController = TextEditingController(text: note),
|
||||
paymentController = TextEditingController(text: paymentMethod);
|
||||
paymentController = TextEditingController(text: paymentMethod),
|
||||
counterpartyController = TextEditingController(text: counterparty);
|
||||
|
||||
void dispose() {
|
||||
amountController.dispose();
|
||||
noteController.dispose();
|
||||
paymentController.dispose();
|
||||
counterpartyController.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
String? _timeFilter; // today | week | month
|
||||
String? _typeFilter;
|
||||
double? _minAmount, _maxAmount;
|
||||
|
||||
Future<void> _search() async {
|
||||
@@ -29,6 +30,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
if (q.isEmpty &&
|
||||
!_aiOnly &&
|
||||
_timeFilter == null &&
|
||||
_typeFilter == null &&
|
||||
_minAmount == null &&
|
||||
_maxAmount == null)
|
||||
return;
|
||||
@@ -58,6 +60,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
maxAmount: _maxAmount,
|
||||
from: from,
|
||||
to: to,
|
||||
type: _typeFilter,
|
||||
);
|
||||
if (mounted)
|
||||
setState(() {
|
||||
@@ -74,7 +77,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final total = _results
|
||||
.where((t) => !t.isIncome)
|
||||
.where((t) => t.isExpense)
|
||||
.fold<double>(0, (s, t) => s + t.amount);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -84,7 +87,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (_) => _search(),
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜备注 / 分类 / 你说过的话',
|
||||
hintText: '搜备注 / 对方 / 分类 / 原话',
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
),
|
||||
),
|
||||
@@ -122,6 +125,20 @@ class _SearchPageState extends State<SearchPage> {
|
||||
},
|
||||
),
|
||||
),
|
||||
for (final option in const [
|
||||
('expense', '支出'),
|
||||
('transfer', '转账'),
|
||||
('income', '收入'),
|
||||
])
|
||||
FilterChip(
|
||||
label: Text(option.$2, style: TextStyle(fontSize: 11)),
|
||||
selected: _typeFilter == option.$1,
|
||||
selectedColor: context.jz.primaryBackground,
|
||||
onSelected: (selected) {
|
||||
setState(() => _typeFilter = selected ? option.$1 : null);
|
||||
_search();
|
||||
},
|
||||
),
|
||||
if (_searched && !_loading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
|
||||
@@ -20,7 +20,9 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
late final TextEditingController _amount;
|
||||
late final TextEditingController _note;
|
||||
late final TextEditingController _payment;
|
||||
late final TextEditingController _counterparty;
|
||||
late String _type;
|
||||
late String _transferDirection;
|
||||
late int _ledgerId;
|
||||
late int _categoryId;
|
||||
late DateTime _occurredAt;
|
||||
@@ -37,7 +39,9 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
);
|
||||
_note = TextEditingController(text: transaction.note ?? '');
|
||||
_payment = TextEditingController(text: transaction.paymentMethod ?? '');
|
||||
_counterparty = TextEditingController(text: transaction.counterparty ?? '');
|
||||
_type = transaction.type;
|
||||
_transferDirection = transaction.transferDirection ?? 'out';
|
||||
_ledgerId = transaction.ledgerId;
|
||||
_categoryId = transaction.categoryId;
|
||||
_occurredAt = transaction.occurredAt;
|
||||
@@ -49,6 +53,7 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
_amount.dispose();
|
||||
_note.dispose();
|
||||
_payment.dispose();
|
||||
_counterparty.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -56,7 +61,12 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await CurrentLedgerStore.instance.ensureLoaded();
|
||||
final categories = await TxApi.categories(_type);
|
||||
final categoryType = _type == 'transfer'
|
||||
? _transferDirection == 'in'
|
||||
? 'income'
|
||||
: 'expense'
|
||||
: _type;
|
||||
final categories = await TxApi.categories(categoryType);
|
||||
if (!categories.any((category) => category.id == _categoryId) &&
|
||||
categories.isNotEmpty) {
|
||||
_categoryId = categories.first.id;
|
||||
@@ -75,6 +85,12 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
await _loadCategories();
|
||||
}
|
||||
|
||||
Future<void> _changeTransferDirection(String direction) async {
|
||||
if (direction == _transferDirection) return;
|
||||
setState(() => _transferDirection = direction);
|
||||
await _loadCategories();
|
||||
}
|
||||
|
||||
Future<void> _pickDateTime() async {
|
||||
final value = await showJzDateTimeSheet(
|
||||
context,
|
||||
@@ -143,6 +159,11 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
amount: amount,
|
||||
note: _note.text.trim(),
|
||||
paymentMethod: _payment.text.trim(),
|
||||
transferDirection: _type == 'transfer' ? _transferDirection : null,
|
||||
counterparty:
|
||||
_type == 'transfer' && _counterparty.text.trim().isNotEmpty
|
||||
? _counterparty.text.trim()
|
||||
: null,
|
||||
occurredAt: _occurredAt,
|
||||
);
|
||||
if (mounted) Navigator.pop(context, updated);
|
||||
@@ -178,10 +199,28 @@ class _TransactionEditPageState extends State<TransactionEditPage> {
|
||||
value: _type,
|
||||
options: const [
|
||||
JzOption(value: 'expense', label: '支出'),
|
||||
JzOption(value: 'transfer', label: '转账'),
|
||||
JzOption(value: 'income', label: '收入'),
|
||||
],
|
||||
onChanged: _changeType,
|
||||
),
|
||||
if (_type == 'transfer') ...[
|
||||
SizedBox(height: 12),
|
||||
JzSegmentedControl<String>(
|
||||
value: _transferDirection,
|
||||
options: const [
|
||||
JzOption(value: 'out', label: '转出'),
|
||||
JzOption(value: 'in', label: '转入'),
|
||||
],
|
||||
onChanged: _changeTransferDirection,
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _counterparty,
|
||||
maxLength: 40,
|
||||
decoration: InputDecoration(labelText: '转账对方'),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _amount,
|
||||
|
||||
@@ -100,11 +100,13 @@ class TxDetailPage extends StatelessWidget {
|
||||
// 类型
|
||||
_Row(
|
||||
k: '类型',
|
||||
child: Text(
|
||||
tx.isIncome ? '收入' : '支出',
|
||||
style: TextStyle(fontSize: 13.5),
|
||||
),
|
||||
child: Text(tx.typeLabel, style: TextStyle(fontSize: 13.5)),
|
||||
),
|
||||
if (tx.isTransfer && tx.counterparty?.isNotEmpty == true)
|
||||
_Row(
|
||||
k: '转账对方',
|
||||
child: Text(tx.counterparty!, style: TextStyle(fontSize: 13.5)),
|
||||
),
|
||||
// 备注
|
||||
if (tx.note != null && tx.note!.isNotEmpty)
|
||||
_Row(
|
||||
|
||||
@@ -17,10 +17,12 @@ class RecognitionBatchPage extends StatefulWidget {
|
||||
|
||||
class _RecognitionBatchPageState extends State<RecognitionBatchPage> {
|
||||
List<RecognitionBatch> _batches = const [];
|
||||
List<RecognitionCandidate> _candidates = const [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
String? _restoringId;
|
||||
String? _confirmingId;
|
||||
String? _ignoringId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -30,10 +32,14 @@ class _RecognitionBatchPageState extends State<RecognitionBatchPage> {
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final batches = await ScreenshotChannel.listRecognitionBatches();
|
||||
final results = await Future.wait([
|
||||
ScreenshotChannel.listRecognitionCandidates(),
|
||||
ScreenshotChannel.listRecognitionBatches(),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_batches = batches;
|
||||
_candidates = results[0] as List<RecognitionCandidate>;
|
||||
_batches = results[1] as List<RecognitionBatch>;
|
||||
_loading = false;
|
||||
_error = null;
|
||||
});
|
||||
@@ -84,10 +90,43 @@ class _RecognitionBatchPageState extends State<RecognitionBatchPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmCandidate(RecognitionCandidate item) async {
|
||||
setState(() => _confirmingId = item.id);
|
||||
try {
|
||||
await RecognitionImportService.handleAction(context, {
|
||||
'action': 'recognition_confirm',
|
||||
'candidateId': item.id,
|
||||
});
|
||||
await _load();
|
||||
} finally {
|
||||
if (mounted) setState(() => _confirmingId = null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ignoreCandidate(RecognitionCandidate item) async {
|
||||
setState(() => _ignoringId = item.id);
|
||||
try {
|
||||
final ignored = await ScreenshotChannel.acknowledgeRecognitionCandidate(
|
||||
item.id,
|
||||
'expired',
|
||||
);
|
||||
if (!ignored) throw StateError('这条识别结果已处理');
|
||||
await _load();
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _ignoringId = null);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('最近 AI 对账')),
|
||||
appBar: AppBar(title: const Text('识别记录')),
|
||||
body: RefreshIndicator(onRefresh: _load, child: _body()),
|
||||
);
|
||||
}
|
||||
@@ -114,26 +153,108 @@ class _RecognitionBatchPageState extends State<RecognitionBatchPage> {
|
||||
],
|
||||
);
|
||||
}
|
||||
if (_batches.isEmpty) {
|
||||
final pending = _candidates
|
||||
.where((item) => item.state == 'pending_confirm')
|
||||
.toList(growable: false);
|
||||
if (_batches.isEmpty && pending.isEmpty) {
|
||||
return ListView(
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.sizeOf(context).height * 0.3),
|
||||
Icon(Icons.fact_check_outlined, size: 42, color: context.jz.text3),
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: Text(
|
||||
'暂无 AI 对账记录',
|
||||
style: TextStyle(color: context.jz.text3),
|
||||
),
|
||||
child: Text('暂无识别记录', style: TextStyle(color: context.jz.text3)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 28),
|
||||
itemCount: _batches.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (_, index) => _batchCard(_batches[index]),
|
||||
children: [
|
||||
if (pending.isNotEmpty) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(2, 4, 2, 10),
|
||||
child: Text(
|
||||
'待确认',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
for (final item in pending) ...[
|
||||
_candidateCard(item),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
if (_batches.isNotEmpty) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(2, 10, 2, 10),
|
||||
child: Text(
|
||||
'AI 对账历史',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
for (final batch in _batches) ...[
|
||||
_batchCard(batch),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _candidateCard(RecognitionCandidate item) {
|
||||
final confirming = _confirmingId == item.id;
|
||||
final ignoring = _ignoringId == item.id;
|
||||
final ambiguous = item.identityConfidence == 'ambiguous_repeat';
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
ambiguous ? Icons.content_copy_rounded : Icons.receipt_long_rounded,
|
||||
color: ambiguous ? AppTheme.orange : AppTheme.primary,
|
||||
),
|
||||
title: Text(
|
||||
item.merchant?.trim().isNotEmpty == true
|
||||
? item.merchant!.trim()
|
||||
: '${item.appName}账单',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(ambiguous ? '疑似连续同额交易,请确认是否为新账单' : '识别证据不足,请确认后入账'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${item.isIncome ? '+' : '-'}¥${item.amount.toStringAsFixed(2)}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '忽略',
|
||||
onPressed: ignoring || confirming
|
||||
? null
|
||||
: () => _ignoreCandidate(item),
|
||||
icon: ignoring
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.close_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '确认入账',
|
||||
onPressed: confirming || ignoring
|
||||
? null
|
||||
: () => _confirmCandidate(item),
|
||||
icon: confirming
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.check_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,13 +339,11 @@ class _RecognitionBatchPageState extends State<RecognitionBatchPage> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${item.type == 'income' ? '+' : '-'}¥${item.amount.toStringAsFixed(2)}',
|
||||
'${item.isIncome ? '+' : '-'}¥${item.amount.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: item.type == 'income'
|
||||
? AppTheme.primary
|
||||
: AppTheme.red,
|
||||
color: item.isIncome ? AppTheme.primary : AppTheme.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -515,7 +515,7 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
||||
children: [
|
||||
Icon(Icons.fact_check_outlined, size: 20),
|
||||
SizedBox(width: 10),
|
||||
Expanded(child: Text('最近 AI 对账')),
|
||||
Expanded(child: Text('识别记录')),
|
||||
Icon(Icons.chevron_right_rounded),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -15,6 +15,9 @@ class TxItem {
|
||||
final String categoryName, categoryIcon, categoryColor, type, source;
|
||||
final double amount;
|
||||
final String? note, paymentMethod, sourceText;
|
||||
final String? transferDirection, counterparty;
|
||||
final String? provider, providerTransactionId, recognitionOccurrenceId;
|
||||
final String? evidenceFingerprint, recognitionConfidence;
|
||||
final DateTime occurredAt, updatedAt;
|
||||
final bool isDeleted;
|
||||
|
||||
@@ -31,6 +34,13 @@ class TxItem {
|
||||
paymentMethod = j['paymentMethod'] as String?,
|
||||
source = j['source'] as String,
|
||||
sourceText = j['sourceText'] as String?,
|
||||
transferDirection = j['transferDirection'] as String?,
|
||||
counterparty = j['counterparty'] as String?,
|
||||
provider = j['provider'] as String?,
|
||||
providerTransactionId = j['providerTransactionId'] as String?,
|
||||
recognitionOccurrenceId = j['recognitionOccurrenceId'] as String?,
|
||||
evidenceFingerprint = j['evidenceFingerprint'] as String?,
|
||||
recognitionConfidence = j['recognitionConfidence'] as String?,
|
||||
occurredAt = ShanghaiTime.parseCivil(j['occurredAt'] as String),
|
||||
updatedAt = DateTime.parse(
|
||||
j['updatedAt'] as String? ?? j['occurredAt'] as String,
|
||||
@@ -48,7 +58,18 @@ class TxItem {
|
||||
source == 'accessibility' ||
|
||||
source == 'notification' ||
|
||||
source == 'local_ocr';
|
||||
bool get isIncome => type == 'income';
|
||||
bool get isTransfer => type == 'transfer';
|
||||
bool get isIncome =>
|
||||
type == 'income' || isTransfer && transferDirection == 'in';
|
||||
bool get isExpense =>
|
||||
type == 'expense' || isTransfer && transferDirection == 'out';
|
||||
String get typeLabel => isTransfer
|
||||
? transferDirection == 'in'
|
||||
? '转入'
|
||||
: '转出'
|
||||
: isIncome
|
||||
? '收入'
|
||||
: '支出';
|
||||
|
||||
String get sourceLabel => switch (source) {
|
||||
'ai_chat' => 'AI 聊天',
|
||||
@@ -191,6 +212,9 @@ class RecognitionBatchDraft {
|
||||
final double amount;
|
||||
final DateTime occurredAt;
|
||||
final String? note, paymentMethod, sourceText;
|
||||
final String? transferDirection, counterparty;
|
||||
final String? provider, providerTransactionId, recognitionOccurrenceId;
|
||||
final String? evidenceFingerprint, recognitionConfidence;
|
||||
|
||||
const RecognitionBatchDraft({
|
||||
required this.candidateId,
|
||||
@@ -203,6 +227,13 @@ class RecognitionBatchDraft {
|
||||
this.note,
|
||||
this.paymentMethod,
|
||||
this.sourceText,
|
||||
this.transferDirection,
|
||||
this.counterparty,
|
||||
this.provider,
|
||||
this.providerTransactionId,
|
||||
this.recognitionOccurrenceId,
|
||||
this.evidenceFingerprint,
|
||||
this.recognitionConfidence,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -336,6 +367,13 @@ class TxApi {
|
||||
String? sourceText,
|
||||
DateTime? occurredAt,
|
||||
String? clientRequestId,
|
||||
String? transferDirection,
|
||||
String? counterparty,
|
||||
String? provider,
|
||||
String? providerTransactionId,
|
||||
String? recognitionOccurrenceId,
|
||||
String? evidenceFingerprint,
|
||||
String? recognitionConfidence,
|
||||
}) async {
|
||||
final payload = <String, dynamic>{
|
||||
'ledgerId': _ledgerId,
|
||||
@@ -350,6 +388,17 @@ class TxApi {
|
||||
occurredAt ?? ShanghaiTime.now,
|
||||
).toIso8601String(),
|
||||
if (clientRequestId != null) 'clientRequestId': clientRequestId,
|
||||
if (transferDirection != null) 'transferDirection': transferDirection,
|
||||
if (counterparty != null) 'counterparty': counterparty,
|
||||
if (provider != null) 'provider': provider,
|
||||
if (providerTransactionId != null)
|
||||
'providerTransactionId': providerTransactionId,
|
||||
if (recognitionOccurrenceId != null)
|
||||
'recognitionOccurrenceId': recognitionOccurrenceId,
|
||||
if (evidenceFingerprint != null)
|
||||
'evidenceFingerprint': evidenceFingerprint,
|
||||
if (recognitionConfidence != null)
|
||||
'recognitionConfidence': recognitionConfidence,
|
||||
};
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly || categoryId < 0 || _ledgerId < 0) {
|
||||
@@ -402,6 +451,13 @@ class TxApi {
|
||||
draft.occurredAt,
|
||||
).toIso8601String(),
|
||||
'clientRequestId': draft.clientRequestId,
|
||||
'transferDirection': draft.transferDirection,
|
||||
'counterparty': draft.counterparty,
|
||||
'provider': draft.provider,
|
||||
'providerTransactionId': draft.providerTransactionId,
|
||||
'recognitionOccurrenceId': draft.recognitionOccurrenceId,
|
||||
'evidenceFingerprint': draft.evidenceFingerprint,
|
||||
'recognitionConfidence': draft.recognitionConfidence,
|
||||
},
|
||||
)
|
||||
.toList(growable: false);
|
||||
@@ -442,6 +498,14 @@ class TxApi {
|
||||
'occurredAt': localPayloads[index]['occurredAt'],
|
||||
'source': drafts[index].source,
|
||||
'sourceText': drafts[index].sourceText,
|
||||
'transferDirection': drafts[index].transferDirection,
|
||||
'counterparty': drafts[index].counterparty,
|
||||
'provider': drafts[index].provider,
|
||||
'providerTransactionId': drafts[index].providerTransactionId,
|
||||
'recognitionOccurrenceId':
|
||||
drafts[index].recognitionOccurrenceId,
|
||||
'evidenceFingerprint': drafts[index].evidenceFingerprint,
|
||||
'recognitionConfidence': drafts[index].recognitionConfidence,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -499,6 +563,8 @@ class TxApi {
|
||||
required DateTime occurredAt,
|
||||
String? note,
|
||||
String? paymentMethod,
|
||||
String? transferDirection,
|
||||
String? counterparty,
|
||||
}) async {
|
||||
final baseUpdatedAt = LocalDatabase.instance.transaction(
|
||||
id,
|
||||
@@ -511,6 +577,8 @@ class TxApi {
|
||||
'amount': amount,
|
||||
'note': note,
|
||||
'paymentMethod': paymentMethod,
|
||||
'transferDirection': transferDirection,
|
||||
'counterparty': counterparty,
|
||||
'occurredAt': ShanghaiTime.civilToUtc(occurredAt).toIso8601String(),
|
||||
if (baseUpdatedAt != null) 'baseUpdatedAt': baseUpdatedAt,
|
||||
};
|
||||
@@ -1447,7 +1515,7 @@ class ParsedDraft {
|
||||
final int categoryId;
|
||||
final String categoryName, categoryIcon, note, type;
|
||||
final double amount;
|
||||
final String? paymentMethod;
|
||||
final String? paymentMethod, transferDirection, counterparty;
|
||||
|
||||
ParsedDraft.fromJson(Map<String, dynamic> j)
|
||||
: matched = j['matched'] as bool,
|
||||
@@ -1456,12 +1524,25 @@ class ParsedDraft {
|
||||
categoryIcon = j['categoryIcon'] as String,
|
||||
amount = (j['amount'] as num).toDouble(),
|
||||
paymentMethod = j['paymentMethod'] as String?,
|
||||
transferDirection = j['transferDirection'] as String?,
|
||||
counterparty = j['counterparty'] as String?,
|
||||
note = j['note'] as String,
|
||||
type = switch (j['type']?.toString().toLowerCase()) {
|
||||
'income' => 'income',
|
||||
'expense' => 'expense',
|
||||
'transfer' => 'transfer',
|
||||
_ => 'unknown',
|
||||
};
|
||||
|
||||
bool get isIncome =>
|
||||
type == 'income' || type == 'transfer' && transferDirection == 'in';
|
||||
String get typeLabel => type == 'transfer'
|
||||
? transferDirection == 'in'
|
||||
? '转入'
|
||||
: '转出'
|
||||
: isIncome
|
||||
? '收入'
|
||||
: '支出';
|
||||
}
|
||||
|
||||
class ParseApi {
|
||||
@@ -1481,8 +1562,8 @@ class ParseApi {
|
||||
String source,
|
||||
String sourceText,
|
||||
) async {
|
||||
if (d.type != 'income' && d.type != 'expense') {
|
||||
throw StateError('请先确认收入或支出类型');
|
||||
if (d.type != 'income' && d.type != 'expense' && d.type != 'transfer') {
|
||||
throw StateError('请先确认账单类型');
|
||||
}
|
||||
return TxApi.create(
|
||||
categoryId: d.categoryId,
|
||||
@@ -1492,6 +1573,8 @@ class ParseApi {
|
||||
paymentMethod: d.paymentMethod,
|
||||
source: source,
|
||||
sourceText: sourceText,
|
||||
transferDirection: d.transferDirection,
|
||||
counterparty: d.counterparty,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -13,6 +14,16 @@ class LocalDatabase {
|
||||
static final instance = LocalDatabase._();
|
||||
static const _secureStorage = FlutterSecureStorage();
|
||||
|
||||
@visibleForTesting
|
||||
static LocalDatabase inMemoryForTesting() {
|
||||
final database = LocalDatabase._();
|
||||
database._database = sqlite3.openInMemory();
|
||||
database._namespace = 'test';
|
||||
database._migrate();
|
||||
database._seedDefaults();
|
||||
return database;
|
||||
}
|
||||
|
||||
Database? _database;
|
||||
String? _namespace;
|
||||
|
||||
@@ -102,9 +113,16 @@ class LocalDatabase {
|
||||
amount REAL NOT NULL,
|
||||
note TEXT,
|
||||
payment_method TEXT,
|
||||
transfer_direction TEXT,
|
||||
counterparty TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
source_text TEXT,
|
||||
client_request_id TEXT,
|
||||
provider TEXT,
|
||||
provider_transaction_id TEXT,
|
||||
recognition_occurrence_id TEXT,
|
||||
evidence_fingerprint TEXT,
|
||||
recognition_confidence TEXT,
|
||||
occurred_at TEXT NOT NULL,
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
@@ -165,11 +183,37 @@ class LocalDatabase {
|
||||
if (!transactionColumns.contains('client_request_id')) {
|
||||
_db.execute('ALTER TABLE transactions ADD COLUMN client_request_id TEXT');
|
||||
}
|
||||
const addedTransactionColumns = <String, String>{
|
||||
'transfer_direction': 'TEXT',
|
||||
'counterparty': 'TEXT',
|
||||
'provider': 'TEXT',
|
||||
'provider_transaction_id': 'TEXT',
|
||||
'recognition_occurrence_id': 'TEXT',
|
||||
'evidence_fingerprint': 'TEXT',
|
||||
'recognition_confidence': 'TEXT',
|
||||
};
|
||||
for (final entry in addedTransactionColumns.entries) {
|
||||
if (!transactionColumns.contains(entry.key)) {
|
||||
_db.execute(
|
||||
'ALTER TABLE transactions ADD COLUMN ${entry.key} ${entry.value}',
|
||||
);
|
||||
}
|
||||
}
|
||||
_db.execute('''
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_local_transactions_client_request
|
||||
ON transactions (client_request_id)
|
||||
WHERE client_request_id IS NOT NULL
|
||||
''');
|
||||
_db.execute('''
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_local_transactions_occurrence
|
||||
ON transactions (recognition_occurrence_id)
|
||||
WHERE recognition_occurrence_id IS NOT NULL
|
||||
''');
|
||||
_db.execute('''
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_local_transactions_provider_tx
|
||||
ON transactions (provider, provider_transaction_id)
|
||||
WHERE provider IS NOT NULL AND provider_transaction_id IS NOT NULL
|
||||
''');
|
||||
final conflictColumns = _db
|
||||
.select('PRAGMA table_info(sync_conflicts)')
|
||||
.map((row) => row['name'])
|
||||
@@ -659,7 +703,7 @@ class LocalDatabase {
|
||||
value['categoryName'] ?? '其他',
|
||||
value['categoryIcon'] ?? 'tag',
|
||||
value['categoryColor'] ?? 'mint',
|
||||
value['type'],
|
||||
_categoryType(value),
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
],
|
||||
);
|
||||
@@ -668,8 +712,11 @@ class LocalDatabase {
|
||||
'''
|
||||
INSERT INTO transactions
|
||||
(id, ledger_id, category_id, type, amount, note, payment_method,
|
||||
source, source_text, occurred_at, is_deleted, deleted_at, sync_state, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'synced', ?)
|
||||
transfer_direction, counterparty, source, source_text, client_request_id,
|
||||
provider, provider_transaction_id, recognition_occurrence_id,
|
||||
evidence_fingerprint, recognition_confidence,
|
||||
occurred_at, is_deleted, deleted_at, sync_state, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'synced', ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
ledger_id = excluded.ledger_id,
|
||||
category_id = excluded.category_id,
|
||||
@@ -677,8 +724,16 @@ class LocalDatabase {
|
||||
amount = excluded.amount,
|
||||
note = excluded.note,
|
||||
payment_method = excluded.payment_method,
|
||||
transfer_direction = excluded.transfer_direction,
|
||||
counterparty = excluded.counterparty,
|
||||
source = excluded.source,
|
||||
source_text = excluded.source_text,
|
||||
client_request_id = excluded.client_request_id,
|
||||
provider = excluded.provider,
|
||||
provider_transaction_id = excluded.provider_transaction_id,
|
||||
recognition_occurrence_id = excluded.recognition_occurrence_id,
|
||||
evidence_fingerprint = excluded.evidence_fingerprint,
|
||||
recognition_confidence = excluded.recognition_confidence,
|
||||
occurred_at = excluded.occurred_at,
|
||||
is_deleted = excluded.is_deleted,
|
||||
sync_state = 'synced',
|
||||
@@ -692,8 +747,16 @@ class LocalDatabase {
|
||||
value['amount'],
|
||||
value['note'],
|
||||
value['paymentMethod'],
|
||||
value['transferDirection'],
|
||||
value['counterparty'],
|
||||
value['source'] ?? 'manual',
|
||||
value['sourceText'],
|
||||
value['clientRequestId'],
|
||||
value['provider'],
|
||||
value['providerTransactionId'],
|
||||
value['recognitionOccurrenceId'],
|
||||
value['evidenceFingerprint'],
|
||||
value['recognitionConfidence'],
|
||||
DateTime.parse(value['occurredAt'] as String).toUtc().toIso8601String(),
|
||||
value['isDeleted'] == true ? 1 : 0,
|
||||
value['updatedAt'] ?? DateTime.now().toUtc().toIso8601String(),
|
||||
@@ -902,25 +965,20 @@ class LocalDatabase {
|
||||
}
|
||||
|
||||
Map<String, dynamic> createTransaction(Map<String, dynamic> value) {
|
||||
final clientRequestId = value['clientRequestId'] as String?;
|
||||
if (clientRequestId != null && clientRequestId.isNotEmpty) {
|
||||
final existing = _db.select(
|
||||
'SELECT id FROM transactions WHERE client_request_id = ? LIMIT 1',
|
||||
[clientRequestId],
|
||||
);
|
||||
if (existing.isNotEmpty) {
|
||||
return transaction(
|
||||
(existing.first['id'] as num).toInt(),
|
||||
includeDeleted: true,
|
||||
)!;
|
||||
}
|
||||
final existingId = _existingTransactionId(value);
|
||||
if (existingId != null) {
|
||||
return transaction(existingId, includeDeleted: true)!;
|
||||
}
|
||||
final clientRequestId = _identity(value['clientRequestId']);
|
||||
final occurrenceId = _identity(value['recognitionOccurrenceId']);
|
||||
final provider = _identity(value['provider']);
|
||||
final providerTransactionId = _identity(value['providerTransactionId']);
|
||||
final categoryId = (value['categoryId'] as num).toInt();
|
||||
final category = _db.select(
|
||||
'SELECT * FROM categories WHERE id = ? AND is_deleted = 0',
|
||||
[categoryId],
|
||||
);
|
||||
if (category.isEmpty || category.first['type'] != value['type']) {
|
||||
if (category.isEmpty || category.first['type'] != _categoryType(value)) {
|
||||
throw StateError('分类与收支类型不一致');
|
||||
}
|
||||
final id = _nextNegativeId('transactions');
|
||||
@@ -930,8 +988,11 @@ class LocalDatabase {
|
||||
'''
|
||||
INSERT INTO transactions
|
||||
(id, ledger_id, category_id, type, amount, note, payment_method,
|
||||
source, source_text, client_request_id, occurred_at, is_deleted, sync_state, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'local', ?)
|
||||
transfer_direction, counterparty, source, source_text, client_request_id,
|
||||
provider, provider_transaction_id, recognition_occurrence_id,
|
||||
evidence_fingerprint, recognition_confidence,
|
||||
occurred_at, is_deleted, sync_state, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'local', ?)
|
||||
''',
|
||||
[
|
||||
id,
|
||||
@@ -941,9 +1002,16 @@ class LocalDatabase {
|
||||
value['amount'],
|
||||
value['note'],
|
||||
value['paymentMethod'],
|
||||
value['transferDirection'],
|
||||
value['counterparty'],
|
||||
value['source'] ?? 'manual',
|
||||
value['sourceText'],
|
||||
clientRequestId,
|
||||
provider,
|
||||
providerTransactionId,
|
||||
occurrenceId,
|
||||
value['evidenceFingerprint'],
|
||||
value['recognitionConfidence'],
|
||||
occurredAt,
|
||||
now,
|
||||
],
|
||||
@@ -959,14 +1027,7 @@ class LocalDatabase {
|
||||
try {
|
||||
final created = <Map<String, dynamic>>[];
|
||||
for (final value in values) {
|
||||
final clientRequestId = value['clientRequestId'] as String?;
|
||||
final existed =
|
||||
clientRequestId != null &&
|
||||
clientRequestId.isNotEmpty &&
|
||||
_db.select(
|
||||
'SELECT 1 FROM transactions WHERE client_request_id = ? LIMIT 1',
|
||||
[clientRequestId],
|
||||
).isNotEmpty;
|
||||
final existed = _existingTransactionId(value) != null;
|
||||
final transaction = createTransaction(value);
|
||||
created.add(transaction);
|
||||
final id = (transaction['id'] as num).toInt();
|
||||
@@ -1000,13 +1061,14 @@ class LocalDatabase {
|
||||
'SELECT type FROM categories WHERE id = ? AND is_deleted = 0',
|
||||
[value['categoryId']],
|
||||
);
|
||||
if (category.isEmpty || category.first['type'] != value['type']) {
|
||||
if (category.isEmpty || category.first['type'] != _categoryType(value)) {
|
||||
throw StateError('分类与收支类型不一致');
|
||||
}
|
||||
_db.execute(
|
||||
'''
|
||||
UPDATE transactions SET ledger_id = ?, category_id = ?, type = ?,
|
||||
amount = ?, note = ?, payment_method = ?, occurred_at = ?,
|
||||
amount = ?, note = ?, payment_method = ?, transfer_direction = ?,
|
||||
counterparty = ?, occurred_at = ?,
|
||||
sync_state = 'local', updated_at = ? WHERE id = ?
|
||||
''',
|
||||
[
|
||||
@@ -1016,6 +1078,8 @@ class LocalDatabase {
|
||||
value['amount'],
|
||||
value['note'],
|
||||
value['paymentMethod'],
|
||||
value['transferDirection'],
|
||||
value['counterparty'],
|
||||
value['occurredAt'],
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
id,
|
||||
@@ -1140,7 +1204,7 @@ class LocalDatabase {
|
||||
ShanghaiTime.civilToUtc(start),
|
||||
ShanghaiTime.civilToUtc(end),
|
||||
);
|
||||
final expenses = items.where((item) => item['type'] == 'expense').toList();
|
||||
final expenses = items.where(_isExpense).toList();
|
||||
final expense = _sumType(items, 'expense');
|
||||
final income = _sumType(items, 'income');
|
||||
final categories = <int, Map<String, dynamic>>{};
|
||||
@@ -1236,7 +1300,7 @@ class LocalDatabase {
|
||||
ShanghaiTime.civilToUtc(start),
|
||||
ShanghaiTime.civilToUtc(end),
|
||||
);
|
||||
final expenses = items.where((item) => item['type'] == 'expense').toList();
|
||||
final expenses = items.where(_isExpense).toList();
|
||||
final grouped = <DateTime, List<Map<String, dynamic>>>{};
|
||||
for (final item in expenses) {
|
||||
final local = ShanghaiTime.parseCivil(item['occurredAt'] as String);
|
||||
@@ -1331,13 +1395,15 @@ class LocalDatabase {
|
||||
final keyword = query?.trim().toLowerCase();
|
||||
if (keyword != null && keyword.isNotEmpty) {
|
||||
final haystack =
|
||||
'${item['note'] ?? ''} ${item['categoryName']} ${item['sourceText'] ?? ''}'
|
||||
'${item['note'] ?? ''} ${item['counterparty'] ?? ''} '
|
||||
'${item['categoryName']} ${item['sourceText'] ?? ''}'
|
||||
.toLowerCase();
|
||||
if (!haystack.contains(keyword)) return false;
|
||||
}
|
||||
if (aiOnly && item['source'] == 'manual') return false;
|
||||
if (categoryId != null && item['categoryId'] != categoryId)
|
||||
if (categoryId != null && item['categoryId'] != categoryId) {
|
||||
return false;
|
||||
}
|
||||
if (type != null && item['type'] != type) return false;
|
||||
if (minAmount != null && amount < minAmount) return false;
|
||||
if (maxAmount != null && amount > maxAmount) return false;
|
||||
@@ -1390,7 +1456,7 @@ class LocalDatabase {
|
||||
final spent = monthItems
|
||||
.where(
|
||||
(item) =>
|
||||
item['type'] == 'expense' &&
|
||||
_isExpense(item) &&
|
||||
(categoryKey == 0 || item['categoryId'] == categoryKey),
|
||||
)
|
||||
.fold<double>(
|
||||
@@ -1526,18 +1592,83 @@ class LocalDatabase {
|
||||
'amount': (row['amount'] as num).toDouble(),
|
||||
'note': row['note'] as String?,
|
||||
'paymentMethod': row['payment_method'] as String?,
|
||||
'transferDirection': row['transfer_direction'] as String?,
|
||||
'counterparty': row['counterparty'] as String?,
|
||||
'source': row['source'] as String,
|
||||
'sourceText': row['source_text'] as String?,
|
||||
'clientRequestId': row['client_request_id'] as String?,
|
||||
'provider': row['provider'] as String?,
|
||||
'providerTransactionId': row['provider_transaction_id'] as String?,
|
||||
'recognitionOccurrenceId': row['recognition_occurrence_id'] as String?,
|
||||
'evidenceFingerprint': row['evidence_fingerprint'] as String?,
|
||||
'recognitionConfidence': row['recognition_confidence'] as String?,
|
||||
'occurredAt': row['occurred_at'] as String,
|
||||
'isDeleted': (row['is_deleted'] as num).toInt() == 1,
|
||||
'updatedAt': row['updated_at'] as String,
|
||||
};
|
||||
|
||||
double _sumType(List<Map<String, dynamic>> items, String type) => items
|
||||
.where((item) => item['type'] == type)
|
||||
.where((item) => type == 'income' ? _isIncome(item) : _isExpense(item))
|
||||
.fold<double>(0, (sum, item) => sum + (item['amount'] as num).toDouble());
|
||||
|
||||
bool _isIncome(Map<String, dynamic> item) =>
|
||||
item['type'] == 'income' ||
|
||||
item['type'] == 'transfer' && item['transferDirection'] == 'in';
|
||||
|
||||
bool _isExpense(Map<String, dynamic> item) =>
|
||||
item['type'] == 'expense' ||
|
||||
item['type'] == 'transfer' && item['transferDirection'] == 'out';
|
||||
|
||||
String _categoryType(Map<String, dynamic> value) {
|
||||
final type = value['type'] as String;
|
||||
final direction = value['transferDirection'] as String?;
|
||||
if (type == 'transfer') {
|
||||
if (direction == 'in') return 'income';
|
||||
if (direction == 'out') return 'expense';
|
||||
throw StateError('转账必须选择转入或转出');
|
||||
}
|
||||
if (direction != null && direction.isNotEmpty) {
|
||||
throw StateError('非转账账单不能设置转账方向');
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
int? _existingTransactionId(Map<String, dynamic> value) {
|
||||
final provider = _identity(value['provider']);
|
||||
final providerTransactionId = _identity(value['providerTransactionId']);
|
||||
if (provider != null && providerTransactionId != null) {
|
||||
final rows = _db.select(
|
||||
'SELECT id FROM transactions '
|
||||
'WHERE provider = ? AND provider_transaction_id = ? LIMIT 1',
|
||||
[provider, providerTransactionId],
|
||||
);
|
||||
if (rows.isNotEmpty) return (rows.first['id'] as num).toInt();
|
||||
}
|
||||
final occurrenceId = _identity(value['recognitionOccurrenceId']);
|
||||
if (occurrenceId != null) {
|
||||
final rows = _db.select(
|
||||
'SELECT id FROM transactions '
|
||||
'WHERE recognition_occurrence_id = ? LIMIT 1',
|
||||
[occurrenceId],
|
||||
);
|
||||
if (rows.isNotEmpty) return (rows.first['id'] as num).toInt();
|
||||
}
|
||||
final clientRequestId = _identity(value['clientRequestId']);
|
||||
if (clientRequestId != null) {
|
||||
final rows = _db.select(
|
||||
'SELECT id FROM transactions WHERE client_request_id = ? LIMIT 1',
|
||||
[clientRequestId],
|
||||
);
|
||||
if (rows.isNotEmpty) return (rows.first['id'] as num).toInt();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _identity(Object? value) {
|
||||
final normalized = value?.toString().trim();
|
||||
return normalized == null || normalized.isEmpty ? null : normalized;
|
||||
}
|
||||
|
||||
String _monthDay(DateTime date) =>
|
||||
'${date.month.toString().padLeft(2, '0')}月${date.day.toString().padLeft(2, '0')}日';
|
||||
}
|
||||
|
||||
@@ -194,6 +194,13 @@ class RecognitionImportService {
|
||||
sourceText: candidate.sourceText,
|
||||
occurredAt: ShanghaiTime.toCivil(occurredUtc),
|
||||
clientRequestId: candidate.clientRequestId,
|
||||
transferDirection: candidate.transferDirection,
|
||||
counterparty: candidate.counterparty,
|
||||
provider: candidate.provider,
|
||||
providerTransactionId: candidate.providerTransactionId,
|
||||
recognitionOccurrenceId: candidate.recognitionOccurrenceId,
|
||||
evidenceFingerprint: candidate.evidenceFingerprint,
|
||||
recognitionConfidence: candidate.identityConfidence,
|
||||
);
|
||||
await ScreenshotChannel.acknowledgeRecognitionCandidate(
|
||||
candidate.id,
|
||||
@@ -227,6 +234,13 @@ class RecognitionImportService {
|
||||
source: candidate.source,
|
||||
sourceText: candidate.sourceText,
|
||||
occurredAt: ShanghaiTime.toCivil(occurredUtc),
|
||||
transferDirection: candidate.transferDirection,
|
||||
counterparty: candidate.counterparty,
|
||||
provider: candidate.provider,
|
||||
providerTransactionId: candidate.providerTransactionId,
|
||||
recognitionOccurrenceId: candidate.recognitionOccurrenceId,
|
||||
evidenceFingerprint: candidate.evidenceFingerprint,
|
||||
recognitionConfidence: candidate.identityConfidence,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -254,11 +268,23 @@ class RecognitionImportService {
|
||||
RecognitionCandidate candidate,
|
||||
Map<String, List<CategoryItem>> cache,
|
||||
) async {
|
||||
if (candidate.type != 'income' && candidate.type != 'expense') {
|
||||
if (candidate.type != 'income' &&
|
||||
candidate.type != 'expense' &&
|
||||
candidate.type != 'transfer') {
|
||||
throw StateError('识别结果缺少明确的收支类型');
|
||||
}
|
||||
final categories = cache[candidate.type] ??= await TxApi.categories(
|
||||
candidate.type,
|
||||
if (candidate.type == 'transfer' &&
|
||||
candidate.transferDirection != 'in' &&
|
||||
candidate.transferDirection != 'out') {
|
||||
throw StateError('转账识别结果缺少方向');
|
||||
}
|
||||
final categoryType = candidate.type == 'transfer'
|
||||
? candidate.transferDirection == 'in'
|
||||
? 'income'
|
||||
: 'expense'
|
||||
: candidate.type;
|
||||
final categories = cache[categoryType] ??= await TxApi.categories(
|
||||
categoryType,
|
||||
);
|
||||
if (categories.isEmpty) throw StateError('当前账本没有可用分类');
|
||||
return categories
|
||||
@@ -315,16 +341,16 @@ class RecognitionImportService {
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: candidate.type == 'income'
|
||||
color: candidate.isIncome
|
||||
? palette.primaryBackground
|
||||
: palette.expenseBackground,
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: Icon(
|
||||
candidate.type == 'income'
|
||||
candidate.isIncome
|
||||
? Icons.south_west_rounded
|
||||
: Icons.north_east_rounded,
|
||||
color: candidate.type == 'income'
|
||||
color: candidate.isIncome
|
||||
? AppTheme.primary
|
||||
: AppTheme.red,
|
||||
),
|
||||
@@ -343,7 +369,7 @@ class RecognitionImportService {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${candidate.appName} · ${candidate.type == 'income' ? '收入' : '支出'}',
|
||||
'${candidate.appName} · ${candidate.typeLabel}',
|
||||
style: TextStyle(
|
||||
color: palette.text2,
|
||||
fontSize: 12,
|
||||
@@ -353,9 +379,9 @@ class RecognitionImportService {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${candidate.type == 'income' ? '+' : '-'}¥${candidate.amount.toStringAsFixed(2)}',
|
||||
'${candidate.isIncome ? '+' : '-'}¥${candidate.amount.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
color: candidate.type == 'income'
|
||||
color: candidate.isIncome
|
||||
? AppTheme.primary
|
||||
: AppTheme.red,
|
||||
fontSize: 18,
|
||||
|
||||
@@ -136,7 +136,11 @@ class RecognitionCandidate {
|
||||
final String id, clientRequestId, state, confidence, type, source, appName;
|
||||
final double amount;
|
||||
final String? merchant, orderId, sourceText, note;
|
||||
final String? transferDirection, counterparty;
|
||||
final String? provider, providerTransactionId, recognitionOccurrenceId;
|
||||
final String? evidenceFingerprint;
|
||||
final String recognitionKind, amountSource;
|
||||
final String identityConfidence;
|
||||
final String? categoryHint, resultFingerprint;
|
||||
final String? batchId, aiAction, aiReason;
|
||||
final int? categoryId;
|
||||
@@ -155,23 +159,39 @@ class RecognitionCandidate {
|
||||
orderId = value['orderId'] as String?,
|
||||
sourceText = value['sourceText'] as String?,
|
||||
note = value['note'] as String?,
|
||||
transferDirection = value['transferDirection']?.toString(),
|
||||
counterparty = value['counterparty']?.toString(),
|
||||
provider = value['provider']?.toString(),
|
||||
providerTransactionId = value['providerTransactionId']?.toString(),
|
||||
recognitionOccurrenceId = value['recognitionOccurrenceId']?.toString(),
|
||||
evidenceFingerprint = value['evidenceFingerprint']?.toString(),
|
||||
recognitionKind = value['recognitionKind']?.toString() ?? 'payment',
|
||||
categoryHint = value['categoryHint']?.toString(),
|
||||
categoryId = (value['categoryId'] as num?)?.toInt(),
|
||||
amountSource = value['amountSource']?.toString() ?? 'result',
|
||||
resultFingerprint = value['resultFingerprint']?.toString(),
|
||||
identityConfidence = value['identityConfidence']?.toString() ?? 'strong',
|
||||
batchId = value['batchId']?.toString(),
|
||||
aiAction = value['aiAction']?.toString(),
|
||||
aiReason = value['aiReason']?.toString(),
|
||||
occurredAtEpochMs = (value['occurredAtEpochMs'] as num).toInt();
|
||||
|
||||
bool get canAutoImport => state == 'auto_ready' && confidence == 'auto';
|
||||
bool get isIncome =>
|
||||
type == 'income' || type == 'transfer' && transferDirection == 'in';
|
||||
String get typeLabel => type == 'transfer'
|
||||
? transferDirection == 'in'
|
||||
? '转入'
|
||||
: '转出'
|
||||
: isIncome
|
||||
? '收入'
|
||||
: '支出';
|
||||
}
|
||||
|
||||
class RecognitionBatchItem {
|
||||
final String candidateId, action, reason, state, type;
|
||||
final double amount;
|
||||
final String? merchant;
|
||||
final String? merchant, transferDirection;
|
||||
final bool canRestore;
|
||||
|
||||
RecognitionBatchItem.fromJson(Map<String, dynamic> value)
|
||||
@@ -182,7 +202,11 @@ class RecognitionBatchItem {
|
||||
type = value['type']?.toString() ?? 'expense',
|
||||
amount = (value['amount'] as num?)?.toDouble() ?? 0,
|
||||
merchant = value['merchant']?.toString(),
|
||||
transferDirection = value['transferDirection']?.toString(),
|
||||
canRestore = value['canRestore'] as bool? ?? false;
|
||||
|
||||
bool get isIncome =>
|
||||
type == 'income' || type == 'transfer' && transferDirection == 'in';
|
||||
}
|
||||
|
||||
class RecognitionBatch {
|
||||
@@ -438,6 +462,26 @@ class ScreenshotChannel {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<RecognitionCandidate>> listRecognitionCandidates() async {
|
||||
try {
|
||||
final values =
|
||||
await _channel.invokeMethod<List<Object?>>(
|
||||
'listRecognitionCandidates',
|
||||
) ??
|
||||
const [];
|
||||
return values
|
||||
.whereType<String>()
|
||||
.map(
|
||||
(value) => RecognitionCandidate.fromJson(
|
||||
jsonDecode(value) as Map<String, dynamic>,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
} on MissingPluginException {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> acknowledgeRecognitionCandidate(
|
||||
String id,
|
||||
String state, {
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.2.5+132
|
||||
version: 1.2.5+133
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0
|
||||
|
||||
@@ -65,6 +65,32 @@ void main() {
|
||||
expect(ledger.transactionCount, 9);
|
||||
});
|
||||
|
||||
test('transfer DTO uses direction for effective income and expense', () {
|
||||
TxItem transfer(String direction) => TxItem.fromJson({
|
||||
'id': direction == 'in' ? 8 : 9,
|
||||
'ledgerId': 3,
|
||||
'categoryId': direction == 'in' ? 101 : 1,
|
||||
'categoryName': direction == 'in' ? '工资' : '餐饮',
|
||||
'categoryIcon': direction == 'in' ? 'money' : 'food',
|
||||
'type': 'transfer',
|
||||
'transferDirection': direction,
|
||||
'counterparty': '张三',
|
||||
'amount': 20,
|
||||
'source': 'accessibility',
|
||||
'occurredAt': '2026-07-25T03:00:00Z',
|
||||
});
|
||||
|
||||
final transferIn = transfer('in');
|
||||
final transferOut = transfer('out');
|
||||
expect(transferIn.isIncome, isTrue);
|
||||
expect(transferIn.isExpense, isFalse);
|
||||
expect(transferIn.typeLabel, '转入');
|
||||
expect(transferOut.isExpense, isTrue);
|
||||
expect(transferOut.isIncome, isFalse);
|
||||
expect(transferOut.typeLabel, '转出');
|
||||
expect(transferOut.counterparty, '张三');
|
||||
});
|
||||
|
||||
test('budget refinement DTO keeps conversation summary and constraints', () {
|
||||
final recommendation = BudgetRecommendations.fromJson({
|
||||
'year': 2026,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:miaoji_zhang/shared/services/local_database.dart';
|
||||
|
||||
void main() {
|
||||
test('本地转账按方向统计并按稳定身份幂等', () {
|
||||
final database = LocalDatabase.inMemoryForTesting();
|
||||
addTearDown(database.close);
|
||||
database.upsertBudget(2026, 7, 1, null, 100, false);
|
||||
database.upsertBudget(2026, 7, 1, 1, 80, false);
|
||||
|
||||
final transferOut = <String, dynamic>{
|
||||
'ledgerId': 1,
|
||||
'categoryId': 1,
|
||||
'type': 'transfer',
|
||||
'transferDirection': 'out',
|
||||
'counterparty': '张三',
|
||||
'amount': 40,
|
||||
'occurredAt': '2026-07-25T03:00:00.000Z',
|
||||
'source': 'accessibility',
|
||||
'clientRequestId': 'wx-event-out-a',
|
||||
'provider': 'wechat',
|
||||
'providerTransactionId': 'wx-order-out-001',
|
||||
'recognitionOccurrenceId': 'wx-occurrence-out-a',
|
||||
};
|
||||
final createdOut = database.createTransaction(transferOut);
|
||||
final providerRetry = database.createTransaction({
|
||||
...transferOut,
|
||||
'clientRequestId': 'wx-event-out-b',
|
||||
'recognitionOccurrenceId': 'wx-occurrence-out-b',
|
||||
});
|
||||
expect(providerRetry['id'], createdOut['id']);
|
||||
|
||||
final transferIn = <String, dynamic>{
|
||||
'ledgerId': 1,
|
||||
'categoryId': 101,
|
||||
'type': 'transfer',
|
||||
'transferDirection': 'in',
|
||||
'counterparty': '张三',
|
||||
'amount': 75,
|
||||
'occurredAt': '2026-07-25T03:05:00.000Z',
|
||||
'source': 'accessibility',
|
||||
'clientRequestId': 'wx-event-in-a',
|
||||
'recognitionOccurrenceId': 'wx-occurrence-in-001',
|
||||
};
|
||||
final createdIn = database.createTransaction(transferIn);
|
||||
final occurrenceRetry = database.createTransaction({
|
||||
...transferIn,
|
||||
'clientRequestId': 'wx-event-in-b',
|
||||
});
|
||||
expect(occurrenceRetry['id'], createdIn['id']);
|
||||
|
||||
final month = database.monthSummary(2026, 7, 1);
|
||||
expect(month['count'], 2);
|
||||
expect(month['expense'], 40);
|
||||
expect(month['income'], 75);
|
||||
expect(month['balance'], 35);
|
||||
|
||||
final stats = database.periodStats('month', DateTime(2026, 7, 25), 1);
|
||||
expect(stats['totalExpense'], 40);
|
||||
expect(stats['totalIncome'], 75);
|
||||
|
||||
final budgets = database.budgets(2026, 7, 1);
|
||||
expect((budgets['total'] as Map<String, dynamic>)['spent'], 40);
|
||||
expect(
|
||||
((budgets['categories'] as List).single as Map<String, dynamic>)['spent'],
|
||||
40,
|
||||
);
|
||||
});
|
||||
|
||||
test('本地数据库拒绝缺少方向的转账', () {
|
||||
final database = LocalDatabase.inMemoryForTesting();
|
||||
addTearDown(database.close);
|
||||
|
||||
expect(
|
||||
() => database.createTransaction({
|
||||
'ledgerId': 1,
|
||||
'categoryId': 1,
|
||||
'type': 'transfer',
|
||||
'amount': 20,
|
||||
'occurredAt': '2026-07-25T03:00:00.000Z',
|
||||
}),
|
||||
throwsStateError,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user