feat: Implement comprehensive log filtering feature with severity, time range, and tag options
This commit is contained in:
+39
-28
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+51
-27
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-10
@@ -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
|
||||
|
||||
+355
-28
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,12 +32,8 @@
|
||||
> Task :app:writeDebugAppMetadata UP-TO-DATE
|
||||
> Task :app:writeDebugSigningConfigVersions UP-TO-DATE
|
||||
> Task :app:kspDebugKotlin
|
||||
|
||||
> 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
|
||||
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:compileDebugJavaWithJavac UP-TO-DATE
|
||||
> Task :app:dexBuilderDebug
|
||||
> Task :app:mergeDebugGlobalSynthetics 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.
|
||||
|
||||
BUILD SUCCESSFUL in 38s
|
||||
36 actionable tasks: 7 executed, 29 up-to-date
|
||||
BUILD SUCCESSFUL in 10s
|
||||
36 actionable tasks: 6 executed, 30 up-to-date
|
||||
|
||||
@@ -25,13 +25,20 @@ let syncStats = {
|
||||
lastReset: Date.now()
|
||||
};
|
||||
|
||||
// Print stats every 30 seconds
|
||||
// Print stats every 60 seconds (reduced spam)
|
||||
setInterval(() => {
|
||||
if (syncStats.totalSyncs > 0) {
|
||||
const elapsed = Math.round((Date.now() - syncStats.lastReset) / 1000);
|
||||
console.log(`\n[STATS] Last ${elapsed}s: ${syncStats.totalSyncs} syncs`);
|
||||
console.log(` Received: ${syncStats.listsReceived} lists, ${syncStats.fieldsReceived} fields, ${syncStats.itemsReceived} items, ${syncStats.valuesReceived} values`);
|
||||
console.log(` Sent: ${syncStats.listsSent} lists, ${syncStats.fieldsSent} fields, ${syncStats.itemsSent} items, ${syncStats.valuesSent} values\n`);
|
||||
console.log(`\n[STATS] Sync Summary (last ${elapsed}s):`);
|
||||
console.log(` ${syncStats.totalSyncs} sync requests processed`);
|
||||
|
||||
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
|
||||
syncStats = {
|
||||
@@ -47,7 +54,7 @@ setInterval(() => {
|
||||
lastReset: Date.now()
|
||||
};
|
||||
}
|
||||
}, 30000);
|
||||
}, 60000); // Changed from 30s to 60s
|
||||
|
||||
// Helper function to get prepared statements (lazy initialization)
|
||||
function getUpsertStatements() {
|
||||
@@ -104,16 +111,36 @@ router.post('/sync', async (req: Request, res: Response) => {
|
||||
syncStats.itemsReceived += incomingItems;
|
||||
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 isInitialSync = lastSyncTimestamp === 0;
|
||||
|
||||
if (hasIncomingData) {
|
||||
console.log(`[SYNC] Received: ${incomingLists} lists, ${incomingFields} fields, ${incomingItems} items, ${incomingValues} values`);
|
||||
// Log list details
|
||||
if (lists && lists.length > 0) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
console.log(`\n[SYNC] [${timestamp}] Receiving data from client`);
|
||||
|
||||
if (incomingLists > 0) {
|
||||
console.log(` [LISTS] ${incomingLists} list(s)`);
|
||||
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
|
||||
@@ -130,7 +157,6 @@ router.post('/sync', async (req: Request, res: Response) => {
|
||||
updatedAt: list.updatedAt,
|
||||
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.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;
|
||||
if (hasOutgoingData) {
|
||||
console.log(`[SYNC] Sending: ${(serverLists as any[]).length} lists, ${(serverFields as any[]).length} fields, ${(serverItems as any[]).length} items, ${(serverItemValues as any[]).length} values`);
|
||||
if (hasOutgoingData && !isInitialSync) {
|
||||
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({
|
||||
@@ -224,7 +252,7 @@ router.post('/sync', async (req: Request, res: Response) => {
|
||||
serverTimestamp
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[SYNC] Error:', error);
|
||||
console.error('[ERROR] Sync error:', error);
|
||||
res.status(500).json({ error: 'Sync failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -189,7 +189,10 @@ router.get('/', (req: Request, res: Response) => {
|
||||
<script>
|
||||
async function loadData() {
|
||||
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) {
|
||||
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)
|
||||
router.get('/web/data', async (req: Request, res: Response) => {
|
||||
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 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();
|
||||
@@ -318,7 +326,8 @@ router.get('/web/data', async (req: Request, res: Response) => {
|
||||
fields: formattedFields.length,
|
||||
items: formattedItems.length,
|
||||
values: (values as any[]).length
|
||||
}
|
||||
},
|
||||
timestamp: Date.now() // Add timestamp for debugging
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user