feat: Implement comprehensive log filtering feature with severity, time range, and tag options
This commit is contained in:
+37
-26
@@ -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(
|
||||||
|
|||||||
+15
-7
@@ -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
|
||||||
|
|||||||
+338
-11
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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