diff --git a/app/src/main/java/com/abdelah/banksms/MainActivity.kt b/app/src/main/java/com/abdelah/banksms/MainActivity.kt index 4b81df3..5f31783 100644 --- a/app/src/main/java/com/abdelah/banksms/MainActivity.kt +++ b/app/src/main/java/com/abdelah/banksms/MainActivity.kt @@ -6,22 +6,32 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.background +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -48,10 +58,12 @@ import com.abdelah.banksms.sync.SyncConfig import com.abdelah.banksms.sync.SyncScheduler import com.abdelah.banksms.ui.theme.BankSMSTheme import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +private enum class DashboardTab { Home, Logs, Settings } +private data class BankItemUi(val bankName: String, val smsCount: Int, val isActive: Boolean) class MainActivity : ComponentActivity() { @@ -72,21 +84,15 @@ class MainActivity : ComponentActivity() { SyncScheduler.ensurePeriodic(this) setContent { - BankSMSTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - SettingsScreen( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - ) - } + BankSMSTheme(darkTheme = true, dynamicColor = false) { + DashboardScreen(modifier = Modifier.fillMaxSize()) } } } } @Composable -private fun SettingsScreen(modifier: Modifier = Modifier) { +private fun DashboardScreen(modifier: Modifier = Modifier) { val context = LocalContext.current var fireflyUrl by remember { mutableStateOf("") } @@ -100,6 +106,8 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { var totalParsed by remember { mutableIntStateOf(0) } var totalSynced by remember { mutableIntStateOf(0) } var totalFailed by remember { mutableIntStateOf(0) } + var bankItems by remember { mutableStateOf(emptyList()) } + var selectedTab by remember { mutableStateOf(DashboardTab.Home) } var refreshTrigger by remember { mutableIntStateOf(0) } val scope = rememberCoroutineScope() @@ -114,84 +122,240 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { totalParsed = withContext(Dispatchers.IO) { dao.countAll() } totalSynced = withContext(Dispatchers.IO) { dao.countByStatus("SENT") } totalFailed = withContext(Dispatchers.IO) { dao.countByStatus("FAILED") } - } - Column( - modifier = modifier - .padding(16.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text("Bank SMS Bridge Settings", style = MaterialTheme.typography.headlineSmall) - HorizontalDivider() - Text("Sync Stats", style = MaterialTheme.typography.titleMedium) - StatsCard(totalParsed = totalParsed, totalSynced = totalSynced, totalFailed = totalFailed) - LogViewer( - isExpanded = isLogViewerExpanded, - onToggle = { isLogViewerExpanded = !isLogViewerExpanded } - ) + val allTransactions = withContext(Dispatchers.IO) { + dao.getByStatuses(listOf("PENDING", "SENT", "FAILED")) + } + val transactionCountByBank = allTransactions.groupingBy { it.bank }.eachCount() + val configuredPlugins = SmsParsePlugin.listFromJson(parserPluginsJson).orEmpty() + bankItems = configuredPlugins.map { plugin -> + val smsCount = transactionCountByBank[plugin.bankName] ?: 0 + BankItemUi( + bankName = plugin.bankName, + smsCount = smsCount, + isActive = smsCount > 0 + ) + } + } - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Button( - onClick = { + Scaffold( + modifier = modifier, + bottomBar = { + NavigationBar { + NavigationBarItem( + selected = selectedTab == DashboardTab.Home, + onClick = { selectedTab = DashboardTab.Home }, + icon = { Text("⌂") }, + label = { Text("Home") } + ) + NavigationBarItem( + selected = selectedTab == DashboardTab.Logs, + onClick = { selectedTab = DashboardTab.Logs }, + icon = { Text("≡") }, + label = { Text("Logs") } + ) + NavigationBarItem( + selected = selectedTab == DashboardTab.Settings, + onClick = { selectedTab = DashboardTab.Settings }, + icon = { Text("⚙") }, + label = { Text("Settings") } + ) + } + } + ) { innerPadding -> + when (selectedTab) { + DashboardTab.Home -> HomeTabContent( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + totalParsed = totalParsed, + totalSynced = totalSynced, + totalFailed = totalFailed, + pluginCount = bankItems.size, + bankItems = bankItems, + onRefresh = { saveMessage = "Stats refreshed" refreshTrigger++ - // Launch a coroutine to clear the message after 5 seconds scope.launch { - delay(5000) // 5 seconds + delay(5000) saveMessage = "" } } - ) { - Text("Refresh stats") - } + ) + + DashboardTab.Logs -> LogsTabContent( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + isExpanded = isLogViewerExpanded, + onToggle = { isLogViewerExpanded = !isLogViewerExpanded } + ) - Button( - onClick = { + DashboardTab.Settings -> SettingsTabContent( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + fireflyUrl = fireflyUrl, + onFireflyUrlChange = { fireflyUrl = it }, + fireflyToken = fireflyToken, + onFireflyTokenChange = { fireflyToken = it }, + retryInterval = retryInterval, + onRetryIntervalChange = { retryInterval = it.filter(Char::isDigit) }, + parserPluginsJson = parserPluginsJson, + onParserPluginsJsonChange = { parserPluginsJson = it }, + isPluginsExpanded = isPluginsExpanded, + onTogglePlugins = { isPluginsExpanded = !isPluginsExpanded }, + saveMessage = saveMessage, + onSave = { val retry = retryInterval.toLongOrNull() ?: 15L val plugins = SmsParsePlugin.listFromJson(parserPluginsJson) if (plugins == null || plugins.isEmpty()) { saveMessage = "Parser plugins JSON is invalid or empty" - return@Button + } else { + SyncConfig.save( + context = context, + baseUrl = fireflyUrl, + token = fireflyToken, + retryIntervalMinutes = retry, + parserPluginsJson = SmsParsePlugin.listToJson(plugins) + ) + SyncScheduler.reconfigurePeriodic(context) + saveMessage = "Settings saved" + refreshTrigger++ + scope.launch { + delay(5000) + saveMessage = "" + } } - - SyncConfig.save( - context = context, - baseUrl = fireflyUrl, - token = fireflyToken, - retryIntervalMinutes = retry, - parserPluginsJson = SmsParsePlugin.listToJson(plugins) - ) - SyncScheduler.reconfigurePeriodic(context) - saveMessage = "Settings saved" - refreshTrigger++ - // Launch a coroutine to clear the message after 5 seconds - scope.launch { - delay(5000) // 5 seconds - saveMessage = "" - } - } - ) { - Text("Save Settings") - } - - Button( - onClick = { + }, + onSyncNow = { SyncScheduler.enqueueImmediate(context) saveMessage = "Sync started" scope.launch { - delay(5000) // 5 seconds + delay(5000) saveMessage = "" } } + ) + } + } +} + +@Composable +private fun HomeTabContent( + modifier: Modifier = Modifier, + totalParsed: Int, + totalSynced: Int, + totalFailed: Int, + pluginCount: Int, + bankItems: List, + onRefresh: () -> Unit +) { + val totalSms = totalParsed + val stats = listOf( + "Total SMS" to totalSms, + "Parsed SMS" to totalParsed, + "Failed SMS" to totalFailed, + "Synced SMS" to totalSynced, + "Plugin count" to pluginCount + ) + + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + Text("Dashboard", style = MaterialTheme.typography.headlineMedium) + } + item { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 260.dp), + userScrollEnabled = false, + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text("Sync now") + items(stats) { (label, value) -> + StatGridCard(title = label, value = value.toString()) + } + } + } + item { + Button(onClick = onRefresh) { + Text("Refresh stats") + } + } + item { + Text("Banks", style = MaterialTheme.typography.titleLarge) + } + if (bankItems.isEmpty()) { + item { + Card(shape = RoundedCornerShape(20.dp)) { + Text( + text = "No banks configured", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } else { + items(bankItems) { bank -> + BankCard(bank) } } + } +} + +@Composable +private fun LogsTabContent( + modifier: Modifier = Modifier, + isExpanded: Boolean, + onToggle: () -> Unit +) { + Column( + modifier = modifier + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("Logs", style = MaterialTheme.typography.headlineMedium) + LogViewer(isExpanded = isExpanded, onToggle = onToggle) + } +} +@Composable +private fun SettingsTabContent( + modifier: Modifier = Modifier, + fireflyUrl: String, + onFireflyUrlChange: (String) -> Unit, + fireflyToken: String, + onFireflyTokenChange: (String) -> Unit, + retryInterval: String, + onRetryIntervalChange: (String) -> Unit, + parserPluginsJson: String, + onParserPluginsJsonChange: (String) -> Unit, + isPluginsExpanded: Boolean, + onTogglePlugins: () -> Unit, + saveMessage: String, + onSave: () -> Unit, + onSyncNow: () -> Unit +) { + Column( + modifier = modifier + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("Settings", style = MaterialTheme.typography.headlineMedium) HorizontalDivider() - + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = onSave) { Text("Save Settings") } + Button(onClick = onSyncNow) { Text("Sync now") } + } if (saveMessage.isNotBlank()) { Text(saveMessage, color = MaterialTheme.colorScheme.primary) @@ -199,7 +363,7 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { OutlinedTextField( value = fireflyUrl, - onValueChange = { fireflyUrl = it }, + onValueChange = onFireflyUrlChange, label = { Text("Firefly Base URL") }, modifier = Modifier.fillMaxWidth(), singleLine = true @@ -207,7 +371,7 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { OutlinedTextField( value = fireflyToken, - onValueChange = { fireflyToken = it }, + onValueChange = onFireflyTokenChange, label = { Text("Firefly API Token") }, modifier = Modifier.fillMaxWidth(), singleLine = true @@ -215,26 +379,25 @@ private fun SettingsScreen(modifier: Modifier = Modifier) { OutlinedTextField( value = retryInterval, - onValueChange = { retryInterval = it.filter(Char::isDigit) }, + onValueChange = onRetryIntervalChange, label = { Text("Retry interval (minutes, min 15)") }, modifier = Modifier.fillMaxWidth(), singleLine = true ) - TextButton(onClick = { isPluginsExpanded = !isPluginsExpanded }) { + TextButton(onClick = onTogglePlugins) { Text(if (isPluginsExpanded) "Hide parser plugins" else "Show parser plugins") } if (isPluginsExpanded) { OutlinedTextField( value = parserPluginsJson, - onValueChange = { parserPluginsJson = it }, + onValueChange = onParserPluginsJsonChange, label = { Text("Parser plugins JSON") }, supportingText = { Text("Each plugin defines sender, hints, debit/credit regex and optional refs") }, modifier = Modifier.fillMaxWidth() ) } - } } @@ -259,9 +422,10 @@ private fun LogViewer(isExpanded: Boolean, onToggle: () -> Unit) { Card( modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) ) { - Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text("Runtime Logs", style = MaterialTheme.typography.titleSmall) Box( modifier = Modifier @@ -282,24 +446,39 @@ private fun LogViewer(isExpanded: Boolean, onToggle: () -> Unit) { } @Composable -private fun StatsCard(totalParsed: Int, totalSynced: Int, totalFailed: Int) { +private fun StatGridCard(title: String, value: String) { Card( modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) ) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Total parsed:") - Text(totalParsed.toString()) - } - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Synced:") - Text(totalSynced.toString()) - } - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Failed:") - Text(totalFailed.toString()) - } + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(title, style = MaterialTheme.typography.labelLarge) + Text(value, style = MaterialTheme.typography.headlineSmall) + } + } +} + +@Composable +private fun BankCard(bank: BankItemUi) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(bank.bankName, style = MaterialTheme.typography.titleMedium) + Text("SMS count: ${bank.smsCount}", style = MaterialTheme.typography.bodyMedium) + Text( + text = if (bank.isActive) "Status: Active" else "Status: Disabled", + color = if (bank.isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline + ) } } }