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