diff --git a/android/app/src/main/java/com/sanctum/advocate/NativeAdvocateModule.kt b/android/app/src/main/java/com/sanctum/advocate/NativeAdvocateModule.kt new file mode 100644 index 0000000..bec5569 --- /dev/null +++ b/android/app/src/main/java/com/sanctum/advocate/NativeAdvocateModule.kt @@ -0,0 +1,118 @@ +package com.sanctum.advocate + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.WritableMap +import com.sanctum.data.LegalCaseEntity +import com.sanctum.data.SanctumDatabase +import com.sanctum.data.WitnessEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.json.JSONObject + +class NativeAdvocateModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String { + return "NativeAdvocateModule" + } + + private fun caseToMap(entity: LegalCaseEntity): WritableMap { + val map = Arguments.createMap() + map.putString("id", entity.id) + map.putString("type", entity.type) + map.putDouble("startedAt", entity.startedAt.toDouble()) // JS numbers are doubles + map.putString("coParentName", entity.coParentName) + if (entity.jurisdiction != null) { + map.putString("jurisdiction", entity.jurisdiction) + } + return map + } + + @ReactMethod + fun listCases(promise: Promise) { + CoroutineScope(Dispatchers.IO).launch { + try { + val db = SanctumDatabase.getInstance(reactApplicationContext) + val cases = db.legalCaseDao().listCases() + val array = Arguments.createArray() + cases.forEach { array.pushMap(caseToMap(it)) } + promise.resolve(array) + } catch (e: Exception) { + promise.reject("ADVOCATE_LIST_FAILED", e) + } + } + } + + @ReactMethod + fun createCase(type: String, coParentName: String, startedAtMillis: Double, jurisdiction: String?, promise: Promise) { + CoroutineScope(Dispatchers.IO).launch { + try { + val db = SanctumDatabase.getInstance(reactApplicationContext) + val entity = LegalCaseEntity( + type = type, + coParentName = coParentName, + startedAt = startedAtMillis.toLong(), + jurisdiction = jurisdiction + ) + db.legalCaseDao().upsertCase(entity) + promise.resolve(caseToMap(entity)) + } catch (e: Exception) { + promise.reject("ADVOCATE_CREATE_FAILED", e) + } + } + } + + @ReactMethod + fun deleteCaseWithCascade(caseId: String, promise: Promise) { + CoroutineScope(Dispatchers.IO).launch { + try { + val db = SanctumDatabase.getInstance(reactApplicationContext) + val witnessDao = db.witnessEventDao() + + db.runInTransaction { + // These DAOs are non-suspend for transaction safety in this block + db.paymentEventDao().clearCaseAssignments(caseId) + db.legalCaseDao().deleteCaseById(caseId) + } + + // Log to Witness: user intentionally deleted a case + val details = JSONObject() + details.put("caseId", caseId) + + val event = WitnessEvent( + kind = "ADVOCATE_CASE_DELETED", + subject = "legal_case:$caseId", + summary = "User deleted legal case and cleared Advocate tags.", + detailsJson = details.toString() + ) + witnessDao.insert(event) + + promise.resolve(null) + } catch (e: Exception) { + // Also log the failure + try { + val db = SanctumDatabase.getInstance(reactApplicationContext) + val details = JSONObject() + details.put("caseId", caseId) + details.put("error", e.message ?: "unknown") + + val event = WitnessEvent( + kind = "ADVOCATE_CASE_DELETE_ERROR", + subject = "legal_case:$caseId", + summary = "Failed to delete legal case $caseId", + detailsJson = details.toString() + ) + db.witnessEventDao().insert(event) + } catch (_: Exception) { + // swallow secondary errors + } + + promise.reject("ADVOCATE_DELETE_FAILED", e) + } + } + } +} diff --git a/android/app/src/main/java/com/sanctum/data/LegalCaseDao.kt b/android/app/src/main/java/com/sanctum/data/LegalCaseDao.kt new file mode 100644 index 0000000..e97afd7 --- /dev/null +++ b/android/app/src/main/java/com/sanctum/data/LegalCaseDao.kt @@ -0,0 +1,20 @@ +package com.sanctum.data + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert + +@Dao +interface LegalCaseDao { + @Query("SELECT * FROM legal_cases") + suspend fun listCases(): List + + @Query("SELECT * FROM legal_cases WHERE id = :caseId") + suspend fun getCase(caseId: String): LegalCaseEntity? + + @Upsert + suspend fun upsertCase(legalCase: LegalCaseEntity) + + @Query("DELETE FROM legal_cases WHERE id = :caseId") + fun deleteCaseById(caseId: String): Int +} diff --git a/android/app/src/main/java/com/sanctum/data/LegalCaseEntity.kt b/android/app/src/main/java/com/sanctum/data/LegalCaseEntity.kt new file mode 100644 index 0000000..915dc7a --- /dev/null +++ b/android/app/src/main/java/com/sanctum/data/LegalCaseEntity.kt @@ -0,0 +1,14 @@ +package com.sanctum.data + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.util.UUID + +@Entity(tableName = "legal_cases") +data class LegalCaseEntity( + @PrimaryKey val id: String = UUID.randomUUID().toString(), + val type: String, // "divorce" | "custody" | "both" + val startedAt: Long, // millis + val coParentName: String, // display name only + val jurisdiction: String? = null +) diff --git a/android/app/src/main/java/com/sanctum/data/PaymentEventDao.kt b/android/app/src/main/java/com/sanctum/data/PaymentEventDao.kt new file mode 100644 index 0000000..87bb907 --- /dev/null +++ b/android/app/src/main/java/com/sanctum/data/PaymentEventDao.kt @@ -0,0 +1,16 @@ +package com.sanctum.data + +import androidx.room.Dao +import androidx.room.Query + +@Dao +interface PaymentEventDao { + @Query(""" + UPDATE payment_events + SET case_id = NULL, + legal_category = NULL, + case_relevance_score = 0 + WHERE case_id = :caseId + """) + fun clearCaseAssignments(caseId: String) +} diff --git a/android/app/src/main/java/com/sanctum/data/PaymentEventEntity.kt b/android/app/src/main/java/com/sanctum/data/PaymentEventEntity.kt new file mode 100644 index 0000000..d005ace --- /dev/null +++ b/android/app/src/main/java/com/sanctum/data/PaymentEventEntity.kt @@ -0,0 +1,27 @@ +package com.sanctum.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "payment_events") +data class PaymentEventEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + + val vendor_raw: String, + val vendor_normalized: String, + val amount_cents: Long?, + val currency: String?, + val seen_ts: Long, + val source_app: String, + val channel: String, // "notification" + + // Extra for Advocate / Bursar + val method: String? = null, // "zelle", "venmo", "card" + val direction: String = "outgoing", // Advocate cares most about outgoing support + val counterparty_name: String? = null, + + // Advocate legal overlay + val case_id: String? = null, + val legal_category: String? = null, // "support_payment", "potential_support", etc. + val case_relevance_score: Int = 0 +) diff --git a/android/app/src/main/java/com/sanctum/data/SanctumDatabase.kt b/android/app/src/main/java/com/sanctum/data/SanctumDatabase.kt new file mode 100644 index 0000000..a8c0a3e --- /dev/null +++ b/android/app/src/main/java/com/sanctum/data/SanctumDatabase.kt @@ -0,0 +1,41 @@ +package com.sanctum.data + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database( + entities = [ + LegalCaseEntity::class, + PaymentEventEntity::class, + WitnessEvent::class, + SubscriptionEntity::class + ], + version = 1, + exportSchema = false +) +abstract class SanctumDatabase : RoomDatabase() { + + abstract fun legalCaseDao(): LegalCaseDao + abstract fun paymentEventDao(): PaymentEventDao + abstract fun witnessEventDao(): WitnessEventDao + // SubscriptionDao would go here, but I don't need to implement it for this task, just the entity registration + + companion object { + @Volatile + private var INSTANCE: SanctumDatabase? = null + + fun getInstance(context: Context): SanctumDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + SanctumDatabase::class.java, + "sanctum_database" + ).build() + INSTANCE = instance + instance + } + } + } +} diff --git a/android/app/src/main/java/com/sanctum/data/SubscriptionEntity.kt b/android/app/src/main/java/com/sanctum/data/SubscriptionEntity.kt new file mode 100644 index 0000000..e6c0481 --- /dev/null +++ b/android/app/src/main/java/com/sanctum/data/SubscriptionEntity.kt @@ -0,0 +1,35 @@ +package com.sanctum.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "subscriptions") +data class SubscriptionEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0L, + + // Core + val vendor: String, + val vendor_normalized: String = vendor.lowercase(), + val last_amount_cents: Long? = null, + val currency: String? = "USD", + val first_seen_ts: Long, + val last_seen_ts: Long, + val occurrences: Int, + + val status: String = "unknown", // "active" | "trial" | "unknown" + val risk: String = "unknown", // "high" | "low" | "unknown" + val risk_score: Int = 0, // 0–100 + + // Actions / UX + val ignored: Boolean = false, + val package_name: String? = null, + val website_url: String? = null, + val next_expected_charge_ts: Long? = null, + val remind_before_days: Int? = null, + + // Foreman metadata + val vertical: String? = null, // "contractor" | null + val category: String? = null, // ContractorCategory.code + val is_job_costable: Boolean = false, + val gl_hint: String? = null +) diff --git a/android/app/src/main/java/com/sanctum/data/WitnessEvent.kt b/android/app/src/main/java/com/sanctum/data/WitnessEvent.kt new file mode 100644 index 0000000..db17f03 --- /dev/null +++ b/android/app/src/main/java/com/sanctum/data/WitnessEvent.kt @@ -0,0 +1,14 @@ +package com.sanctum.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "witness_events") +data class WitnessEvent( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val at: Long = System.currentTimeMillis(), + val kind: String, // e.g., "BURSAR_RISK_CHANGED", "ADVOCATE_SUPPORT_TAGGED" + val subject: String?, // "subscription:42", "case:CASE-123", etc. + val summary: String, + val detailsJson: String? = null // JSON for context payload +) diff --git a/android/app/src/main/java/com/sanctum/data/WitnessEventDao.kt b/android/app/src/main/java/com/sanctum/data/WitnessEventDao.kt new file mode 100644 index 0000000..a64e734 --- /dev/null +++ b/android/app/src/main/java/com/sanctum/data/WitnessEventDao.kt @@ -0,0 +1,10 @@ +package com.sanctum.data + +import androidx.room.Dao +import androidx.room.Insert + +@Dao +interface WitnessEventDao { + @Insert + suspend fun insert(event: WitnessEvent) +} diff --git a/app/NativeAdvocateModule.ts b/app/NativeAdvocateModule.ts new file mode 100644 index 0000000..77d14a8 --- /dev/null +++ b/app/NativeAdvocateModule.ts @@ -0,0 +1,28 @@ +import { NativeModules } from 'react-native'; + +export type LegalCaseType = 'divorce' | 'custody' | 'both'; + +export type LegalCase = { + id: string; + type: LegalCaseType; + startedAt: number; + coParentName: string; + jurisdiction?: string | null; +}; + +type NativeAdvocateModuleType = { + listCases(): Promise; + createCase( + type: LegalCaseType, + coParentName: string, + startedAtMillis: number, + jurisdiction?: string + ): Promise; + deleteCaseWithCascade(caseId: string): Promise; +}; + +const { NativeAdvocateModule } = NativeModules as { + NativeAdvocateModule: NativeAdvocateModuleType; +}; + +export default NativeAdvocateModule; diff --git a/app/hooks/useAdvocateCases.ts b/app/hooks/useAdvocateCases.ts new file mode 100644 index 0000000..31745d1 --- /dev/null +++ b/app/hooks/useAdvocateCases.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useState } from 'react'; +import NativeAdvocateModule, { LegalCase, LegalCaseType } from '../NativeAdvocateModule'; + +type State = { + cases: LegalCase[]; + loading: boolean; + error?: string; +}; + +export function useAdvocateCases() { + const [state, setState] = useState({ + cases: [], + loading: false, + }); + + const refresh = useCallback(async () => { + setState((prev) => ({ ...prev, loading: true, error: undefined })); + try { + const cases = await NativeAdvocateModule.listCases(); + setState({ cases, loading: false }); + } catch (e: any) { + setState((prev) => ({ + ...prev, + loading: false, + error: e?.message ?? 'Failed to load cases.', + })); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const createCase = useCallback( + async ( + type: LegalCaseType, + coParentName: string, + startedAtMillis: number, + jurisdiction?: string + ) => { + const trimmed = coParentName.trim(); + if (!trimmed) { + throw new Error('Co-parent name is required.'); + } + const created = await NativeAdvocateModule.createCase( + type, + trimmed, + startedAtMillis, + jurisdiction + ); + setState((prev) => ({ + ...prev, + cases: [...prev.cases, created], + })); + return created; + }, + [] + ); + + const deleteCase = useCallback(async (caseId: string) => { + await NativeAdvocateModule.deleteCaseWithCascade(caseId); + setState((prev) => ({ + ...prev, + cases: prev.cases.filter((c) => c.id !== caseId), + })); + }, []); + + return { + cases: state.cases, + loading: state.loading, + error: state.error, + refresh, + createCase, + deleteCase, + }; +} diff --git a/app/screens/SupportLedgerScreen.tsx b/app/screens/SupportLedgerScreen.tsx new file mode 100644 index 0000000..91997cd --- /dev/null +++ b/app/screens/SupportLedgerScreen.tsx @@ -0,0 +1,119 @@ +import React, { useCallback } from 'react'; +import { Alert, ScrollView, View, Text, TouchableOpacity, FlatList, ActivityIndicator } from 'react-native'; +import { useNavigation, useRoute } from '@react-navigation/native'; +import { useSupportLedger } from '../hooks/useSupportLedger'; +import { useAdvocateCases } from '../hooks/useAdvocateCases'; + +type RouteParams = { + caseId: string; +}; + +export const SupportLedgerScreen: React.FC = () => { + const navigation = useNavigation(); + const route = useRoute(); + const { caseId } = route.params as RouteParams; + + const { summary, payments, loading } = useSupportLedger(caseId); + const { deleteCase } = useAdvocateCases(); + + const onDeleteCase = useCallback(() => { + Alert.alert( + 'Delete this case?', + 'This will delete this case’s ledger and Advocate tags from Sanctum: Advocate. Your original messages, photos, and payment records will remain on your device.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete case', + style: 'destructive', + onPress: async () => { + try { + await deleteCase(caseId); + navigation.goBack(); + } catch (e) { + Alert.alert( + 'Could not delete case', + 'Something went wrong while deleting this case. Your records are unchanged.' + ); + } + }, + }, + ] + ); + }, [caseId, deleteCase, navigation]); + + const renderPaymentItem = ({ item }: { item: any }) => ( + + + + {item.vendor || item.counterparty_name || 'Unknown'} + + + {item.amount_cents ? `$${(item.amount_cents / 100).toFixed(2)}` : '$-.--'} + + + + + {new Date(item.seen_ts || Date.now()).toLocaleDateString()} + + + {item.legal_category || 'Uncategorized'} + + + + ); + + return ( + + + Support Ledger + Case ID: {caseId} + {loading && } + {!loading && summary && ( + + TOTAL SUPPORT PAID + + {summary.total ? `$${(summary.total / 100).toFixed(2)}` : '$0.00'} + + + )} + + + + PAYMENTS + item.id ? item.id.toString() : index.toString()} + ListEmptyComponent={ + !loading ? No payments tagged yet. : null + } + /> + + + + + Danger zone + + + Deleting this case removes its support ledger and Advocate tags from Sanctum on this device. + It does not delete your original bank records, messages, or photos. + + + + Delete this case + + + + + ); +};