Refactor SettingsScreen and related components for improved readability and maintainability

- Updated SettingsScreen.kt to enhance UI structure and organization.
- Refactored SettingsViewModel.kt for better state management.
- Improved theme handling in Theme.kt with clearer structure.
- Enhanced typography definitions in Type.kt for consistency.
- Updated Logger.kt to improve logging functionality and clarity.
This commit is contained in:
2025-10-25 17:39:35 +02:00
parent 14c80197cc
commit 9b82b317d3
29 changed files with 6756 additions and 6460 deletions
@@ -6,9 +6,9 @@ import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.ui.navigation.AppNavigation
import com.collabtable.app.ui.theme.CollabTableTheme
@@ -22,7 +22,8 @@ class MainActivity : ComponentActivity() {
val dynamicColor by prefs.dynamicColor.collectAsState()
val amoledDark by prefs.amoledDark.collectAsState()
val darkTheme = when (themeMode) {
val darkTheme =
when (themeMode) {
PreferencesManager.THEME_MODE_LIGHT -> false
PreferencesManager.THEME_MODE_DARK -> true
else -> androidx.compose.foundation.isSystemInDarkTheme()
@@ -31,7 +32,7 @@ class MainActivity : ComponentActivity() {
CollabTableTheme(darkTheme = darkTheme, dynamicColor = dynamicColor, amoledDark = amoledDark) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
color = MaterialTheme.colorScheme.background,
) {
AppNavigation()
}
@@ -14,17 +14,21 @@ object ApiClient {
private var retrofit: Retrofit? = null
private var context: Context? = null
private val loggingInterceptor = HttpLoggingInterceptor().apply {
private val loggingInterceptor =
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
private val authInterceptor = Interceptor { chain ->
private val authInterceptor =
Interceptor { chain ->
val request = chain.request()
val password = context?.let {
val password =
context?.let {
PreferencesManager.getInstance(it).getServerPassword()
}
val newRequest = if (!password.isNullOrBlank()) {
val newRequest =
if (!password.isNullOrBlank()) {
request.newBuilder()
.header("Authorization", "Bearer $password")
.build()
@@ -35,7 +39,8 @@ object ApiClient {
chain.proceed(newRequest)
}
private val okHttpClient = OkHttpClient.Builder()
private val okHttpClient =
OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
@@ -13,7 +13,7 @@ data class SyncRequest(
val lists: List<CollabList>,
val fields: List<Field>,
val items: List<Item>,
val itemValues: List<ItemValue>
val itemValues: List<ItemValue>,
)
data class SyncResponse(
@@ -21,10 +21,12 @@ data class SyncResponse(
val fields: List<Field>,
val items: List<Item>,
val itemValues: List<ItemValue>,
val serverTimestamp: Long
val serverTimestamp: Long,
)
interface CollabTableApi {
@POST("sync")
suspend fun sync(@Body request: SyncRequest): Response<SyncResponse>
suspend fun sync(
@Body request: SyncRequest,
): Response<SyncResponse>
}
@@ -21,17 +21,19 @@ import java.util.concurrent.TimeUnit
private data class MessageEnvelope(
val id: String? = null,
val type: String,
val payload: Any? = null
val payload: Any? = null,
)
object WebSocketSyncClient {
private val gson = Gson()
private val logging = HttpLoggingInterceptor().apply {
private val logging =
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
}
private val client: OkHttpClient = OkHttpClient.Builder()
private val client: OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(logging)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
@@ -46,7 +48,8 @@ object WebSocketSyncClient {
if (url.endsWith("/")) url = url.dropLast(1)
// Replace scheme
url = when {
url =
when {
url.startsWith("https://") -> "wss://" + url.removePrefix("https://")
url.startsWith("http://") -> "ws://" + url.removePrefix("http://")
else -> "ws://$url" // best effort
@@ -56,7 +59,11 @@ object WebSocketSyncClient {
return if (url.endsWith("/api")) "$url/ws" else "$url/api/ws"
}
suspend fun sync(context: Context, request: SyncRequest): Result<SyncResponse> = withContext(Dispatchers.IO) {
suspend fun sync(
context: Context,
request: SyncRequest,
): Result<SyncResponse> =
withContext(Dispatchers.IO) {
val prefs = PreferencesManager.getInstance(context)
val baseUrl = prefs.getServerUrl()
val password = prefs.getServerPassword()
@@ -72,19 +79,27 @@ object WebSocketSyncClient {
}
val httpRequest = httpRequestBuilder.build()
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
val listener =
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
// Send sync message when opened
val envelope = MessageEnvelope(
val envelope =
MessageEnvelope(
id = messageId,
type = "sync",
payload = request
payload = request,
)
val json = gson.toJson(envelope)
webSocket.send(json)
}
override fun onMessage(webSocket: WebSocket, text: String) {
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
try {
val root = gson.fromJson(text, JsonObject::class.java)
val type = root.get("type")?.asString
@@ -108,7 +123,11 @@ object WebSocketSyncClient {
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
// 1008 indicates policy violation, used here for Unauthorized
if (!deferred.isCompleted) {
if (code == 1008) {
@@ -119,7 +138,11 @@ object WebSocketSyncClient {
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
if (!deferred.isCompleted) {
deferred.complete(Result.failure(t))
}
@@ -25,7 +25,10 @@ interface FieldDao {
suspend fun updateField(field: Field)
@Query("UPDATE fields SET isDeleted = 1, updatedAt = :timestamp WHERE id = :fieldId")
suspend fun softDeleteField(fieldId: String, timestamp: Long)
suspend fun softDeleteField(
fieldId: String,
timestamp: Long,
)
@Query("DELETE FROM fields WHERE id = :fieldId")
suspend fun deleteField(fieldId: String)
@@ -36,13 +39,16 @@ interface FieldDao {
// Reorder fields in a single transaction for clarity and consistency
@Transaction
suspend fun reorderFieldsInTransaction(reorderedFields: List<Field>, timestamp: Long) {
suspend fun reorderFieldsInTransaction(
reorderedFields: List<Field>,
timestamp: Long,
) {
reorderedFields.forEachIndexed { index, field ->
updateField(
field.copy(
order = index,
updatedAt = timestamp
)
updatedAt = timestamp,
),
)
}
}
@@ -30,7 +30,10 @@ interface ItemDao {
suspend fun updateItem(item: Item)
@Query("UPDATE items SET isDeleted = 1, updatedAt = :timestamp WHERE id = :itemId")
suspend fun softDeleteItem(itemId: String, timestamp: Long)
suspend fun softDeleteItem(
itemId: String,
timestamp: Long,
)
@Query("DELETE FROM items WHERE id = :itemId")
suspend fun deleteItem(itemId: String)
@@ -30,7 +30,10 @@ interface ListDao {
suspend fun updateList(list: CollabList)
@Query("UPDATE lists SET isDeleted = 1, updatedAt = :timestamp WHERE id = :listId")
suspend fun softDeleteList(listId: String, timestamp: Long)
suspend fun softDeleteList(
listId: String,
timestamp: Long,
)
@Query("DELETE FROM lists WHERE id = :listId")
suspend fun deleteList(listId: String)
@@ -9,27 +9,31 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import com.collabtable.app.data.dao.*
import com.collabtable.app.data.model.*
val MIGRATION_1_2 = object : Migration(1, 2) {
val MIGRATION_1_2 =
object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE fields ADD COLUMN fieldType TEXT NOT NULL DEFAULT 'STRING'")
database.execSQL("ALTER TABLE fields ADD COLUMN fieldOptions TEXT NOT NULL DEFAULT ''")
}
}
}
@Database(
entities = [
CollabList::class,
Field::class,
Item::class,
ItemValue::class
ItemValue::class,
],
version = 2,
exportSchema = false
exportSchema = false,
)
abstract class CollabTableDatabase : RoomDatabase() {
abstract fun listDao(): ListDao
abstract fun fieldDao(): FieldDao
abstract fun itemDao(): ItemDao
abstract fun itemValueDao(): ItemValueDao
companion object {
@@ -38,10 +42,11 @@ abstract class CollabTableDatabase : RoomDatabase() {
fun getDatabase(context: Context): CollabTableDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
val instance =
Room.databaseBuilder(
context.applicationContext,
CollabTableDatabase::class.java,
"collab_table_database"
"collab_table_database",
)
.addMigrations(MIGRATION_1_2)
.build()
@@ -9,5 +9,5 @@ data class CollabList(
val name: String,
val createdAt: Long,
val updatedAt: Long,
val isDeleted: Boolean = false
val isDeleted: Boolean = false,
)
@@ -41,7 +41,7 @@ enum class FieldType {
// Other types
RATING,
COLOR,
LOCATION
LOCATION,
}
@Entity(
@@ -51,10 +51,10 @@ enum class FieldType {
entity = CollabList::class,
parentColumns = ["id"],
childColumns = ["listId"],
onDelete = ForeignKey.CASCADE
)
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("listId")]
indices = [Index("listId")],
)
data class Field(
@PrimaryKey val id: String,
@@ -65,7 +65,7 @@ data class Field(
val order: Int,
val createdAt: Long,
val updatedAt: Long,
val isDeleted: Boolean = false
val isDeleted: Boolean = false,
) {
fun getType(): FieldType {
return try {
@@ -12,15 +12,15 @@ import androidx.room.PrimaryKey
entity = CollabList::class,
parentColumns = ["id"],
childColumns = ["listId"],
onDelete = ForeignKey.CASCADE
)
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("listId")]
indices = [Index("listId")],
)
data class Item(
@PrimaryKey val id: String,
val listId: String,
val createdAt: Long,
val updatedAt: Long,
val isDeleted: Boolean = false
val isDeleted: Boolean = false,
)
@@ -12,21 +12,21 @@ import androidx.room.PrimaryKey
entity = Item::class,
parentColumns = ["id"],
childColumns = ["itemId"],
onDelete = ForeignKey.CASCADE
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = Field::class,
parentColumns = ["id"],
childColumns = ["fieldId"],
onDelete = ForeignKey.CASCADE
)
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("itemId"), Index("fieldId")]
indices = [Index("itemId"), Index("fieldId")],
)
data class ItemValue(
@PrimaryKey val id: String,
val itemId: String,
val fieldId: String,
val value: String,
val updatedAt: Long
val updatedAt: Long,
)
@@ -7,7 +7,7 @@ data class ItemWithValues(
@Embedded val item: Item,
@Relation(
parentColumn = "id",
entityColumn = "itemId"
entityColumn = "itemId",
)
val values: List<ItemValue>
val values: List<ItemValue>,
)
@@ -7,7 +7,7 @@ data class ListWithFields(
@Embedded val list: CollabList,
@Relation(
parentColumn = "id",
entityColumn = "listId"
entityColumn = "listId",
)
val fields: List<Field>
val fields: List<Field>,
)
@@ -79,7 +79,8 @@ class PreferencesManager(context: Context) {
}
fun setThemeMode(mode: String) {
val normalized = when (mode.lowercase()) {
val normalized =
when (mode.lowercase()) {
THEME_MODE_LIGHT, THEME_MODE_DARK, THEME_MODE_SYSTEM -> mode.lowercase()
else -> THEME_MODE_SYSTEM
}
@@ -1,14 +1,14 @@
package com.collabtable.app.data.repository
import android.content.Context
import androidx.room.withTransaction
import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.api.WebSocketSyncClient
import com.collabtable.app.data.api.SyncRequest
import com.collabtable.app.data.api.WebSocketSyncClient
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.utils.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.room.withTransaction
class SyncRepository(context: Context) {
private val appContext = context.applicationContext
@@ -24,7 +24,8 @@ class SyncRepository(context: Context) {
prefs.edit().putLong("last_sync_timestamp", timestamp).apply()
}
suspend fun performSync(): Result<Unit> = withContext(Dispatchers.IO) {
suspend fun performSync(): Result<Unit> =
withContext(Dispatchers.IO) {
try {
val lastSync = getLastSyncTimestamp()
val isInitialSync = lastSync == 0L
@@ -43,22 +44,24 @@ class SyncRepository(context: Context) {
if (localTotal > 0) {
Logger.i(
"Sync",
"[OUT] Sending changes (lists=${localLists.size}, fields=${localFields.size}, items=${localItems.size}, values=${localValues.size})"
"[OUT] Sending changes (lists=${localLists.size}, fields=${localFields.size}, items=${localItems.size}, values=${localValues.size})",
)
}
// Send to server and get updates
val syncRequest = SyncRequest(
val syncRequest =
SyncRequest(
lastSyncTimestamp = lastSync,
lists = localLists,
fields = localFields,
items = localItems,
itemValues = localValues
itemValues = localValues,
)
// Try WebSocket first; on failure, fall back to HTTP
val wsResult = WebSocketSyncClient.sync(appContext, syncRequest)
val syncResponse = if (wsResult.isSuccess) {
val syncResponse =
if (wsResult.isSuccess) {
wsResult.getOrThrow()
} else {
val response = api.sync(syncRequest)
@@ -79,7 +82,7 @@ class SyncRepository(context: Context) {
if (inTotal > 0) {
Logger.i(
"Sync",
"[IN] Received changes (lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, items=${syncResponse.items.size}, values=${syncResponse.itemValues.size})"
"[IN] Received changes (lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, items=${syncResponse.items.size}, values=${syncResponse.itemValues.size})",
)
}
@@ -114,7 +117,8 @@ class SyncRepository(context: Context) {
// 4) Filter item values to only those whose parents (item and field) exist locally
val existingItemIds = database.itemDao().getAllItemIds().toSet()
val existingFieldIds = database.fieldDao().getAllFieldIds().toSet()
val filteredValues = syncResponse.itemValues.filter { v ->
val filteredValues =
syncResponse.itemValues.filter { v ->
v.itemId in existingItemIds && v.fieldId in existingFieldIds
}
if (filteredValues.size != syncResponse.itemValues.size) {
@@ -22,7 +22,8 @@ fun AppNavigation() {
val preferencesManager = remember { PreferencesManager.getInstance(context) }
// Determine start destination based on first run
val startDestination = if (preferencesManager.isFirstRun()) {
val startDestination =
if (preferencesManager.isFirstRun()) {
"server_setup"
} else {
"lists"
@@ -30,7 +31,7 @@ fun AppNavigation() {
NavHost(
navController = navController,
startDestination = startDestination
startDestination = startDestination,
) {
composable("server_setup") {
ServerSetupScreen(
@@ -39,7 +40,7 @@ fun AppNavigation() {
navController.navigate("lists") {
popUpTo("server_setup") { inclusive = true }
}
}
},
)
}
@@ -53,18 +54,18 @@ fun AppNavigation() {
},
onNavigateToLogs = {
navController.navigate("logs")
}
},
)
}
composable(
route = "list/{listId}",
arguments = listOf(navArgument("listId") { type = NavType.StringType })
arguments = listOf(navArgument("listId") { type = NavType.StringType }),
) { backStackEntry ->
val listId = backStackEntry.arguments?.getString("listId") ?: return@composable
ListDetailScreen(
listId = listId,
onNavigateBack = { navController.popBackStack() }
onNavigateBack = { navController.popBackStack() },
)
}
@@ -76,13 +77,13 @@ fun AppNavigation() {
navController.navigate("server_setup") {
popUpTo(0) { inclusive = true }
}
}
},
)
}
composable("logs") {
LogsScreen(
onNavigateBack = { navController.popBackStack() }
onNavigateBack = { navController.popBackStack() },
)
}
}
@@ -17,7 +17,7 @@ import java.util.UUID
class ListDetailViewModel(
private val database: CollabTableDatabase,
private val listId: String,
private val context: Context
private val context: Context,
) : ViewModel() {
private val _list = MutableStateFlow<CollabList?>(null)
val list: StateFlow<CollabList?> = _list.asStateFlow()
@@ -79,19 +79,24 @@ class ListDetailViewModel(
database.listDao().updateList(
currentList.copy(
name = newName.trim(),
updatedAt = System.currentTimeMillis()
)
updatedAt = System.currentTimeMillis(),
),
)
performSync()
}
}
}
fun addField(name: String, fieldType: String = "STRING", fieldOptions: String = "") {
fun addField(
name: String,
fieldType: String = "STRING",
fieldOptions: String = "",
) {
viewModelScope.launch {
val timestamp = System.currentTimeMillis()
val maxOrder = _fields.value.maxOfOrNull { it.order } ?: -1
val newField = Field(
val newField =
Field(
id = UUID.randomUUID().toString(),
listId = listId,
name = name,
@@ -99,19 +104,20 @@ class ListDetailViewModel(
fieldOptions = fieldOptions,
order = maxOrder + 1,
createdAt = timestamp,
updatedAt = timestamp
updatedAt = timestamp,
)
// Create empty ItemValue entries for this new field for all existing items
val existingItems = _items.value
val newValues = if (existingItems.isNotEmpty()) {
val newValues =
if (existingItems.isNotEmpty()) {
existingItems.map { itemWithValues ->
ItemValue(
id = UUID.randomUUID().toString(),
itemId = itemWithValues.item.id,
fieldId = newField.id,
value = "",
updatedAt = timestamp
updatedAt = timestamp,
)
}
} else {
@@ -137,7 +143,11 @@ class ListDetailViewModel(
}
}
fun updateField(fieldId: String, fieldType: String, fieldOptions: String) {
fun updateField(
fieldId: String,
fieldType: String,
fieldOptions: String,
) {
viewModelScope.launch {
val field = database.fieldDao().getFieldById(fieldId)
if (field != null) {
@@ -145,8 +155,8 @@ class ListDetailViewModel(
field.copy(
fieldType = fieldType,
fieldOptions = fieldOptions,
updatedAt = System.currentTimeMillis()
)
updatedAt = System.currentTimeMillis(),
),
)
performSync()
}
@@ -156,23 +166,25 @@ class ListDetailViewModel(
fun addItem() {
viewModelScope.launch {
val timestamp = System.currentTimeMillis()
val newItem = Item(
val newItem =
Item(
id = UUID.randomUUID().toString(),
listId = listId,
createdAt = timestamp,
updatedAt = timestamp
updatedAt = timestamp,
)
// Insert item and its values atomically
database.withTransaction {
database.itemDao().insertItem(newItem)
// Create empty values for each field
val values = _fields.value.map { field ->
val values =
_fields.value.map { field ->
ItemValue(
id = UUID.randomUUID().toString(),
itemId = newItem.id,
fieldId = field.id,
value = "",
updatedAt = timestamp
updatedAt = timestamp,
)
}
if (values.isNotEmpty()) {
@@ -186,23 +198,25 @@ class ListDetailViewModel(
fun addItemWithValues(fieldValues: Map<String, String>) {
viewModelScope.launch {
val timestamp = System.currentTimeMillis()
val newItem = Item(
val newItem =
Item(
id = UUID.randomUUID().toString(),
listId = listId,
createdAt = timestamp,
updatedAt = timestamp
updatedAt = timestamp,
)
// Insert item and provided values atomically
database.withTransaction {
database.itemDao().insertItem(newItem)
// Create values for each field with the provided values
val values = _fields.value.map { field ->
val values =
_fields.value.map { field ->
ItemValue(
id = UUID.randomUUID().toString(),
itemId = newItem.id,
fieldId = field.id,
value = fieldValues[field.id] ?: "",
updatedAt = timestamp
updatedAt = timestamp,
)
}
if (values.isNotEmpty()) {
@@ -213,15 +227,18 @@ class ListDetailViewModel(
}
}
fun updateItemValue(itemValueId: String, newValue: String) {
fun updateItemValue(
itemValueId: String,
newValue: String,
) {
viewModelScope.launch {
val itemValue = database.itemValueDao().getValueById(itemValueId)
if (itemValue != null) {
database.itemValueDao().updateValue(
itemValue.copy(
value = newValue,
updatedAt = System.currentTimeMillis()
)
updatedAt = System.currentTimeMillis(),
),
)
performSync()
}
@@ -29,7 +29,7 @@ import java.util.*
fun ListsScreen(
onNavigateToList: (String) -> Unit,
onNavigateToSettings: () -> Unit,
onNavigateToLogs: () -> Unit = {}
onNavigateToLogs: () -> Unit = {},
) {
val context = LocalContext.current
val database = remember { CollabTableDatabase.getDatabase(context) }
@@ -57,37 +57,39 @@ fun ListsScreen(
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
}
},
colors = TopAppBarDefaults.topAppBarColors(
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { showCreateDialog = true }
onClick = { showCreateDialog = true },
) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.create_list))
}
}
},
) { padding ->
Box(
modifier = Modifier
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.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)
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularProgressIndicator()
Text(
text = "Syncing with server...",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else if (lists.isEmpty()) {
@@ -95,17 +97,17 @@ fun ListsScreen(
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(R.string.no_lists),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Tap + to create your first table",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
@@ -113,14 +115,14 @@ fun ListsScreen(
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.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 }
onDeleteClick = { listToDelete = list },
)
}
}
@@ -134,7 +136,7 @@ fun ListsScreen(
onCreate = { name ->
viewModel.createList(name)
showCreateDialog = false
}
},
)
}
@@ -145,7 +147,7 @@ fun ListsScreen(
onRename = { newName ->
viewModel.renameList(list.id, newName)
listToEdit = null
}
},
)
}
@@ -159,7 +161,7 @@ fun ListsScreen(
onClick = {
viewModel.deleteList(list.id)
listToDelete = null
}
},
) {
Text(stringResource(R.string.delete))
}
@@ -168,7 +170,7 @@ fun ListsScreen(
TextButton(onClick = { listToDelete = null }) {
Text(stringResource(R.string.cancel))
}
}
},
)
}
}
@@ -178,30 +180,32 @@ fun ListItem(
list: CollabList,
onListClick: () -> Unit,
onEditClick: () -> Unit,
onDeleteClick: () -> Unit
onDeleteClick: () -> Unit,
) {
Card(
modifier = Modifier
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onListClick)
.clickable(onClick = onListClick),
) {
Row(
modifier = Modifier
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = list.name,
style = MaterialTheme.typography.titleMedium
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatDate(list.updatedAt),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row {
@@ -209,14 +213,14 @@ fun ListItem(
Icon(
Icons.Default.Edit,
contentDescription = "Edit",
tint = MaterialTheme.colorScheme.primary
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = onDeleteClick) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.delete),
tint = MaterialTheme.colorScheme.error
tint = MaterialTheme.colorScheme.error,
)
}
}
@@ -227,7 +231,7 @@ fun ListItem(
@Composable
fun CreateListDialog(
onDismiss: () -> Unit,
onCreate: (String) -> Unit
onCreate: (String) -> Unit,
) {
var listName by remember { mutableStateOf("") }
@@ -239,13 +243,13 @@ fun CreateListDialog(
value = listName,
onValueChange = { listName = it },
label = { Text(stringResource(R.string.list_name)) },
singleLine = true
singleLine = true,
)
},
confirmButton = {
TextButton(
onClick = { if (listName.isNotBlank()) onCreate(listName.trim()) },
enabled = listName.isNotBlank()
enabled = listName.isNotBlank(),
) {
Text(stringResource(R.string.save))
}
@@ -254,7 +258,7 @@ fun CreateListDialog(
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
},
)
}
@@ -262,7 +266,7 @@ fun CreateListDialog(
private fun RenameListDialog(
currentName: String,
onDismiss: () -> Unit,
onRename: (String) -> Unit
onRename: (String) -> Unit,
) {
var listName by remember { mutableStateOf(currentName) }
@@ -274,13 +278,13 @@ private fun RenameListDialog(
value = listName,
onValueChange = { listName = it },
label = { Text(stringResource(R.string.list_name)) },
singleLine = true
singleLine = true,
)
},
confirmButton = {
TextButton(
onClick = { if (listName.isNotBlank()) onRename(listName.trim()) },
enabled = listName.isNotBlank() && listName.trim() != currentName
enabled = listName.isNotBlank() && listName.trim() != currentName,
) {
Text("Rename")
}
@@ -289,7 +293,7 @@ private fun RenameListDialog(
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
},
)
}
@@ -15,7 +15,7 @@ import java.util.UUID
class ListsViewModel(
private val database: CollabTableDatabase,
private val context: Context
private val context: Context,
) : ViewModel() {
private val _lists = MutableStateFlow<List<CollabList>>(emptyList())
val lists: StateFlow<List<CollabList>> = _lists.asStateFlow()
@@ -62,11 +62,12 @@ class ListsViewModel(
viewModelScope.launch {
Logger.i("Tables", " Creating table: \"$name\"")
val timestamp = System.currentTimeMillis()
val newList = CollabList(
val newList =
CollabList(
id = UUID.randomUUID().toString(),
name = name,
createdAt = timestamp,
updatedAt = timestamp
updatedAt = timestamp,
)
database.listDao().insertList(newList)
// Sync immediately after creating
@@ -74,7 +75,10 @@ class ListsViewModel(
}
}
fun renameList(listId: String, newName: String) {
fun renameList(
listId: String,
newName: String,
) {
viewModelScope.launch {
val list = database.listDao().getListById(listId)
if (list != null && newName.isNotBlank()) {
@@ -82,8 +86,8 @@ class ListsViewModel(
database.listDao().updateList(
list.copy(
name = newName.trim(),
updatedAt = System.currentTimeMillis()
)
updatedAt = System.currentTimeMillis(),
),
)
// Sync immediately after renaming
performSync()
@@ -25,7 +25,6 @@ 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),
@@ -34,33 +33,33 @@ enum class TimeRange(val label: String, val milliseconds: Long) {
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)
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 = ""
val searchText: String = "",
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LogsScreen(
onNavigateBack: () -> Unit
) {
fun LogsScreen(onNavigateBack: () -> Unit) {
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) {
val allTags =
remember(logs) {
logs.map { it.tag }.distinct().sorted()
}
// Apply filters
val filteredLogs = remember(logs, filters) {
val filteredLogs =
remember(logs, filters) {
val now = System.currentTimeMillis()
val cutoffTime = now - filters.timeRange.milliseconds
@@ -72,9 +71,11 @@ fun LogsScreen(
// Tag filter (empty means show all)
(filters.tags.isEmpty() || log.tag in filters.tags) &&
// Search text filter
(filters.searchText.isEmpty() ||
(
filters.searchText.isEmpty() ||
log.message.contains(filters.searchText, ignoreCase = true) ||
log.tag.contains(filters.searchText, ignoreCase = true))
log.tag.contains(filters.searchText, ignoreCase = true)
)
}
}
@@ -97,9 +98,12 @@ fun LogsScreen(
actions = {
IconButton(onClick = { showFilterSheet = true }) {
Badge(
containerColor = if (hasActiveFilters(filters))
containerColor =
if (hasActiveFilters(filters)) {
MaterialTheme.colorScheme.error
else Color.Transparent
} else {
Color.Transparent
},
) {
Icon(Icons.Default.FilterList, contentDescription = "Filter logs")
}
@@ -108,26 +112,29 @@ fun LogsScreen(
Icon(Icons.Default.Delete, contentDescription = "Clear logs")
}
},
colors = TopAppBarDefaults.topAppBarColors(
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
)
}
},
) { padding ->
Column(
modifier = Modifier
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(padding),
) {
// Active filters chips
if (hasActiveFilters(filters)) {
LazyRow(
modifier = Modifier
modifier =
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Severity filters
if (filters.severities.size < LogLevel.values().size) {
@@ -135,11 +142,12 @@ fun LogsScreen(
FilterChip(
selected = true,
onClick = {
filters = filters.copy(
severities = filters.severities - level
filters =
filters.copy(
severities = filters.severities - level,
)
},
label = { Text(level.name, fontSize = 12.sp) }
label = { Text(level.name, fontSize = 12.sp) },
)
}
}
@@ -152,7 +160,7 @@ fun LogsScreen(
onClick = {
filters = filters.copy(timeRange = TimeRange.ALL_TIME)
},
label = { Text(filters.timeRange.label, fontSize = 12.sp) }
label = { Text(filters.timeRange.label, fontSize = 12.sp) },
)
}
}
@@ -164,7 +172,7 @@ fun LogsScreen(
onClick = {
filters = filters.copy(tags = filters.tags - tag)
},
label = { Text(tag, fontSize = 12.sp) }
label = { Text(tag, fontSize = 12.sp) },
)
}
@@ -180,9 +188,9 @@ fun LogsScreen(
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(16.dp)
modifier = Modifier.size(16.dp),
)
}
},
)
}
}
@@ -192,21 +200,22 @@ fun LogsScreen(
if (filteredLogs.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
contentAlignment = Alignment.Center,
) {
Text(
text = if (logs.isEmpty()) "No logs yet" else "No logs match filters",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(
state = listState,
modifier = Modifier
modifier =
Modifier
.fillMaxSize()
.background(Color(0xFF1E1E1E)),
contentPadding = PaddingValues(8.dp)
contentPadding = PaddingValues(8.dp),
) {
items(filteredLogs) { log ->
LogItem(log)
@@ -222,7 +231,7 @@ fun LogsScreen(
filters = filters,
allTags = allTags,
onFiltersChanged = { filters = it },
onDismiss = { showFilterSheet = false }
onDismiss = { showFilterSheet = false },
)
}
}
@@ -240,26 +249,27 @@ fun FilterBottomSheet(
filters: LogFilters,
allTags: List<String>,
onFiltersChanged: (LogFilters) -> Unit,
onDismiss: () -> Unit
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
sheetState = sheetState,
) {
Column(
modifier = Modifier
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp)
.verticalScroll(rememberScrollState())
.padding(bottom = 32.dp)
.padding(bottom = 32.dp),
) {
Text(
text = "Filter Logs",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
modifier = Modifier.padding(bottom = 16.dp),
)
// Severity filters
@@ -267,33 +277,35 @@ fun FilterBottomSheet(
text = "Severity",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 8.dp)
modifier = Modifier.padding(bottom = 8.dp),
)
LogLevel.values().forEach { level ->
Row(
modifier = Modifier
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = level in filters.severities,
onCheckedChange = { checked ->
onFiltersChanged(
filters.copy(
severities = if (checked) {
severities =
if (checked) {
filters.severities + level
} else {
filters.severities - level
}
},
),
)
)
}
},
)
Text(
text = level.name,
modifier = Modifier.padding(start = 8.dp)
modifier = Modifier.padding(start = 8.dp),
)
}
}
@@ -305,25 +317,26 @@ fun FilterBottomSheet(
text = "Time Range",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 8.dp)
modifier = Modifier.padding(bottom = 8.dp),
)
TimeRange.values().forEach { range ->
Row(
modifier = Modifier
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = filters.timeRange == range,
onClick = {
onFiltersChanged(filters.copy(timeRange = range))
}
},
)
Text(
text = range.label,
modifier = Modifier.padding(start = 8.dp)
modifier = Modifier.padding(start = 8.dp),
)
}
}
@@ -335,7 +348,7 @@ fun FilterBottomSheet(
text = "Tags (${filters.tags.size} selected)",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 8.dp)
modifier = Modifier.padding(bottom = 8.dp),
)
if (allTags.isEmpty()) {
@@ -343,26 +356,27 @@ fun FilterBottomSheet(
text = "No tags available",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
modifier = Modifier.padding(vertical = 8.dp),
)
} else {
Row(
modifier = Modifier
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.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")
}
@@ -370,28 +384,30 @@ fun FilterBottomSheet(
allTags.forEach { tag ->
Row(
modifier = Modifier
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = tag in filters.tags,
onCheckedChange = { checked ->
onFiltersChanged(
filters.copy(
tags = if (checked) {
tags =
if (checked) {
filters.tags + tag
} else {
filters.tags - tag
}
},
),
)
)
}
},
)
Text(
text = tag,
modifier = Modifier.padding(start = 8.dp)
modifier = Modifier.padding(start = 8.dp),
)
}
}
@@ -404,7 +420,7 @@ fun FilterBottomSheet(
onClick = {
onFiltersChanged(LogFilters())
},
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
) {
Text("Reset All Filters")
}
@@ -414,7 +430,8 @@ fun FilterBottomSheet(
@Composable
fun LogItem(log: LogEntry) {
val color = when (log.level) {
val color =
when (log.level) {
LogLevel.DEBUG -> Color(0xFF808080)
LogLevel.INFO -> Color(0xFF4FC3F7)
LogLevel.WARN -> Color(0xFFFFA726)
@@ -426,8 +443,9 @@ fun LogItem(log: LogEntry) {
color = color,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
modifier = Modifier
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
.padding(vertical = 2.dp),
)
}
@@ -21,9 +21,7 @@ import com.collabtable.app.data.preferences.PreferencesManager
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ServerSetupScreen(
onSetupComplete: () -> Unit
) {
fun ServerSetupScreen(onSetupComplete: () -> Unit) {
val context = LocalContext.current
val preferencesManager = remember { PreferencesManager.getInstance(context) }
val viewModel = remember { ServerSetupViewModel(preferencesManager) }
@@ -45,20 +43,22 @@ fun ServerSetupScreen(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Server Setup") },
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
colors =
TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
)
}
},
) { padding ->
Column(
modifier = Modifier
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Spacer(modifier = Modifier.height(4.dp))
@@ -72,31 +72,35 @@ fun ServerSetupScreen(
isError = validationError != null,
supportingText = {
when {
validationError != null -> Text(
validationError != null ->
Text(
text = validationError ?: "",
color = MaterialTheme.colorScheme.error
color = MaterialTheme.colorScheme.error,
)
else -> Text("Hostname only. Port optional (80/443 default). No http://, no /api/")
}
},
trailingIcon = {
when {
isValidating -> CircularProgressIndicator(
isValidating ->
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
strokeWidth = 2.dp,
)
validationResult == true -> Icon(
validationResult == true ->
Icon(
Icons.Default.CheckCircle,
contentDescription = "Valid",
tint = MaterialTheme.colorScheme.primary
tint = MaterialTheme.colorScheme.primary,
)
validationError != null -> Icon(
validationError != null ->
Icon(
Icons.Default.Error,
contentDescription = "Error",
tint = MaterialTheme.colorScheme.error
tint = MaterialTheme.colorScheme.error,
)
}
}
},
)
OutlinedTextField(
@@ -112,23 +116,23 @@ fun ServerSetupScreen(
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
contentDescription = if (passwordVisible) "Hide password" else "Show password"
contentDescription = if (passwordVisible) "Hide password" else "Show password",
)
}
},
supportingText = { Text("Server password for authentication") }
supportingText = { Text("Server password for authentication") },
)
Button(
onClick = { viewModel.validateAndSaveServerUrl(serverUrl.trim(), serverPassword.trim()) },
modifier = Modifier.fillMaxWidth(),
enabled = serverUrl.isNotBlank() && serverPassword.isNotBlank() && !isValidating
enabled = serverUrl.isNotBlank() && serverPassword.isNotBlank() && !isValidating,
) {
if (isValidating) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
Text("Validating...")
@@ -141,7 +145,7 @@ fun ServerSetupScreen(
text = "Tip: Emulator host 10.0.2.2:3000 · Physical device uses your PC IP",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
)
}
}
@@ -1,9 +1,10 @@
package com.collabtable.app.ui.screens
import android.os.NetworkOnMainThreadException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.preferences.PreferencesManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -12,7 +13,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import android.os.NetworkOnMainThreadException
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
@@ -20,7 +20,7 @@ import java.net.UnknownHostException
import java.util.concurrent.TimeUnit
class ServerSetupViewModel(
private val preferencesManager: PreferencesManager
private val preferencesManager: PreferencesManager,
) : ViewModel() {
private val _isValidating = MutableStateFlow(false)
val isValidating: StateFlow<Boolean> = _isValidating.asStateFlow()
@@ -31,12 +31,16 @@ class ServerSetupViewModel(
private val _validationError = MutableStateFlow<String?>(null)
val validationError: StateFlow<String?> = _validationError.asStateFlow()
private val okHttpClient = OkHttpClient.Builder()
private val okHttpClient =
OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
fun validateAndSaveServerUrl(url: String, password: String) {
fun validateAndSaveServerUrl(
url: String,
password: String,
) {
viewModelScope.launch {
_isValidating.value = true
_validationError.value = null
@@ -88,19 +92,22 @@ class ServerSetupViewModel(
// Try to reach the health endpoint (no auth required)
val healthUrl = normalizedUrl.replace("/api/", "/health")
val healthRequest = Request.Builder()
val healthRequest =
Request.Builder()
.url(healthUrl)
.get()
.build()
try {
// Execute network call on IO dispatcher
val healthResponse = withContext(Dispatchers.IO) {
val healthResponse =
withContext(Dispatchers.IO) {
okHttpClient.newCall(healthRequest).execute()
}
if (!healthResponse.isSuccessful) {
val errorMessage = when (healthResponse.code) {
val errorMessage =
when (healthResponse.code) {
404 -> "Health endpoint not found. Check server URL."
500 -> "Server internal error. Check server logs."
503 -> "Service unavailable. Server may be starting up."
@@ -123,14 +130,16 @@ class ServerSetupViewModel(
}
val httpsHealthUrl = httpsUrl.replace("/api/", "/health")
val httpsHealthRequest = Request.Builder()
val httpsHealthRequest =
Request.Builder()
.url(httpsHealthUrl)
.get()
.build()
try {
// Execute HTTPS check on IO dispatcher
val httpsResponse = withContext(Dispatchers.IO) {
val httpsResponse =
withContext(Dispatchers.IO) {
okHttpClient.newCall(httpsHealthRequest).execute()
}
if (httpsResponse.isSuccessful) {
@@ -145,14 +154,16 @@ class ServerSetupViewModel(
// Now validate the password by making an authenticated request
val testUrl = "${finalUrl}lists"
val authRequest = Request.Builder()
val authRequest =
Request.Builder()
.url(testUrl)
.header("Authorization", "Bearer $password")
.get()
.build()
// Execute auth check on IO dispatcher
val authResponse = withContext(Dispatchers.IO) {
val authResponse =
withContext(Dispatchers.IO) {
okHttpClient.newCall(authRequest).execute()
}
@@ -168,7 +179,8 @@ class ServerSetupViewModel(
} else if (authResponse.code == 401) {
_validationError.value = "Invalid password. Please check and try again."
} else {
val errorMessage = when (authResponse.code) {
val errorMessage =
when (authResponse.code) {
403 -> "Access forbidden. Check server configuration."
404 -> "API endpoint not found. Check URL path."
500 -> "Server error. Check server logs."
@@ -3,24 +3,23 @@ package com.collabtable.app.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.SettingsBrightness
import androidx.compose.material3.*
import androidx.compose.material3.FilterChip
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.collabtable.app.R
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.data.repository.SyncRepository
import com.collabtable.app.utils.Logger
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.SettingsBrightness
import androidx.compose.ui.Alignment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -29,7 +28,7 @@ import kotlinx.coroutines.withContext
@Composable
fun SettingsScreen(
onNavigateBack: () -> Unit,
onLeaveServer: () -> Unit = {}
onLeaveServer: () -> Unit = {},
) {
val context = LocalContext.current
val preferencesManager = remember { PreferencesManager.getInstance(context) }
@@ -53,44 +52,47 @@ fun SettingsScreen(
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
)
}
},
) { padding ->
Column(
modifier = Modifier
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Server Connection",
style = MaterialTheme.typography.titleMedium
style = MaterialTheme.typography.titleMedium,
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "Connected to",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = displayUrl,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@@ -99,31 +101,31 @@ fun SettingsScreen(
Text(
text = "Appearance",
style = MaterialTheme.typography.titleMedium
style = MaterialTheme.typography.titleMedium,
)
// Theme mode selector
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = themeMode == PreferencesManager.THEME_MODE_SYSTEM,
onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_SYSTEM) },
label = { Text("System") },
leadingIcon = { Icon(Icons.Default.SettingsBrightness, contentDescription = null) }
leadingIcon = { Icon(Icons.Default.SettingsBrightness, contentDescription = null) },
)
FilterChip(
selected = themeMode == PreferencesManager.THEME_MODE_LIGHT,
onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_LIGHT) },
label = { Text("Light") },
leadingIcon = { Icon(Icons.Default.LightMode, contentDescription = null) }
leadingIcon = { Icon(Icons.Default.LightMode, contentDescription = null) },
)
FilterChip(
selected = themeMode == PreferencesManager.THEME_MODE_DARK,
onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_DARK) },
label = { Text("Dark") },
leadingIcon = { Icon(Icons.Default.DarkMode, contentDescription = null) }
leadingIcon = { Icon(Icons.Default.DarkMode, contentDescription = null) },
)
}
@@ -131,14 +133,14 @@ fun SettingsScreen(
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Use Material 3 colors")
Text(
text = "Dynamic colors on supported devices",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = dynamicColor, onCheckedChange = { preferencesManager.setDynamicColorEnabled(it) })
@@ -148,14 +150,14 @@ fun SettingsScreen(
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("AMOLED dark")
Text(
text = "Pure black background in dark mode",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = amoledDark, onCheckedChange = { preferencesManager.setAmoledDarkEnabled(it) })
@@ -165,16 +167,17 @@ fun SettingsScreen(
onClick = { showLeaveDialog = true },
modifier = Modifier.fillMaxWidth(),
enabled = !isLeaving,
colors = ButtonDefaults.buttonColors(
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
contentColor = MaterialTheme.colorScheme.onError,
),
) {
if (isLeaving) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
color = MaterialTheme.colorScheme.onError,
strokeWidth = 2.dp
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
Text("Leaving...")
@@ -185,38 +188,39 @@ fun SettingsScreen(
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "Leaving removes local data",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Text(
text = "Disconnects and clears local database",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Text(
text = "Clears stored server URL and password",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Text(
text = "Deletes all local data (tables, fields, items)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Text(
text = "Returns to server setup screen",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
@@ -227,7 +231,9 @@ fun SettingsScreen(
onDismissRequest = { showLeaveDialog = false },
title = { Text("Leave Server?") },
text = {
Text("All your data will be synced to the server before disconnecting. After leaving, all local data will be deleted and you'll need to set up a new connection. This action cannot be undone.")
Text(
"All your data will be synced to the server before disconnecting. After leaving, all local data will be deleted and you'll need to set up a new connection. This action cannot be undone.",
)
},
confirmButton = {
TextButton(
@@ -269,9 +275,10 @@ fun SettingsScreen(
}
},
enabled = !isLeaving,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
colors =
ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text("Leave Server")
}
@@ -280,7 +287,7 @@ fun SettingsScreen(
TextButton(onClick = { showLeaveDialog = false }) {
Text("Cancel")
}
}
},
)
}
}
@@ -300,7 +307,8 @@ private fun formatServerUrlForDisplay(raw: String): String {
val authority = if (firstSlash >= 0) s.substring(0, firstSlash) else s
val rest = if (firstSlash >= 0) s.substring(firstSlash) else ""
// Remove default ports from authority
val authorityStripped = when {
val authorityStripped =
when {
authority.endsWith(":80") -> authority.removeSuffix(":80")
authority.endsWith(":443") -> authority.removeSuffix(":443")
else -> authority
@@ -2,15 +2,15 @@ package com.collabtable.app.ui.screens
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.preferences.PreferencesManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class SettingsViewModel(
private val preferencesManager: PreferencesManager
private val preferencesManager: PreferencesManager,
) : ViewModel() {
private val _serverUrl = MutableStateFlow(preferencesManager.getServerUrl())
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
@@ -16,26 +16,29 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
private val DarkColorScheme =
darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
tertiary = Pink80,
)
private val LightColorScheme = lightColorScheme(
private val LightColorScheme =
lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
)
tertiary = Pink40,
)
@Composable
fun CollabTableTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
amoledDark: Boolean = false,
content: @Composable () -> Unit
content: @Composable () -> Unit,
) {
var colorScheme = when {
var colorScheme =
when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
@@ -45,10 +48,11 @@ fun CollabTableTheme(
}
if (darkTheme && amoledDark) {
colorScheme = colorScheme.copy(
colorScheme =
colorScheme.copy(
background = Color.Black,
surface = Color.Black,
surfaceVariant = Color.Black
surfaceVariant = Color.Black,
)
}
val view = LocalView.current
@@ -63,6 +67,6 @@ fun CollabTableTheme(
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
content = content,
)
}
@@ -6,12 +6,14 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
bodyLarge = TextStyle(
val Typography =
Typography(
bodyLarge =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
letterSpacing = 0.5.sp,
),
)
)
@@ -11,7 +11,7 @@ data class LogEntry(
val timestamp: Long,
val level: LogLevel,
val tag: String,
val message: String
val message: String,
) {
fun toFormattedString(): String {
val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
@@ -21,7 +21,10 @@ data class LogEntry(
}
enum class LogLevel {
DEBUG, INFO, WARN, ERROR
DEBUG,
INFO,
WARN,
ERROR,
}
object Logger {
@@ -30,22 +33,35 @@ object Logger {
private const val MAX_LOGS = 500
fun d(tag: String, message: String) {
fun d(
tag: String,
message: String,
) {
log(LogLevel.DEBUG, tag, message)
Log.d(tag, message)
}
fun i(tag: String, message: String) {
fun i(
tag: String,
message: String,
) {
log(LogLevel.INFO, tag, message)
Log.i(tag, message)
}
fun w(tag: String, message: String) {
fun w(
tag: String,
message: String,
) {
log(LogLevel.WARN, tag, message)
Log.w(tag, message)
}
fun e(tag: String, message: String, throwable: Throwable? = null) {
fun e(
tag: String,
message: String,
throwable: Throwable? = null,
) {
val msg = if (throwable != null) "$message: ${throwable.message}" else message
log(LogLevel.ERROR, tag, msg)
if (throwable != null) {
@@ -55,12 +71,17 @@ object Logger {
}
}
private fun log(level: LogLevel, tag: String, message: String) {
val entry = LogEntry(
private fun log(
level: LogLevel,
tag: String,
message: String,
) {
val entry =
LogEntry(
timestamp = System.currentTimeMillis(),
level = level,
tag = tag,
message = message
message = message,
)
_logs.value = (_logs.value + entry).takeLast(MAX_LOGS)