mirror of
https://github.com/andwati/ledga.git
synced 2026-11-03 02:32:24 +00:00
Initial release — Ledga M-Pesa spending tracker
Complete Android app (Kotlin/Jetpack Compose) with: - SMS parser for all 17 M-Pesa transaction types - Room database with auto-categorization engine - Real-time SMS capture via BroadcastReceiver - Onboarding flow with SMS history import - Home dashboard with balance, spending, donut chart - Transactions list with search, filters, day grouping - Trends screen with Vico bar charts, top merchants - Budget system with progress tracking - Transaction detail sheet with re-categorization - Notifications (daily/weekly summaries, budget alerts) - Google Drive backup & restore - Manual export/import (CSV + JSON ZIP) - Self-update system via GitHub Releases API - Theme system (System/Light/Dark) + font scaling - Adaptive app icon with splash screen - GitHub Actions CI/CD for release APK builds
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.RECEIVE_SMS" />
|
||||
<uses-permission android:name="android.permission.READ_SMS" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name=".LedgaApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Ledga">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Ledga.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<receiver
|
||||
android:name=".receiver.SmsReceiver"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BROADCAST_SMS">
|
||||
<intent-filter android:priority="999">
|
||||
<action android:name="android.provider.Telephony.SMS_RECEIVED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ledga.app
|
||||
|
||||
import android.app.Application
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import com.ledga.app.worker.NotificationHelper
|
||||
import com.ledga.app.worker.NotificationScheduler
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class LedgaApp : Application(), Configuration.Provider {
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
NotificationHelper.createChannels(this)
|
||||
NotificationScheduler.scheduleDailySummary(this)
|
||||
NotificationScheduler.scheduleWeeklySummary(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.ledga.app
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.ledga.app.data.repository.FontScale
|
||||
import com.ledga.app.data.repository.SettingsRepository
|
||||
import com.ledga.app.ui.navigation.AppNavigation
|
||||
import com.ledga.app.ui.navigation.HomeRoute
|
||||
import com.ledga.app.ui.navigation.LedgaBottomNavBar
|
||||
import com.ledga.app.ui.navigation.OnboardingRoute
|
||||
import com.ledga.app.ui.navigation.UnparsedSmsRoute
|
||||
import com.ledga.app.ui.theme.LedgaTheme
|
||||
import com.ledga.app.ui.theme.ThemeMode
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class MainViewModel @Inject constructor(
|
||||
settingsRepository: SettingsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
val themeMode: StateFlow<ThemeMode> = settingsRepository.getThemeMode()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ThemeMode.SYSTEM)
|
||||
|
||||
val hasCompletedOnboarding: StateFlow<Boolean> = settingsRepository.hasCompletedOnboarding()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
|
||||
|
||||
val fontScale: StateFlow<FontScale> = settingsRepository.getFontScale()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), FontScale.SYSTEM)
|
||||
}
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val viewModel: MainViewModel = hiltViewModel()
|
||||
val themeMode by viewModel.themeMode.collectAsState()
|
||||
val hasCompletedOnboarding by viewModel.hasCompletedOnboarding.collectAsState()
|
||||
val fontScale by viewModel.fontScale.collectAsState()
|
||||
|
||||
LedgaTheme(themeMode = themeMode) {
|
||||
val density = LocalDensity.current
|
||||
val scaledDensity = if (fontScale == FontScale.SYSTEM) density
|
||||
else Density(density.density, fontScale.scale)
|
||||
|
||||
CompositionLocalProvider(LocalDensity provides scaledDensity) {
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
val startDestination: Any = if (hasCompletedOnboarding) HomeRoute else OnboardingRoute
|
||||
|
||||
val showBottomBar = currentRoute != null &&
|
||||
!currentRoute.contains("Onboarding") &&
|
||||
!currentRoute.contains("UnparsedSms") &&
|
||||
!currentRoute.contains("Budget")
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
if (showBottomBar) {
|
||||
LedgaBottomNavBar(navController = navController)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
AppNavigation(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.ledga.app.data.db
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import com.ledga.app.data.db.dao.BudgetDao
|
||||
import com.ledga.app.data.db.dao.CategoryDao
|
||||
import com.ledga.app.data.db.dao.CategoryRuleDao
|
||||
import com.ledga.app.data.db.dao.TransactionDao
|
||||
import com.ledga.app.data.db.entity.Budget
|
||||
import com.ledga.app.data.db.entity.Category
|
||||
import com.ledga.app.data.db.entity.CategoryRule
|
||||
import com.ledga.app.data.db.entity.TransactionEntity
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
TransactionEntity::class,
|
||||
Category::class,
|
||||
Budget::class,
|
||||
CategoryRule::class
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = true
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun transactionDao(): TransactionDao
|
||||
abstract fun categoryDao(): CategoryDao
|
||||
abstract fun categoryRuleDao(): CategoryRuleDao
|
||||
abstract fun budgetDao(): BudgetDao
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ledga.app.data.db
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.ledga.app.data.db.entity.MatchType
|
||||
import com.ledga.app.data.parser.TransactionDirection
|
||||
import com.ledga.app.data.parser.TransactionType
|
||||
|
||||
class Converters {
|
||||
|
||||
@TypeConverter
|
||||
fun fromTransactionType(value: TransactionType): String = value.name
|
||||
|
||||
@TypeConverter
|
||||
fun toTransactionType(value: String): TransactionType = TransactionType.valueOf(value)
|
||||
|
||||
@TypeConverter
|
||||
fun fromTransactionDirection(value: TransactionDirection): String = value.name
|
||||
|
||||
@TypeConverter
|
||||
fun toTransactionDirection(value: String): TransactionDirection = TransactionDirection.valueOf(value)
|
||||
|
||||
@TypeConverter
|
||||
fun fromMatchType(value: MatchType): String = value.name
|
||||
|
||||
@TypeConverter
|
||||
fun toMatchType(value: String): MatchType = MatchType.valueOf(value)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.ledga.app.data.db
|
||||
|
||||
import com.ledga.app.data.db.entity.Category
|
||||
import com.ledga.app.data.db.entity.CategoryRule
|
||||
import com.ledga.app.data.db.entity.MatchType
|
||||
|
||||
object DefaultData {
|
||||
|
||||
val DEFAULT_CATEGORIES = listOf(
|
||||
Category(id = 1, name = "Groceries", icon = "shopping_cart", color = "#4CAF50"),
|
||||
Category(id = 2, name = "Transport", icon = "directions_car", color = "#2196F3"),
|
||||
Category(id = 3, name = "Bills & Utilities", icon = "receipt", color = "#FF9800"),
|
||||
Category(id = 4, name = "Airtime & Data", icon = "phone_android", color = "#9C27B0"),
|
||||
Category(id = 5, name = "Food & Dining", icon = "restaurant", color = "#E91E63"),
|
||||
Category(id = 6, name = "Send Money", icon = "person", color = "#607D8B"),
|
||||
Category(id = 7, name = "Received", icon = "account_balance_wallet", color = "#00BCD4"),
|
||||
Category(id = 8, name = "Withdrawal", icon = "atm", color = "#795548"),
|
||||
Category(id = 9, name = "Deposit", icon = "savings", color = "#8BC34A"),
|
||||
Category(id = 10, name = "Shopping", icon = "shopping_bag", color = "#FF5722"),
|
||||
Category(id = 11, name = "International", icon = "public", color = "#3F51B5"),
|
||||
Category(id = 12, name = "Savings & Loans", icon = "account_balance", color = "#009688"),
|
||||
Category(id = 13, name = "Other", icon = "category", color = "#9E9E9E"),
|
||||
)
|
||||
|
||||
val DEFAULT_RULES = listOf(
|
||||
// Groceries (id=1)
|
||||
CategoryRule(categoryId = 1, matchType = MatchType.RECIPIENT_NAME, matchValue = "NAIVAS"),
|
||||
CategoryRule(categoryId = 1, matchType = MatchType.RECIPIENT_NAME, matchValue = "QUICKMART"),
|
||||
CategoryRule(categoryId = 1, matchType = MatchType.RECIPIENT_NAME, matchValue = "CARREFOUR"),
|
||||
CategoryRule(categoryId = 1, matchType = MatchType.RECIPIENT_NAME, matchValue = "CLEANSHELF"),
|
||||
|
||||
// Transport (id=2)
|
||||
CategoryRule(categoryId = 2, matchType = MatchType.RECIPIENT_NAME, matchValue = "UBER"),
|
||||
CategoryRule(categoryId = 2, matchType = MatchType.RECIPIENT_NAME, matchValue = "BOLT"),
|
||||
CategoryRule(categoryId = 2, matchType = MatchType.RECIPIENT_NAME, matchValue = "LITTLE"),
|
||||
|
||||
// Bills & Utilities (id=3)
|
||||
CategoryRule(categoryId = 3, matchType = MatchType.RECIPIENT_NAME, matchValue = "KPLC"),
|
||||
CategoryRule(categoryId = 3, matchType = MatchType.RECIPIENT_NAME, matchValue = "KENYA POWER"),
|
||||
CategoryRule(categoryId = 3, matchType = MatchType.PAYBILL, matchValue = "888880"),
|
||||
CategoryRule(categoryId = 3, matchType = MatchType.PAYBILL, matchValue = "888888"),
|
||||
CategoryRule(categoryId = 3, matchType = MatchType.RECIPIENT_NAME, matchValue = "NAIROBI WATER"),
|
||||
CategoryRule(categoryId = 3, matchType = MatchType.PAYBILL, matchValue = "444400"),
|
||||
CategoryRule(categoryId = 3, matchType = MatchType.RECIPIENT_NAME, matchValue = "DSTV"),
|
||||
CategoryRule(categoryId = 3, matchType = MatchType.RECIPIENT_NAME, matchValue = "GOTV"),
|
||||
CategoryRule(categoryId = 3, matchType = MatchType.RECIPIENT_NAME, matchValue = "SHOWMAX"),
|
||||
|
||||
// Food & Dining (id=5)
|
||||
CategoryRule(categoryId = 5, matchType = MatchType.RECIPIENT_NAME, matchValue = "JAVA"),
|
||||
CategoryRule(categoryId = 5, matchType = MatchType.RECIPIENT_NAME, matchValue = "KFC"),
|
||||
CategoryRule(categoryId = 5, matchType = MatchType.RECIPIENT_NAME, matchValue = "CHICKEN INN"),
|
||||
CategoryRule(categoryId = 5, matchType = MatchType.RECIPIENT_NAME, matchValue = "PIZZA INN"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ledga.app.data.db
|
||||
|
||||
/**
|
||||
* Room database migrations.
|
||||
*
|
||||
* NEVER use fallbackToDestructiveMigration() — this destroys all user data.
|
||||
* Always define explicit migrations for schema changes.
|
||||
*
|
||||
* Version history:
|
||||
* 1 - Initial schema (transactions, categories, budgets, category_rules)
|
||||
*/
|
||||
object Migrations {
|
||||
// Future migrations will be added here as:
|
||||
// val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
// override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// db.execSQL("ALTER TABLE ...")
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.ledga.app.data.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.ledga.app.data.db.entity.Budget
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface BudgetDao {
|
||||
|
||||
@Query("SELECT * FROM budgets WHERE isActive = 1")
|
||||
fun getActiveBudgets(): Flow<List<Budget>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(budget: Budget): Long
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ledga.app.data.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.ledga.app.data.db.entity.Category
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface CategoryDao {
|
||||
|
||||
@Query("SELECT * FROM categories ORDER BY name")
|
||||
fun getAllCategories(): Flow<List<Category>>
|
||||
|
||||
@Query("SELECT * FROM categories WHERE id = :id")
|
||||
suspend fun getCategoryById(id: Long): Category?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(categories: List<Category>)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ledga.app.data.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.ledga.app.data.db.entity.CategoryRule
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface CategoryRuleDao {
|
||||
|
||||
@Query("SELECT * FROM category_rules")
|
||||
fun getAllRules(): Flow<List<CategoryRule>>
|
||||
|
||||
@Query("SELECT * FROM category_rules WHERE categoryId = :categoryId")
|
||||
fun getRulesForCategory(categoryId: Long): Flow<List<CategoryRule>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(rules: List<CategoryRule>)
|
||||
|
||||
@Query("SELECT * FROM category_rules")
|
||||
suspend fun getAllRulesSync(): List<CategoryRule>
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.ledga.app.data.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.ledga.app.data.db.entity.TransactionEntity
|
||||
import com.ledga.app.data.db.entity.TransactionWithCategory
|
||||
import com.ledga.app.data.parser.TransactionType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
data class CategorySpending(
|
||||
val categoryId: Long?,
|
||||
val totalAmount: Double
|
||||
)
|
||||
|
||||
data class DailySpending(
|
||||
val dayTimestamp: Long,
|
||||
val totalAmount: Double
|
||||
)
|
||||
|
||||
data class TopMerchant(
|
||||
val recipientName: String,
|
||||
val totalAmount: Double,
|
||||
val transactionCount: Int
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface TransactionDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insert(transaction: TransactionEntity): Long
|
||||
|
||||
@Query("UPDATE transactions SET categoryId = :categoryId WHERE id = :id")
|
||||
suspend fun updateCategory(id: Long, categoryId: Long?)
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM transactions ORDER BY timestamp DESC LIMIT :limit")
|
||||
fun getRecentTransactions(limit: Int = 10): Flow<List<TransactionWithCategory>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM transactions WHERE timestamp BETWEEN :startTime AND :endTime ORDER BY timestamp DESC")
|
||||
fun getTransactions(startTime: Long, endTime: Long): Flow<List<TransactionWithCategory>>
|
||||
|
||||
@Query("SELECT * FROM transactions ORDER BY timestamp DESC LIMIT 1")
|
||||
fun getLatestTransaction(): Flow<TransactionEntity?>
|
||||
|
||||
@Query("""
|
||||
SELECT categoryId, SUM(amount) as totalAmount
|
||||
FROM transactions
|
||||
WHERE direction = 'OUTFLOW' AND timestamp BETWEEN :startTime AND :endTime
|
||||
GROUP BY categoryId
|
||||
""")
|
||||
fun getSpendingByCategory(startTime: Long, endTime: Long): Flow<List<CategorySpending>>
|
||||
|
||||
@Query("SELECT COALESCE(SUM(amount), 0.0) FROM transactions WHERE direction = 'OUTFLOW' AND timestamp BETWEEN :startTime AND :endTime")
|
||||
fun getTotalSpending(startTime: Long, endTime: Long): Flow<Double>
|
||||
|
||||
@Query("SELECT COALESCE(SUM(transactionCost), 0.0) FROM transactions WHERE timestamp BETWEEN :startTime AND :endTime")
|
||||
fun getTotalFees(startTime: Long, endTime: Long): Flow<Double>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM transactions WHERE recipientName LIKE '%' || :query || '%' ORDER BY timestamp DESC")
|
||||
fun searchTransactions(query: String): Flow<List<TransactionWithCategory>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM transactions WHERE type IN (:types) ORDER BY timestamp DESC")
|
||||
fun getTransactionsByType(types: List<TransactionType>): Flow<List<TransactionWithCategory>>
|
||||
|
||||
@Query("SELECT * FROM transactions WHERE transactionCode = :code LIMIT 1")
|
||||
suspend fun getTransactionByCode(code: String): TransactionEntity?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM transactions WHERE type = 'UNKNOWN' ORDER BY timestamp DESC")
|
||||
fun getUnparsedTransactions(): Flow<List<TransactionWithCategory>>
|
||||
|
||||
// Trends: daily spending totals for a period
|
||||
@Query("""
|
||||
SELECT (timestamp / 86400000) * 86400000 as dayTimestamp, SUM(amount) as totalAmount
|
||||
FROM transactions
|
||||
WHERE direction = 'OUTFLOW' AND timestamp BETWEEN :startTime AND :endTime
|
||||
GROUP BY dayTimestamp
|
||||
ORDER BY dayTimestamp ASC
|
||||
""")
|
||||
fun getDailySpending(startTime: Long, endTime: Long): Flow<List<DailySpending>>
|
||||
|
||||
// Trends: top merchants by total spending
|
||||
@Query("""
|
||||
SELECT recipientName, SUM(amount) as totalAmount, COUNT(*) as transactionCount
|
||||
FROM transactions
|
||||
WHERE direction = 'OUTFLOW' AND recipientName IS NOT NULL
|
||||
AND timestamp BETWEEN :startTime AND :endTime
|
||||
GROUP BY recipientName
|
||||
ORDER BY totalAmount DESC
|
||||
LIMIT :limit
|
||||
""")
|
||||
fun getTopMerchants(startTime: Long, endTime: Long, limit: Int = 5): Flow<List<TopMerchant>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM transactions WHERE type = 'UNKNOWN'")
|
||||
fun getUnparsedCount(): Flow<Int>
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.ledga.app.data.db.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "budgets",
|
||||
indices = [Index(value = ["categoryId"])],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Category::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["categoryId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
]
|
||||
)
|
||||
data class Budget(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
val categoryId: Long?,
|
||||
val monthlyLimit: Double,
|
||||
val isActive: Boolean = true
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ledga.app.data.db.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "categories")
|
||||
data class Category(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
val name: String,
|
||||
val icon: String,
|
||||
val color: String,
|
||||
val isDefault: Boolean = true
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ledga.app.data.db.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
enum class MatchType {
|
||||
RECIPIENT_NAME, PHONE, TILL, PAYBILL
|
||||
}
|
||||
|
||||
@Entity(
|
||||
tableName = "category_rules",
|
||||
indices = [Index(value = ["categoryId"])],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Category::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["categoryId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
]
|
||||
)
|
||||
data class CategoryRule(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
val categoryId: Long,
|
||||
val matchType: MatchType,
|
||||
val matchValue: String
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ledga.app.data.db.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.ledga.app.data.parser.TransactionDirection
|
||||
import com.ledga.app.data.parser.TransactionType
|
||||
|
||||
@Entity(
|
||||
tableName = "transactions",
|
||||
indices = [
|
||||
Index(value = ["transactionCode"], unique = true),
|
||||
Index(value = ["categoryId"]),
|
||||
Index(value = ["timestamp"]),
|
||||
Index(value = ["type"])
|
||||
],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Category::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["categoryId"],
|
||||
onDelete = ForeignKey.SET_NULL
|
||||
)
|
||||
]
|
||||
)
|
||||
data class TransactionEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
val transactionCode: String,
|
||||
val type: TransactionType,
|
||||
val amount: Double,
|
||||
val transactionCost: Double,
|
||||
val recipientName: String?,
|
||||
val recipientPhone: String?,
|
||||
val accountNumber: String?,
|
||||
val destinationCountry: String?,
|
||||
val balance: Double,
|
||||
val direction: TransactionDirection,
|
||||
val categoryId: Long?,
|
||||
val fulizaAmount: Double?,
|
||||
val fulizaOutstanding: Double?,
|
||||
val reversedTransactionCode: String?,
|
||||
val rawSms: String,
|
||||
val timestamp: Long,
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.ledga.app.data.db.entity
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Relation
|
||||
|
||||
data class TransactionWithCategory(
|
||||
@Embedded val transaction: TransactionEntity,
|
||||
@Relation(
|
||||
parentColumn = "categoryId",
|
||||
entityColumn = "id"
|
||||
)
|
||||
val category: Category?
|
||||
)
|
||||
@@ -0,0 +1,388 @@
|
||||
package com.ledga.app.data.parser
|
||||
|
||||
import com.ledga.app.util.DateUtils
|
||||
|
||||
enum class TransactionType {
|
||||
SEND, BUY_GOODS, PAY_BILL, WITHDRAW_AGENT, WITHDRAW_ATM,
|
||||
DEPOSIT, RECEIVED, AIRTIME_SELF, AIRTIME_OTHER, MPESA_GLOBAL,
|
||||
FULIZA, FULIZA_REPAYMENT, FULIZA_REVERSAL, MSHWARI, KCB_MPESA,
|
||||
REVERSAL, UNKNOWN
|
||||
}
|
||||
|
||||
enum class TransactionDirection { INFLOW, OUTFLOW }
|
||||
|
||||
data class ParsedTransaction(
|
||||
val transactionCode: String,
|
||||
val type: TransactionType,
|
||||
val amount: Double,
|
||||
val transactionCost: Double,
|
||||
val recipientName: String?,
|
||||
val recipientPhone: String?,
|
||||
val accountNumber: String?,
|
||||
val destinationCountry: String?,
|
||||
val balance: Double,
|
||||
val direction: TransactionDirection,
|
||||
val fulizaAmount: Double?,
|
||||
val fulizaOutstanding: Double?,
|
||||
val reversedTransactionCode: String?,
|
||||
val timestamp: Long,
|
||||
val rawSms: String
|
||||
)
|
||||
|
||||
sealed interface ParseResult {
|
||||
data class Success(val transaction: ParsedTransaction) : ParseResult
|
||||
data class Failure(val rawSms: String, val reason: String) : ParseResult
|
||||
}
|
||||
|
||||
object MpesaSmsParser {
|
||||
|
||||
private val AMOUNT_REGEX = Regex("""Ksh([\d,]+\.\d{2})""")
|
||||
private val TRANSACTION_CODE_REGEX = Regex("""^([A-Z0-9]{10})\s""")
|
||||
private val DATE_REGEX = Regex("""on (\d{1,2}/\d{1,2}/\d{2,4} at \d{1,2}:\d{2} [AP]M)""")
|
||||
private val BALANCE_REGEX = Regex("""(?:balance is|account balance is)\s*Ksh([\d,]+\.\d{2})""", RegexOption.IGNORE_CASE)
|
||||
private val COST_REGEX = Regex("""Transaction cost,?\s*Ksh([\d,]+\.\d{2})""", RegexOption.IGNORE_CASE)
|
||||
private val FULIZA_AMOUNT_REGEX = Regex("""Fuliza M-PESA amount is Ksh([\d,]+\.\d{2})""")
|
||||
private val FULIZA_OUTSTANDING_REGEX = Regex("""Fuliza M-PESA outstanding amount is Ksh([\d,]+\.\d{2})""")
|
||||
|
||||
fun isMpesaMessage(sender: String): Boolean {
|
||||
return sender.equals("MPESA", ignoreCase = true)
|
||||
}
|
||||
|
||||
fun parse(smsBody: String, smsTimestamp: Long = System.currentTimeMillis()): ParseResult {
|
||||
val code = extractTransactionCode(smsBody)
|
||||
?: return ParseResult.Failure(smsBody, "No transaction code found")
|
||||
|
||||
if (!smsBody.contains("Confirmed", ignoreCase = true)) {
|
||||
return ParseResult.Failure(smsBody, "Not a confirmed transaction")
|
||||
}
|
||||
|
||||
val timestamp = extractDate(smsBody) ?: smsTimestamp
|
||||
val balance = extractBalance(smsBody) ?: 0.0
|
||||
val cost = extractCost(smsBody) ?: 0.0
|
||||
|
||||
return try {
|
||||
val transaction = when {
|
||||
isReversal(smsBody) -> parseReversal(smsBody, code, balance, timestamp)
|
||||
isFulizaReversal(smsBody) -> parseFulizaReversal(smsBody, code, timestamp)
|
||||
isFulizaRepayment(smsBody) -> parseFulizaRepayment(smsBody, code, timestamp)
|
||||
isMshwari(smsBody) -> parseMshwari(smsBody, code, balance, cost, timestamp)
|
||||
isKcbMpesa(smsBody) -> parseKcbMpesa(smsBody, code, balance, cost, timestamp)
|
||||
isMpesaGlobal(smsBody) -> parseMpesaGlobal(smsBody, code, balance, cost, timestamp)
|
||||
isFuliza(smsBody) -> parseFuliza(smsBody, code, balance, cost, timestamp)
|
||||
isDeposit(smsBody) -> parseDeposit(smsBody, code, balance, timestamp)
|
||||
isReceived(smsBody) -> parseReceived(smsBody, code, balance, timestamp)
|
||||
isAirtimeSelf(smsBody) -> parseAirtimeSelf(smsBody, code, balance, cost, timestamp)
|
||||
isAirtimeOther(smsBody) -> parseAirtimeOther(smsBody, code, balance, cost, timestamp)
|
||||
isWithdrawAtm(smsBody) -> parseWithdrawAtm(smsBody, code, balance, cost, timestamp)
|
||||
isWithdrawAgent(smsBody) -> parseWithdrawAgent(smsBody, code, balance, cost, timestamp)
|
||||
isPayBill(smsBody) -> parsePayBill(smsBody, code, balance, cost, timestamp)
|
||||
isBuyGoods(smsBody) -> parseBuyGoods(smsBody, code, balance, cost, timestamp)
|
||||
isSendMoney(smsBody) -> parseSendMoney(smsBody, code, balance, cost, timestamp)
|
||||
else -> parseUnknown(smsBody, code, balance, cost, timestamp)
|
||||
}
|
||||
ParseResult.Success(transaction)
|
||||
} catch (e: Exception) {
|
||||
ParseResult.Failure(smsBody, "Parse error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Type detection ---
|
||||
|
||||
private fun isReversal(sms: String) =
|
||||
sms.contains("has been reversed") && !sms.contains("Fuliza", ignoreCase = true)
|
||||
|
||||
private fun isFulizaReversal(sms: String) =
|
||||
sms.contains("Fuliza M-PESA of", ignoreCase = true) && sms.contains("has been reversed")
|
||||
|
||||
private fun isFulizaRepayment(sms: String) =
|
||||
sms.contains("paid", ignoreCase = true) && sms.contains("to Fuliza M-PESA", ignoreCase = true)
|
||||
|
||||
private fun isMshwari(sms: String) =
|
||||
sms.contains("M-Shwari", ignoreCase = true)
|
||||
|
||||
private fun isKcbMpesa(sms: String) =
|
||||
sms.contains("KCB M-Pesa", ignoreCase = true) || sms.contains("KCB M-PESA", ignoreCase = true)
|
||||
|
||||
private fun isMpesaGlobal(sms: String) =
|
||||
sms.contains("M-PESA Global", ignoreCase = true) || sms.contains("MPESA Global", ignoreCase = true)
|
||||
|
||||
private fun isFuliza(sms: String) =
|
||||
sms.contains("Fuliza M-PESA amount is", ignoreCase = true)
|
||||
|
||||
private fun isDeposit(sms: String) =
|
||||
sms.contains("You have deposited", ignoreCase = true)
|
||||
|
||||
private fun isReceived(sms: String) =
|
||||
sms.contains("You have received", ignoreCase = true)
|
||||
|
||||
private fun isAirtimeSelf(sms: String) =
|
||||
sms.contains("airtime purchased", ignoreCase = true)
|
||||
|
||||
private fun isAirtimeOther(sms: String) =
|
||||
sms.contains("bought", ignoreCase = true) && sms.contains("airtime for", ignoreCase = true)
|
||||
|
||||
private fun isWithdrawAtm(sms: String) =
|
||||
sms.contains("withdrawn", ignoreCase = true) && sms.contains("from an ATM", ignoreCase = true)
|
||||
|
||||
private fun isWithdrawAgent(sms: String) =
|
||||
sms.contains("withdrawn", ignoreCase = true) && sms.contains("from ", ignoreCase = true)
|
||||
|
||||
private fun isPayBill(sms: String) =
|
||||
sms.contains("paid to", ignoreCase = true) && sms.contains("Account Number", ignoreCase = true)
|
||||
|
||||
private fun isBuyGoods(sms: String) =
|
||||
sms.contains("paid to", ignoreCase = true) && !sms.contains("Account Number", ignoreCase = true)
|
||||
|
||||
private fun isSendMoney(sms: String) =
|
||||
sms.contains("sent to", ignoreCase = true)
|
||||
|
||||
// --- Parsers ---
|
||||
|
||||
private fun parseSendMoney(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
|
||||
val amount = extractFirstAmount(sms) ?: 0.0
|
||||
val sendToRegex = Regex("""sent to\s+(.+?)\s+(\d{10,})\s+on""", RegexOption.IGNORE_CASE)
|
||||
val match = sendToRegex.find(sms)
|
||||
return ParsedTransaction(
|
||||
transactionCode = code, type = TransactionType.SEND, amount = amount,
|
||||
transactionCost = cost, recipientName = match?.groupValues?.get(1)?.trim(),
|
||||
recipientPhone = match?.groupValues?.get(2), accountNumber = null,
|
||||
destinationCountry = null, balance = balance, direction = TransactionDirection.OUTFLOW,
|
||||
fulizaAmount = null, fulizaOutstanding = null, reversedTransactionCode = null,
|
||||
timestamp = timestamp, rawSms = sms
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseBuyGoods(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
|
||||
val amount = extractFirstAmount(sms) ?: 0.0
|
||||
val paidToRegex = Regex("""paid to\s+(.+?)\.?\s+on""", RegexOption.IGNORE_CASE)
|
||||
val match = paidToRegex.find(sms)
|
||||
return ParsedTransaction(
|
||||
transactionCode = code, type = TransactionType.BUY_GOODS, amount = amount,
|
||||
transactionCost = cost, recipientName = match?.groupValues?.get(1)?.trim(),
|
||||
recipientPhone = null, accountNumber = null, destinationCountry = null,
|
||||
balance = balance, direction = TransactionDirection.OUTFLOW,
|
||||
fulizaAmount = null, fulizaOutstanding = null, reversedTransactionCode = null,
|
||||
timestamp = timestamp, rawSms = sms
|
||||
)
|
||||
}
|
||||
|
||||
private fun parsePayBill(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
|
||||
val amount = extractFirstAmount(sms) ?: 0.0
|
||||
val paidToRegex = Regex("""paid to\s+(.+?)\.\s*Account Number\s+(\S+?)\.?\s+on""", RegexOption.IGNORE_CASE)
|
||||
val match = paidToRegex.find(sms)
|
||||
return ParsedTransaction(
|
||||
transactionCode = code, type = TransactionType.PAY_BILL, amount = amount,
|
||||
transactionCost = cost, recipientName = match?.groupValues?.get(1)?.trim(),
|
||||
recipientPhone = null, accountNumber = match?.groupValues?.get(2)?.trim(),
|
||||
destinationCountry = null, balance = balance, direction = TransactionDirection.OUTFLOW,
|
||||
fulizaAmount = null, fulizaOutstanding = null, reversedTransactionCode = null,
|
||||
timestamp = timestamp, rawSms = sms
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseWithdrawAgent(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
|
||||
val amount = extractFirstAmount(sms) ?: 0.0
|
||||
val fromRegex = Regex("""withdrawn.*?from\s+(.+?)\s+(\d{4,})\s+on""", RegexOption.IGNORE_CASE)
|
||||
val match = fromRegex.find(sms)
|
||||
return ParsedTransaction(
|
||||
transactionCode = code, type = TransactionType.WITHDRAW_AGENT, amount = amount,
|
||||
transactionCost = cost, recipientName = match?.groupValues?.get(1)?.trim(),
|
||||
recipientPhone = match?.groupValues?.get(2), accountNumber = null,
|
||||
destinationCountry = null, balance = balance, direction = TransactionDirection.OUTFLOW,
|
||||
fulizaAmount = null, fulizaOutstanding = null, reversedTransactionCode = null,
|
||||
timestamp = timestamp, rawSms = sms
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseWithdrawAtm(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
|
||||
val amount = extractFirstAmount(sms) ?: 0.0
|
||||
return ParsedTransaction(
|
||||
transactionCode = code, type = TransactionType.WITHDRAW_ATM, amount = amount,
|
||||
transactionCost = cost, recipientName = "ATM", recipientPhone = null,
|
||||
accountNumber = null, destinationCountry = null, balance = balance,
|
||||
direction = TransactionDirection.OUTFLOW, fulizaAmount = null, fulizaOutstanding = null,
|
||||
reversedTransactionCode = null, timestamp = timestamp, rawSms = sms
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseDeposit(sms: String, code: String, balance: Double, timestamp: Long): ParsedTransaction {
|
||||
val amount = extractFirstAmount(sms) ?: 0.0
|
||||
return ParsedTransaction(
|
||||
transactionCode = code, type = TransactionType.DEPOSIT, amount = amount,
|
||||
transactionCost = 0.0, recipientName = null, recipientPhone = null,
|
||||
accountNumber = null, destinationCountry = null, balance = balance,
|
||||
direction = TransactionDirection.INFLOW, fulizaAmount = null, fulizaOutstanding = null,
|
||||
reversedTransactionCode = null, timestamp = timestamp, rawSms = sms
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseReceived(sms: String, code: String, balance: Double, timestamp: Long): ParsedTransaction {
|
||||
val amount = extractFirstAmount(sms) ?: 0.0
|
||||
val fromRegex = Regex("""received.*?from\s+(.+?)\s+(\d{10,})\s+on""", RegexOption.IGNORE_CASE)
|
||||
val match = fromRegex.find(sms)
|
||||
return ParsedTransaction(
|
||||
transactionCode = code, type = TransactionType.RECEIVED, amount = amount,
|
||||
transactionCost = 0.0, recipientName = match?.groupValues?.get(1)?.trim(),
|
||||
recipientPhone = match?.groupValues?.get(2), accountNumber = null,
|
||||
destinationCountry = null, balance = balance, direction = TransactionDirection.INFLOW,
|
||||
fulizaAmount = null, fulizaOutstanding = null, reversedTransactionCode = null,
|
||||
timestamp = timestamp, rawSms = sms
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseAirtimeSelf(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
|
||||
val amount = extractFirstAmount(sms) ?: 0.0
|
||||
return ParsedTransaction(
|
||||
transactionCode = code, type = TransactionType.AIRTIME_SELF, amount = amount,
|
||||
transactionCost = cost, recipientName = null, recipientPhone = null,
|
||||
accountNumber = null, destinationCountry = null, balance = balance,
|
||||
direction = TransactionDirection.OUTFLOW, fulizaAmount = null, fulizaOutstanding = null,
|
||||
reversedTransactionCode = null, timestamp = timestamp, rawSms = sms
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseAirtimeOther(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
|
||||
val amount = extractFirstAmount(sms) ?: 0.0
|
||||
val forRegex = Regex("""airtime for\s+(\d{10,})""", RegexOption.IGNORE_CASE)
|
||||
val match = forRegex.find(sms)
|
||||
return ParsedTransaction(
|
||||
transactionCode = code, type = TransactionType.AIRTIME_OTHER, amount = amount,
|
||||
transactionCost = cost, recipientName = null, recipientPhone = match?.groupValues?.get(1),
|
||||
accountNumber = null, destinationCountry = null, balance = balance,
|
||||
direction = TransactionDirection.OUTFLOW, fulizaAmount = null, fulizaOutstanding = null,
|
||||
reversedTransactionCode = null, timestamp = timestamp, rawSms = sms
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseMpesaGlobal(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
|
||||
val amount = extractFirstAmount(sms) ?: 0.0
|
||||
val globalRegex = Regex("""sent to\s+(.+?)\s+(\+\d{7,15})\s+\((.+?)\)""", RegexOption.IGNORE_CASE)
|
||||
val match = globalRegex.find(sms)
|
||||
return ParsedTransaction(
|
||||
transactionCode = code, type = TransactionType.MPESA_GLOBAL, amount = amount,
|
||||
transactionCost = cost, recipientName = match?.groupValues?.get(1)?.trim(),
|
||||
recipientPhone = match?.groupValues?.get(2), accountNumber = null,
|
||||
destinationCountry = match?.groupValues?.get(3)?.trim(), balance = balance,
|
||||
direction = TransactionDirection.OUTFLOW, fulizaAmount = null, fulizaOutstanding = null,
|
||||
reversedTransactionCode = null, timestamp = timestamp, rawSms = sms
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseFuliza(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
|
||||
val amount = extractFirstAmount(sms) ?: 0.0
|
||||
val fulizaAmt = FULIZA_AMOUNT_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) }
|
||||
val fulizaOut = FULIZA_OUTSTANDING_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) }
|
||||
|
||||
val sendToRegex = Regex("""sent to\s+(.+?)\s+(\d{10,})\s+on""", RegexOption.IGNORE_CASE)
|
||||
val match = sendToRegex.find(sms)
|
||||
|
||||
return ParsedTransaction(
|
||||
transactionCode = code, type = TransactionType.FULIZA, amount = amount,
|
||||
transactionCost = cost, recipientName = match?.groupValues?.get(1)?.trim(),
|
||||
recipientPhone = match?.groupValues?.get(2), accountNumber = null,
|
||||
destinationCountry = null, balance = balance, direction = TransactionDirection.OUTFLOW,
|
||||
fulizaAmount = fulizaAmt, fulizaOutstanding = fulizaOut,
|
||||
reversedTransactionCode = null, timestamp = timestamp, rawSms = sms
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseFulizaRepayment(sms: String, code: String, timestamp: Long): ParsedTransaction {
|
||||
val amount = extractFirstAmount(sms) ?: 0.0
|
||||
val fulizaOut = FULIZA_OUTSTANDING_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) }
|
||||
return ParsedTransaction(
|
||||
transactionCode = code, type = TransactionType.FULIZA_REPAYMENT, amount = amount,
|
||||
transactionCost = 0.0, recipientName = null, recipientPhone = null,
|
||||
accountNumber = null, destinationCountry = null, balance = 0.0,
|
||||
direction = TransactionDirection.OUTFLOW, fulizaAmount = null, fulizaOutstanding = fulizaOut,
|
||||
reversedTransactionCode = null, timestamp = timestamp, rawSms = sms
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseFulizaReversal(sms: String, code: String, timestamp: Long): ParsedTransaction {
|
||||
val amount = extractFirstAmount(sms) ?: 0.0
|
||||
val fulizaOut = FULIZA_OUTSTANDING_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) }
|
||||
return ParsedTransaction(
|
||||
transactionCode = code, type = TransactionType.FULIZA_REVERSAL, amount = amount,
|
||||
transactionCost = 0.0, recipientName = null, recipientPhone = null,
|
||||
accountNumber = null, destinationCountry = null, balance = 0.0,
|
||||
direction = TransactionDirection.INFLOW, fulizaAmount = null, fulizaOutstanding = fulizaOut,
|
||||
reversedTransactionCode = null, timestamp = timestamp, rawSms = sms
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseReversal(sms: String, code: String, balance: Double, timestamp: Long): ParsedTransaction {
|
||||
val reversedCodeRegex = Regex("""Transaction\s+([A-Z0-9]{10})\s+has been reversed""")
|
||||
val match = reversedCodeRegex.find(sms)
|
||||
return ParsedTransaction(
|
||||
transactionCode = code, type = TransactionType.REVERSAL, amount = 0.0,
|
||||
transactionCost = 0.0, recipientName = null, recipientPhone = null,
|
||||
accountNumber = null, destinationCountry = null, balance = balance,
|
||||
direction = TransactionDirection.INFLOW, fulizaAmount = null, fulizaOutstanding = null,
|
||||
reversedTransactionCode = match?.groupValues?.get(1), timestamp = timestamp, rawSms = sms
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseMshwari(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
|
||||
val amount = extractFirstAmount(sms) ?: 0.0
|
||||
val isInflow = sms.contains("from M-Shwari", ignoreCase = true)
|
||||
return ParsedTransaction(
|
||||
transactionCode = code, type = TransactionType.MSHWARI, amount = amount,
|
||||
transactionCost = cost, recipientName = "M-Shwari", recipientPhone = null,
|
||||
accountNumber = null, destinationCountry = null, balance = balance,
|
||||
direction = if (isInflow) TransactionDirection.INFLOW else TransactionDirection.OUTFLOW,
|
||||
fulizaAmount = null, fulizaOutstanding = null, reversedTransactionCode = null,
|
||||
timestamp = timestamp, rawSms = sms
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseKcbMpesa(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
|
||||
val amount = extractFirstAmount(sms) ?: 0.0
|
||||
val isInflow = sms.contains("from KCB", ignoreCase = true)
|
||||
return ParsedTransaction(
|
||||
transactionCode = code, type = TransactionType.KCB_MPESA, amount = amount,
|
||||
transactionCost = cost, recipientName = "KCB M-Pesa", recipientPhone = null,
|
||||
accountNumber = null, destinationCountry = null, balance = balance,
|
||||
direction = if (isInflow) TransactionDirection.INFLOW else TransactionDirection.OUTFLOW,
|
||||
fulizaAmount = null, fulizaOutstanding = null, reversedTransactionCode = null,
|
||||
timestamp = timestamp, rawSms = sms
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseUnknown(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
|
||||
val amount = extractFirstAmount(sms) ?: 0.0
|
||||
return ParsedTransaction(
|
||||
transactionCode = code, type = TransactionType.UNKNOWN, amount = amount,
|
||||
transactionCost = cost, recipientName = null, recipientPhone = null,
|
||||
accountNumber = null, destinationCountry = null, balance = balance,
|
||||
direction = TransactionDirection.OUTFLOW, fulizaAmount = null, fulizaOutstanding = null,
|
||||
reversedTransactionCode = null, timestamp = timestamp, rawSms = sms
|
||||
)
|
||||
}
|
||||
|
||||
// --- Extraction helpers ---
|
||||
|
||||
private fun extractTransactionCode(sms: String): String? {
|
||||
return TRANSACTION_CODE_REGEX.find(sms)?.groupValues?.get(1)
|
||||
}
|
||||
|
||||
private fun extractFirstAmount(sms: String): Double? {
|
||||
return AMOUNT_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) }
|
||||
}
|
||||
|
||||
private fun extractBalance(sms: String): Double? {
|
||||
return BALANCE_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) }
|
||||
}
|
||||
|
||||
private fun extractCost(sms: String): Double? {
|
||||
return COST_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) }
|
||||
}
|
||||
|
||||
private fun extractDate(sms: String): Long? {
|
||||
val match = DATE_REGEX.find(sms) ?: return null
|
||||
return DateUtils.parseMpesaDate(match.groupValues[1])
|
||||
}
|
||||
|
||||
private fun parseAmount(amountStr: String): Double {
|
||||
return amountStr.replace(",", "").toDoubleOrNull() ?: 0.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.ledga.app.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignIn
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
|
||||
import com.google.android.gms.common.api.Scope
|
||||
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
|
||||
import com.google.api.client.http.ByteArrayContent
|
||||
import com.google.api.client.http.javanet.NetHttpTransport
|
||||
import com.google.api.client.json.gson.GsonFactory
|
||||
import com.google.api.services.drive.Drive
|
||||
import com.google.api.services.drive.DriveScopes
|
||||
import com.ledga.app.data.db.dao.TransactionDao
|
||||
import com.ledga.app.data.db.entity.TransactionEntity
|
||||
import com.ledga.app.data.parser.TransactionDirection
|
||||
import com.ledga.app.data.parser.TransactionType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Serializable
|
||||
data class BackupData(
|
||||
val version: Int = 1,
|
||||
val backedUpAt: Long = System.currentTimeMillis(),
|
||||
val transactions: List<BackupTransaction>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BackupTransaction(
|
||||
val transactionCode: String,
|
||||
val type: String,
|
||||
val amount: Double,
|
||||
val transactionCost: Double,
|
||||
val recipientName: String?,
|
||||
val recipientPhone: String?,
|
||||
val accountNumber: String?,
|
||||
val destinationCountry: String?,
|
||||
val balance: Double,
|
||||
val direction: String,
|
||||
val categoryId: Long?,
|
||||
val fulizaAmount: Double?,
|
||||
val fulizaOutstanding: Double?,
|
||||
val reversedTransactionCode: String?,
|
||||
val rawSms: String,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class BackupRepository @Inject constructor(
|
||||
private val transactionDao: TransactionDao
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val BACKUP_FILENAME = "ledga-backup.json"
|
||||
|
||||
fun getSignInIntent(context: Context): Intent {
|
||||
val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
|
||||
.requestEmail()
|
||||
.requestScopes(Scope(DriveScopes.DRIVE_APPDATA))
|
||||
.build()
|
||||
return GoogleSignIn.getClient(context, gso).signInIntent
|
||||
}
|
||||
|
||||
fun isSignedIn(context: Context): Boolean {
|
||||
return GoogleSignIn.getLastSignedInAccount(context) != null
|
||||
}
|
||||
|
||||
fun getAccountEmail(context: Context): String? {
|
||||
return GoogleSignIn.getLastSignedInAccount(context)?.email
|
||||
}
|
||||
|
||||
suspend fun performBackup(context: Context): Boolean = withContext(Dispatchers.IO) {
|
||||
val account = GoogleSignIn.getLastSignedInAccount(context) ?: return@withContext false
|
||||
val driveService = getDriveService(context, account)
|
||||
|
||||
val transactions = transactionDao.getRecentTransactions(100000).first()
|
||||
val backupData = BackupData(
|
||||
transactions = transactions.map { it.transaction.toBackup() }
|
||||
)
|
||||
val content = json.encodeToString(backupData)
|
||||
|
||||
// Find existing backup file
|
||||
val existingFileId = findBackupFile(driveService)
|
||||
|
||||
if (existingFileId != null) {
|
||||
// Update existing
|
||||
val mediaContent = ByteArrayContent.fromString("application/json", content)
|
||||
driveService.files().update(existingFileId, null, mediaContent).execute()
|
||||
} else {
|
||||
// Create new
|
||||
val fileMetadata = com.google.api.services.drive.model.File().apply {
|
||||
name = BACKUP_FILENAME
|
||||
parents = listOf("appDataFolder")
|
||||
}
|
||||
val mediaContent = ByteArrayContent.fromString("application/json", content)
|
||||
driveService.files().create(fileMetadata, mediaContent)
|
||||
.setFields("id")
|
||||
.execute()
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
suspend fun restoreBackup(context: Context): Int = withContext(Dispatchers.IO) {
|
||||
val account = GoogleSignIn.getLastSignedInAccount(context) ?: return@withContext 0
|
||||
val driveService = getDriveService(context, account)
|
||||
|
||||
val fileId = findBackupFile(driveService) ?: return@withContext 0
|
||||
|
||||
val outputStream = java.io.ByteArrayOutputStream()
|
||||
driveService.files().get(fileId).executeMediaAndDownloadTo(outputStream)
|
||||
val content = outputStream.toString("UTF-8")
|
||||
|
||||
val backupData = json.decodeFromString<BackupData>(content)
|
||||
var imported = 0
|
||||
backupData.transactions.forEach { bt ->
|
||||
val entity = bt.toEntity()
|
||||
val id = transactionDao.insert(entity)
|
||||
if (id != -1L) imported++
|
||||
}
|
||||
imported
|
||||
}
|
||||
|
||||
suspend fun hasBackup(context: Context): Boolean = withContext(Dispatchers.IO) {
|
||||
val account = GoogleSignIn.getLastSignedInAccount(context) ?: return@withContext false
|
||||
val driveService = getDriveService(context, account)
|
||||
findBackupFile(driveService) != null
|
||||
}
|
||||
|
||||
private fun getDriveService(context: Context, account: GoogleSignInAccount): Drive {
|
||||
val credential = GoogleAccountCredential.usingOAuth2(
|
||||
context, listOf(DriveScopes.DRIVE_APPDATA)
|
||||
)
|
||||
credential.selectedAccount = account.account
|
||||
return Drive.Builder(NetHttpTransport(), GsonFactory.getDefaultInstance(), credential)
|
||||
.setApplicationName("Ledga")
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun findBackupFile(driveService: Drive): String? {
|
||||
val result = driveService.files().list()
|
||||
.setSpaces("appDataFolder")
|
||||
.setQ("name = '$BACKUP_FILENAME'")
|
||||
.setFields("files(id)")
|
||||
.execute()
|
||||
return result.files?.firstOrNull()?.id
|
||||
}
|
||||
|
||||
private fun TransactionEntity.toBackup() = BackupTransaction(
|
||||
transactionCode = transactionCode, type = type.name, amount = amount,
|
||||
transactionCost = transactionCost, recipientName = recipientName,
|
||||
recipientPhone = recipientPhone, accountNumber = accountNumber,
|
||||
destinationCountry = destinationCountry, balance = balance,
|
||||
direction = direction.name, categoryId = categoryId,
|
||||
fulizaAmount = fulizaAmount, fulizaOutstanding = fulizaOutstanding,
|
||||
reversedTransactionCode = reversedTransactionCode, rawSms = rawSms,
|
||||
timestamp = timestamp
|
||||
)
|
||||
|
||||
private fun BackupTransaction.toEntity() = TransactionEntity(
|
||||
transactionCode = transactionCode,
|
||||
type = try { TransactionType.valueOf(type) } catch (e: Exception) { TransactionType.UNKNOWN },
|
||||
amount = amount, transactionCost = transactionCost,
|
||||
recipientName = recipientName, recipientPhone = recipientPhone,
|
||||
accountNumber = accountNumber, destinationCountry = destinationCountry,
|
||||
balance = balance,
|
||||
direction = try { TransactionDirection.valueOf(direction) } catch (e: Exception) { TransactionDirection.OUTFLOW },
|
||||
categoryId = categoryId, fulizaAmount = fulizaAmount,
|
||||
fulizaOutstanding = fulizaOutstanding, reversedTransactionCode = reversedTransactionCode,
|
||||
rawSms = rawSms, timestamp = timestamp
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ledga.app.data.repository
|
||||
|
||||
import com.ledga.app.data.db.dao.CategoryDao
|
||||
import com.ledga.app.data.db.dao.CategoryRuleDao
|
||||
import com.ledga.app.data.db.entity.Category
|
||||
import com.ledga.app.data.db.entity.CategoryRule
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class CategoryRepository @Inject constructor(
|
||||
private val categoryDao: CategoryDao,
|
||||
private val categoryRuleDao: CategoryRuleDao
|
||||
) {
|
||||
fun getAllCategories(): Flow<List<Category>> = categoryDao.getAllCategories()
|
||||
|
||||
fun getAllRules(): Flow<List<CategoryRule>> = categoryRuleDao.getAllRules()
|
||||
|
||||
suspend fun getCategoryById(id: Long): Category? = categoryDao.getCategoryById(id)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.ledga.app.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.ledga.app.data.db.dao.CategoryDao
|
||||
import com.ledga.app.data.db.dao.TransactionDao
|
||||
import com.ledga.app.data.db.entity.TransactionEntity
|
||||
import com.ledga.app.data.parser.TransactionDirection
|
||||
import com.ledga.app.data.parser.TransactionType
|
||||
import com.ledga.app.util.CurrencyFormatter
|
||||
import com.ledga.app.util.DateUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Serializable
|
||||
data class ExportTransaction(
|
||||
val transactionCode: String,
|
||||
val type: String,
|
||||
val amount: Double,
|
||||
val transactionCost: Double,
|
||||
val recipientName: String?,
|
||||
val recipientPhone: String?,
|
||||
val accountNumber: String?,
|
||||
val destinationCountry: String?,
|
||||
val balance: Double,
|
||||
val direction: String,
|
||||
val categoryId: Long?,
|
||||
val fulizaAmount: Double?,
|
||||
val fulizaOutstanding: Double?,
|
||||
val reversedTransactionCode: String?,
|
||||
val rawSms: String,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ExportData(
|
||||
val version: Int = 1,
|
||||
val exportedAt: Long = System.currentTimeMillis(),
|
||||
val transactions: List<ExportTransaction>
|
||||
)
|
||||
|
||||
data class ExportResult(val success: Boolean, val count: Int)
|
||||
data class ImportFromFileResult(val total: Int, val imported: Int, val skipped: Int)
|
||||
|
||||
@Singleton
|
||||
class ExportImportRepository @Inject constructor(
|
||||
private val transactionDao: TransactionDao,
|
||||
private val categoryDao: CategoryDao
|
||||
) {
|
||||
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun exportToZip(context: Context, uri: Uri): ExportResult = withContext(Dispatchers.IO) {
|
||||
val transactions = transactionDao.getRecentTransactions(10000).first()
|
||||
val categories = categoryDao.getAllCategories().first()
|
||||
val categoryMap = categories.associateBy { it.id }
|
||||
|
||||
context.contentResolver.openOutputStream(uri)?.use { os ->
|
||||
ZipOutputStream(os).use { zip ->
|
||||
// CSV
|
||||
zip.putNextEntry(ZipEntry("transactions.csv"))
|
||||
val csvHeader = "Date,Time,Code,Type,Amount,Fee,Recipient,Phone,Account,Balance,Category\n"
|
||||
zip.write(csvHeader.toByteArray())
|
||||
transactions.forEach { twc ->
|
||||
val t = twc.transaction
|
||||
val cat = categoryMap[t.categoryId]?.name ?: ""
|
||||
val line = "${DateUtils.formatDate(t.timestamp)},${DateUtils.formatTime(t.timestamp)},${t.transactionCode},${t.type},${t.amount},${t.transactionCost},${csvEscape(t.recipientName ?: "")},${t.recipientPhone ?: ""},${t.accountNumber ?: ""},${t.balance},${csvEscape(cat)}\n"
|
||||
zip.write(line.toByteArray())
|
||||
}
|
||||
zip.closeEntry()
|
||||
|
||||
// JSON
|
||||
zip.putNextEntry(ZipEntry("data.json"))
|
||||
val exportData = ExportData(
|
||||
transactions = transactions.map { it.transaction.toExport() }
|
||||
)
|
||||
zip.write(json.encodeToString(exportData).toByteArray())
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
|
||||
ExportResult(success = true, count = transactions.size)
|
||||
}
|
||||
|
||||
suspend fun importFromZip(context: Context, uri: Uri): ImportFromFileResult = withContext(Dispatchers.IO) {
|
||||
var total = 0
|
||||
var imported = 0
|
||||
var skipped = 0
|
||||
|
||||
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
ZipInputStream(inputStream).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
if (entry.name == "data.json") {
|
||||
val reader = BufferedReader(InputStreamReader(zip))
|
||||
val content = reader.readText()
|
||||
val exportData = json.decodeFromString<ExportData>(content)
|
||||
total = exportData.transactions.size
|
||||
|
||||
exportData.transactions.forEach { et ->
|
||||
val entity = et.toEntity()
|
||||
val id = transactionDao.insert(entity)
|
||||
if (id != -1L) imported++ else skipped++
|
||||
}
|
||||
}
|
||||
zip.closeEntry()
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImportFromFileResult(total = total, imported = imported, skipped = skipped)
|
||||
}
|
||||
|
||||
private fun TransactionEntity.toExport() = ExportTransaction(
|
||||
transactionCode = transactionCode, type = type.name, amount = amount,
|
||||
transactionCost = transactionCost, recipientName = recipientName,
|
||||
recipientPhone = recipientPhone, accountNumber = accountNumber,
|
||||
destinationCountry = destinationCountry, balance = balance,
|
||||
direction = direction.name, categoryId = categoryId,
|
||||
fulizaAmount = fulizaAmount, fulizaOutstanding = fulizaOutstanding,
|
||||
reversedTransactionCode = reversedTransactionCode, rawSms = rawSms,
|
||||
timestamp = timestamp
|
||||
)
|
||||
|
||||
private fun ExportTransaction.toEntity() = TransactionEntity(
|
||||
transactionCode = transactionCode,
|
||||
type = try { TransactionType.valueOf(type) } catch (e: Exception) { TransactionType.UNKNOWN },
|
||||
amount = amount, transactionCost = transactionCost,
|
||||
recipientName = recipientName, recipientPhone = recipientPhone,
|
||||
accountNumber = accountNumber, destinationCountry = destinationCountry,
|
||||
balance = balance,
|
||||
direction = try { TransactionDirection.valueOf(direction) } catch (e: Exception) { TransactionDirection.OUTFLOW },
|
||||
categoryId = categoryId, fulizaAmount = fulizaAmount,
|
||||
fulizaOutstanding = fulizaOutstanding, reversedTransactionCode = reversedTransactionCode,
|
||||
rawSms = rawSms, timestamp = timestamp
|
||||
)
|
||||
|
||||
private fun csvEscape(value: String): String {
|
||||
return if (value.contains(",") || value.contains("\"") || value.contains("\n")) {
|
||||
"\"${value.replace("\"", "\"\"")}\""
|
||||
} else value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.ledga.app.data.repository
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.doublePreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.ledga.app.ui.theme.ThemeMode
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SettingsRepository @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>
|
||||
) {
|
||||
companion object {
|
||||
private val THEME_MODE = stringPreferencesKey("theme_mode")
|
||||
private val ONBOARDING_COMPLETED = booleanPreferencesKey("onboarding_completed")
|
||||
private val FONT_SCALE = stringPreferencesKey("font_scale")
|
||||
private val DAILY_SUMMARY = booleanPreferencesKey("daily_summary")
|
||||
private val DAILY_SUMMARY_HOUR = intPreferencesKey("daily_summary_hour")
|
||||
private val WEEKLY_SUMMARY = booleanPreferencesKey("weekly_summary")
|
||||
private val BUDGET_ALERTS = booleanPreferencesKey("budget_alerts")
|
||||
private val LARGE_TXN_ALERT = booleanPreferencesKey("large_txn_alert")
|
||||
private val LARGE_TXN_THRESHOLD = doublePreferencesKey("large_txn_threshold")
|
||||
}
|
||||
|
||||
fun getThemeMode(): Flow<ThemeMode> = dataStore.data.map { prefs ->
|
||||
when (prefs[THEME_MODE]) {
|
||||
"LIGHT" -> ThemeMode.LIGHT
|
||||
"DARK" -> ThemeMode.DARK
|
||||
else -> ThemeMode.SYSTEM
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setThemeMode(mode: ThemeMode) {
|
||||
dataStore.edit { it[THEME_MODE] = mode.name }
|
||||
}
|
||||
|
||||
fun hasCompletedOnboarding(): Flow<Boolean> = dataStore.data.map { prefs ->
|
||||
prefs[ONBOARDING_COMPLETED] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingCompleted() {
|
||||
dataStore.edit { it[ONBOARDING_COMPLETED] = true }
|
||||
}
|
||||
|
||||
// --- Notification settings ---
|
||||
|
||||
fun getDailySummaryEnabled(): Flow<Boolean> = dataStore.data.map { it[DAILY_SUMMARY] ?: true }
|
||||
suspend fun setDailySummaryEnabled(enabled: Boolean) { dataStore.edit { it[DAILY_SUMMARY] = enabled } }
|
||||
|
||||
fun getDailySummaryHour(): Flow<Int> = dataStore.data.map { it[DAILY_SUMMARY_HOUR] ?: 20 } // 8 PM default
|
||||
suspend fun setDailySummaryHour(hour: Int) { dataStore.edit { it[DAILY_SUMMARY_HOUR] = hour } }
|
||||
|
||||
fun getWeeklySummaryEnabled(): Flow<Boolean> = dataStore.data.map { it[WEEKLY_SUMMARY] ?: true }
|
||||
suspend fun setWeeklySummaryEnabled(enabled: Boolean) { dataStore.edit { it[WEEKLY_SUMMARY] = enabled } }
|
||||
|
||||
fun getBudgetAlertsEnabled(): Flow<Boolean> = dataStore.data.map { it[BUDGET_ALERTS] ?: true }
|
||||
suspend fun setBudgetAlertsEnabled(enabled: Boolean) { dataStore.edit { it[BUDGET_ALERTS] = enabled } }
|
||||
|
||||
fun getLargeTransactionAlertEnabled(): Flow<Boolean> = dataStore.data.map { it[LARGE_TXN_ALERT] ?: false }
|
||||
suspend fun setLargeTransactionAlertEnabled(enabled: Boolean) { dataStore.edit { it[LARGE_TXN_ALERT] = enabled } }
|
||||
|
||||
fun getLargeTransactionThreshold(): Flow<Double> = dataStore.data.map { it[LARGE_TXN_THRESHOLD] ?: 5000.0 }
|
||||
suspend fun setLargeTransactionThreshold(amount: Double) { dataStore.edit { it[LARGE_TXN_THRESHOLD] = amount } }
|
||||
|
||||
// --- Font scale ---
|
||||
|
||||
fun getFontScale(): Flow<FontScale> = dataStore.data.map { prefs ->
|
||||
when (prefs[FONT_SCALE]) {
|
||||
"SMALL" -> FontScale.SMALL
|
||||
"MEDIUM" -> FontScale.MEDIUM
|
||||
"LARGE" -> FontScale.LARGE
|
||||
"EXTRA_LARGE" -> FontScale.EXTRA_LARGE
|
||||
else -> FontScale.SYSTEM
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setFontScale(scale: FontScale) {
|
||||
dataStore.edit { it[FONT_SCALE] = scale.name }
|
||||
}
|
||||
}
|
||||
|
||||
enum class FontScale(val label: String, val scale: Float) {
|
||||
SYSTEM("System Default", 1.0f),
|
||||
SMALL("Small", 0.85f),
|
||||
MEDIUM("Medium", 1.0f),
|
||||
LARGE("Large", 1.15f),
|
||||
EXTRA_LARGE("Extra Large", 1.3f)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.ledga.app.data.repository
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import com.ledga.app.data.parser.MpesaSmsParser
|
||||
import com.ledga.app.data.parser.ParseResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class ImportResult(
|
||||
val total: Int,
|
||||
val imported: Int,
|
||||
val failed: Int
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class SmsImporter @Inject constructor(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val transactionRepository: TransactionRepository
|
||||
) {
|
||||
suspend fun importHistory(
|
||||
onProgress: (imported: Int, total: Int) -> Unit = { _, _ -> }
|
||||
): ImportResult = withContext(Dispatchers.IO) {
|
||||
val smsList = readMpesaSms()
|
||||
val total = smsList.size
|
||||
var imported = 0
|
||||
var failed = 0
|
||||
|
||||
smsList.forEachIndexed { index, sms ->
|
||||
val result = MpesaSmsParser.parse(sms.body, sms.date)
|
||||
when (result) {
|
||||
is ParseResult.Success -> {
|
||||
val id = transactionRepository.insertTransaction(result.transaction)
|
||||
if (id != -1L) imported++
|
||||
}
|
||||
is ParseResult.Failure -> failed++
|
||||
}
|
||||
onProgress(index + 1, total)
|
||||
}
|
||||
|
||||
ImportResult(total = total, imported = imported, failed = failed)
|
||||
}
|
||||
|
||||
private fun readMpesaSms(): List<SmsMessage> {
|
||||
val messages = mutableListOf<SmsMessage>()
|
||||
val uri = Uri.parse("content://sms/inbox")
|
||||
val cursor = contentResolver.query(
|
||||
uri,
|
||||
arrayOf("body", "date", "address"),
|
||||
"address = ?",
|
||||
arrayOf("MPESA"),
|
||||
"date DESC"
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
val bodyIndex = it.getColumnIndexOrThrow("body")
|
||||
val dateIndex = it.getColumnIndexOrThrow("date")
|
||||
while (it.moveToNext()) {
|
||||
messages.add(
|
||||
SmsMessage(
|
||||
body = it.getString(bodyIndex),
|
||||
date = it.getLong(dateIndex)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
private data class SmsMessage(val body: String, val date: Long)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.ledga.app.data.repository
|
||||
|
||||
import com.ledga.app.data.db.dao.CategoryRuleDao
|
||||
import com.ledga.app.data.db.dao.CategorySpending
|
||||
import com.ledga.app.data.db.dao.DailySpending
|
||||
import com.ledga.app.data.db.dao.TopMerchant
|
||||
import com.ledga.app.data.db.dao.TransactionDao
|
||||
import com.ledga.app.data.db.entity.MatchType
|
||||
import com.ledga.app.data.db.entity.TransactionEntity
|
||||
import com.ledga.app.data.db.entity.TransactionWithCategory
|
||||
import com.ledga.app.data.parser.ParsedTransaction
|
||||
import com.ledga.app.data.parser.TransactionType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class TransactionRepository @Inject constructor(
|
||||
private val transactionDao: TransactionDao,
|
||||
private val categoryRuleDao: CategoryRuleDao
|
||||
) {
|
||||
suspend fun insertTransaction(parsed: ParsedTransaction): Long {
|
||||
val categoryId = autoCategorize(parsed)
|
||||
val entity = TransactionEntity(
|
||||
transactionCode = parsed.transactionCode,
|
||||
type = parsed.type,
|
||||
amount = parsed.amount,
|
||||
transactionCost = parsed.transactionCost,
|
||||
recipientName = parsed.recipientName,
|
||||
recipientPhone = parsed.recipientPhone,
|
||||
accountNumber = parsed.accountNumber,
|
||||
destinationCountry = parsed.destinationCountry,
|
||||
balance = parsed.balance,
|
||||
direction = parsed.direction,
|
||||
categoryId = categoryId,
|
||||
fulizaAmount = parsed.fulizaAmount,
|
||||
fulizaOutstanding = parsed.fulizaOutstanding,
|
||||
reversedTransactionCode = parsed.reversedTransactionCode,
|
||||
rawSms = parsed.rawSms,
|
||||
timestamp = parsed.timestamp
|
||||
)
|
||||
return transactionDao.insert(entity)
|
||||
}
|
||||
|
||||
fun getRecentTransactions(limit: Int = 10): Flow<List<TransactionWithCategory>> =
|
||||
transactionDao.getRecentTransactions(limit)
|
||||
|
||||
fun getTransactions(startTime: Long, endTime: Long): Flow<List<TransactionWithCategory>> =
|
||||
transactionDao.getTransactions(startTime, endTime)
|
||||
|
||||
fun getLatestTransaction(): Flow<TransactionEntity?> =
|
||||
transactionDao.getLatestTransaction()
|
||||
|
||||
fun getSpendingByCategory(startTime: Long, endTime: Long): Flow<List<CategorySpending>> =
|
||||
transactionDao.getSpendingByCategory(startTime, endTime)
|
||||
|
||||
fun getTotalSpending(startTime: Long, endTime: Long): Flow<Double> =
|
||||
transactionDao.getTotalSpending(startTime, endTime)
|
||||
|
||||
fun getTotalFees(startTime: Long, endTime: Long): Flow<Double> =
|
||||
transactionDao.getTotalFees(startTime, endTime)
|
||||
|
||||
fun searchTransactions(query: String): Flow<List<TransactionWithCategory>> =
|
||||
transactionDao.searchTransactions(query)
|
||||
|
||||
fun getTransactionsByType(types: List<TransactionType>): Flow<List<TransactionWithCategory>> =
|
||||
transactionDao.getTransactionsByType(types)
|
||||
|
||||
fun getDailySpending(startTime: Long, endTime: Long): Flow<List<DailySpending>> =
|
||||
transactionDao.getDailySpending(startTime, endTime)
|
||||
|
||||
fun getTopMerchants(startTime: Long, endTime: Long, limit: Int = 5): Flow<List<TopMerchant>> =
|
||||
transactionDao.getTopMerchants(startTime, endTime, limit)
|
||||
|
||||
fun getUnparsedTransactions(): Flow<List<TransactionWithCategory>> =
|
||||
transactionDao.getUnparsedTransactions()
|
||||
|
||||
fun getUnparsedCount(): Flow<Int> =
|
||||
transactionDao.getUnparsedCount()
|
||||
|
||||
suspend fun updateCategory(transactionId: Long, categoryId: Long?) {
|
||||
transactionDao.updateCategory(transactionId, categoryId)
|
||||
}
|
||||
|
||||
private suspend fun autoCategorize(parsed: ParsedTransaction): Long? {
|
||||
// 1. Type-based defaults
|
||||
val typeDefault = when (parsed.type) {
|
||||
TransactionType.AIRTIME_SELF, TransactionType.AIRTIME_OTHER -> 4L // Airtime & Data
|
||||
TransactionType.SEND -> 6L // Send Money
|
||||
TransactionType.RECEIVED -> 7L // Received
|
||||
TransactionType.WITHDRAW_AGENT, TransactionType.WITHDRAW_ATM -> 8L // Withdrawal
|
||||
TransactionType.DEPOSIT -> 9L // Deposit
|
||||
TransactionType.MPESA_GLOBAL -> 11L // International
|
||||
TransactionType.MSHWARI, TransactionType.KCB_MPESA -> 12L // Savings & Loans
|
||||
TransactionType.FULIZA, TransactionType.FULIZA_REPAYMENT, TransactionType.FULIZA_REVERSAL -> 12L
|
||||
TransactionType.REVERSAL -> 13L // Other
|
||||
TransactionType.UNKNOWN -> 13L
|
||||
else -> null
|
||||
}
|
||||
|
||||
// 2. Rule-based matching (overrides type default for BUY_GOODS, PAY_BILL, SEND)
|
||||
if (parsed.recipientName != null) {
|
||||
val rules = categoryRuleDao.getAllRulesSync()
|
||||
val nameUpper = parsed.recipientName.uppercase()
|
||||
|
||||
// Check recipient name rules
|
||||
for (rule in rules) {
|
||||
if (rule.matchType == MatchType.RECIPIENT_NAME &&
|
||||
nameUpper.contains(rule.matchValue.uppercase())
|
||||
) {
|
||||
return rule.categoryId
|
||||
}
|
||||
}
|
||||
|
||||
// Check paybill rules
|
||||
if (parsed.accountNumber != null) {
|
||||
for (rule in rules) {
|
||||
if (rule.matchType == MatchType.PAYBILL &&
|
||||
parsed.accountNumber == rule.matchValue
|
||||
) {
|
||||
return rule.categoryId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return typeDefault ?: 13L // Default to "Other"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.ledga.app.di
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "ledga_settings")
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> {
|
||||
return context.dataStore
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideContentResolver(@ApplicationContext context: Context): ContentResolver {
|
||||
return context.contentResolver
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.ledga.app.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.ledga.app.data.db.AppDatabase
|
||||
import com.ledga.app.data.db.DefaultData
|
||||
import com.ledga.app.data.db.dao.BudgetDao
|
||||
import com.ledga.app.data.db.dao.CategoryDao
|
||||
import com.ledga.app.data.db.dao.CategoryRuleDao
|
||||
import com.ledga.app.data.db.dao.TransactionDao
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DatabaseModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
|
||||
return Room.databaseBuilder(
|
||||
context,
|
||||
AppDatabase::class.java,
|
||||
"ledga.db"
|
||||
)
|
||||
.addCallback(object : RoomDatabase.Callback() {
|
||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||
super.onCreate(db)
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val database = Room.databaseBuilder(
|
||||
context, AppDatabase::class.java, "ledga.db"
|
||||
).build()
|
||||
database.categoryDao().insertAll(DefaultData.DEFAULT_CATEGORIES)
|
||||
database.categoryRuleDao().insertAll(DefaultData.DEFAULT_RULES)
|
||||
}
|
||||
}
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideTransactionDao(db: AppDatabase): TransactionDao = db.transactionDao()
|
||||
|
||||
@Provides
|
||||
fun provideCategoryDao(db: AppDatabase): CategoryDao = db.categoryDao()
|
||||
|
||||
@Provides
|
||||
fun provideCategoryRuleDao(db: AppDatabase): CategoryRuleDao = db.categoryRuleDao()
|
||||
|
||||
@Provides
|
||||
fun provideBudgetDao(db: AppDatabase): BudgetDao = db.budgetDao()
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.ledga.app.receiver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Telephony
|
||||
import com.ledga.app.data.parser.MpesaSmsParser
|
||||
import com.ledga.app.data.parser.ParseResult
|
||||
import com.ledga.app.data.repository.SettingsRepository
|
||||
import com.ledga.app.data.repository.TransactionRepository
|
||||
import com.ledga.app.worker.TransactionAlerts
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SmsReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject
|
||||
lateinit var transactionRepository: TransactionRepository
|
||||
|
||||
@Inject
|
||||
lateinit var transactionAlerts: TransactionAlerts
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return
|
||||
|
||||
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)
|
||||
val pendingResult = goAsync()
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
// Group message parts by sender (multi-part SMS)
|
||||
val fullMessages = messages
|
||||
.groupBy { it.displayOriginatingAddress }
|
||||
.mapValues { (_, parts) -> parts.joinToString("") { it.displayMessageBody } }
|
||||
|
||||
for ((sender, body) in fullMessages) {
|
||||
if (!MpesaSmsParser.isMpesaMessage(sender ?: "")) continue
|
||||
|
||||
when (val result = MpesaSmsParser.parse(body, System.currentTimeMillis())) {
|
||||
is ParseResult.Success -> {
|
||||
transactionRepository.insertTransaction(result.transaction)
|
||||
// Check for alerts
|
||||
val largeTxnEnabled = settingsRepository.getLargeTransactionAlertEnabled().first()
|
||||
val largeTxnThreshold = settingsRepository.getLargeTransactionThreshold().first()
|
||||
val budgetEnabled = settingsRepository.getBudgetAlertsEnabled().first()
|
||||
transactionAlerts.checkAlerts(
|
||||
context = context,
|
||||
transaction = result.transaction,
|
||||
largeTransactionThreshold = largeTxnThreshold,
|
||||
largeTransactionEnabled = largeTxnEnabled,
|
||||
budgetAlertsEnabled = budgetEnabled
|
||||
)
|
||||
}
|
||||
is ParseResult.Failure -> {
|
||||
// Store as UNKNOWN for later review
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ledga.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ledga.app.ui.theme.InflowGreen
|
||||
import com.ledga.app.ui.theme.OutflowRed
|
||||
import com.ledga.app.util.CurrencyFormatter
|
||||
|
||||
@Composable
|
||||
fun BudgetProgressBar(
|
||||
label: String,
|
||||
spent: Double,
|
||||
limit: Double,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val progress = if (limit > 0) (spent / limit).toFloat().coerceIn(0f, 1f) else 0f
|
||||
val percentage = if (limit > 0) ((spent / limit) * 100).toInt() else 0
|
||||
val color = when {
|
||||
percentage >= 100 -> OutflowRed
|
||||
percentage >= 80 -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f)
|
||||
else -> InflowGreen
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "${CurrencyFormatter.formatKshCompact(spent)} / ${CurrencyFormatter.formatKshCompact(limit)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.fillMaxWidth().height(8.dp),
|
||||
color = color,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ledga.app.ui.components
|
||||
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun CategoryChip(
|
||||
label: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
FilterChip(
|
||||
modifier = modifier,
|
||||
selected = isSelected,
|
||||
onClick = onClick,
|
||||
label = { Text(label) }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.ledga.app.ui.components
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ledga.app.util.CurrencyFormatter
|
||||
|
||||
data class DonutSegment(
|
||||
val label: String,
|
||||
val value: Float,
|
||||
val color: Color
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DonutChart(
|
||||
segments: List<DonutSegment>,
|
||||
totalAmount: Double,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val animationProgress = remember { Animatable(0f) }
|
||||
|
||||
LaunchedEffect(segments) {
|
||||
animationProgress.snapTo(0f)
|
||||
animationProgress.animateTo(1f, animationSpec = tween(800))
|
||||
}
|
||||
|
||||
val total = segments.sumOf { it.value.toDouble() }.toFloat()
|
||||
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
Canvas(modifier = Modifier.size(180.dp)) {
|
||||
val strokeWidth = 32.dp.toPx()
|
||||
val radius = (size.minDimension - strokeWidth) / 2
|
||||
var startAngle = -90f
|
||||
|
||||
if (total == 0f) {
|
||||
drawArc(
|
||||
color = Color.LightGray,
|
||||
startAngle = 0f,
|
||||
sweepAngle = 360f,
|
||||
useCenter = false,
|
||||
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
|
||||
)
|
||||
return@Canvas
|
||||
}
|
||||
|
||||
segments.forEach { segment ->
|
||||
val sweep = (segment.value / total) * 360f * animationProgress.value
|
||||
drawArc(
|
||||
color = segment.color,
|
||||
startAngle = startAngle,
|
||||
sweepAngle = sweep,
|
||||
useCenter = false,
|
||||
style = Stroke(width = strokeWidth, cap = StrokeCap.Butt)
|
||||
)
|
||||
startAngle += (segment.value / total) * 360f * animationProgress.value
|
||||
}
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = CurrencyFormatter.formatKshCompact(totalAmount),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = "Total Spent",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.ledga.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
enum class Period(val label: String) {
|
||||
TODAY("Today"),
|
||||
THIS_WEEK("This Week"),
|
||||
THIS_MONTH("Month")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PeriodSelector(
|
||||
selected: Period,
|
||||
onSelect: (Period) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Period.entries.forEach { period ->
|
||||
CategoryChip(
|
||||
label = period.label,
|
||||
isSelected = selected == period,
|
||||
onClick = { onSelect(period) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.ledga.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun StatCard(
|
||||
label: String,
|
||||
value: String,
|
||||
subtitle: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.ledga.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ShoppingCart
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ledga.app.data.db.entity.Category
|
||||
import com.ledga.app.data.db.entity.TransactionEntity
|
||||
import com.ledga.app.data.parser.TransactionDirection
|
||||
import com.ledga.app.ui.theme.InflowGreen
|
||||
import com.ledga.app.ui.theme.OutflowRed
|
||||
import com.ledga.app.util.CurrencyFormatter
|
||||
import com.ledga.app.util.DateUtils
|
||||
|
||||
@Composable
|
||||
fun TransactionCard(
|
||||
transaction: TransactionEntity,
|
||||
category: Category?,
|
||||
onClick: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isInflow = transaction.direction == TransactionDirection.INFLOW
|
||||
val amountColor = if (isInflow) InflowGreen else OutflowRed
|
||||
val displayName = transaction.recipientName ?: transaction.type.name.replace("_", " ")
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Category icon
|
||||
Icon(
|
||||
imageVector = Icons.Default.ShoppingCart,
|
||||
contentDescription = category?.name,
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = category?.color?.let { parseColor(it) }
|
||||
?: MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// Name and time
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
text = DateUtils.formatTime(transaction.timestamp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Amount
|
||||
Text(
|
||||
text = CurrencyFormatter.formatKshSigned(transaction.amount, isInflow),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = amountColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parseColor(hex: String): Color {
|
||||
return try {
|
||||
Color(android.graphics.Color.parseColor(hex))
|
||||
} catch (e: Exception) {
|
||||
Color.Gray
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.ledga.app.ui.home
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.ledga.app.ui.components.DonutChart
|
||||
import com.ledga.app.ui.components.PeriodSelector
|
||||
import com.ledga.app.ui.components.StatCard
|
||||
import com.ledga.app.ui.components.TransactionCard
|
||||
import com.ledga.app.util.CurrencyFormatter
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
viewModel: HomeViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Greeting
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = state.greeting,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
|
||||
// Balance card
|
||||
item {
|
||||
StatCard(
|
||||
label = "M-PESA Balance",
|
||||
value = state.balance?.let { CurrencyFormatter.formatKsh(it) } ?: "—",
|
||||
subtitle = "from last transaction",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// Spending summary
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
StatCard(
|
||||
label = state.monthLabel,
|
||||
value = CurrencyFormatter.formatKshCompact(state.totalSpending),
|
||||
subtitle = "Spent",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
StatCard(
|
||||
label = "Fees",
|
||||
value = CurrencyFormatter.formatKsh(state.totalFees),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Period selector
|
||||
item {
|
||||
PeriodSelector(
|
||||
selected = state.selectedPeriod,
|
||||
onSelect = { viewModel.selectPeriod(it) }
|
||||
)
|
||||
}
|
||||
|
||||
// Donut chart
|
||||
item {
|
||||
if (state.donutSegments.isNotEmpty()) {
|
||||
DonutChart(
|
||||
segments = state.donutSegments,
|
||||
totalAmount = state.totalSpending,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Recent transactions header
|
||||
item {
|
||||
Text(
|
||||
text = "Recent Transactions",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
|
||||
// Transaction list
|
||||
if (state.recentTransactions.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "No transactions yet. Your M-Pesa transactions will appear here.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 24.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
items = state.recentTransactions,
|
||||
key = { it.transaction.id }
|
||||
) { twc ->
|
||||
TransactionCard(
|
||||
transaction = twc.transaction,
|
||||
category = twc.category
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.ledga.app.ui.home
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import android.app.Application
|
||||
import com.ledga.app.BuildConfig
|
||||
import com.ledga.app.data.db.dao.CategorySpending
|
||||
import com.ledga.app.data.db.entity.Category
|
||||
import com.ledga.app.data.db.entity.TransactionWithCategory
|
||||
import com.ledga.app.data.repository.CategoryRepository
|
||||
import com.ledga.app.data.repository.TransactionRepository
|
||||
import com.ledga.app.ui.components.DonutSegment
|
||||
import com.ledga.app.ui.components.Period
|
||||
import com.ledga.app.ui.components.parseColor
|
||||
import com.ledga.app.util.DateUtils
|
||||
import com.ledga.app.worker.GitHubRelease
|
||||
import com.ledga.app.worker.UpdateChecker
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import android.content.Context
|
||||
import javax.inject.Inject
|
||||
|
||||
data class HomeUiState(
|
||||
val greeting: String = "",
|
||||
val balance: Double? = null,
|
||||
val totalSpending: Double = 0.0,
|
||||
val totalFees: Double = 0.0,
|
||||
val donutSegments: List<DonutSegment> = emptyList(),
|
||||
val recentTransactions: List<TransactionWithCategory> = emptyList(),
|
||||
val selectedPeriod: Period = Period.THIS_MONTH,
|
||||
val monthLabel: String = "",
|
||||
val updateAvailable: GitHubRelease? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(
|
||||
@ApplicationContext private val appContext: Context,
|
||||
private val transactionRepository: TransactionRepository,
|
||||
private val categoryRepository: CategoryRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _updateAvailable = MutableStateFlow<GitHubRelease?>(null)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val release = UpdateChecker.checkForUpdate(
|
||||
context = appContext,
|
||||
owner = "ngenohkevin",
|
||||
repo = "ledga",
|
||||
currentVersion = BuildConfig.VERSION_NAME
|
||||
)
|
||||
_updateAvailable.value = release
|
||||
}
|
||||
}
|
||||
|
||||
private val _selectedPeriod = MutableStateFlow(Period.THIS_MONTH)
|
||||
|
||||
private val timeRange = _selectedPeriod.flatMapLatest { period ->
|
||||
val now = System.currentTimeMillis()
|
||||
val start = when (period) {
|
||||
Period.TODAY -> DateUtils.getStartOfDay(now)
|
||||
Period.THIS_WEEK -> DateUtils.getStartOfWeek(now)
|
||||
Period.THIS_MONTH -> DateUtils.getStartOfMonth(now)
|
||||
}
|
||||
kotlinx.coroutines.flow.flowOf(start to now)
|
||||
}
|
||||
|
||||
val uiState: StateFlow<HomeUiState> = combine(
|
||||
_selectedPeriod,
|
||||
timeRange,
|
||||
transactionRepository.getLatestTransaction(),
|
||||
transactionRepository.getRecentTransactions(10),
|
||||
categoryRepository.getAllCategories()
|
||||
) { period, (start, end), latestTransaction, recentTransactions, categories ->
|
||||
val categoryMap = categories.associateBy { it.id }
|
||||
|
||||
HomeUiState(
|
||||
greeting = DateUtils.greeting(),
|
||||
balance = latestTransaction?.balance,
|
||||
recentTransactions = recentTransactions,
|
||||
selectedPeriod = period,
|
||||
monthLabel = DateUtils.formatMonthYear(System.currentTimeMillis())
|
||||
)
|
||||
}.combine(
|
||||
timeRange.flatMapLatest { (start, end) ->
|
||||
combine(
|
||||
transactionRepository.getTotalSpending(start, end),
|
||||
transactionRepository.getTotalFees(start, end),
|
||||
transactionRepository.getSpendingByCategory(start, end),
|
||||
categoryRepository.getAllCategories()
|
||||
) { spending, fees, categorySpending, categories ->
|
||||
Triple(spending, fees, buildDonutSegments(categorySpending, categories))
|
||||
}
|
||||
}
|
||||
) { state, (spending, fees, segments) ->
|
||||
state.copy(
|
||||
totalSpending = spending,
|
||||
totalFees = fees,
|
||||
donutSegments = segments
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomeUiState())
|
||||
|
||||
fun selectPeriod(period: Period) {
|
||||
_selectedPeriod.value = period
|
||||
}
|
||||
|
||||
private fun buildDonutSegments(
|
||||
spending: List<CategorySpending>,
|
||||
categories: List<Category>
|
||||
): List<DonutSegment> {
|
||||
val categoryMap = categories.associateBy { it.id }
|
||||
return spending
|
||||
.sortedByDescending { it.totalAmount }
|
||||
.take(6)
|
||||
.map { cs ->
|
||||
val cat = categoryMap[cs.categoryId]
|
||||
DonutSegment(
|
||||
label = cat?.name ?: "Other",
|
||||
value = cs.totalAmount.toFloat(),
|
||||
color = cat?.color?.let { parseColor(it) } ?: Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.ledga.app.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import com.ledga.app.ui.home.HomeScreen
|
||||
import com.ledga.app.ui.onboarding.OnboardingScreen
|
||||
import com.ledga.app.ui.settings.BudgetScreen
|
||||
import com.ledga.app.ui.settings.SettingsScreen
|
||||
import com.ledga.app.ui.settings.UnparsedSmsScreen
|
||||
import com.ledga.app.ui.transactions.TransactionsScreen
|
||||
import com.ledga.app.ui.trends.TrendsScreen
|
||||
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
navController: NavHostController,
|
||||
startDestination: Any,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = modifier
|
||||
) {
|
||||
composable<OnboardingRoute> {
|
||||
OnboardingScreen(
|
||||
onComplete = {
|
||||
navController.navigate(HomeRoute) {
|
||||
popUpTo(OnboardingRoute) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<HomeRoute> {
|
||||
HomeScreen()
|
||||
}
|
||||
composable<TransactionsRoute> {
|
||||
TransactionsScreen()
|
||||
}
|
||||
composable<TrendsRoute> {
|
||||
TrendsScreen()
|
||||
}
|
||||
composable<SettingsRoute> {
|
||||
SettingsScreen(
|
||||
onNavigateToUnparsed = { navController.navigate(UnparsedSmsRoute) },
|
||||
onNavigateToBudgets = { navController.navigate(BudgetRoute) }
|
||||
)
|
||||
}
|
||||
composable<UnparsedSmsRoute> {
|
||||
UnparsedSmsScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable<BudgetRoute> {
|
||||
BudgetScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.ledga.app.ui.navigation
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.List
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.TrendingUp
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
|
||||
data class BottomNavItem<T : Any>(
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
val route: T
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LedgaBottomNavBar(navController: NavController) {
|
||||
val items = listOf(
|
||||
BottomNavItem("Home", Icons.Default.Home, HomeRoute),
|
||||
BottomNavItem("Transactions", Icons.Default.List, TransactionsRoute),
|
||||
BottomNavItem("Trends", Icons.Default.TrendingUp, TrendsRoute),
|
||||
BottomNavItem("Settings", Icons.Default.Settings, SettingsRoute),
|
||||
)
|
||||
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
NavigationBar {
|
||||
items.forEach { item ->
|
||||
NavigationBarItem(
|
||||
icon = { Icon(item.icon, contentDescription = item.label) },
|
||||
label = { Text(item.label) },
|
||||
selected = currentDestination?.hasRoute(item.route::class) == true,
|
||||
onClick = {
|
||||
navController.navigate(item.route) {
|
||||
popUpTo(HomeRoute) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ledga.app.ui.navigation
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable object HomeRoute
|
||||
@Serializable object TransactionsRoute
|
||||
@Serializable object TrendsRoute
|
||||
@Serializable object SettingsRoute
|
||||
@Serializable object OnboardingRoute
|
||||
@Serializable object UnparsedSmsRoute
|
||||
@Serializable object BudgetRoute
|
||||
@@ -0,0 +1,266 @@
|
||||
package com.ledga.app.ui.onboarding
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.MailOutline
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
onComplete: () -> Unit,
|
||||
viewModel: OnboardingViewModel = hiltViewModel()
|
||||
) {
|
||||
val currentStep by viewModel.currentStep.collectAsState()
|
||||
val importProgress by viewModel.importProgress.collectAsState()
|
||||
|
||||
val smsPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { permissions ->
|
||||
if (permissions.values.any { it }) {
|
||||
viewModel.nextStep()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
AnimatedContent(targetState = currentStep, label = "onboarding") { step ->
|
||||
when (step) {
|
||||
0 -> WelcomeStep(onNext = { viewModel.nextStep() })
|
||||
1 -> SmsPermissionStep(
|
||||
onRequestPermission = {
|
||||
val permissions = mutableListOf(
|
||||
Manifest.permission.RECEIVE_SMS,
|
||||
Manifest.permission.READ_SMS
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
smsPermissionLauncher.launch(permissions.toTypedArray())
|
||||
}
|
||||
)
|
||||
2 -> ImportStep(
|
||||
progress = importProgress,
|
||||
onImport = { viewModel.startImport() },
|
||||
onSkip = { viewModel.skipImport() }
|
||||
)
|
||||
3 -> CompletionStep(
|
||||
importResult = importProgress.result,
|
||||
onComplete = {
|
||||
viewModel.completeOnboarding()
|
||||
onComplete()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Step indicators
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
repeat(4) { index ->
|
||||
Icon(
|
||||
imageVector = if (index <= currentStep) Icons.Default.CheckCircle else Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = if (index <= currentStep) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomeStep(onNext: () -> Unit) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Welcome to Ledga",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Track your M-Pesa spending automatically.\nNo typing, no manual entry.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Get Started")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SmsPermissionStep(onRequestPermission: () -> Unit) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "SMS Access",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Ledga reads your M-Pesa SMS to track your spending.\n\nYour messages never leave your phone. Everything stays private and offline.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Button(onClick = onRequestPermission, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Allow SMS Access")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImportStep(
|
||||
progress: ImportProgress,
|
||||
onImport: () -> Unit,
|
||||
onSkip: () -> Unit
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MailOutline,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Import History",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Scan your existing M-Pesa messages to build your spending history.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (progress.isRunning) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
LinearProgressIndicator(
|
||||
progress = {
|
||||
if (progress.total > 0) progress.current.toFloat() / progress.total
|
||||
else 0f
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Processing ${progress.current} of ${progress.total} messages...",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(onClick = onImport, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Import History")
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedButton(onClick = onSkip, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Skip — start fresh")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompletionStep(
|
||||
importResult: com.ledga.app.data.repository.ImportResult?,
|
||||
onComplete: () -> Unit
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "You're all set!",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
val subtitle = if (importResult != null) {
|
||||
"Imported ${importResult.imported} transactions.\nLedga will now track your M-Pesa spending."
|
||||
} else {
|
||||
"Ledga will now track your M-Pesa spending in the background."
|
||||
}
|
||||
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Button(onClick = onComplete, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Go to Dashboard")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.ledga.app.ui.onboarding
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.ledga.app.data.repository.ImportResult
|
||||
import com.ledga.app.data.repository.SettingsRepository
|
||||
import com.ledga.app.data.repository.SmsImporter
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ImportProgress(
|
||||
val current: Int = 0,
|
||||
val total: Int = 0,
|
||||
val isRunning: Boolean = false,
|
||||
val result: ImportResult? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class OnboardingViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val smsImporter: SmsImporter
|
||||
) : ViewModel() {
|
||||
|
||||
private val _currentStep = MutableStateFlow(0)
|
||||
val currentStep: StateFlow<Int> = _currentStep
|
||||
|
||||
private val _importProgress = MutableStateFlow(ImportProgress())
|
||||
val importProgress: StateFlow<ImportProgress> = _importProgress
|
||||
|
||||
fun nextStep() {
|
||||
_currentStep.value = (_currentStep.value + 1).coerceAtMost(3)
|
||||
}
|
||||
|
||||
fun startImport() {
|
||||
viewModelScope.launch {
|
||||
_importProgress.value = ImportProgress(isRunning = true)
|
||||
val result = smsImporter.importHistory { current, total ->
|
||||
_importProgress.value = ImportProgress(
|
||||
current = current,
|
||||
total = total,
|
||||
isRunning = true
|
||||
)
|
||||
}
|
||||
_importProgress.value = ImportProgress(
|
||||
current = result.total,
|
||||
total = result.total,
|
||||
isRunning = false,
|
||||
result = result
|
||||
)
|
||||
nextStep()
|
||||
}
|
||||
}
|
||||
|
||||
fun skipImport() {
|
||||
nextStep()
|
||||
}
|
||||
|
||||
fun completeOnboarding() {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setOnboardingCompleted()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.ledga.app.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.ledga.app.ui.components.BudgetProgressBar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BudgetScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: BudgetViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
var overallBudgetInput by remember { mutableStateOf("") }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Budgets") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Overall budget
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Overall Monthly Budget",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
if (state.overallBudget != null) {
|
||||
BudgetProgressBar(
|
||||
label = "This Month",
|
||||
spent = state.totalSpentThisMonth,
|
||||
limit = state.overallBudget!!
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = overallBudgetInput,
|
||||
onValueChange = { overallBudgetInput = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text("Ksh") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
overallBudgetInput.toDoubleOrNull()?.let {
|
||||
viewModel.setOverallBudget(it)
|
||||
overallBudgetInput = ""
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Set")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Category budgets
|
||||
item {
|
||||
Text(
|
||||
text = "Category Budgets",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
|
||||
items(state.categoryBudgets) { cb ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
BudgetProgressBar(
|
||||
label = cb.categoryName,
|
||||
spent = cb.spent,
|
||||
limit = cb.limit
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.categoryBudgets.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "Set an overall budget above to start tracking.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.ledga.app.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.ledga.app.data.db.dao.BudgetDao
|
||||
import com.ledga.app.data.db.entity.Budget
|
||||
import com.ledga.app.data.repository.CategoryRepository
|
||||
import com.ledga.app.data.repository.TransactionRepository
|
||||
import com.ledga.app.util.DateUtils
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class CategoryBudgetItem(
|
||||
val categoryName: String,
|
||||
val spent: Double,
|
||||
val limit: Double
|
||||
)
|
||||
|
||||
data class BudgetUiState(
|
||||
val overallBudget: Double? = null,
|
||||
val totalSpentThisMonth: Double = 0.0,
|
||||
val categoryBudgets: List<CategoryBudgetItem> = emptyList()
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class BudgetViewModel @Inject constructor(
|
||||
private val budgetDao: BudgetDao,
|
||||
private val transactionRepository: TransactionRepository,
|
||||
private val categoryRepository: CategoryRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val monthStart = DateUtils.getStartOfMonth()
|
||||
private val now = System.currentTimeMillis()
|
||||
|
||||
val uiState: StateFlow<BudgetUiState> = combine(
|
||||
budgetDao.getActiveBudgets(),
|
||||
transactionRepository.getTotalSpending(monthStart, now),
|
||||
transactionRepository.getSpendingByCategory(monthStart, now),
|
||||
categoryRepository.getAllCategories()
|
||||
) { budgets, totalSpent, categorySpending, categories ->
|
||||
val overallBudget = budgets.find { it.categoryId == null }?.monthlyLimit
|
||||
val catMap = categories.associateBy { it.id }
|
||||
val spendingMap = categorySpending.associate { it.categoryId to it.totalAmount }
|
||||
|
||||
val categoryBudgets = budgets
|
||||
.filter { it.categoryId != null }
|
||||
.mapNotNull { budget ->
|
||||
val cat = catMap[budget.categoryId] ?: return@mapNotNull null
|
||||
CategoryBudgetItem(
|
||||
categoryName = cat.name,
|
||||
spent = spendingMap[budget.categoryId] ?: 0.0,
|
||||
limit = budget.monthlyLimit
|
||||
)
|
||||
}
|
||||
|
||||
BudgetUiState(
|
||||
overallBudget = overallBudget,
|
||||
totalSpentThisMonth = totalSpent,
|
||||
categoryBudgets = categoryBudgets
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BudgetUiState())
|
||||
|
||||
fun setOverallBudget(amount: Double) {
|
||||
viewModelScope.launch {
|
||||
budgetDao.insert(Budget(categoryId = null, monthlyLimit = amount, isActive = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package com.ledga.app.ui.settings
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.ledga.app.data.repository.FontScale
|
||||
import com.ledga.app.ui.theme.ThemeMode
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onNavigateToUnparsed: () -> Unit,
|
||||
onNavigateToBudgets: () -> Unit,
|
||||
viewModel: SettingsViewModel = hiltViewModel()
|
||||
) {
|
||||
val themeMode by viewModel.themeMode.collectAsState()
|
||||
val fontScale by viewModel.fontScale.collectAsState()
|
||||
val dailySummary by viewModel.dailySummaryEnabled.collectAsState()
|
||||
val weeklySummary by viewModel.weeklySummaryEnabled.collectAsState()
|
||||
val budgetAlerts by viewModel.budgetAlertsEnabled.collectAsState()
|
||||
val largeTxnAlert by viewModel.largeTxnAlertEnabled.collectAsState()
|
||||
val unparsedCount by viewModel.unparsedCount.collectAsState()
|
||||
val importStatus by viewModel.importStatus.collectAsState()
|
||||
val exportResult by viewModel.exportResult.collectAsState()
|
||||
val fileImportResult by viewModel.fileImportResult.collectAsState()
|
||||
|
||||
val exportLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/zip")
|
||||
) { uri -> uri?.let { viewModel.exportData(it) } }
|
||||
|
||||
val importLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
) { uri -> uri?.let { viewModel.importData(it) } }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("Settings", style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
// Appearance
|
||||
SettingsSection(title = "Appearance") {
|
||||
ThemeSelector(
|
||||
currentMode = themeMode,
|
||||
onSelect = { viewModel.setThemeMode(it) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text("Font Size", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
FontScale.entries.forEach { scale ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { viewModel.setFontScale(scale) }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = fontScale == scale,
|
||||
onClick = { viewModel.setFontScale(scale) }
|
||||
)
|
||||
Text(text = scale.label, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notifications
|
||||
SettingsSection(title = "Notifications") {
|
||||
SettingsToggle("Daily Summary", dailySummary) { viewModel.setDailySummaryEnabled(it) }
|
||||
SettingsToggle("Weekly Summary", weeklySummary) { viewModel.setWeeklySummaryEnabled(it) }
|
||||
SettingsToggle("Budget Alerts", budgetAlerts) { viewModel.setBudgetAlertsEnabled(it) }
|
||||
SettingsToggle("Large Transaction Alert", largeTxnAlert) { viewModel.setLargeTxnAlertEnabled(it) }
|
||||
}
|
||||
|
||||
// Budgets
|
||||
SettingsSection(title = "Budgets") {
|
||||
Text(
|
||||
text = "Manage Budgets",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onNavigateToBudgets)
|
||||
.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Data
|
||||
SettingsSection(title = "Data") {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.importSmsHistory() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Import SMS History")
|
||||
}
|
||||
|
||||
if (importStatus != null) {
|
||||
Text(
|
||||
text = "Imported ${importStatus!!.imported} of ${importStatus!!.total} messages",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = { exportLauncher.launch("ledga-export.zip") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Export Data (CSV + JSON)")
|
||||
}
|
||||
if (exportResult != null) {
|
||||
Text(
|
||||
text = "Exported ${exportResult!!.count} transactions",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
OutlinedButton(
|
||||
onClick = { importLauncher.launch(arrayOf("application/zip")) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Import Data from ZIP")
|
||||
}
|
||||
if (fileImportResult != null) {
|
||||
Text(
|
||||
text = "Imported ${fileImportResult!!.imported} of ${fileImportResult!!.total} (${fileImportResult!!.skipped} duplicates skipped)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
if (unparsedCount > 0) {
|
||||
Text(
|
||||
text = "Unparsed Messages ($unparsedCount)",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onNavigateToUnparsed)
|
||||
.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Backup
|
||||
SettingsSection(title = "Backup") {
|
||||
val backupStatus by viewModel.backupStatus.collectAsState()
|
||||
val signedIn = viewModel.isSignedIn()
|
||||
val email = viewModel.getAccountEmail()
|
||||
|
||||
if (signedIn && email != null) {
|
||||
Text(
|
||||
text = "Google Account: $email",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.backupNow() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Back Up Now")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.restoreBackup() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Restore from Backup")
|
||||
}
|
||||
|
||||
if (backupStatus != null) {
|
||||
Text(
|
||||
text = backupStatus!!,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// About
|
||||
SettingsSection(title = "About") {
|
||||
Text(
|
||||
text = "Ledga v1.0.0",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "All data stays on your device.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsSection(
|
||||
title: String,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeSelector(
|
||||
currentMode: ThemeMode,
|
||||
onSelect: (ThemeMode) -> Unit
|
||||
) {
|
||||
ThemeMode.entries.forEach { mode ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelect(mode) }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = currentMode == mode,
|
||||
onClick = { onSelect(mode) }
|
||||
)
|
||||
Text(
|
||||
text = mode.name.lowercase().replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsToggle(
|
||||
label: String,
|
||||
checked: Boolean,
|
||||
onToggle: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Switch(checked = checked, onCheckedChange = onToggle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.ledga.app.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import android.net.Uri
|
||||
import com.ledga.app.data.repository.BackupRepository
|
||||
import com.ledga.app.data.repository.ExportImportRepository
|
||||
import com.ledga.app.data.repository.ExportResult
|
||||
import com.ledga.app.data.repository.FontScale
|
||||
import com.ledga.app.data.repository.ImportFromFileResult
|
||||
import com.ledga.app.data.repository.ImportResult
|
||||
import com.ledga.app.data.repository.SettingsRepository
|
||||
import com.ledga.app.data.repository.SmsImporter
|
||||
import com.ledga.app.data.repository.TransactionRepository
|
||||
import com.ledga.app.ui.theme.ThemeMode
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
@ApplicationContext private val appContext: Context,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val transactionRepository: TransactionRepository,
|
||||
private val smsImporter: SmsImporter,
|
||||
private val exportImportRepository: ExportImportRepository,
|
||||
private val backupRepository: BackupRepository
|
||||
) : ViewModel() {
|
||||
|
||||
val themeMode: StateFlow<ThemeMode> = settingsRepository.getThemeMode()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ThemeMode.SYSTEM)
|
||||
|
||||
val unparsedCount: StateFlow<Int> = transactionRepository.getUnparsedCount()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
|
||||
val fontScale: StateFlow<FontScale> = settingsRepository.getFontScale()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), FontScale.SYSTEM)
|
||||
|
||||
val dailySummaryEnabled: StateFlow<Boolean> = settingsRepository.getDailySummaryEnabled()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
|
||||
|
||||
val weeklySummaryEnabled: StateFlow<Boolean> = settingsRepository.getWeeklySummaryEnabled()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
|
||||
|
||||
val budgetAlertsEnabled: StateFlow<Boolean> = settingsRepository.getBudgetAlertsEnabled()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
|
||||
|
||||
val largeTxnAlertEnabled: StateFlow<Boolean> = settingsRepository.getLargeTransactionAlertEnabled()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
val largeTxnThreshold: StateFlow<Double> = settingsRepository.getLargeTransactionThreshold()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 5000.0)
|
||||
|
||||
private val _importStatus = MutableStateFlow<ImportResult?>(null)
|
||||
val importStatus: StateFlow<ImportResult?> = _importStatus
|
||||
|
||||
fun setThemeMode(mode: ThemeMode) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setThemeMode(mode)
|
||||
}
|
||||
}
|
||||
|
||||
fun setFontScale(scale: FontScale) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setFontScale(scale)
|
||||
}
|
||||
}
|
||||
|
||||
fun setDailySummaryEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { settingsRepository.setDailySummaryEnabled(enabled) }
|
||||
}
|
||||
|
||||
fun setWeeklySummaryEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { settingsRepository.setWeeklySummaryEnabled(enabled) }
|
||||
}
|
||||
|
||||
fun setBudgetAlertsEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { settingsRepository.setBudgetAlertsEnabled(enabled) }
|
||||
}
|
||||
|
||||
fun setLargeTxnAlertEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { settingsRepository.setLargeTransactionAlertEnabled(enabled) }
|
||||
}
|
||||
|
||||
fun setLargeTxnThreshold(amount: Double) {
|
||||
viewModelScope.launch { settingsRepository.setLargeTransactionThreshold(amount) }
|
||||
}
|
||||
|
||||
fun importSmsHistory() {
|
||||
viewModelScope.launch {
|
||||
val result = smsImporter.importHistory()
|
||||
_importStatus.value = result
|
||||
}
|
||||
}
|
||||
|
||||
private val _exportResult = MutableStateFlow<ExportResult?>(null)
|
||||
val exportResult: StateFlow<ExportResult?> = _exportResult
|
||||
|
||||
private val _fileImportResult = MutableStateFlow<ImportFromFileResult?>(null)
|
||||
val fileImportResult: StateFlow<ImportFromFileResult?> = _fileImportResult
|
||||
|
||||
fun exportData(uri: Uri) {
|
||||
viewModelScope.launch {
|
||||
_exportResult.value = exportImportRepository.exportToZip(appContext, uri)
|
||||
}
|
||||
}
|
||||
|
||||
fun importData(uri: Uri) {
|
||||
viewModelScope.launch {
|
||||
_fileImportResult.value = exportImportRepository.importFromZip(appContext, uri)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Google Drive Backup ---
|
||||
|
||||
fun isSignedIn(): Boolean = backupRepository.isSignedIn(appContext)
|
||||
fun getAccountEmail(): String? = backupRepository.getAccountEmail(appContext)
|
||||
fun getSignInIntent() = backupRepository.getSignInIntent(appContext)
|
||||
|
||||
private val _backupStatus = MutableStateFlow<String?>(null)
|
||||
val backupStatus: StateFlow<String?> = _backupStatus
|
||||
|
||||
fun backupNow() {
|
||||
viewModelScope.launch {
|
||||
_backupStatus.value = "Backing up..."
|
||||
val success = backupRepository.performBackup(appContext)
|
||||
_backupStatus.value = if (success) "Backup complete" else "Backup failed — sign in first"
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreBackup() {
|
||||
viewModelScope.launch {
|
||||
_backupStatus.value = "Restoring..."
|
||||
val count = backupRepository.restoreBackup(appContext)
|
||||
_backupStatus.value = "Restored $count transactions"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.ledga.app.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.ledga.app.data.repository.TransactionRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UnparsedSmsScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: UnparsedSmsViewModel = hiltViewModel()
|
||||
) {
|
||||
val transactions by viewModel.unparsedTransactions.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Unparsed Messages") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (transactions.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "No unparsed messages.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(transactions) { twc ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = twc.transaction.rawSms,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ledga.app.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.ledga.app.data.db.entity.TransactionWithCategory
|
||||
import com.ledga.app.data.repository.TransactionRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class UnparsedSmsViewModel @Inject constructor(
|
||||
transactionRepository: TransactionRepository
|
||||
) : ViewModel() {
|
||||
|
||||
val unparsedTransactions: StateFlow<List<TransactionWithCategory>> =
|
||||
transactionRepository.getUnparsedTransactions()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ledga.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Primary brand color (M-Pesa green)
|
||||
val MpesaGreen = Color(0xFF4CAF50)
|
||||
val MpesaGreenDark = Color(0xFF388E3C)
|
||||
val MpesaGreenLight = Color(0xFFC8E6C9)
|
||||
|
||||
// Category colors
|
||||
val CategoryGroceries = Color(0xFF4CAF50)
|
||||
val CategoryTransport = Color(0xFF2196F3)
|
||||
val CategoryBills = Color(0xFFFF9800)
|
||||
val CategoryAirtime = Color(0xFF9C27B0)
|
||||
val CategoryFood = Color(0xFFE91E63)
|
||||
val CategorySendMoney = Color(0xFF607D8B)
|
||||
val CategoryReceived = Color(0xFF00BCD4)
|
||||
val CategoryWithdrawal = Color(0xFF795548)
|
||||
val CategoryDeposit = Color(0xFF8BC34A)
|
||||
val CategoryShopping = Color(0xFFFF5722)
|
||||
val CategoryInternational = Color(0xFF3F51B5)
|
||||
val CategorySavings = Color(0xFF009688)
|
||||
val CategoryOther = Color(0xFF9E9E9E)
|
||||
|
||||
// Semantic colors
|
||||
val InflowGreen = Color(0xFF2E7D32)
|
||||
val OutflowRed = Color(0xFFC62828)
|
||||
val InflowGreenLight = Color(0xFF66BB6A)
|
||||
val OutflowRedLight = Color(0xFFEF5350)
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ledga.app.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = MpesaGreen,
|
||||
onPrimary = androidx.compose.ui.graphics.Color.White,
|
||||
primaryContainer = MpesaGreenLight,
|
||||
onPrimaryContainer = MpesaGreenDark,
|
||||
)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = MpesaGreenLight,
|
||||
onPrimary = MpesaGreenDark,
|
||||
primaryContainer = MpesaGreen,
|
||||
onPrimaryContainer = MpesaGreenLight,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LedgaTheme(
|
||||
themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val darkTheme = when (themeMode) {
|
||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
}
|
||||
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = LedgaTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.ledga.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
|
||||
val LedgaTypography = Typography()
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.ledga.app.ui.transactions
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ledga.app.data.db.entity.Category
|
||||
import com.ledga.app.data.db.entity.TransactionEntity
|
||||
import com.ledga.app.data.parser.TransactionDirection
|
||||
import com.ledga.app.ui.components.parseColor
|
||||
import com.ledga.app.ui.theme.InflowGreen
|
||||
import com.ledga.app.ui.theme.OutflowRed
|
||||
import com.ledga.app.util.CurrencyFormatter
|
||||
import com.ledga.app.util.DateUtils
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TransactionDetailSheet(
|
||||
transaction: TransactionEntity,
|
||||
category: Category?,
|
||||
categories: List<Category>,
|
||||
onDismiss: () -> Unit,
|
||||
onCategoryChange: (Long) -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val isInflow = transaction.direction == TransactionDirection.INFLOW
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp)
|
||||
) {
|
||||
// Amount
|
||||
Text(
|
||||
text = CurrencyFormatter.formatKshSigned(transaction.amount, isInflow),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = if (isInflow) InflowGreen else OutflowRed,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Recipient
|
||||
Text(
|
||||
text = transaction.recipientName ?: transaction.type.name.replace("_", " "),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Details
|
||||
DetailRow("Date", DateUtils.formatDate(transaction.timestamp))
|
||||
DetailRow("Time", DateUtils.formatTime(transaction.timestamp))
|
||||
DetailRow("Type", transaction.type.name.replace("_", " "))
|
||||
DetailRow("Code", transaction.transactionCode)
|
||||
DetailRow("Balance After", CurrencyFormatter.formatKsh(transaction.balance))
|
||||
|
||||
if (transaction.transactionCost > 0) {
|
||||
DetailRow("Fee", CurrencyFormatter.formatKsh(transaction.transactionCost))
|
||||
}
|
||||
if (transaction.recipientPhone != null) {
|
||||
DetailRow("Phone", transaction.recipientPhone)
|
||||
}
|
||||
if (transaction.accountNumber != null) {
|
||||
DetailRow("Account", transaction.accountNumber)
|
||||
}
|
||||
if (transaction.destinationCountry != null) {
|
||||
DetailRow("Country", transaction.destinationCountry)
|
||||
}
|
||||
if (transaction.fulizaAmount != null) {
|
||||
DetailRow("Fuliza Amount", CurrencyFormatter.formatKsh(transaction.fulizaAmount))
|
||||
}
|
||||
if (transaction.fulizaOutstanding != null) {
|
||||
DetailRow("Fuliza Outstanding", CurrencyFormatter.formatKsh(transaction.fulizaOutstanding))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Category selector
|
||||
Text(
|
||||
text = "Category",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(categories) { cat ->
|
||||
val isSelected = cat.id == transaction.categoryId
|
||||
Surface(
|
||||
modifier = Modifier.clickable { onCategoryChange(cat.id) },
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(10.dp),
|
||||
shape = CircleShape,
|
||||
color = parseColor(cat.color)
|
||||
) {}
|
||||
Text(
|
||||
text = cat.name,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.ledga.app.ui.transactions
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.ledga.app.ui.components.CategoryChip
|
||||
import com.ledga.app.ui.components.TransactionCard
|
||||
|
||||
@Composable
|
||||
fun TransactionsScreen(
|
||||
viewModel: TransactionsViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
// Detail bottom sheet
|
||||
if (state.selectedTransaction != null) {
|
||||
TransactionDetailSheet(
|
||||
transaction = state.selectedTransaction!!.transaction,
|
||||
category = state.selectedTransaction!!.category,
|
||||
categories = state.categories,
|
||||
onDismiss = { viewModel.clearSelection() },
|
||||
onCategoryChange = { categoryId ->
|
||||
viewModel.changeCategory(
|
||||
state.selectedTransaction!!.transaction.id,
|
||||
categoryId
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Search bar
|
||||
OutlinedTextField(
|
||||
value = state.searchQuery,
|
||||
onValueChange = { viewModel.setSearchQuery(it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Search transactions...") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, "Search") },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Filter chips
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(TransactionsViewModel.FILTERS.keys.toList()) { filter ->
|
||||
CategoryChip(
|
||||
label = filter,
|
||||
isSelected = state.activeFilter == filter,
|
||||
onClick = { viewModel.setFilter(filter) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Transactions grouped by day
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
state.groupedTransactions.forEach { (day, transactions) ->
|
||||
// Day header
|
||||
item(key = "header_$day") {
|
||||
Text(
|
||||
text = day,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Transactions for that day
|
||||
items(
|
||||
items = transactions,
|
||||
key = { it.transaction.id }
|
||||
) { twc ->
|
||||
TransactionCard(
|
||||
transaction = twc.transaction,
|
||||
category = twc.category,
|
||||
onClick = { viewModel.selectTransaction(twc) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.groupedTransactions.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "No transactions found.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.ledga.app.ui.transactions
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.ledga.app.data.db.entity.Category
|
||||
import com.ledga.app.data.db.entity.TransactionWithCategory
|
||||
import com.ledga.app.data.parser.TransactionType
|
||||
import com.ledga.app.data.repository.CategoryRepository
|
||||
import com.ledga.app.data.repository.TransactionRepository
|
||||
import com.ledga.app.util.DateUtils
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
data class TransactionsUiState(
|
||||
val groupedTransactions: Map<String, List<TransactionWithCategory>> = emptyMap(),
|
||||
val searchQuery: String = "",
|
||||
val activeFilter: String = "All",
|
||||
val categories: List<Category> = emptyList(),
|
||||
val selectedTransaction: TransactionWithCategory? = null
|
||||
)
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@HiltViewModel
|
||||
class TransactionsViewModel @Inject constructor(
|
||||
private val transactionRepository: TransactionRepository,
|
||||
private val categoryRepository: CategoryRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
private val _activeFilter = MutableStateFlow("All")
|
||||
|
||||
companion object {
|
||||
val FILTERS = mapOf(
|
||||
"All" to emptyList<TransactionType>(),
|
||||
"Sent" to listOf(TransactionType.SEND, TransactionType.MPESA_GLOBAL),
|
||||
"Received" to listOf(TransactionType.RECEIVED),
|
||||
"Bills" to listOf(TransactionType.PAY_BILL),
|
||||
"Goods" to listOf(TransactionType.BUY_GOODS),
|
||||
"Withdraw" to listOf(TransactionType.WITHDRAW_AGENT, TransactionType.WITHDRAW_ATM),
|
||||
)
|
||||
}
|
||||
|
||||
private val transactions = combine(
|
||||
_searchQuery.debounce(300),
|
||||
_activeFilter
|
||||
) { query, filter ->
|
||||
Pair(query, filter)
|
||||
}.flatMapLatest { (query, filter) ->
|
||||
val types = FILTERS[filter] ?: emptyList()
|
||||
when {
|
||||
query.isNotBlank() -> transactionRepository.searchTransactions(query)
|
||||
types.isNotEmpty() -> transactionRepository.getTransactionsByType(types)
|
||||
else -> transactionRepository.getRecentTransactions(200)
|
||||
}
|
||||
}
|
||||
|
||||
private val _selectedTransaction = MutableStateFlow<TransactionWithCategory?>(null)
|
||||
|
||||
val uiState: StateFlow<TransactionsUiState> = combine(
|
||||
transactions,
|
||||
_searchQuery,
|
||||
_activeFilter,
|
||||
categoryRepository.getAllCategories(),
|
||||
_selectedTransaction
|
||||
) { txns, query, filter, categories, selected ->
|
||||
val grouped = txns.groupBy { twc ->
|
||||
DateUtils.formatRelativeDate(twc.transaction.timestamp)
|
||||
}
|
||||
TransactionsUiState(
|
||||
groupedTransactions = grouped,
|
||||
searchQuery = query,
|
||||
activeFilter = filter,
|
||||
categories = categories,
|
||||
selectedTransaction = selected
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TransactionsUiState())
|
||||
|
||||
fun setSearchQuery(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun setFilter(filter: String) {
|
||||
_activeFilter.value = filter
|
||||
}
|
||||
|
||||
fun selectTransaction(twc: TransactionWithCategory) {
|
||||
_selectedTransaction.value = twc
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
_selectedTransaction.value = null
|
||||
}
|
||||
|
||||
fun changeCategory(transactionId: Long, categoryId: Long) {
|
||||
viewModelScope.launch {
|
||||
transactionRepository.updateCategory(transactionId, categoryId)
|
||||
_selectedTransaction.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package com.ledga.app.ui.trends
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStart
|
||||
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberColumnCartesianLayer
|
||||
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
|
||||
import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis
|
||||
import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis
|
||||
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer
|
||||
import com.patrykandpatrick.vico.core.cartesian.data.columnSeries
|
||||
import com.ledga.app.ui.components.CategoryChip
|
||||
import com.ledga.app.ui.components.StatCard
|
||||
import com.ledga.app.ui.components.parseColor
|
||||
import com.ledga.app.util.CurrencyFormatter
|
||||
|
||||
@Composable
|
||||
fun TrendsScreen(
|
||||
viewModel: TrendsViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item { Spacer(modifier = Modifier.height(8.dp)) }
|
||||
|
||||
item {
|
||||
Text("Trends", style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
|
||||
// Period selector
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TrendsPeriod.entries.forEach { period ->
|
||||
CategoryChip(
|
||||
label = period.label,
|
||||
isSelected = state.selectedPeriod == period,
|
||||
onClick = { viewModel.selectPeriod(period) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary cards
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
StatCard(
|
||||
label = "Total Spent",
|
||||
value = CurrencyFormatter.formatKshCompact(state.totalSpending),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
StatCard(
|
||||
label = "Total Fees",
|
||||
value = CurrencyFormatter.formatKsh(state.totalFees),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Daily spending bar chart
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Daily Spending",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
if (state.dailySpending.isNotEmpty()) {
|
||||
DailySpendingChart(
|
||||
dailySpending = state.dailySpending.map { it.totalAmount },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "No spending data for this period.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Category breakdown
|
||||
if (state.categorySpending.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "By Category",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
|
||||
items(state.categorySpending) { cs ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(12.dp),
|
||||
shape = CircleShape,
|
||||
color = parseColor(cs.color)
|
||||
) {}
|
||||
Text(
|
||||
text = cs.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
Text(
|
||||
text = CurrencyFormatter.formatKsh(cs.totalAmount),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Top merchants
|
||||
if (state.topMerchants.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Top Merchants",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
|
||||
items(state.topMerchants) { merchant ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = merchant.recipientName,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = "${merchant.transactionCount} transactions",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = CurrencyFormatter.formatKsh(merchant.totalAmount),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DailySpendingChart(
|
||||
dailySpending: List<Double>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val modelProducer = remember { CartesianChartModelProducer() }
|
||||
|
||||
LaunchedEffect(dailySpending) {
|
||||
if (dailySpending.isNotEmpty()) {
|
||||
modelProducer.runTransaction {
|
||||
columnSeries {
|
||||
series(dailySpending.map { it.toFloat() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CartesianChartHost(
|
||||
chart = rememberCartesianChart(
|
||||
rememberColumnCartesianLayer(),
|
||||
startAxis = VerticalAxis.rememberStart(),
|
||||
bottomAxis = HorizontalAxis.rememberBottom(),
|
||||
),
|
||||
modelProducer = modelProducer,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.ledga.app.ui.trends
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.ledga.app.data.db.dao.CategorySpending
|
||||
import com.ledga.app.data.db.dao.DailySpending
|
||||
import com.ledga.app.data.db.dao.TopMerchant
|
||||
import com.ledga.app.data.repository.CategoryRepository
|
||||
import com.ledga.app.data.repository.TransactionRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.util.Calendar
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class TrendsPeriod(val label: String, val days: Int) {
|
||||
WEEK("7D", 7),
|
||||
MONTH("30D", 30),
|
||||
QUARTER("90D", 90),
|
||||
YEAR("1Y", 365)
|
||||
}
|
||||
|
||||
data class TrendsUiState(
|
||||
val selectedPeriod: TrendsPeriod = TrendsPeriod.MONTH,
|
||||
val dailySpending: List<DailySpending> = emptyList(),
|
||||
val totalSpending: Double = 0.0,
|
||||
val totalFees: Double = 0.0,
|
||||
val topMerchants: List<TopMerchant> = emptyList(),
|
||||
val categorySpending: List<CategorySpendingWithName> = emptyList()
|
||||
)
|
||||
|
||||
data class CategorySpendingWithName(
|
||||
val name: String,
|
||||
val color: String,
|
||||
val totalAmount: Double
|
||||
)
|
||||
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class TrendsViewModel @Inject constructor(
|
||||
private val transactionRepository: TransactionRepository,
|
||||
private val categoryRepository: CategoryRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _selectedPeriod = MutableStateFlow(TrendsPeriod.MONTH)
|
||||
|
||||
private val timeRange = _selectedPeriod.flatMapLatest { period ->
|
||||
val now = System.currentTimeMillis()
|
||||
val start = Calendar.getInstance().apply {
|
||||
timeInMillis = now
|
||||
add(Calendar.DAY_OF_YEAR, -period.days)
|
||||
}.timeInMillis
|
||||
flowOf(start to now)
|
||||
}
|
||||
|
||||
val uiState: StateFlow<TrendsUiState> = combine(
|
||||
_selectedPeriod,
|
||||
timeRange.flatMapLatest { (s, e) -> transactionRepository.getDailySpending(s, e) },
|
||||
timeRange.flatMapLatest { (s, e) -> transactionRepository.getTotalSpending(s, e) },
|
||||
timeRange.flatMapLatest { (s, e) -> transactionRepository.getTotalFees(s, e) },
|
||||
timeRange.flatMapLatest { (s, e) -> transactionRepository.getTopMerchants(s, e) },
|
||||
) { period, daily, spending, fees, merchants ->
|
||||
TrendsUiState(
|
||||
selectedPeriod = period,
|
||||
dailySpending = daily,
|
||||
totalSpending = spending,
|
||||
totalFees = fees,
|
||||
topMerchants = merchants,
|
||||
)
|
||||
}.combine(
|
||||
combine(
|
||||
timeRange.flatMapLatest { (s, e) -> transactionRepository.getSpendingByCategory(s, e) },
|
||||
categoryRepository.getAllCategories()
|
||||
) { spending, categories ->
|
||||
val catMap = categories.associateBy { it.id }
|
||||
spending.map { cs ->
|
||||
val cat = catMap[cs.categoryId]
|
||||
CategorySpendingWithName(
|
||||
name = cat?.name ?: "Other",
|
||||
color = cat?.color ?: "#9E9E9E",
|
||||
totalAmount = cs.totalAmount
|
||||
)
|
||||
}.sortedByDescending { it.totalAmount }
|
||||
}
|
||||
) { state, categorySpending ->
|
||||
state.copy(categorySpending = categorySpending)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrendsUiState())
|
||||
|
||||
fun selectPeriod(period: TrendsPeriod) {
|
||||
_selectedPeriod.value = period
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ledga.app.util
|
||||
|
||||
import java.text.NumberFormat
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
|
||||
object CurrencyFormatter {
|
||||
|
||||
private val formatter = NumberFormat.getNumberInstance(Locale.US).apply {
|
||||
minimumFractionDigits = 2
|
||||
maximumFractionDigits = 2
|
||||
}
|
||||
|
||||
fun formatKsh(amount: Double): String {
|
||||
val formatted = formatter.format(abs(amount))
|
||||
val prefix = if (amount < 0) "-" else ""
|
||||
return "${prefix}Ksh $formatted"
|
||||
}
|
||||
|
||||
fun formatKshSigned(amount: Double, isInflow: Boolean): String {
|
||||
val formatted = formatter.format(abs(amount))
|
||||
val sign = if (isInflow) "+" else "-"
|
||||
return "${sign}Ksh $formatted"
|
||||
}
|
||||
|
||||
fun formatKshCompact(amount: Double): String {
|
||||
val abs = abs(amount)
|
||||
val prefix = if (amount < 0) "-" else ""
|
||||
return when {
|
||||
abs >= 1_000_000 -> "${prefix}Ksh ${String.format("%.1fM", abs / 1_000_000)}"
|
||||
abs >= 1_000 -> "${prefix}Ksh ${String.format("%.1fK", abs / 1_000)}"
|
||||
else -> formatKsh(amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.ledga.app.util
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
object DateUtils {
|
||||
|
||||
private val mpesaDateFormat = SimpleDateFormat("d/M/yy 'at' h:mm a", Locale.ENGLISH)
|
||||
|
||||
fun parseMpesaDate(dateStr: String): Long {
|
||||
return try {
|
||||
mpesaDateFormat.parse(dateStr)?.time ?: System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
fun formatRelativeDate(timestamp: Long): String {
|
||||
val now = Calendar.getInstance()
|
||||
val date = Calendar.getInstance().apply { timeInMillis = timestamp }
|
||||
|
||||
return when {
|
||||
isSameDay(now, date) -> "Today"
|
||||
isYesterday(now, date) -> "Yesterday"
|
||||
else -> SimpleDateFormat("EEE, d MMM", Locale.ENGLISH).format(Date(timestamp))
|
||||
}
|
||||
}
|
||||
|
||||
fun formatTime(timestamp: Long): String {
|
||||
return SimpleDateFormat("h:mm a", Locale.ENGLISH).format(Date(timestamp))
|
||||
}
|
||||
|
||||
fun formatMonthYear(timestamp: Long): String {
|
||||
return SimpleDateFormat("MMMM yyyy", Locale.ENGLISH).format(Date(timestamp))
|
||||
}
|
||||
|
||||
fun formatDate(timestamp: Long): String {
|
||||
return SimpleDateFormat("d MMM yyyy", Locale.ENGLISH).format(Date(timestamp))
|
||||
}
|
||||
|
||||
fun greeting(): String {
|
||||
val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
|
||||
return when {
|
||||
hour < 12 -> "Good morning"
|
||||
hour < 17 -> "Good afternoon"
|
||||
else -> "Good evening"
|
||||
}
|
||||
}
|
||||
|
||||
fun getStartOfDay(timestamp: Long = System.currentTimeMillis()): Long {
|
||||
return Calendar.getInstance().apply {
|
||||
timeInMillis = timestamp
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
}
|
||||
|
||||
fun getStartOfWeek(timestamp: Long = System.currentTimeMillis()): Long {
|
||||
return Calendar.getInstance().apply {
|
||||
timeInMillis = timestamp
|
||||
set(Calendar.DAY_OF_WEEK, firstDayOfWeek)
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
}
|
||||
|
||||
fun getStartOfMonth(timestamp: Long = System.currentTimeMillis()): Long {
|
||||
return Calendar.getInstance().apply {
|
||||
timeInMillis = timestamp
|
||||
set(Calendar.DAY_OF_MONTH, 1)
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
}
|
||||
|
||||
private fun isSameDay(a: Calendar, b: Calendar): Boolean {
|
||||
return a.get(Calendar.YEAR) == b.get(Calendar.YEAR) &&
|
||||
a.get(Calendar.DAY_OF_YEAR) == b.get(Calendar.DAY_OF_YEAR)
|
||||
}
|
||||
|
||||
private fun isYesterday(now: Calendar, date: Calendar): Boolean {
|
||||
val yesterday = Calendar.getInstance().apply {
|
||||
timeInMillis = now.timeInMillis
|
||||
add(Calendar.DAY_OF_YEAR, -1)
|
||||
}
|
||||
return isSameDay(yesterday, date)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.ledga.app.worker
|
||||
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.ledga.app.data.repository.BackupRepository
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
@HiltWorker
|
||||
class BackupWorker @AssistedInject constructor(
|
||||
@Assisted context: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
private val backupRepository: BackupRepository
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
return try {
|
||||
backupRepository.performBackup(applicationContext)
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
if (runAttemptCount < 3) Result.retry() else Result.failure()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.ledga.app.worker
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
||||
object NotificationHelper {
|
||||
|
||||
const val CHANNEL_SUMMARIES = "spending_summaries"
|
||||
const val CHANNEL_BUDGET = "budget_alerts"
|
||||
const val CHANNEL_LARGE_TXN = "large_transactions"
|
||||
|
||||
fun createChannels(context: Context) {
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
|
||||
val summaries = NotificationChannel(
|
||||
CHANNEL_SUMMARIES,
|
||||
"Spending Summaries",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = "Daily and weekly spending summaries"
|
||||
}
|
||||
|
||||
val budget = NotificationChannel(
|
||||
CHANNEL_BUDGET,
|
||||
"Budget Alerts",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Alerts when budget limits are reached"
|
||||
}
|
||||
|
||||
val largeTxn = NotificationChannel(
|
||||
CHANNEL_LARGE_TXN,
|
||||
"Large Transactions",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = "Alerts for large transactions"
|
||||
}
|
||||
|
||||
manager.createNotificationChannels(listOf(summaries, budget, largeTxn))
|
||||
}
|
||||
|
||||
fun showNotification(
|
||||
context: Context,
|
||||
channelId: String,
|
||||
notificationId: Int,
|
||||
title: String,
|
||||
body: String
|
||||
) {
|
||||
val notification = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
try {
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
} catch (e: SecurityException) {
|
||||
// POST_NOTIFICATIONS permission not granted
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.ledga.app.worker
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import java.util.Calendar
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object NotificationScheduler {
|
||||
|
||||
fun scheduleDailySummary(context: Context, hour: Int = 20) {
|
||||
val now = Calendar.getInstance()
|
||||
val target = Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, hour)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
if (before(now)) add(Calendar.DAY_OF_YEAR, 1)
|
||||
}
|
||||
val initialDelay = target.timeInMillis - now.timeInMillis
|
||||
|
||||
val request = PeriodicWorkRequestBuilder<DailySummaryWorker>(1, TimeUnit.DAYS)
|
||||
.setInitialDelay(initialDelay, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
"daily_summary",
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelDailySummary(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork("daily_summary")
|
||||
}
|
||||
|
||||
fun scheduleWeeklySummary(context: Context) {
|
||||
val now = Calendar.getInstance()
|
||||
val target = Calendar.getInstance().apply {
|
||||
set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY)
|
||||
set(Calendar.HOUR_OF_DAY, 19)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
if (before(now)) add(Calendar.WEEK_OF_YEAR, 1)
|
||||
}
|
||||
val initialDelay = target.timeInMillis - now.timeInMillis
|
||||
|
||||
val request = PeriodicWorkRequestBuilder<WeeklySummaryWorker>(7, TimeUnit.DAYS)
|
||||
.setInitialDelay(initialDelay, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
"weekly_summary",
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelWeeklySummary(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork("weekly_summary")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.ledga.app.worker
|
||||
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.ledga.app.data.db.dao.TransactionDao
|
||||
import com.ledga.app.util.CurrencyFormatter
|
||||
import com.ledga.app.util.DateUtils
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
@HiltWorker
|
||||
class DailySummaryWorker @AssistedInject constructor(
|
||||
@Assisted context: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
private val transactionDao: TransactionDao
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val start = DateUtils.getStartOfDay()
|
||||
val end = System.currentTimeMillis()
|
||||
|
||||
val totalSpent = transactionDao.getTotalSpending(start, end).first()
|
||||
val totalFees = transactionDao.getTotalFees(start, end).first()
|
||||
|
||||
if (totalSpent > 0) {
|
||||
val body = buildString {
|
||||
append("You spent ${CurrencyFormatter.formatKsh(totalSpent)} today")
|
||||
if (totalFees > 0) {
|
||||
append(" (${CurrencyFormatter.formatKsh(totalFees)} in fees)")
|
||||
}
|
||||
append(".")
|
||||
}
|
||||
|
||||
NotificationHelper.showNotification(
|
||||
context = applicationContext,
|
||||
channelId = NotificationHelper.CHANNEL_SUMMARIES,
|
||||
notificationId = 1001,
|
||||
title = "Daily Spending Summary",
|
||||
body = body
|
||||
)
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
@HiltWorker
|
||||
class WeeklySummaryWorker @AssistedInject constructor(
|
||||
@Assisted context: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
private val transactionDao: TransactionDao
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val start = DateUtils.getStartOfWeek()
|
||||
val end = System.currentTimeMillis()
|
||||
|
||||
val totalSpent = transactionDao.getTotalSpending(start, end).first()
|
||||
val totalFees = transactionDao.getTotalFees(start, end).first()
|
||||
|
||||
if (totalSpent > 0) {
|
||||
val body = buildString {
|
||||
append("This week you spent ${CurrencyFormatter.formatKsh(totalSpent)}")
|
||||
if (totalFees > 0) {
|
||||
append(" (${CurrencyFormatter.formatKsh(totalFees)} in fees)")
|
||||
}
|
||||
append(".")
|
||||
}
|
||||
|
||||
NotificationHelper.showNotification(
|
||||
context = applicationContext,
|
||||
channelId = NotificationHelper.CHANNEL_SUMMARIES,
|
||||
notificationId = 1002,
|
||||
title = "Weekly Spending Summary",
|
||||
body = body
|
||||
)
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.ledga.app.worker
|
||||
|
||||
import android.content.Context
|
||||
import com.ledga.app.data.db.dao.BudgetDao
|
||||
import com.ledga.app.data.db.dao.TransactionDao
|
||||
import com.ledga.app.data.parser.ParsedTransaction
|
||||
import com.ledga.app.util.CurrencyFormatter
|
||||
import com.ledga.app.util.DateUtils
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class TransactionAlerts @Inject constructor(
|
||||
private val transactionDao: TransactionDao,
|
||||
private val budgetDao: BudgetDao
|
||||
) {
|
||||
suspend fun checkAlerts(
|
||||
context: Context,
|
||||
transaction: ParsedTransaction,
|
||||
largeTransactionThreshold: Double,
|
||||
largeTransactionEnabled: Boolean,
|
||||
budgetAlertsEnabled: Boolean
|
||||
) {
|
||||
// Large transaction alert
|
||||
if (largeTransactionEnabled && transaction.amount >= largeTransactionThreshold) {
|
||||
val name = transaction.recipientName ?: transaction.type.name.replace("_", " ")
|
||||
NotificationHelper.showNotification(
|
||||
context = context,
|
||||
channelId = NotificationHelper.CHANNEL_LARGE_TXN,
|
||||
notificationId = 2000 + (transaction.transactionCode.hashCode() and 0xFFFF),
|
||||
title = "Large Transaction",
|
||||
body = "${CurrencyFormatter.formatKsh(transaction.amount)} to $name"
|
||||
)
|
||||
}
|
||||
|
||||
// Budget alerts
|
||||
if (budgetAlertsEnabled) {
|
||||
checkBudgetAlerts(context)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkBudgetAlerts(context: Context) {
|
||||
val start = DateUtils.getStartOfMonth()
|
||||
val end = System.currentTimeMillis()
|
||||
val totalSpent = transactionDao.getTotalSpending(start, end).first()
|
||||
|
||||
val budgets = budgetDao.getActiveBudgets().first()
|
||||
val overallBudget = budgets.find { it.categoryId == null } ?: return
|
||||
|
||||
val percentage = ((totalSpent / overallBudget.monthlyLimit) * 100).toInt()
|
||||
|
||||
when {
|
||||
percentage >= 100 -> {
|
||||
NotificationHelper.showNotification(
|
||||
context = context,
|
||||
channelId = NotificationHelper.CHANNEL_BUDGET,
|
||||
notificationId = 3001,
|
||||
title = "Budget Exceeded!",
|
||||
body = "You've spent ${CurrencyFormatter.formatKsh(totalSpent)} — ${percentage}% of your ${CurrencyFormatter.formatKsh(overallBudget.monthlyLimit)} monthly budget."
|
||||
)
|
||||
}
|
||||
percentage >= 80 -> {
|
||||
NotificationHelper.showNotification(
|
||||
context = context,
|
||||
channelId = NotificationHelper.CHANNEL_BUDGET,
|
||||
notificationId = 3002,
|
||||
title = "Budget Warning",
|
||||
body = "You've used ${percentage}% of your monthly budget (${CurrencyFormatter.formatKsh(totalSpent)} of ${CurrencyFormatter.formatKsh(overallBudget.monthlyLimit)})."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.ledga.app.worker
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.net.URL
|
||||
|
||||
@Serializable
|
||||
data class GitHubRelease(
|
||||
val tag_name: String,
|
||||
val name: String? = null,
|
||||
val body: String? = null,
|
||||
val assets: List<GitHubAsset> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GitHubAsset(
|
||||
val name: String,
|
||||
val browser_download_url: String,
|
||||
val size: Long = 0
|
||||
)
|
||||
|
||||
@HiltWorker
|
||||
class UpdateCheckWorker @AssistedInject constructor(
|
||||
@Assisted context: Context,
|
||||
@Assisted params: WorkerParameters
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
// This worker is a placeholder — the actual check is done in UpdateChecker
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
object UpdateChecker {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private const val PREFS_NAME = "update_prefs"
|
||||
private const val LAST_CHECK_KEY = "last_check"
|
||||
|
||||
suspend fun checkForUpdate(
|
||||
context: Context,
|
||||
owner: String,
|
||||
repo: String,
|
||||
currentVersion: String
|
||||
): GitHubRelease? = withContext(Dispatchers.IO) {
|
||||
// Rate limit: max once per day
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val lastCheck = prefs.getLong(LAST_CHECK_KEY, 0)
|
||||
val oneDayMs = 24 * 60 * 60 * 1000L
|
||||
if (System.currentTimeMillis() - lastCheck < oneDayMs) return@withContext null
|
||||
|
||||
prefs.edit().putLong(LAST_CHECK_KEY, System.currentTimeMillis()).apply()
|
||||
|
||||
try {
|
||||
val url = "https://api.github.com/repos/$owner/$repo/releases/latest"
|
||||
val response = URL(url).readText()
|
||||
val release = json.decodeFromString<GitHubRelease>(response)
|
||||
|
||||
val latestVersion = release.tag_name.removePrefix("v")
|
||||
if (isNewerVersion(latestVersion, currentVersion)) release else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun downloadApk(context: Context, downloadUrl: String, filename: String): File? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val cacheDir = File(context.cacheDir, "updates")
|
||||
cacheDir.mkdirs()
|
||||
val file = File(cacheDir, filename)
|
||||
|
||||
URL(downloadUrl).openStream().use { input ->
|
||||
file.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
file
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun installApk(context: Context, file: File) {
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
private fun isNewerVersion(latest: String, current: String): Boolean {
|
||||
val latestParts = latest.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
val currentParts = current.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
for (i in 0 until maxOf(latestParts.size, currentParts.size)) {
|
||||
val l = latestParts.getOrElse(i) { 0 }
|
||||
val c = currentParts.getOrElse(i) { 0 }
|
||||
if (l > c) return true
|
||||
if (l < c) return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#4CAF50"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- "L" letter for Ledga -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M36,30 L36,72 L72,72 L72,64 L44,64 L44,30 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Ledga</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Ledga" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Ledga.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">#4CAF50</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.Ledga</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="updates" path="updates/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,310 @@
|
||||
package com.ledga.app.parser
|
||||
|
||||
import com.ledga.app.data.parser.MpesaSmsParser
|
||||
import com.ledga.app.data.parser.ParseResult
|
||||
import com.ledga.app.data.parser.TransactionDirection
|
||||
import com.ledga.app.data.parser.TransactionType
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class MpesaSmsParserTest {
|
||||
|
||||
private fun assertSuccess(sms: String): ParseResult.Success {
|
||||
val result = MpesaSmsParser.parse(sms)
|
||||
assertTrue("Expected Success but got: $result", result is ParseResult.Success)
|
||||
return result as ParseResult.Success
|
||||
}
|
||||
|
||||
// --- Sender filter ---
|
||||
|
||||
@Test
|
||||
fun `isMpesaMessage accepts MPESA sender`() {
|
||||
assertTrue(MpesaSmsParser.isMpesaMessage("MPESA"))
|
||||
assertTrue(MpesaSmsParser.isMpesaMessage("mpesa"))
|
||||
assertTrue(MpesaSmsParser.isMpesaMessage("Mpesa"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isMpesaMessage rejects other senders`() {
|
||||
assertFalse(MpesaSmsParser.isMpesaMessage("KCB"))
|
||||
assertFalse(MpesaSmsParser.isMpesaMessage(""))
|
||||
assertFalse(MpesaSmsParser.isMpesaMessage("SAFARICOM"))
|
||||
}
|
||||
|
||||
// --- Send Money ---
|
||||
|
||||
@Test
|
||||
fun `parse send money`() {
|
||||
val sms = "RK31B7X4ZQ Confirmed. Ksh500.00 sent to JOHN DOE 0712345678 on 21/3/26 at 1:30 PM. New M-PESA balance is Ksh1,200.00. Transaction cost, Ksh0.00."
|
||||
val result = assertSuccess(sms)
|
||||
val t = result.transaction
|
||||
assertEquals("RK31B7X4ZQ", t.transactionCode)
|
||||
assertEquals(TransactionType.SEND, t.type)
|
||||
assertEquals(500.0, t.amount, 0.01)
|
||||
assertEquals("JOHN DOE", t.recipientName)
|
||||
assertEquals("0712345678", t.recipientPhone)
|
||||
assertEquals(1200.0, t.balance, 0.01)
|
||||
assertEquals(0.0, t.transactionCost, 0.01)
|
||||
assertEquals(TransactionDirection.OUTFLOW, t.direction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse send money large amount`() {
|
||||
val sms = "AB12CD34EF Confirmed. Ksh100,000.00 sent to JANE SMITH 0798765432 on 1/1/26 at 12:00 AM. New M-PESA balance is Ksh50,000.00. Transaction cost, Ksh105.00."
|
||||
val result = assertSuccess(sms)
|
||||
assertEquals(100000.0, result.transaction.amount, 0.01)
|
||||
assertEquals(50000.0, result.transaction.balance, 0.01)
|
||||
assertEquals(105.0, result.transaction.transactionCost, 0.01)
|
||||
}
|
||||
|
||||
// --- Buy Goods ---
|
||||
|
||||
@Test
|
||||
fun `parse buy goods`() {
|
||||
val sms = "RK31B7X4ZQ Confirmed. Ksh1,200.00 paid to NAIVAS SUPERMARKET. on 21/3/26 at 2:15 PM.New M-PESA balance is Ksh3,500.00. Transaction cost, Ksh0.00."
|
||||
val result = assertSuccess(sms)
|
||||
val t = result.transaction
|
||||
assertEquals(TransactionType.BUY_GOODS, t.type)
|
||||
assertEquals(1200.0, t.amount, 0.01)
|
||||
assertEquals("NAIVAS SUPERMARKET", t.recipientName)
|
||||
assertEquals(3500.0, t.balance, 0.01)
|
||||
assertEquals(TransactionDirection.OUTFLOW, t.direction)
|
||||
}
|
||||
|
||||
// --- Pay Bill ---
|
||||
|
||||
@Test
|
||||
fun `parse pay bill`() {
|
||||
val sms = "RK31B7X4ZQ Confirmed. Ksh2,500.00 paid to KPLC PREPAID. Account Number 12345678. on 21/3/26 at 3:00 PM. New M-PESA balance is Ksh1,000.00. Transaction cost, Ksh0.00."
|
||||
val result = assertSuccess(sms)
|
||||
val t = result.transaction
|
||||
assertEquals(TransactionType.PAY_BILL, t.type)
|
||||
assertEquals(2500.0, t.amount, 0.01)
|
||||
assertEquals("KPLC PREPAID", t.recipientName)
|
||||
assertEquals("12345678", t.accountNumber)
|
||||
assertEquals(1000.0, t.balance, 0.01)
|
||||
}
|
||||
|
||||
// --- Withdraw Agent ---
|
||||
|
||||
@Test
|
||||
fun `parse withdraw agent`() {
|
||||
val sms = "RK31B7X4ZQ Confirmed.You have withdrawn Ksh1,000.00 from JAMES AGENT 543210 on 21/3/26 at 4:00 PM.New M-PESA balance is Ksh500.00. Transaction cost, Ksh28.00."
|
||||
val result = assertSuccess(sms)
|
||||
val t = result.transaction
|
||||
assertEquals(TransactionType.WITHDRAW_AGENT, t.type)
|
||||
assertEquals(1000.0, t.amount, 0.01)
|
||||
assertEquals("JAMES AGENT", t.recipientName)
|
||||
assertEquals("543210", t.recipientPhone)
|
||||
assertEquals(500.0, t.balance, 0.01)
|
||||
assertEquals(28.0, t.transactionCost, 0.01)
|
||||
assertEquals(TransactionDirection.OUTFLOW, t.direction)
|
||||
}
|
||||
|
||||
// --- Withdraw ATM ---
|
||||
|
||||
@Test
|
||||
fun `parse withdraw ATM`() {
|
||||
val sms = "RK31B7X4ZQ Confirmed. You have withdrawn Ksh5,000.00 from an ATM on 21/3/26 at 4:30 PM. New M-PESA balance is Ksh2,000.00. Transaction cost, Ksh34.00."
|
||||
val result = assertSuccess(sms)
|
||||
val t = result.transaction
|
||||
assertEquals(TransactionType.WITHDRAW_ATM, t.type)
|
||||
assertEquals(5000.0, t.amount, 0.01)
|
||||
assertEquals("ATM", t.recipientName)
|
||||
assertEquals(2000.0, t.balance, 0.01)
|
||||
assertEquals(34.0, t.transactionCost, 0.01)
|
||||
}
|
||||
|
||||
// --- Deposit ---
|
||||
|
||||
@Test
|
||||
fun `parse deposit`() {
|
||||
val sms = "RK31B7X4ZQ Confirmed.You have deposited Ksh5,000.00 to your M-PESA account on 21/3/26 at 10:00 AM.New M-PESA balance is Ksh5,500.00."
|
||||
val result = assertSuccess(sms)
|
||||
val t = result.transaction
|
||||
assertEquals(TransactionType.DEPOSIT, t.type)
|
||||
assertEquals(5000.0, t.amount, 0.01)
|
||||
assertEquals(5500.0, t.balance, 0.01)
|
||||
assertEquals(TransactionDirection.INFLOW, t.direction)
|
||||
}
|
||||
|
||||
// --- Received Money ---
|
||||
|
||||
@Test
|
||||
fun `parse received money`() {
|
||||
val sms = "RK31B7X4ZQ Confirmed.You have received Ksh2,000.00 from JANE DOE 0798765432 on 21/3/26 at 11:00 AM.New M-PESA balance is Ksh7,500.00."
|
||||
val result = assertSuccess(sms)
|
||||
val t = result.transaction
|
||||
assertEquals(TransactionType.RECEIVED, t.type)
|
||||
assertEquals(2000.0, t.amount, 0.01)
|
||||
assertEquals("JANE DOE", t.recipientName)
|
||||
assertEquals("0798765432", t.recipientPhone)
|
||||
assertEquals(7500.0, t.balance, 0.01)
|
||||
assertEquals(TransactionDirection.INFLOW, t.direction)
|
||||
}
|
||||
|
||||
// --- Airtime Self ---
|
||||
|
||||
@Test
|
||||
fun `parse airtime self`() {
|
||||
val sms = "RK31B7X4ZQ Confirmed. Ksh100.00 of airtime purchased on 21/3/26 at 12:00 PM.New M-PESA balance is Ksh7,400.00. Transaction cost, Ksh0.00."
|
||||
val result = assertSuccess(sms)
|
||||
val t = result.transaction
|
||||
assertEquals(TransactionType.AIRTIME_SELF, t.type)
|
||||
assertEquals(100.0, t.amount, 0.01)
|
||||
assertEquals(7400.0, t.balance, 0.01)
|
||||
assertEquals(TransactionDirection.OUTFLOW, t.direction)
|
||||
}
|
||||
|
||||
// --- Airtime Other ---
|
||||
|
||||
@Test
|
||||
fun `parse airtime for others`() {
|
||||
val sms = "RK31B7X4ZQ Confirmed. You bought Ksh100.00 of airtime for 0712345678 on 21/3/26 at 12:30 PM.New M-PESA balance is Ksh7,300.00. Transaction cost, Ksh0.00."
|
||||
val result = assertSuccess(sms)
|
||||
val t = result.transaction
|
||||
assertEquals(TransactionType.AIRTIME_OTHER, t.type)
|
||||
assertEquals(100.0, t.amount, 0.01)
|
||||
assertEquals("0712345678", t.recipientPhone)
|
||||
assertEquals(7300.0, t.balance, 0.01)
|
||||
}
|
||||
|
||||
// --- M-Pesa Global ---
|
||||
|
||||
@Test
|
||||
fun `parse mpesa global`() {
|
||||
val sms = "RK31B7X4ZQ Confirmed. Ksh5,000.00 sent to JOHN DOE +44712345678 (United Kingdom) via M-PESA Global on 21/3/26 at 1:30 PM. New M-PESA balance is Ksh10,000.00. Transaction cost, Ksh150.00."
|
||||
val result = assertSuccess(sms)
|
||||
val t = result.transaction
|
||||
assertEquals(TransactionType.MPESA_GLOBAL, t.type)
|
||||
assertEquals(5000.0, t.amount, 0.01)
|
||||
assertEquals("JOHN DOE", t.recipientName)
|
||||
assertEquals("+44712345678", t.recipientPhone)
|
||||
assertEquals("United Kingdom", t.destinationCountry)
|
||||
assertEquals(10000.0, t.balance, 0.01)
|
||||
assertEquals(150.0, t.transactionCost, 0.01)
|
||||
}
|
||||
|
||||
// --- Fuliza ---
|
||||
|
||||
@Test
|
||||
fun `parse fuliza borrow`() {
|
||||
val sms = "RK31B7X4ZQ Confirmed. Ksh500.00 sent to JOHN DOE 0712345678 on 21/3/26 at 1:30 PM. New M-PESA balance is Ksh0.00. Fuliza M-PESA amount is Ksh500.00. Fuliza M-PESA outstanding amount is Ksh500.00."
|
||||
val result = assertSuccess(sms)
|
||||
val t = result.transaction
|
||||
assertEquals(TransactionType.FULIZA, t.type)
|
||||
assertEquals(500.0, t.amount, 0.01)
|
||||
assertEquals(500.0, t.fulizaAmount!!, 0.01)
|
||||
assertEquals(500.0, t.fulizaOutstanding!!, 0.01)
|
||||
assertEquals("JOHN DOE", t.recipientName)
|
||||
assertEquals(TransactionDirection.OUTFLOW, t.direction)
|
||||
}
|
||||
|
||||
// --- Fuliza Repayment ---
|
||||
|
||||
@Test
|
||||
fun `parse fuliza repayment`() {
|
||||
val sms = "RK31B7X4ZQ Confirmed. You have paid Ksh200.00 to Fuliza M-PESA on 21/3/26 at 2:00 PM. Fuliza M-PESA outstanding amount is Ksh300.00."
|
||||
val result = assertSuccess(sms)
|
||||
val t = result.transaction
|
||||
assertEquals(TransactionType.FULIZA_REPAYMENT, t.type)
|
||||
assertEquals(200.0, t.amount, 0.01)
|
||||
assertEquals(300.0, t.fulizaOutstanding!!, 0.01)
|
||||
assertEquals(TransactionDirection.OUTFLOW, t.direction)
|
||||
}
|
||||
|
||||
// --- Fuliza Reversal ---
|
||||
|
||||
@Test
|
||||
fun `parse fuliza reversal`() {
|
||||
val sms = "RK31B7X4ZQ Confirmed. Fuliza M-PESA of Ksh500.00 has been reversed on 21/3/26 at 3:00 PM. Fuliza M-PESA outstanding amount is Ksh0.00."
|
||||
val result = assertSuccess(sms)
|
||||
val t = result.transaction
|
||||
assertEquals(TransactionType.FULIZA_REVERSAL, t.type)
|
||||
assertEquals(500.0, t.amount, 0.01)
|
||||
assertEquals(0.0, t.fulizaOutstanding!!, 0.01)
|
||||
assertEquals(TransactionDirection.INFLOW, t.direction)
|
||||
}
|
||||
|
||||
// --- Reversal ---
|
||||
|
||||
@Test
|
||||
fun `parse reversal`() {
|
||||
val sms = "RK31B7X4ZQ Confirmed. Transaction RJ12345678 has been reversed. Your account balance is Ksh2,000.00."
|
||||
val result = assertSuccess(sms)
|
||||
val t = result.transaction
|
||||
assertEquals(TransactionType.REVERSAL, t.type)
|
||||
assertEquals("RJ12345678", t.reversedTransactionCode)
|
||||
assertEquals(2000.0, t.balance, 0.01)
|
||||
assertEquals(TransactionDirection.INFLOW, t.direction)
|
||||
}
|
||||
|
||||
// --- M-Shwari ---
|
||||
|
||||
@Test
|
||||
fun `parse mshwari transfer to`() {
|
||||
val sms = "RK31B7X4ZQ Confirmed. Ksh1,000.00 transferred to M-Shwari account on 21/3/26 at 5:00 PM. New M-PESA balance is Ksh500.00."
|
||||
val result = assertSuccess(sms)
|
||||
val t = result.transaction
|
||||
assertEquals(TransactionType.MSHWARI, t.type)
|
||||
assertEquals(1000.0, t.amount, 0.01)
|
||||
assertEquals(TransactionDirection.OUTFLOW, t.direction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse mshwari transfer from`() {
|
||||
val sms = "RK31B7X4ZQ Confirmed. Ksh1,000.00 transferred from M-Shwari account on 21/3/26 at 5:00 PM. New M-PESA balance is Ksh1,500.00."
|
||||
val result = assertSuccess(sms)
|
||||
assertEquals(TransactionDirection.INFLOW, result.transaction.direction)
|
||||
}
|
||||
|
||||
// --- KCB M-Pesa ---
|
||||
|
||||
@Test
|
||||
fun `parse kcb mpesa`() {
|
||||
val sms = "RK31B7X4ZQ Confirmed. Ksh2,000.00 transferred to KCB M-Pesa account on 21/3/26 at 6:00 PM. New M-PESA balance is Ksh3,000.00."
|
||||
val result = assertSuccess(sms)
|
||||
val t = result.transaction
|
||||
assertEquals(TransactionType.KCB_MPESA, t.type)
|
||||
assertEquals(2000.0, t.amount, 0.01)
|
||||
assertEquals(TransactionDirection.OUTFLOW, t.direction)
|
||||
}
|
||||
|
||||
// --- Edge cases ---
|
||||
|
||||
@Test
|
||||
fun `parse failure for non-confirmed message`() {
|
||||
val sms = "RK31B7X4ZQ Failed. Ksh500.00 sent to JOHN DOE."
|
||||
val result = MpesaSmsParser.parse(sms)
|
||||
assertTrue(result is ParseResult.Failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse failure for no transaction code`() {
|
||||
val sms = "Confirmed. Ksh500.00 sent to JOHN DOE on 21/3/26 at 1:30 PM."
|
||||
val result = MpesaSmsParser.parse(sms)
|
||||
assertTrue(result is ParseResult.Failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse small amount`() {
|
||||
val sms = "AB12CD34EF Confirmed. Ksh1.00 of airtime purchased on 21/3/26 at 12:00 PM.New M-PESA balance is Ksh0.50. Transaction cost, Ksh0.00."
|
||||
val result = assertSuccess(sms)
|
||||
assertEquals(1.0, result.transaction.amount, 0.01)
|
||||
assertEquals(0.5, result.transaction.balance, 0.01)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown type for unrecognized mpesa message`() {
|
||||
val sms = "AB12CD34EF Confirmed. Some new M-PESA feature we don't know about."
|
||||
val result = assertSuccess(sms)
|
||||
assertEquals(TransactionType.UNKNOWN, result.transaction.type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transaction code extraction`() {
|
||||
val sms = "RK31B7X4ZQ Confirmed. Ksh500.00 sent to JOHN DOE 0712345678 on 21/3/26 at 1:30 PM. New M-PESA balance is Ksh1,200.00. Transaction cost, Ksh0.00."
|
||||
val result = assertSuccess(sms)
|
||||
assertEquals("RK31B7X4ZQ", result.transaction.transactionCode)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user