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) {
try {
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
val localLists = database.listDao().getListsUpdatedSince(lastSync)
@@ -32,11 +36,19 @@ class SyncRepository(context: Context) {
val localItems = database.itemDao().getItemsUpdatedSince(lastSync)
val localValues = database.itemValueDao().getValuesUpdatedSince(lastSync)
Logger.i("Sync", "Sending to server: ${localLists.size} lists, ${localFields.size} fields, ${localItems.size} items")
// Log details of lists being sent
localLists.forEach { list ->
Logger.d("Sync", " List: ${list.id} - ${list.name} (updated: ${list.updatedAt})")
// Only log when sending data
if (localLists.isNotEmpty() || localFields.isNotEmpty() || localItems.isNotEmpty()) {
Logger.i("Sync", "⬆️ Sending to server:")
if (localLists.isNotEmpty()) {
Logger.i("Sync", " 📋 ${localLists.size} list(s)")
localLists.forEach { list ->
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
@@ -53,44 +65,43 @@ class SyncRepository(context: Context) {
if (response.isSuccessful) {
val syncResponse = response.body()!!
Logger.i("Sync", "Received from server: ${syncResponse.lists.size} lists, ${syncResponse.fields.size} fields, ${syncResponse.items.size} items")
// Log details of lists being received
syncResponse.lists.forEach { list ->
Logger.d("Sync", " Received list: ${list.id} - ${list.name} (updated: ${list.updatedAt}, deleted: ${list.isDeleted})")
// Only log when receiving data
if (syncResponse.lists.isNotEmpty() || syncResponse.fields.isNotEmpty() ||
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 ->
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
Logger.d("Sync", "Inserting ${syncResponse.lists.size} lists into local database")
database.listDao().insertLists(syncResponse.lists)
Logger.d("Sync", "Inserting ${syncResponse.fields.size} fields into local database")
database.fieldDao().insertFields(syncResponse.fields)
Logger.d("Sync", "Inserting ${syncResponse.items.size} items into local database")
database.itemDao().insertItems(syncResponse.items)
Logger.d("Sync", "Inserting ${syncResponse.itemValues.size} item values into local database")
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
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)
} 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()}"))
}
} catch (e: Exception) {
Logger.e("Sync", "Sync error", e)
Logger.e("Sync", "Sync error: ${e.message}")
return@withContext Result.failure(e)
}
}
@@ -71,35 +71,59 @@ fun ListsScreen(
}
}
) { padding ->
if (lists.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.no_lists),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(lists, key = { it.id }) { list ->
ListItem(
list = list,
onListClick = { onNavigateToList(list.id) },
onEditClick = { listToEdit = list },
onDeleteClick = { listToDelete = list }
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
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 = stringResource(R.string.no_lists),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Tap + to create your first list",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
// Show lists
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(lists, key = { it.id }) { list ->
ListItem(
list = list,
onListClick = { onNavigateToList(list.id) },
onEditClick = { listToEdit = list },
onDeleteClick = { listToDelete = list }
)
}
}
}
}
}
@@ -27,9 +27,11 @@ class ListsViewModel(
init {
loadLists()
// Do initial sync immediately, then start periodic sync
// Perform initial sync immediately on startup, then start periodic sync
viewModelScope.launch {
performSync() // Initial sync on startup
_isLoading.value = true
performSync()
_isLoading.value = false
startPeriodicSync()
}
}
@@ -50,12 +52,15 @@ class ListsViewModel(
}
private suspend fun performSync() {
syncRepository.performSync()
val result = syncRepository.performSync()
result.onFailure { error ->
Logger.e("Lists", "❌ Sync failed: ${error.message}")
}
}
fun createList(name: String) {
viewModelScope.launch {
Logger.i("ListsViewModel", "Creating list: $name")
Logger.i("Lists", " Creating list: \"$name\"")
val timestamp = System.currentTimeMillis()
val newList = CollabList(
id = UUID.randomUUID().toString(),
@@ -71,9 +76,9 @@ class ListsViewModel(
fun renameList(listId: String, newName: String) {
viewModelScope.launch {
Logger.i("ListsViewModel", "Renaming list: $listId to $newName")
val list = database.listDao().getListById(listId)
if (list != null && newName.isNotBlank()) {
Logger.i("Lists", "✏️ Renaming: \"${list.name}\"\"$newName\"")
database.listDao().updateList(
list.copy(
name = newName.trim(),
@@ -88,16 +93,19 @@ class ListsViewModel(
fun deleteList(listId: String) {
viewModelScope.launch {
Logger.i("ListsViewModel", "Deleting list: $listId")
database.listDao().softDeleteList(listId, System.currentTimeMillis())
// Sync immediately after deleting
performSync()
val list = database.listDao().getListById(listId)
if (list != null) {
Logger.i("Lists", "🗑️ Deleting list: \"${list.name}\"")
database.listDao().softDeleteList(listId, System.currentTimeMillis())
// Sync immediately after deleting
performSync()
}
}
}
fun manualSync() {
viewModelScope.launch {
Logger.i("ListsViewModel", "Manual sync triggered")
Logger.i("Lists", "🔄 Manual sync requested")
_isLoading.value = true
performSync()
_isLoading.value = false
@@ -3,22 +3,47 @@ package com.collabtable.app.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
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.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.sp
import com.collabtable.app.utils.LogEntry
import com.collabtable.app.utils.LogLevel
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)
@Composable
fun LogsScreen(
@@ -26,24 +51,59 @@ fun LogsScreen(
) {
val logs by Logger.logs.collectAsState()
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
LaunchedEffect(logs.size) {
if (logs.isNotEmpty()) {
listState.animateScrollToItem(logs.size - 1)
LaunchedEffect(filteredLogs.size) {
if (filteredLogs.isNotEmpty()) {
listState.animateScrollToItem(filteredLogs.size - 1)
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Logs") },
title = { Text("Logs (${filteredLogs.size}/${logs.size})") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Back")
}
},
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() }) {
Icon(Icons.Default.Delete, contentDescription = "Clear logs")
}
@@ -55,32 +115,299 @@ fun LogsScreen(
)
}
) { padding ->
if (logs.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Text(
text = "No logs yet",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(Color(0xFF1E1E1E)),
contentPadding = PaddingValues(8.dp)
) {
items(logs) { log ->
LogItem(log)
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// 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 = if (logs.isEmpty()) "No logs yet" else "No logs match filters",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF1E1E1E)),
contentPadding = PaddingValues(8.dp)
) {
items(filteredLogs) { log ->
LogItem(log)
}
}
}
}
}
// 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")
}
}
}
}