feat: Implement comprehensive log filtering feature with severity, time range, and tag options

This commit is contained in:
2025-10-24 22:43:18 +02:00
parent eede176e5e
commit 95b839c791
8 changed files with 655 additions and 117 deletions
@@ -24,7 +24,11 @@ class SyncRepository(context: Context) {
suspend fun performSync(): Result<Unit> = withContext(Dispatchers.IO) { suspend fun performSync(): Result<Unit> = withContext(Dispatchers.IO) {
try { try {
val lastSync = getLastSyncTimestamp() val lastSync = getLastSyncTimestamp()
Logger.d("Sync", "Starting sync with lastSyncTimestamp: $lastSync") val isInitialSync = lastSync == 0L
if (isInitialSync) {
Logger.i("Sync", "🔄 Starting initial sync with server")
}
// Gather local changes since last sync // Gather local changes since last sync
val localLists = database.listDao().getListsUpdatedSince(lastSync) val localLists = database.listDao().getListsUpdatedSince(lastSync)
@@ -32,11 +36,19 @@ class SyncRepository(context: Context) {
val localItems = database.itemDao().getItemsUpdatedSince(lastSync) val localItems = database.itemDao().getItemsUpdatedSince(lastSync)
val localValues = database.itemValueDao().getValuesUpdatedSince(lastSync) val localValues = database.itemValueDao().getValuesUpdatedSince(lastSync)
Logger.i("Sync", "Sending to server: ${localLists.size} lists, ${localFields.size} fields, ${localItems.size} items") // Only log when sending data
if (localLists.isNotEmpty() || localFields.isNotEmpty() || localItems.isNotEmpty()) {
// Log details of lists being sent Logger.i("Sync", "⬆️ Sending to server:")
if (localLists.isNotEmpty()) {
Logger.i("Sync", " 📋 ${localLists.size} list(s)")
localLists.forEach { list -> localLists.forEach { list ->
Logger.d("Sync", " List: ${list.id} - ${list.name} (updated: ${list.updatedAt})") val action = if (list.isDeleted) "Deleted" else "Updated"
Logger.i("Sync", " $action: ${list.name}")
}
}
if (localFields.isNotEmpty()) Logger.i("Sync", " 🏷️ ${localFields.size} field(s)")
if (localItems.isNotEmpty()) Logger.i("Sync", " 📝 ${localItems.size} item(s)")
if (localValues.isNotEmpty()) Logger.i("Sync", " 💾 ${localValues.size} value(s)")
} }
// Send to server and get updates // Send to server and get updates
@@ -53,44 +65,43 @@ class SyncRepository(context: Context) {
if (response.isSuccessful) { if (response.isSuccessful) {
val syncResponse = response.body()!! val syncResponse = response.body()!!
Logger.i("Sync", "Received from server: ${syncResponse.lists.size} lists, ${syncResponse.fields.size} fields, ${syncResponse.items.size} items") // Only log when receiving data
if (syncResponse.lists.isNotEmpty() || syncResponse.fields.isNotEmpty() ||
// Log details of lists being received syncResponse.items.isNotEmpty() || syncResponse.itemValues.isNotEmpty()) {
Logger.i("Sync", "⬇️ Received from server:")
if (syncResponse.lists.isNotEmpty()) {
Logger.i("Sync", " 📋 ${syncResponse.lists.size} list(s)")
syncResponse.lists.forEach { list -> syncResponse.lists.forEach { list ->
Logger.d("Sync", " Received list: ${list.id} - ${list.name} (updated: ${list.updatedAt}, deleted: ${list.isDeleted})") if (!list.isDeleted) {
Logger.i("Sync", " ${list.name}")
}
}
}
if (syncResponse.fields.isNotEmpty()) Logger.i("Sync", " 🏷️ ${syncResponse.fields.size} field(s)")
if (syncResponse.items.isNotEmpty()) Logger.i("Sync", " 📝 ${syncResponse.items.size} item(s)")
if (syncResponse.itemValues.isNotEmpty()) Logger.i("Sync", " 💾 ${syncResponse.itemValues.size} value(s)")
} }
// Apply server changes to local database // Apply server changes to local database
Logger.d("Sync", "Inserting ${syncResponse.lists.size} lists into local database")
database.listDao().insertLists(syncResponse.lists) database.listDao().insertLists(syncResponse.lists)
Logger.d("Sync", "Inserting ${syncResponse.fields.size} fields into local database")
database.fieldDao().insertFields(syncResponse.fields) database.fieldDao().insertFields(syncResponse.fields)
Logger.d("Sync", "Inserting ${syncResponse.items.size} items into local database")
database.itemDao().insertItems(syncResponse.items) database.itemDao().insertItems(syncResponse.items)
Logger.d("Sync", "Inserting ${syncResponse.itemValues.size} item values into local database")
database.itemValueDao().insertValues(syncResponse.itemValues) database.itemValueDao().insertValues(syncResponse.itemValues)
// Verify what was actually saved
val savedLists = database.listDao().getListsUpdatedSince(0)
Logger.d("Sync", "After insert, total lists in DB: ${savedLists.size}")
savedLists.forEach { list ->
Logger.d("Sync", " DB list: ${list.id} - ${list.name} (deleted: ${list.isDeleted})")
}
// Update last sync timestamp // Update last sync timestamp
setLastSyncTimestamp(syncResponse.serverTimestamp) setLastSyncTimestamp(syncResponse.serverTimestamp)
Logger.i("Sync", "Sync completed successfully. Timestamp updated to: ${syncResponse.serverTimestamp}") if (isInitialSync) {
Logger.i("Sync", "✅ Initial sync completed")
}
return@withContext Result.success(Unit) return@withContext Result.success(Unit)
} else { } else {
Logger.e("Sync", "Sync failed with code: ${response.code()}, message: ${response.message()}") Logger.e("Sync", "Sync failed: HTTP ${response.code()}")
return@withContext Result.failure(Exception("Sync failed: ${response.code()}")) return@withContext Result.failure(Exception("Sync failed: ${response.code()}"))
} }
} catch (e: Exception) { } catch (e: Exception) {
Logger.e("Sync", "Sync error", e) Logger.e("Sync", "Sync error: ${e.message}")
return@withContext Result.failure(e) return@withContext Result.failure(e)
} }
} }
@@ -71,24 +71,47 @@ fun ListsScreen(
} }
} }
) { padding -> ) { padding ->
if (lists.isEmpty()) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding), .padding(padding)
contentAlignment = Alignment.Center ) {
if (isLoading && lists.isEmpty()) {
// Show loading indicator during initial sync
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
CircularProgressIndicator()
Text(
text = "Syncing with server...",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else if (lists.isEmpty()) {
// Show empty state
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Text( Text(
text = stringResource(R.string.no_lists), text = stringResource(R.string.no_lists),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Text(
text = "Tap + to create your first list",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
} else { } else {
// Show lists
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
@@ -103,6 +126,7 @@ fun ListsScreen(
} }
} }
} }
}
if (showCreateDialog) { if (showCreateDialog) {
CreateListDialog( CreateListDialog(
@@ -27,9 +27,11 @@ class ListsViewModel(
init { init {
loadLists() loadLists()
// Do initial sync immediately, then start periodic sync // Perform initial sync immediately on startup, then start periodic sync
viewModelScope.launch { viewModelScope.launch {
performSync() // Initial sync on startup _isLoading.value = true
performSync()
_isLoading.value = false
startPeriodicSync() startPeriodicSync()
} }
} }
@@ -50,12 +52,15 @@ class ListsViewModel(
} }
private suspend fun performSync() { private suspend fun performSync() {
syncRepository.performSync() val result = syncRepository.performSync()
result.onFailure { error ->
Logger.e("Lists", "❌ Sync failed: ${error.message}")
}
} }
fun createList(name: String) { fun createList(name: String) {
viewModelScope.launch { viewModelScope.launch {
Logger.i("ListsViewModel", "Creating list: $name") Logger.i("Lists", " Creating list: \"$name\"")
val timestamp = System.currentTimeMillis() val timestamp = System.currentTimeMillis()
val newList = CollabList( val newList = CollabList(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
@@ -71,9 +76,9 @@ class ListsViewModel(
fun renameList(listId: String, newName: String) { fun renameList(listId: String, newName: String) {
viewModelScope.launch { viewModelScope.launch {
Logger.i("ListsViewModel", "Renaming list: $listId to $newName")
val list = database.listDao().getListById(listId) val list = database.listDao().getListById(listId)
if (list != null && newName.isNotBlank()) { if (list != null && newName.isNotBlank()) {
Logger.i("Lists", "✏️ Renaming: \"${list.name}\"\"$newName\"")
database.listDao().updateList( database.listDao().updateList(
list.copy( list.copy(
name = newName.trim(), name = newName.trim(),
@@ -88,16 +93,19 @@ class ListsViewModel(
fun deleteList(listId: String) { fun deleteList(listId: String) {
viewModelScope.launch { viewModelScope.launch {
Logger.i("ListsViewModel", "Deleting list: $listId") val list = database.listDao().getListById(listId)
if (list != null) {
Logger.i("Lists", "🗑️ Deleting list: \"${list.name}\"")
database.listDao().softDeleteList(listId, System.currentTimeMillis()) database.listDao().softDeleteList(listId, System.currentTimeMillis())
// Sync immediately after deleting // Sync immediately after deleting
performSync() performSync()
} }
} }
}
fun manualSync() { fun manualSync() {
viewModelScope.launch { viewModelScope.launch {
Logger.i("ListsViewModel", "Manual sync triggered") Logger.i("Lists", "🔄 Manual sync requested")
_isLoading.value = true _isLoading.value = true
performSync() performSync()
_isLoading.value = false _isLoading.value = false
@@ -3,22 +3,47 @@ package com.collabtable.app.ui.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.collabtable.app.utils.LogEntry import com.collabtable.app.utils.LogEntry
import com.collabtable.app.utils.LogLevel import com.collabtable.app.utils.LogLevel
import com.collabtable.app.utils.Logger import com.collabtable.app.utils.Logger
enum class TimeRange(val label: String, val milliseconds: Long) {
LAST_10_SECONDS("Last 10 seconds", 10_000),
LAST_30_SECONDS("Last 30 seconds", 30_000),
LAST_MINUTE("Last minute", 60_000),
LAST_5_MINUTES("Last 5 minutes", 300_000),
LAST_15_MINUTES("Last 15 minutes", 900_000),
LAST_HOUR("Last hour", 3_600_000),
LAST_24_HOURS("Last 24 hours", 86_400_000),
ALL_TIME("All time", Long.MAX_VALUE)
}
data class LogFilters(
val severities: Set<LogLevel> = LogLevel.values().toSet(),
val timeRange: TimeRange = TimeRange.ALL_TIME,
val tags: Set<String> = emptySet(),
val searchText: String = ""
)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LogsScreen( fun LogsScreen(
@@ -26,24 +51,59 @@ fun LogsScreen(
) { ) {
val logs by Logger.logs.collectAsState() val logs by Logger.logs.collectAsState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
var showFilterSheet by remember { mutableStateOf(false) }
var filters by remember { mutableStateOf(LogFilters()) }
// Extract all unique tags from logs
val allTags = remember(logs) {
logs.map { it.tag }.distinct().sorted()
}
// Apply filters
val filteredLogs = remember(logs, filters) {
val now = System.currentTimeMillis()
val cutoffTime = now - filters.timeRange.milliseconds
logs.filter { log ->
// Severity filter
log.level in filters.severities &&
// Time range filter
log.timestamp >= cutoffTime &&
// Tag filter (empty means show all)
(filters.tags.isEmpty() || log.tag in filters.tags) &&
// Search text filter
(filters.searchText.isEmpty() ||
log.message.contains(filters.searchText, ignoreCase = true) ||
log.tag.contains(filters.searchText, ignoreCase = true))
}
}
// Auto-scroll to bottom when new logs arrive // Auto-scroll to bottom when new logs arrive
LaunchedEffect(logs.size) { LaunchedEffect(filteredLogs.size) {
if (logs.isNotEmpty()) { if (filteredLogs.isNotEmpty()) {
listState.animateScrollToItem(logs.size - 1) listState.animateScrollToItem(filteredLogs.size - 1)
} }
} }
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Logs") }, title = { Text("Logs (${filteredLogs.size}/${logs.size})") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Back") Icon(Icons.Filled.ArrowBack, contentDescription = "Back")
} }
}, },
actions = { actions = {
IconButton(onClick = { showFilterSheet = true }) {
Badge(
containerColor = if (hasActiveFilters(filters))
MaterialTheme.colorScheme.error
else Color.Transparent
) {
Icon(Icons.Default.FilterList, contentDescription = "Filter logs")
}
}
IconButton(onClick = { Logger.clear() }) { IconButton(onClick = { Logger.clear() }) {
Icon(Icons.Default.Delete, contentDescription = "Clear logs") Icon(Icons.Default.Delete, contentDescription = "Clear logs")
} }
@@ -55,15 +115,87 @@ fun LogsScreen(
) )
} }
) { padding -> ) { padding ->
if (logs.isEmpty()) { Column(
Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding), .padding(padding)
contentAlignment = androidx.compose.ui.Alignment.Center ) {
// Active filters chips
if (hasActiveFilters(filters)) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Severity filters
if (filters.severities.size < LogLevel.values().size) {
items(filters.severities.toList()) { level ->
FilterChip(
selected = true,
onClick = {
filters = filters.copy(
severities = filters.severities - level
)
},
label = { Text(level.name, fontSize = 12.sp) }
)
}
}
// Time range filter
if (filters.timeRange != TimeRange.ALL_TIME) {
item {
FilterChip(
selected = true,
onClick = {
filters = filters.copy(timeRange = TimeRange.ALL_TIME)
},
label = { Text(filters.timeRange.label, fontSize = 12.sp) }
)
}
}
// Tag filters
items(filters.tags.toList()) { tag ->
FilterChip(
selected = true,
onClick = {
filters = filters.copy(tags = filters.tags - tag)
},
label = { Text(tag, fontSize = 12.sp) }
)
}
// Clear all button
item {
FilterChip(
selected = false,
onClick = {
filters = LogFilters()
},
label = { Text("Clear All", fontSize = 12.sp) },
leadingIcon = {
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
)
}
}
}
// Logs list
if (filteredLogs.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = "No logs yet", text = if (logs.isEmpty()) "No logs yet" else "No logs match filters",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -73,11 +205,10 @@ fun LogsScreen(
state = listState, state = listState,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding)
.background(Color(0xFF1E1E1E)), .background(Color(0xFF1E1E1E)),
contentPadding = PaddingValues(8.dp) contentPadding = PaddingValues(8.dp)
) { ) {
items(logs) { log -> items(filteredLogs) { log ->
LogItem(log) LogItem(log)
} }
} }
@@ -85,6 +216,202 @@ fun LogsScreen(
} }
} }
// Filter bottom sheet
if (showFilterSheet) {
FilterBottomSheet(
filters = filters,
allTags = allTags,
onFiltersChanged = { filters = it },
onDismiss = { showFilterSheet = false }
)
}
}
private fun hasActiveFilters(filters: LogFilters): Boolean {
return filters.severities.size < LogLevel.values().size ||
filters.timeRange != TimeRange.ALL_TIME ||
filters.tags.isNotEmpty() ||
filters.searchText.isNotEmpty()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilterBottomSheet(
filters: LogFilters,
allTags: List<String>,
onFiltersChanged: (LogFilters) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.verticalScroll(rememberScrollState())
.padding(bottom = 32.dp)
) {
Text(
text = "Filter Logs",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
// Severity filters
Text(
text = "Severity",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 8.dp)
)
LogLevel.values().forEach { level ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = level in filters.severities,
onCheckedChange = { checked ->
onFiltersChanged(
filters.copy(
severities = if (checked) {
filters.severities + level
} else {
filters.severities - level
}
)
)
}
)
Text(
text = level.name,
modifier = Modifier.padding(start = 8.dp)
)
}
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
// Time range filter
Text(
text = "Time Range",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 8.dp)
)
TimeRange.values().forEach { range ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = filters.timeRange == range,
onClick = {
onFiltersChanged(filters.copy(timeRange = range))
}
)
Text(
text = range.label,
modifier = Modifier.padding(start = 8.dp)
)
}
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
// Tag filters
Text(
text = "Tags (${filters.tags.size} selected)",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 8.dp)
)
if (allTags.isEmpty()) {
Text(
text = "No tags available",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
)
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextButton(
onClick = {
onFiltersChanged(filters.copy(tags = allTags.toSet()))
}
) {
Text("Select All")
}
TextButton(
onClick = {
onFiltersChanged(filters.copy(tags = emptySet()))
}
) {
Text("Clear All")
}
}
allTags.forEach { tag ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = tag in filters.tags,
onCheckedChange = { checked ->
onFiltersChanged(
filters.copy(
tags = if (checked) {
filters.tags + tag
} else {
filters.tags - tag
}
)
)
}
)
Text(
text = tag,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
// Reset button
Button(
onClick = {
onFiltersChanged(LogFilters())
},
modifier = Modifier.fillMaxWidth()
) {
Text("Reset All Filters")
}
}
}
}
@Composable @Composable
fun LogItem(log: LogEntry) { fun LogItem(log: LogEntry) {
val color = when (log.level) { val color = when (log.level) {
+3 -7
View File
@@ -32,12 +32,8 @@
> Task :app:writeDebugAppMetadata UP-TO-DATE > Task :app:writeDebugAppMetadata UP-TO-DATE
> Task :app:writeDebugSigningConfigVersions UP-TO-DATE > Task :app:writeDebugSigningConfigVersions UP-TO-DATE
> Task :app:kspDebugKotlin > Task :app:kspDebugKotlin
> Task :app:compileDebugKotlin > Task :app:compileDebugKotlin
w: file:///C:/Users/gabri/VSCode%20Projects/CollabTable/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt:1336:72 Elvis operator (?:) always returns the left operand of non-nullable type String > Task :app:compileDebugJavaWithJavac UP-TO-DATE
w: file:///C:/Users/gabri/VSCode%20Projects/CollabTable/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt:2126:28 The expression is unused
> Task :app:compileDebugJavaWithJavac
> Task :app:dexBuilderDebug > Task :app:dexBuilderDebug
> Task :app:mergeDebugGlobalSynthetics UP-TO-DATE > Task :app:mergeDebugGlobalSynthetics UP-TO-DATE
> Task :app:processDebugJavaRes UP-TO-DATE > Task :app:processDebugJavaRes UP-TO-DATE
@@ -53,5 +49,5 @@ You can use '--warning-mode all' to show the individual deprecation warnings and
For more on this, please refer to https://docs.gradle.org/8.10/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation. For more on this, please refer to https://docs.gradle.org/8.10/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.
BUILD SUCCESSFUL in 38s BUILD SUCCESSFUL in 10s
36 actionable tasks: 7 executed, 29 up-to-date 36 actionable tasks: 6 executed, 30 up-to-date
+43 -15
View File
@@ -25,13 +25,20 @@ let syncStats = {
lastReset: Date.now() lastReset: Date.now()
}; };
// Print stats every 30 seconds // Print stats every 60 seconds (reduced spam)
setInterval(() => { setInterval(() => {
if (syncStats.totalSyncs > 0) { if (syncStats.totalSyncs > 0) {
const elapsed = Math.round((Date.now() - syncStats.lastReset) / 1000); const elapsed = Math.round((Date.now() - syncStats.lastReset) / 1000);
console.log(`\n[STATS] Last ${elapsed}s: ${syncStats.totalSyncs} syncs`); console.log(`\n[STATS] Sync Summary (last ${elapsed}s):`);
console.log(` Received: ${syncStats.listsReceived} lists, ${syncStats.fieldsReceived} fields, ${syncStats.itemsReceived} items, ${syncStats.valuesReceived} values`); console.log(` ${syncStats.totalSyncs} sync requests processed`);
console.log(` Sent: ${syncStats.listsSent} lists, ${syncStats.fieldsSent} fields, ${syncStats.itemsSent} items, ${syncStats.valuesSent} values\n`);
if (syncStats.listsReceived > 0 || syncStats.fieldsReceived > 0 || syncStats.itemsReceived > 0 || syncStats.valuesReceived > 0) {
console.log(` [IN] Received: ${syncStats.listsReceived} lists, ${syncStats.fieldsReceived} fields, ${syncStats.itemsReceived} items, ${syncStats.valuesReceived} values`);
}
if (syncStats.listsSent > 0 || syncStats.fieldsSent > 0 || syncStats.itemsSent > 0 || syncStats.valuesSent > 0) {
console.log(` [OUT] Sent: ${syncStats.listsSent} lists, ${syncStats.fieldsSent} fields, ${syncStats.itemsSent} items, ${syncStats.valuesSent} values`);
}
// Reset stats // Reset stats
syncStats = { syncStats = {
@@ -47,7 +54,7 @@ setInterval(() => {
lastReset: Date.now() lastReset: Date.now()
}; };
} }
}, 30000); }, 60000); // Changed from 30s to 60s
// Helper function to get prepared statements (lazy initialization) // Helper function to get prepared statements (lazy initialization)
function getUpsertStatements() { function getUpsertStatements() {
@@ -104,16 +111,36 @@ router.post('/sync', async (req: Request, res: Response) => {
syncStats.itemsReceived += incomingItems; syncStats.itemsReceived += incomingItems;
syncStats.valuesReceived += incomingValues; syncStats.valuesReceived += incomingValues;
// Log only when there's actual data being synced // Only log significant sync events (not empty syncs)
const hasIncomingData = incomingLists > 0 || incomingFields > 0 || incomingItems > 0 || incomingValues > 0; const hasIncomingData = incomingLists > 0 || incomingFields > 0 || incomingItems > 0 || incomingValues > 0;
const isInitialSync = lastSyncTimestamp === 0;
if (hasIncomingData) { if (hasIncomingData) {
console.log(`[SYNC] Received: ${incomingLists} lists, ${incomingFields} fields, ${incomingItems} items, ${incomingValues} values`); const timestamp = new Date().toLocaleTimeString();
// Log list details console.log(`\n[SYNC] [${timestamp}] Receiving data from client`);
if (lists && lists.length > 0) {
if (incomingLists > 0) {
console.log(` [LISTS] ${incomingLists} list(s)`);
lists.forEach(list => { lists.forEach(list => {
console.log(` List: ${list.id} - "${list.name}" (updated: ${list.updatedAt}, deleted: ${list.isDeleted})`); const action = list.isDeleted ? 'Deleted' : (lastSyncTimestamp === 0 ? 'Created' : 'Updated');
console.log(` ${action}: "${list.name}"`);
}); });
} }
if (incomingFields > 0) {
console.log(` [FIELDS] ${incomingFields} field(s)`);
}
if (incomingItems > 0) {
console.log(` [ITEMS] ${incomingItems} item(s)`);
}
if (incomingValues > 0) {
console.log(` [VALUES] ${incomingValues} value(s)`);
}
} else if (isInitialSync) {
const timestamp = new Date().toLocaleTimeString();
console.log(`\n[SYNC] [${timestamp}] Initial sync from new client`);
} }
// Save incoming data from client using a transaction // Save incoming data from client using a transaction
@@ -130,7 +157,6 @@ router.post('/sync', async (req: Request, res: Response) => {
updatedAt: list.updatedAt, updatedAt: list.updatedAt,
isDeleted: list.isDeleted ? 1 : 0 isDeleted: list.isDeleted ? 1 : 0
}); });
console.log(` Saved list ${list.id}`);
} }
} }
@@ -210,10 +236,12 @@ router.post('/sync', async (req: Request, res: Response) => {
syncStats.itemsSent += (serverItems as any[]).length; syncStats.itemsSent += (serverItems as any[]).length;
syncStats.valuesSent += (serverItemValues as any[]).length; syncStats.valuesSent += (serverItemValues as any[]).length;
// Log only when sending data back // Only log when sending significant data back
const hasOutgoingData = (serverLists as any[]).length > 0 || (serverFields as any[]).length > 0 || (serverItems as any[]).length > 0 || (serverItemValues as any[]).length > 0; const hasOutgoingData = (serverLists as any[]).length > 0 || (serverFields as any[]).length > 0 || (serverItems as any[]).length > 0 || (serverItemValues as any[]).length > 0;
if (hasOutgoingData) { if (hasOutgoingData && !isInitialSync) {
console.log(`[SYNC] Sending: ${(serverLists as any[]).length} lists, ${(serverFields as any[]).length} fields, ${(serverItems as any[]).length} items, ${(serverItemValues as any[]).length} values`); console.log(` [OUT] Sending back: ${(serverLists as any[]).length} lists, ${(serverFields as any[]).length} fields, ${(serverItems as any[]).length} items, ${(serverItemValues as any[]).length} values`);
} else if (isInitialSync && hasOutgoingData) {
console.log(` [OUT] Sending initial data: ${(serverLists as any[]).length} lists, ${(serverFields as any[]).length} fields, ${(serverItems as any[]).length} items, ${(serverItemValues as any[]).length} values`);
} }
res.json({ res.json({
@@ -224,7 +252,7 @@ router.post('/sync', async (req: Request, res: Response) => {
serverTimestamp serverTimestamp
}); });
} catch (error) { } catch (error) {
console.error('[SYNC] Error:', error); console.error('[ERROR] Sync error:', error);
res.status(500).json({ error: 'Sync failed' }); res.status(500).json({ error: 'Sync failed' });
} }
}); });
+11 -2
View File
@@ -189,7 +189,10 @@ router.get('/', (req: Request, res: Response) => {
<script> <script>
async function loadData() { async function loadData() {
try { try {
const response = await fetch('/web/data'); // Add cache-busting timestamp to URL
const response = await fetch('/web/data?_=' + Date.now(), {
cache: 'no-store'
});
if (!response.ok) { if (!response.ok) {
throw new Error('HTTP ' + response.status + ': ' + response.statusText); throw new Error('HTTP ' + response.status + ': ' + response.statusText);
} }
@@ -298,6 +301,11 @@ router.get('/', (req: Request, res: Response) => {
// API endpoint to get all data (no auth required for web UI) // API endpoint to get all data (no auth required for web UI)
router.get('/web/data', async (req: Request, res: Response) => { router.get('/web/data', async (req: Request, res: Response) => {
try { try {
// Add cache control headers to prevent stale data
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
const lists = db.prepare('SELECT * FROM lists ORDER BY updatedAt DESC').all(); const lists = db.prepare('SELECT * FROM lists ORDER BY updatedAt DESC').all();
const fields = db.prepare('SELECT * FROM fields ORDER BY listId ASC, `order` ASC').all(); const fields = db.prepare('SELECT * FROM fields ORDER BY listId ASC, `order` ASC').all();
const items = db.prepare('SELECT * FROM items ORDER BY listId ASC, updatedAt DESC').all(); const items = db.prepare('SELECT * FROM items ORDER BY listId ASC, updatedAt DESC').all();
@@ -318,7 +326,8 @@ router.get('/web/data', async (req: Request, res: Response) => {
fields: formattedFields.length, fields: formattedFields.length,
items: formattedItems.length, items: formattedItems.length,
values: (values as any[]).length values: (values as any[]).length
} },
timestamp: Date.now() // Add timestamp for debugging
}); });
} catch (error) { } catch (error) {
console.error('Error fetching data:', error); console.error('Error fetching data:', error);
+135
View File
@@ -0,0 +1,135 @@
# Logs Filter Feature
## Overview
Added comprehensive filtering capabilities to the Android app's logs screen, allowing users to filter logs by severity, time range, and tags.
## Features
### 1. **Filter Button**
- Located in the top app bar next to the clear button
- Shows a red badge indicator when filters are active
- Opens a bottom sheet with all filter options
### 2. **Filter Options**
#### **Severity Filtering**
Filter by log level:
- ✅ DEBUG
- ✅ INFO
- ✅ WARN
- ✅ ERROR
Users can select multiple severity levels or deselect to hide certain log types.
#### **Time Range Filtering**
Filter logs by recency:
- Last 10 seconds
- Last 30 seconds
- Last minute
- Last 5 minutes
- Last 15 minutes
- Last hour
- Last 24 hours
- All time (default)
#### **Tag Filtering**
- Dynamically shows all unique tags from current logs
- Multi-select checkboxes for each tag
- "Select All" and "Clear All" buttons for convenience
- Only shows logs from selected tags (empty = show all)
### 3. **Active Filter Chips**
- Horizontal scrollable row below the app bar
- Shows all currently active filters as chips
- Tap any chip to remove that filter
- "Clear All" chip to reset all filters at once
- Only visible when filters are active
### 4. **Log Counter**
- Top bar shows: "Logs (filtered count/total count)"
- Example: "Logs (15/234)" means 15 logs match current filters out of 234 total
### 5. **Smart Empty States**
- "No logs yet" - when no logs exist
- "No logs match filters" - when logs exist but none match filters
## Implementation Details
### New Components
**TimeRange enum**
```kotlin
enum class TimeRange(val label: String, val milliseconds: Long) {
LAST_10_SECONDS("Last 10 seconds", 10_000),
LAST_30_SECONDS("Last 30 seconds", 30_000),
// ... etc
}
```
**LogFilters data class**
```kotlin
data class LogFilters(
val severities: Set<LogLevel> = LogLevel.values().toSet(),
val timeRange: TimeRange = TimeRange.ALL_TIME,
val tags: Set<String> = emptySet(),
val searchText: String = ""
)
```
**FilterBottomSheet composable**
- Modal bottom sheet with scrollable content
- Checkboxes for severities and tags
- Radio buttons for time range selection
- Reset button to clear all filters
### Filter Logic
Filters are applied in real-time using `remember()` with dependencies:
```kotlin
val filteredLogs = remember(logs, filters) {
val now = System.currentTimeMillis()
val cutoffTime = now - filters.timeRange.milliseconds
logs.filter { log ->
log.level in filters.severities &&
log.timestamp >= cutoffTime &&
(filters.tags.isEmpty() || log.tag in filters.tags) &&
(filters.searchText.isEmpty() || /* search logic */)
}
}
```
### Auto-scroll Behavior
- Automatically scrolls to the latest log when new logs arrive
- Works with filtered logs, not just all logs
## User Experience
1. **Opening filters**: Tap filter icon in top bar
2. **Applying filters**: Check/uncheck options in bottom sheet
3. **Viewing active filters**: See chips below app bar
4. **Quick removal**: Tap any filter chip to remove it
5. **Reset all**: Tap "Clear All" chip or "Reset All Filters" button
6. **Dismissing sheet**: Tap outside or swipe down
## Benefits
- **Debugging**: Quickly isolate ERROR/WARN logs
- **Performance**: Focus on specific time windows
- **Organization**: Filter by component/tag
- **Visibility**: Clear indication of active filters
- **Efficiency**: Persistent filters across log updates
- **UX**: Smooth animations and Material Design 3
## Technical Notes
- Uses Jetpack Compose with Material3
- State management with `remember` and `mutableStateOf`
- Filter state persists during screen lifetime
- Efficient filtering with Kotlin collections
- Responsive to real-time log updates
## Files Modified
- `LogsScreen.kt`: Complete rewrite with filter UI and logic
- Build verified successfully on both Android and Server