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
@@ -1,41 +1,42 @@
package com.collabtable.app package com.collabtable.app
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent 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
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
val prefs = PreferencesManager.getInstance(this) val prefs = PreferencesManager.getInstance(this)
val themeMode by prefs.themeMode.collectAsState() val themeMode by prefs.themeMode.collectAsState()
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) {
Surface( CollabTableTheme(darkTheme = darkTheme, dynamicColor = dynamicColor, amoledDark = amoledDark) {
modifier = Modifier.fillMaxSize(), Surface(
color = MaterialTheme.colorScheme.background modifier = Modifier.fillMaxSize(),
) { color = MaterialTheme.colorScheme.background,
AppNavigation() ) {
} AppNavigation()
} }
} }
} }
} }
}
@@ -1,73 +1,78 @@
package com.collabtable.app.data.api package com.collabtable.app.data.api
import android.content.Context import android.content.Context
import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.preferences.PreferencesManager
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
object ApiClient { object ApiClient {
private var baseUrl: String = "http://10.0.2.2:3000/api/" // Default for Android emulator private var baseUrl: String = "http://10.0.2.2:3000/api/" // Default for Android emulator
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() private val authInterceptor =
val password = context?.let { Interceptor { chain ->
PreferencesManager.getInstance(it).getServerPassword() val request = chain.request()
} val password =
context?.let {
val newRequest = if (!password.isNullOrBlank()) { PreferencesManager.getInstance(it).getServerPassword()
request.newBuilder() }
.header("Authorization", "Bearer $password")
.build() val newRequest =
} else { if (!password.isNullOrBlank()) {
request request.newBuilder()
} .header("Authorization", "Bearer $password")
.build()
chain.proceed(newRequest) } else {
} request
}
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(authInterceptor) chain.proceed(newRequest)
.addInterceptor(loggingInterceptor) }
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) private val okHttpClient =
.writeTimeout(30, TimeUnit.SECONDS) OkHttpClient.Builder()
.build() .addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
private fun buildRetrofit(): Retrofit { .connectTimeout(30, TimeUnit.SECONDS)
return Retrofit.Builder() .readTimeout(30, TimeUnit.SECONDS)
.baseUrl(baseUrl) .writeTimeout(30, TimeUnit.SECONDS)
.client(okHttpClient) .build()
.addConverterFactory(GsonConverterFactory.create())
.build() private fun buildRetrofit(): Retrofit {
} return Retrofit.Builder()
.baseUrl(baseUrl)
fun initialize(context: Context) { .client(okHttpClient)
this.context = context.applicationContext .addConverterFactory(GsonConverterFactory.create())
val prefs = PreferencesManager.getInstance(context) .build()
baseUrl = prefs.getServerUrl() }
retrofit = buildRetrofit()
} fun initialize(context: Context) {
this.context = context.applicationContext
fun setBaseUrl(url: String) { val prefs = PreferencesManager.getInstance(context)
baseUrl = if (url.endsWith("/")) url else "$url/" baseUrl = prefs.getServerUrl()
retrofit = buildRetrofit() retrofit = buildRetrofit()
} }
val api: CollabTableApi fun setBaseUrl(url: String) {
get() { baseUrl = if (url.endsWith("/")) url else "$url/"
if (retrofit == null) { retrofit = buildRetrofit()
retrofit = buildRetrofit() }
}
return retrofit!!.create(CollabTableApi::class.java) val api: CollabTableApi
} get() {
} if (retrofit == null) {
retrofit = buildRetrofit()
}
return retrofit!!.create(CollabTableApi::class.java)
}
}
@@ -1,30 +1,32 @@
package com.collabtable.app.data.api package com.collabtable.app.data.api
import com.collabtable.app.data.model.CollabList import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.model.Field import com.collabtable.app.data.model.Field
import com.collabtable.app.data.model.Item import com.collabtable.app.data.model.Item
import com.collabtable.app.data.model.ItemValue import com.collabtable.app.data.model.ItemValue
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.POST import retrofit2.http.POST
data class SyncRequest( data class SyncRequest(
val lastSyncTimestamp: Long, val lastSyncTimestamp: Long,
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(
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>,
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>
}
@@ -1,143 +1,166 @@
package com.collabtable.app.data.api package com.collabtable.app.data.api
import android.content.Context import android.content.Context
import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.utils.Logger import com.collabtable.app.utils.Logger
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonObject import com.google.gson.JsonObject
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit 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()
.addInterceptor(logging) private val client: OkHttpClient =
.connectTimeout(15, TimeUnit.SECONDS) OkHttpClient.Builder()
.readTimeout(30, TimeUnit.SECONDS) .addInterceptor(logging)
.writeTimeout(30, TimeUnit.SECONDS) .connectTimeout(15, TimeUnit.SECONDS)
.pingInterval(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.build() .writeTimeout(30, TimeUnit.SECONDS)
.pingInterval(15, TimeUnit.SECONDS)
private fun buildWebSocketUrl(httpApiBaseUrl: String): String { .build()
// Expecting something like: http://host:port/api/
var url = httpApiBaseUrl private fun buildWebSocketUrl(httpApiBaseUrl: String): String {
// Ensure trailing slash removed for manipulation // Expecting something like: http://host:port/api/
if (url.endsWith("/")) url = url.dropLast(1) var url = httpApiBaseUrl
// Ensure trailing slash removed for manipulation
// Replace scheme if (url.endsWith("/")) url = url.dropLast(1)
url = when {
url.startsWith("https://") -> "wss://" + url.removePrefix("https://") // Replace scheme
url.startsWith("http://") -> "ws://" + url.removePrefix("http://") url =
else -> "ws://$url" // best effort when {
} url.startsWith("https://") -> "wss://" + url.removePrefix("https://")
url.startsWith("http://") -> "ws://" + url.removePrefix("http://")
// Replace trailing /api with /api/ws else -> "ws://$url" // best effort
return if (url.endsWith("/api")) "$url/ws" else "$url/api/ws" }
}
// Replace trailing /api with /api/ws
suspend fun sync(context: Context, request: SyncRequest): Result<SyncResponse> = withContext(Dispatchers.IO) { return if (url.endsWith("/api")) "$url/ws" else "$url/api/ws"
val prefs = PreferencesManager.getInstance(context) }
val baseUrl = prefs.getServerUrl()
val password = prefs.getServerPassword() suspend fun sync(
val wsUrl = buildWebSocketUrl(baseUrl) context: Context,
val messageId = UUID.randomUUID().toString() request: SyncRequest,
): Result<SyncResponse> =
val deferred = CompletableDeferred<Result<SyncResponse>>() withContext(Dispatchers.IO) {
var socket: WebSocket? = null val prefs = PreferencesManager.getInstance(context)
val baseUrl = prefs.getServerUrl()
val httpRequestBuilder = Request.Builder().url(wsUrl) val password = prefs.getServerPassword()
if (!password.isNullOrBlank()) { val wsUrl = buildWebSocketUrl(baseUrl)
httpRequestBuilder.addHeader("Authorization", "Bearer $password") val messageId = UUID.randomUUID().toString()
}
val httpRequest = httpRequestBuilder.build() val deferred = CompletableDeferred<Result<SyncResponse>>()
var socket: WebSocket? = null
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) { val httpRequestBuilder = Request.Builder().url(wsUrl)
// Send sync message when opened if (!password.isNullOrBlank()) {
val envelope = MessageEnvelope( httpRequestBuilder.addHeader("Authorization", "Bearer $password")
id = messageId, }
type = "sync", val httpRequest = httpRequestBuilder.build()
payload = request
) val listener =
val json = gson.toJson(envelope) object : WebSocketListener() {
webSocket.send(json) override fun onOpen(
} webSocket: WebSocket,
response: Response,
override fun onMessage(webSocket: WebSocket, text: String) { ) {
try { // Send sync message when opened
val root = gson.fromJson(text, JsonObject::class.java) val envelope =
val type = root.get("type")?.asString MessageEnvelope(
val id = root.get("id")?.asString id = messageId,
if (type == "syncResponse" && id == messageId) { type = "sync",
val payload = root.getAsJsonObject("payload") payload = request,
val resp = gson.fromJson(payload, SyncResponse::class.java) )
if (!deferred.isCompleted) { val json = gson.toJson(envelope)
deferred.complete(Result.success(resp)) webSocket.send(json)
webSocket.close(1000, "done") }
}
} else if (type == "error" && !deferred.isCompleted) { override fun onMessage(
val msg = root.getAsJsonObject("payload")?.get("message")?.asString ?: "WebSocket error" webSocket: WebSocket,
deferred.complete(Result.failure(Exception(msg))) text: String,
webSocket.close(1002, "error") ) {
} try {
} catch (e: Exception) { val root = gson.fromJson(text, JsonObject::class.java)
if (!deferred.isCompleted) { val type = root.get("type")?.asString
deferred.complete(Result.failure(e)) 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) {
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { deferred.complete(Result.success(resp))
// 1008 indicates policy violation, used here for Unauthorized webSocket.close(1000, "done")
if (!deferred.isCompleted) { }
if (code == 1008) { } else if (type == "error" && !deferred.isCompleted) {
deferred.complete(Result.failure(Exception("Unauthorized (WS 1008)"))) val msg = root.getAsJsonObject("payload")?.get("message")?.asString ?: "WebSocket error"
} else { deferred.complete(Result.failure(Exception(msg)))
deferred.complete(Result.failure(Exception("WebSocket closing ($code): $reason"))) webSocket.close(1002, "error")
} }
} } catch (e: Exception) {
} if (!deferred.isCompleted) {
deferred.complete(Result.failure(e))
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { }
if (!deferred.isCompleted) { }
deferred.complete(Result.failure(t)) }
}
} override fun onClosing(
} webSocket: WebSocket,
code: Int,
try { reason: String,
socket = client.newWebSocket(httpRequest, listener) ) {
// Wait up to 30s for response (WS roundtrip) // 1008 indicates policy violation, used here for Unauthorized
val result = withTimeout(30_000) { deferred.await() } if (!deferred.isCompleted) {
return@withContext result if (code == 1008) {
} catch (e: Exception) { deferred.complete(Result.failure(Exception("Unauthorized (WS 1008)")))
Logger.w("WS", "Falling back to HTTP sync: ${e.message}") } else {
return@withContext Result.failure(e) deferred.complete(Result.failure(Exception("WebSocket closing ($code): $reason")))
} finally { }
// OkHttp cleans up sockets automatically when no references remain }
// but we try to close if still open }
socket?.close(1000, null)
} override fun onFailure(
} webSocket: WebSocket,
} t: Throwable,
response: Response?,
) {
if (!deferred.isCompleted) {
deferred.complete(Result.failure(t))
}
}
}
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)
}
}
}
@@ -1,49 +1,55 @@
package com.collabtable.app.data.dao package com.collabtable.app.data.dao
import androidx.room.* import androidx.room.*
import com.collabtable.app.data.model.Field import com.collabtable.app.data.model.Field
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface FieldDao { interface FieldDao {
@Query("SELECT * FROM fields WHERE listId = :listId AND isDeleted = 0 ORDER BY `order`") @Query("SELECT * FROM fields WHERE listId = :listId AND isDeleted = 0 ORDER BY `order`")
fun getFieldsForList(listId: String): Flow<List<Field>> fun getFieldsForList(listId: String): Flow<List<Field>>
@Query("SELECT * FROM fields WHERE id = :fieldId") @Query("SELECT * FROM fields WHERE id = :fieldId")
suspend fun getFieldById(fieldId: String): Field? suspend fun getFieldById(fieldId: String): Field?
@Query("SELECT id FROM fields") @Query("SELECT id FROM fields")
suspend fun getAllFieldIds(): List<String> suspend fun getAllFieldIds(): List<String>
@Upsert @Upsert
suspend fun insertField(field: Field) suspend fun insertField(field: Field)
@Upsert @Upsert
suspend fun insertFields(fields: List<Field>) suspend fun insertFields(fields: List<Field>)
@Update @Update
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,
@Query("DELETE FROM fields WHERE id = :fieldId") timestamp: Long,
suspend fun deleteField(fieldId: String) )
// Get all fields updated since timestamp, including deleted ones for sync @Query("DELETE FROM fields WHERE id = :fieldId")
@Query("SELECT * FROM fields WHERE updatedAt >= :since") suspend fun deleteField(fieldId: String)
suspend fun getFieldsUpdatedSince(since: Long): List<Field>
// Get all fields updated since timestamp, including deleted ones for sync
// Reorder fields in a single transaction for clarity and consistency @Query("SELECT * FROM fields WHERE updatedAt >= :since")
@Transaction suspend fun getFieldsUpdatedSince(since: Long): List<Field>
suspend fun reorderFieldsInTransaction(reorderedFields: List<Field>, timestamp: Long) {
reorderedFields.forEachIndexed { index, field -> // Reorder fields in a single transaction for clarity and consistency
updateField( @Transaction
field.copy( suspend fun reorderFieldsInTransaction(
order = index, reorderedFields: List<Field>,
updatedAt = timestamp timestamp: Long,
) ) {
) reorderedFields.forEachIndexed { index, field ->
} updateField(
} field.copy(
} order = index,
updatedAt = timestamp,
),
)
}
}
}
@@ -1,41 +1,44 @@
package com.collabtable.app.data.dao package com.collabtable.app.data.dao
import androidx.room.* import androidx.room.*
import com.collabtable.app.data.model.Item import com.collabtable.app.data.model.Item
import com.collabtable.app.data.model.ItemWithValues import com.collabtable.app.data.model.ItemWithValues
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface ItemDao { interface ItemDao {
@Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY updatedAt DESC") @Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY updatedAt DESC")
fun getItemsForList(listId: String): Flow<List<Item>> fun getItemsForList(listId: String): Flow<List<Item>>
@Transaction @Transaction
@Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY updatedAt DESC") @Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY updatedAt DESC")
fun getItemsWithValuesForList(listId: String): Flow<List<ItemWithValues>> fun getItemsWithValuesForList(listId: String): Flow<List<ItemWithValues>>
@Query("SELECT * FROM items WHERE id = :itemId") @Query("SELECT * FROM items WHERE id = :itemId")
suspend fun getItemById(itemId: String): Item? suspend fun getItemById(itemId: String): Item?
@Query("SELECT id FROM items") @Query("SELECT id FROM items")
suspend fun getAllItemIds(): List<String> suspend fun getAllItemIds(): List<String>
@Upsert @Upsert
suspend fun insertItem(item: Item) suspend fun insertItem(item: Item)
@Upsert @Upsert
suspend fun insertItems(items: List<Item>) suspend fun insertItems(items: List<Item>)
@Update @Update
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,
@Query("DELETE FROM items WHERE id = :itemId") timestamp: Long,
suspend fun deleteItem(itemId: String) )
// Get all items updated since timestamp, including deleted ones for sync @Query("DELETE FROM items WHERE id = :itemId")
@Query("SELECT * FROM items WHERE updatedAt >= :since") suspend fun deleteItem(itemId: String)
suspend fun getItemsUpdatedSince(since: Long): List<Item>
} // Get all items updated since timestamp, including deleted ones for sync
@Query("SELECT * FROM items WHERE updatedAt >= :since")
suspend fun getItemsUpdatedSince(since: Long): List<Item>
}
@@ -1,41 +1,44 @@
package com.collabtable.app.data.dao package com.collabtable.app.data.dao
import androidx.room.* import androidx.room.*
import com.collabtable.app.data.model.CollabList import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.model.ListWithFields import com.collabtable.app.data.model.ListWithFields
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface ListDao { interface ListDao {
@Query("SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC") @Query("SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC")
fun getAllLists(): Flow<List<CollabList>> fun getAllLists(): Flow<List<CollabList>>
@Transaction @Transaction
@Query("SELECT * FROM lists WHERE id = :listId AND isDeleted = 0") @Query("SELECT * FROM lists WHERE id = :listId AND isDeleted = 0")
fun getListWithFields(listId: String): Flow<ListWithFields?> fun getListWithFields(listId: String): Flow<ListWithFields?>
@Query("SELECT * FROM lists WHERE id = :listId") @Query("SELECT * FROM lists WHERE id = :listId")
suspend fun getListById(listId: String): CollabList? suspend fun getListById(listId: String): CollabList?
@Query("SELECT id FROM lists") @Query("SELECT id FROM lists")
suspend fun getAllListIds(): List<String> suspend fun getAllListIds(): List<String>
@Upsert @Upsert
suspend fun insertList(list: CollabList) suspend fun insertList(list: CollabList)
@Upsert @Upsert
suspend fun insertLists(lists: List<CollabList>) suspend fun insertLists(lists: List<CollabList>)
@Update @Update
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,
@Query("DELETE FROM lists WHERE id = :listId") timestamp: Long,
suspend fun deleteList(listId: String) )
// Get all lists updated since timestamp, including deleted ones for sync @Query("DELETE FROM lists WHERE id = :listId")
@Query("SELECT * FROM lists WHERE updatedAt >= :since") suspend fun deleteList(listId: String)
suspend fun getListsUpdatedSince(since: Long): List<CollabList>
} // Get all lists updated since timestamp, including deleted ones for sync
@Query("SELECT * FROM lists WHERE updatedAt >= :since")
suspend fun getListsUpdatedSince(since: Long): List<CollabList>
}
@@ -1,61 +1,66 @@
package com.collabtable.app.data.database package com.collabtable.app.data.database
import android.content.Context import android.content.Context
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase 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(
entities = [ @Database(
CollabList::class, entities = [
Field::class, CollabList::class,
Item::class, Field::class,
ItemValue::class Item::class,
], ItemValue::class,
version = 2, ],
exportSchema = false version = 2,
) exportSchema = false,
abstract class CollabTableDatabase : RoomDatabase() { )
abstract fun listDao(): ListDao abstract class CollabTableDatabase : RoomDatabase() {
abstract fun fieldDao(): FieldDao abstract fun listDao(): ListDao
abstract fun itemDao(): ItemDao
abstract fun itemValueDao(): ItemValueDao abstract fun fieldDao(): FieldDao
companion object { abstract fun itemDao(): ItemDao
@Volatile
private var INSTANCE: CollabTableDatabase? = null abstract fun itemValueDao(): ItemValueDao
fun getDatabase(context: Context): CollabTableDatabase { companion object {
return INSTANCE ?: synchronized(this) { @Volatile
val instance = Room.databaseBuilder( private var INSTANCE: CollabTableDatabase? = null
context.applicationContext,
CollabTableDatabase::class.java, fun getDatabase(context: Context): CollabTableDatabase {
"collab_table_database" return INSTANCE ?: synchronized(this) {
) val instance =
.addMigrations(MIGRATION_1_2) Room.databaseBuilder(
.build() context.applicationContext,
INSTANCE = instance CollabTableDatabase::class.java,
instance "collab_table_database",
} )
} .addMigrations(MIGRATION_1_2)
.build()
fun clearDatabase(context: Context) { INSTANCE = instance
synchronized(this) { instance
INSTANCE?.close() }
context.deleteDatabase("collab_table_database") }
INSTANCE = null
} fun clearDatabase(context: Context) {
} synchronized(this) {
} INSTANCE?.close()
} context.deleteDatabase("collab_table_database")
INSTANCE = null
}
}
}
}
@@ -1,13 +1,13 @@
package com.collabtable.app.data.model package com.collabtable.app.data.model
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@Entity(tableName = "lists") @Entity(tableName = "lists")
data class CollabList( data class CollabList(
@PrimaryKey val id: String, @PrimaryKey val id: String,
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,
) )
@@ -1,116 +1,116 @@
package com.collabtable.app.data.model package com.collabtable.app.data.model
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
enum class FieldType { enum class FieldType {
// Text types // Text types
TEXT, TEXT,
MULTILINE_TEXT, MULTILINE_TEXT,
// Number types // Number types
NUMBER, NUMBER,
CURRENCY, CURRENCY,
PERCENTAGE, PERCENTAGE,
// Selection types // Selection types
DROPDOWN, DROPDOWN,
AUTOCOMPLETE, AUTOCOMPLETE,
CHECKBOX, CHECKBOX,
SWITCH, SWITCH,
// Link types // Link types
URL, URL,
EMAIL, EMAIL,
PHONE, PHONE,
// Date/Time types // Date/Time types
DATE, DATE,
TIME, TIME,
DATETIME, DATETIME,
DURATION, DURATION,
// Media types // Media types
IMAGE, IMAGE,
FILE, FILE,
BARCODE, BARCODE,
SIGNATURE, SIGNATURE,
// Other types // Other types
RATING, RATING,
COLOR, COLOR,
LOCATION LOCATION,
} }
@Entity( @Entity(
tableName = "fields", tableName = "fields",
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
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,
val listId: String, val listId: String,
val name: String, val name: String,
val fieldType: String = "TEXT", val fieldType: String = "TEXT",
val fieldOptions: String = "", // JSON string for dropdown options, currency symbol, etc. val fieldOptions: String = "", // JSON string for dropdown options, currency symbol, etc.
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 {
FieldType.valueOf(fieldType) FieldType.valueOf(fieldType)
} catch (e: Exception) { } catch (e: Exception) {
// Handle legacy field types // Handle legacy field types
when (fieldType) { when (fieldType) {
"STRING" -> FieldType.TEXT "STRING" -> FieldType.TEXT
"PRICE" -> FieldType.CURRENCY "PRICE" -> FieldType.CURRENCY
"AMOUNT" -> FieldType.NUMBER "AMOUNT" -> FieldType.NUMBER
"SIZE" -> FieldType.TEXT "SIZE" -> FieldType.TEXT
else -> FieldType.TEXT else -> FieldType.TEXT
} }
} }
} }
fun getDropdownOptions(): List<String> { fun getDropdownOptions(): List<String> {
return if (fieldType == "DROPDOWN" && fieldOptions.isNotBlank()) { return if (fieldType == "DROPDOWN" && fieldOptions.isNotBlank()) {
fieldOptions.split("|") fieldOptions.split("|")
} else { } else {
emptyList() emptyList()
} }
} }
fun getCurrency(): String { fun getCurrency(): String {
return if ((fieldType == "CURRENCY" || fieldType == "PRICE") && fieldOptions.isNotBlank()) { return if ((fieldType == "CURRENCY" || fieldType == "PRICE") && fieldOptions.isNotBlank()) {
fieldOptions fieldOptions
} else { } else {
"CHF" "CHF"
} }
} }
fun getMaxRating(): Int { fun getMaxRating(): Int {
return if (fieldType == "RATING" && fieldOptions.isNotBlank()) { return if (fieldType == "RATING" && fieldOptions.isNotBlank()) {
fieldOptions.toIntOrNull() ?: 5 fieldOptions.toIntOrNull() ?: 5
} else { } else {
5 5
} }
} }
fun getAutocompleteOptions(): List<String> { fun getAutocompleteOptions(): List<String> {
return if (fieldType == "AUTOCOMPLETE" && fieldOptions.isNotBlank()) { return if (fieldType == "AUTOCOMPLETE" && fieldOptions.isNotBlank()) {
fieldOptions.split("|") fieldOptions.split("|")
} else { } else {
emptyList() emptyList()
} }
} }
} }
@@ -1,26 +1,26 @@
package com.collabtable.app.data.model package com.collabtable.app.data.model
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@Entity( @Entity(
tableName = "items", tableName = "items",
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
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,
) )
@@ -1,32 +1,32 @@
package com.collabtable.app.data.model package com.collabtable.app.data.model
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@Entity( @Entity(
tableName = "item_values", tableName = "item_values",
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
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,
) )
@@ -1,13 +1,13 @@
package com.collabtable.app.data.model package com.collabtable.app.data.model
import androidx.room.Embedded import androidx.room.Embedded
import androidx.room.Relation import androidx.room.Relation
data class ItemWithValues( 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>,
) )
@@ -1,13 +1,13 @@
package com.collabtable.app.data.model package com.collabtable.app.data.model
import androidx.room.Embedded import androidx.room.Embedded
import androidx.room.Relation import androidx.room.Relation
data class ListWithFields( 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>,
) )
@@ -1,130 +1,131 @@
package com.collabtable.app.data.preferences package com.collabtable.app.data.preferences
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
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
class PreferencesManager(context: Context) { class PreferencesManager(context: Context) {
private val prefs: SharedPreferences = private val prefs: SharedPreferences =
context.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE) context.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE)
private val _serverUrl = MutableStateFlow(getServerUrl()) private val _serverUrl = MutableStateFlow(getServerUrl())
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow() val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
private val _themeMode = MutableStateFlow(getThemeMode()) private val _themeMode = MutableStateFlow(getThemeMode())
val themeMode: StateFlow<String> = _themeMode.asStateFlow() val themeMode: StateFlow<String> = _themeMode.asStateFlow()
private val _dynamicColor = MutableStateFlow(isDynamicColorEnabled()) private val _dynamicColor = MutableStateFlow(isDynamicColorEnabled())
val dynamicColor: StateFlow<Boolean> = _dynamicColor.asStateFlow() val dynamicColor: StateFlow<Boolean> = _dynamicColor.asStateFlow()
private val _amoledDark = MutableStateFlow(isAmoledDarkEnabled()) private val _amoledDark = MutableStateFlow(isAmoledDarkEnabled())
val amoledDark: StateFlow<Boolean> = _amoledDark.asStateFlow() val amoledDark: StateFlow<Boolean> = _amoledDark.asStateFlow()
fun getServerUrl(): String { fun getServerUrl(): String {
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL
} }
fun setServerUrl(url: String) { fun setServerUrl(url: String) {
val raw = url.trim() val raw = url.trim()
if (raw.isBlank()) { if (raw.isBlank()) {
// Remove the key entirely to fall back to default URL on reads // Remove the key entirely to fall back to default URL on reads
prefs.edit().remove(KEY_SERVER_URL).apply() prefs.edit().remove(KEY_SERVER_URL).apply()
_serverUrl.value = getServerUrl() _serverUrl.value = getServerUrl()
return return
} }
val cleanUrl = raw.let { if (it.endsWith("/")) it else "$it/" } val cleanUrl = raw.let { if (it.endsWith("/")) it else "$it/" }
prefs.edit().putString(KEY_SERVER_URL, cleanUrl).apply() prefs.edit().putString(KEY_SERVER_URL, cleanUrl).apply()
_serverUrl.value = cleanUrl _serverUrl.value = cleanUrl
} }
fun isFirstRun(): Boolean { fun isFirstRun(): Boolean {
return prefs.getBoolean(KEY_FIRST_RUN, true) return prefs.getBoolean(KEY_FIRST_RUN, true)
} }
fun setIsFirstRun(isFirstRun: Boolean) { fun setIsFirstRun(isFirstRun: Boolean) {
prefs.edit().putBoolean(KEY_FIRST_RUN, isFirstRun).apply() prefs.edit().putBoolean(KEY_FIRST_RUN, isFirstRun).apply()
} }
fun getServerPassword(): String? { fun getServerPassword(): String? {
return prefs.getString(KEY_SERVER_PASSWORD, null) return prefs.getString(KEY_SERVER_PASSWORD, null)
} }
fun setServerPassword(password: String) { fun setServerPassword(password: String) {
if (password.isBlank()) { if (password.isBlank()) {
prefs.edit().remove(KEY_SERVER_PASSWORD).apply() prefs.edit().remove(KEY_SERVER_PASSWORD).apply()
} else { } else {
prefs.edit().putString(KEY_SERVER_PASSWORD, password).apply() prefs.edit().putString(KEY_SERVER_PASSWORD, password).apply()
} }
} }
// Clear sync-related state so the next sync starts from scratch // Clear sync-related state so the next sync starts from scratch
fun clearSyncState() { fun clearSyncState() {
prefs.edit().remove(KEY_LAST_SYNC_TIMESTAMP).apply() prefs.edit().remove(KEY_LAST_SYNC_TIMESTAMP).apply()
} }
fun clearServerSettings() { fun clearServerSettings() {
prefs.edit() prefs.edit()
.remove(KEY_SERVER_URL) .remove(KEY_SERVER_URL)
.remove(KEY_SERVER_PASSWORD) .remove(KEY_SERVER_PASSWORD)
.remove(KEY_LAST_SYNC_TIMESTAMP) .remove(KEY_LAST_SYNC_TIMESTAMP)
.apply() .apply()
_serverUrl.value = getServerUrl() _serverUrl.value = getServerUrl()
} }
// Theme settings // Theme settings
fun getThemeMode(): String { fun getThemeMode(): String {
return prefs.getString(KEY_THEME_MODE, THEME_MODE_SYSTEM) ?: THEME_MODE_SYSTEM return prefs.getString(KEY_THEME_MODE, THEME_MODE_SYSTEM) ?: THEME_MODE_SYSTEM
} }
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() }
_themeMode.value = normalized prefs.edit().putString(KEY_THEME_MODE, normalized).apply()
} _themeMode.value = normalized
}
fun isDynamicColorEnabled(): Boolean {
return prefs.getBoolean(KEY_DYNAMIC_COLOR, true) fun isDynamicColorEnabled(): Boolean {
} return prefs.getBoolean(KEY_DYNAMIC_COLOR, true)
}
fun setDynamicColorEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_DYNAMIC_COLOR, enabled).apply() fun setDynamicColorEnabled(enabled: Boolean) {
_dynamicColor.value = enabled prefs.edit().putBoolean(KEY_DYNAMIC_COLOR, enabled).apply()
} _dynamicColor.value = enabled
}
fun isAmoledDarkEnabled(): Boolean {
return prefs.getBoolean(KEY_AMOLED_DARK, false) fun isAmoledDarkEnabled(): Boolean {
} return prefs.getBoolean(KEY_AMOLED_DARK, false)
}
fun setAmoledDarkEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_AMOLED_DARK, enabled).apply() fun setAmoledDarkEnabled(enabled: Boolean) {
_amoledDark.value = enabled prefs.edit().putBoolean(KEY_AMOLED_DARK, enabled).apply()
} _amoledDark.value = enabled
}
companion object {
private const val KEY_SERVER_URL = "server_url" companion object {
private const val KEY_FIRST_RUN = "first_run" private const val KEY_SERVER_URL = "server_url"
private const val KEY_SERVER_PASSWORD = "server_password" private const val KEY_FIRST_RUN = "first_run"
private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp" private const val KEY_SERVER_PASSWORD = "server_password"
private const val KEY_THEME_MODE = "theme_mode" // system|light|dark private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp"
private const val KEY_DYNAMIC_COLOR = "dynamic_color" private const val KEY_THEME_MODE = "theme_mode" // system|light|dark
private const val KEY_AMOLED_DARK = "amoled_dark" private const val KEY_DYNAMIC_COLOR = "dynamic_color"
private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/" private const val KEY_AMOLED_DARK = "amoled_dark"
const val THEME_MODE_SYSTEM = "system" private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/"
const val THEME_MODE_LIGHT = "light" const val THEME_MODE_SYSTEM = "system"
const val THEME_MODE_DARK = "dark" const val THEME_MODE_LIGHT = "light"
const val THEME_MODE_DARK = "dark"
@Volatile
private var instance: PreferencesManager? = null @Volatile
private var instance: PreferencesManager? = null
fun getInstance(context: Context): PreferencesManager {
return instance ?: synchronized(this) { fun getInstance(context: Context): PreferencesManager {
instance ?: PreferencesManager(context.applicationContext).also { instance = it } return instance ?: synchronized(this) {
} instance ?: PreferencesManager(context.applicationContext).also { instance = it }
} }
} }
} }
}
@@ -1,146 +1,150 @@
package com.collabtable.app.data.repository package com.collabtable.app.data.repository
import android.content.Context import android.content.Context
import com.collabtable.app.data.api.ApiClient import androidx.room.withTransaction
import com.collabtable.app.data.api.WebSocketSyncClient import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.api.SyncRequest import com.collabtable.app.data.api.SyncRequest
import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.api.WebSocketSyncClient
import com.collabtable.app.utils.Logger import com.collabtable.app.data.database.CollabTableDatabase
import kotlinx.coroutines.Dispatchers import com.collabtable.app.utils.Logger
import kotlinx.coroutines.withContext import kotlinx.coroutines.Dispatchers
import androidx.room.withTransaction import kotlinx.coroutines.withContext
class SyncRepository(context: Context) { class SyncRepository(context: Context) {
private val appContext = context.applicationContext private val appContext = context.applicationContext
private val database = CollabTableDatabase.getDatabase(appContext) private val database = CollabTableDatabase.getDatabase(appContext)
private val api = ApiClient.api private val api = ApiClient.api
private val prefs = appContext.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE) private val prefs = appContext.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE)
private fun getLastSyncTimestamp(): Long { private fun getLastSyncTimestamp(): Long {
return prefs.getLong("last_sync_timestamp", 0) return prefs.getLong("last_sync_timestamp", 0)
} }
private fun setLastSyncTimestamp(timestamp: Long) { private fun setLastSyncTimestamp(timestamp: Long) {
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
val localLists = database.listDao().getListsUpdatedSince(lastSync) // Gather local changes since last sync
val localFields = database.fieldDao().getFieldsUpdatedSince(lastSync) val localLists = database.listDao().getListsUpdatedSince(lastSync)
val localItems = database.itemDao().getItemsUpdatedSince(lastSync) val localFields = database.fieldDao().getFieldsUpdatedSince(lastSync)
val localValues = database.itemValueDao().getValuesUpdatedSince(lastSync) val localItems = database.itemDao().getItemsUpdatedSince(lastSync)
val localValues = database.itemValueDao().getValuesUpdatedSince(lastSync)
// Only log send when there are actual local changes
val localTotal = localLists.size + localFields.size + localItems.size + localValues.size // Only log send when there are actual local changes
if (localTotal > 0) { val localTotal = localLists.size + localFields.size + localItems.size + localValues.size
Logger.i( if (localTotal > 0) {
"Sync", Logger.i(
"[OUT] Sending changes (lists=${localLists.size}, fields=${localFields.size}, items=${localItems.size}, values=${localValues.size})" "Sync",
) "[OUT] Sending changes (lists=${localLists.size}, fields=${localFields.size}, items=${localItems.size}, values=${localValues.size})",
} )
}
// Send to server and get updates
val syncRequest = SyncRequest( // Send to server and get updates
lastSyncTimestamp = lastSync, val syncRequest =
lists = localLists, SyncRequest(
fields = localFields, lastSyncTimestamp = lastSync,
items = localItems, lists = localLists,
itemValues = localValues fields = localFields,
) items = localItems,
itemValues = localValues,
// Try WebSocket first; on failure, fall back to HTTP )
val wsResult = WebSocketSyncClient.sync(appContext, syncRequest)
val syncResponse = if (wsResult.isSuccess) { // Try WebSocket first; on failure, fall back to HTTP
wsResult.getOrThrow() val wsResult = WebSocketSyncClient.sync(appContext, syncRequest)
} else { val syncResponse =
val response = api.sync(syncRequest) if (wsResult.isSuccess) {
if (!response.isSuccessful) { wsResult.getOrThrow()
if (response.code() == 401) { } else {
// Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error. val response = api.sync(syncRequest)
Logger.e("Sync", "[ERROR] Unauthorized (401). Check server password in Settings.") if (!response.isSuccessful) {
setLastSyncTimestamp(0) if (response.code() == 401) {
return@withContext Result.failure(Exception("Unauthorized (401). Please verify server password.")) // Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error.
} Logger.e("Sync", "[ERROR] Unauthorized (401). Check server password in Settings.")
Logger.e("Sync", "[ERROR] Sync failed: HTTP ${response.code()}") setLastSyncTimestamp(0)
return@withContext Result.failure(Exception("Sync failed: ${response.code()}")) return@withContext Result.failure(Exception("Unauthorized (401). Please verify server password."))
} }
response.body()!! Logger.e("Sync", "[ERROR] Sync failed: HTTP ${response.code()}")
} return@withContext Result.failure(Exception("Sync failed: ${response.code()}"))
// Only log receive when there are actual server changes }
val inTotal = syncResponse.lists.size + syncResponse.fields.size + syncResponse.items.size + syncResponse.itemValues.size response.body()!!
if (inTotal > 0) { }
Logger.i( // Only log receive when there are actual server changes
"Sync", val inTotal = syncResponse.lists.size + syncResponse.fields.size + syncResponse.items.size + syncResponse.itemValues.size
"[IN] Received changes (lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, items=${syncResponse.items.size}, values=${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) // Apply server changes to local database atomically in correct order
database.withTransaction {
// Build set of existing list ids to guard child inserts // 1) Upsert lists first
val existingListIds = database.listDao().getAllListIds().toSet() database.listDao().insertLists(syncResponse.lists)
// 2) Filter and upsert fields whose parent list exists // Build set of existing list ids to guard child inserts
val filteredFields = syncResponse.fields.filter { f -> f.listId in existingListIds } val existingListIds = database.listDao().getAllListIds().toSet()
if (filteredFields.size != syncResponse.fields.size) {
val dropped = syncResponse.fields.size - filteredFields.size // 2) Filter and upsert fields whose parent list exists
Logger.w("Sync", "Dropping $dropped field(s) with missing parent list to avoid FK errors") val filteredFields = syncResponse.fields.filter { f -> f.listId in existingListIds }
} if (filteredFields.size != syncResponse.fields.size) {
if (filteredFields.isNotEmpty()) { val dropped = syncResponse.fields.size - filteredFields.size
database.fieldDao().insertFields(filteredFields) Logger.w("Sync", "Dropping $dropped field(s) with missing parent list to avoid FK errors")
} }
if (filteredFields.isNotEmpty()) {
// 3) Filter and upsert items whose parent list exists database.fieldDao().insertFields(filteredFields)
val filteredItems = syncResponse.items.filter { i -> i.listId in existingListIds } }
if (filteredItems.size != syncResponse.items.size) {
val dropped = syncResponse.items.size - filteredItems.size // 3) Filter and upsert items whose parent list exists
Logger.w("Sync", "Dropping $dropped item(s) with missing parent list to avoid FK errors") val filteredItems = syncResponse.items.filter { i -> i.listId in existingListIds }
} if (filteredItems.size != syncResponse.items.size) {
if (filteredItems.isNotEmpty()) { val dropped = syncResponse.items.size - filteredItems.size
database.itemDao().insertItems(filteredItems) Logger.w("Sync", "Dropping $dropped item(s) with missing parent list to avoid FK errors")
} }
if (filteredItems.isNotEmpty()) {
// 4) Filter item values to only those whose parents (item and field) exist locally database.itemDao().insertItems(filteredItems)
val existingItemIds = database.itemDao().getAllItemIds().toSet() }
val existingFieldIds = database.fieldDao().getAllFieldIds().toSet()
val filteredValues = syncResponse.itemValues.filter { v -> // 4) Filter item values to only those whose parents (item and field) exist locally
v.itemId in existingItemIds && v.fieldId in existingFieldIds val existingItemIds = database.itemDao().getAllItemIds().toSet()
} val existingFieldIds = database.fieldDao().getAllFieldIds().toSet()
if (filteredValues.size != syncResponse.itemValues.size) { val filteredValues =
val dropped = syncResponse.itemValues.size - filteredValues.size syncResponse.itemValues.filter { v ->
Logger.w("Sync", "Dropping $dropped item value(s) with missing parents to avoid FK errors") v.itemId in existingItemIds && v.fieldId in existingFieldIds
} }
if (filteredValues.isNotEmpty()) { if (filteredValues.size != syncResponse.itemValues.size) {
database.itemValueDao().insertValues(filteredValues) 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()) {
// Update last sync timestamp database.itemValueDao().insertValues(filteredValues)
setLastSyncTimestamp(syncResponse.serverTimestamp) }
}
if (isInitialSync) {
Logger.i("Sync", "[SYNC] Initial sync completed") // Update last sync timestamp
} setLastSyncTimestamp(syncResponse.serverTimestamp)
return@withContext Result.success(Unit) if (isInitialSync) {
} catch (e: Exception) { Logger.i("Sync", "[SYNC] Initial sync completed")
// If WS indicated unauthorized, align behavior with HTTP path }
if (e.message?.contains("Unauthorized") == true) {
setLastSyncTimestamp(0) return@withContext Result.success(Unit)
} } catch (e: Exception) {
Logger.e("Sync", "[ERROR] Sync error: ${e.message}") // If WS indicated unauthorized, align behavior with HTTP path
return@withContext Result.failure(e) if (e.message?.contains("Unauthorized") == true) {
} setLastSyncTimestamp(0)
} }
} Logger.e("Sync", "[ERROR] Sync error: ${e.message}")
return@withContext Result.failure(e)
}
}
}
@@ -1,89 +1,90 @@
package com.collabtable.app.ui.navigation package com.collabtable.app.ui.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.collabtable.app.data.preferences.PreferencesManager import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.ui.screens.ListDetailScreen import com.collabtable.app.ui.screens.ListDetailScreen
import com.collabtable.app.ui.screens.ListsScreen import com.collabtable.app.ui.screens.ListsScreen
import com.collabtable.app.ui.screens.LogsScreen import com.collabtable.app.ui.screens.LogsScreen
import com.collabtable.app.ui.screens.ServerSetupScreen import com.collabtable.app.ui.screens.ServerSetupScreen
import com.collabtable.app.ui.screens.SettingsScreen import com.collabtable.app.ui.screens.SettingsScreen
@Composable @Composable
fun AppNavigation() { fun AppNavigation() {
val navController = rememberNavController() val navController = rememberNavController()
val context = LocalContext.current val context = LocalContext.current
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(
navController = navController, NavHost(
startDestination = startDestination navController = navController,
) { startDestination = startDestination,
composable("server_setup") { ) {
ServerSetupScreen( composable("server_setup") {
onSetupComplete = { ServerSetupScreen(
// Navigate to lists and clear back stack onSetupComplete = {
navController.navigate("lists") { // Navigate to lists and clear back stack
popUpTo("server_setup") { inclusive = true } navController.navigate("lists") {
} popUpTo("server_setup") { inclusive = true }
} }
) },
} )
}
composable("lists") {
ListsScreen( composable("lists") {
onNavigateToList = { listId -> ListsScreen(
navController.navigate("list/$listId") onNavigateToList = { listId ->
}, navController.navigate("list/$listId")
onNavigateToSettings = { },
navController.navigate("settings") onNavigateToSettings = {
}, navController.navigate("settings")
onNavigateToLogs = { },
navController.navigate("logs") onNavigateToLogs = {
} navController.navigate("logs")
) },
} )
}
composable(
route = "list/{listId}", composable(
arguments = listOf(navArgument("listId") { type = NavType.StringType }) route = "list/{listId}",
) { backStackEntry -> arguments = listOf(navArgument("listId") { type = NavType.StringType }),
val listId = backStackEntry.arguments?.getString("listId") ?: return@composable ) { backStackEntry ->
ListDetailScreen( val listId = backStackEntry.arguments?.getString("listId") ?: return@composable
listId = listId, ListDetailScreen(
onNavigateBack = { navController.popBackStack() } listId = listId,
) onNavigateBack = { navController.popBackStack() },
} )
}
composable("settings") {
SettingsScreen( composable("settings") {
onNavigateBack = { navController.popBackStack() }, SettingsScreen(
onLeaveServer = { onNavigateBack = { navController.popBackStack() },
// Navigate back to server setup and clear entire back stack onLeaveServer = {
navController.navigate("server_setup") { // Navigate back to server setup and clear entire back stack
popUpTo(0) { inclusive = true } navController.navigate("server_setup") {
} popUpTo(0) { inclusive = true }
} }
) },
} )
}
composable("logs") {
LogsScreen( composable("logs") {
onNavigateBack = { navController.popBackStack() } LogsScreen(
) onNavigateBack = { navController.popBackStack() },
} )
} }
} }
}
File diff suppressed because it is too large Load Diff
@@ -1,246 +1,263 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import android.content.Context import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.room.withTransaction import androidx.room.withTransaction
import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.* import com.collabtable.app.data.model.*
import com.collabtable.app.data.repository.SyncRepository import com.collabtable.app.data.repository.SyncRepository
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.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID 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()
private val _fields = MutableStateFlow<List<Field>>(emptyList()) private val _fields = MutableStateFlow<List<Field>>(emptyList())
val fields: StateFlow<List<Field>> = _fields.asStateFlow() val fields: StateFlow<List<Field>> = _fields.asStateFlow()
private val _items = MutableStateFlow<List<ItemWithValues>>(emptyList()) private val _items = MutableStateFlow<List<ItemWithValues>>(emptyList())
val items: StateFlow<List<ItemWithValues>> = _items.asStateFlow() val items: StateFlow<List<ItemWithValues>> = _items.asStateFlow()
private val syncRepository = SyncRepository(context) private val syncRepository = SyncRepository(context)
init { init {
loadListData() loadListData()
startPeriodicSync() startPeriodicSync()
} }
private fun loadListData() { private fun loadListData() {
viewModelScope.launch { viewModelScope.launch {
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 {
database.itemDao().getItemsWithValuesForList(listId).collect { itemsData -> database.itemDao().getItemsWithValuesForList(listId).collect { itemsData ->
_items.value = itemsData _items.value = itemsData
} }
} }
viewModelScope.launch { viewModelScope.launch {
database.fieldDao().getFieldsForList(listId) database.fieldDao().getFieldsForList(listId)
.debounce(75) .debounce(75)
.collect { fieldsData -> .collect { fieldsData ->
_fields.value = fieldsData _fields.value = fieldsData
} }
} }
} }
private fun startPeriodicSync() { private fun startPeriodicSync() {
viewModelScope.launch { viewModelScope.launch {
while (true) { while (true) {
performSync() performSync()
kotlinx.coroutines.delay(5000) // Sync every 5 seconds kotlinx.coroutines.delay(5000) // Sync every 5 seconds
} }
} }
} }
private suspend fun performSync() { private suspend fun performSync() {
syncRepository.performSync() syncRepository.performSync()
} }
fun renameList(newName: String) { fun renameList(newName: String) {
viewModelScope.launch { viewModelScope.launch {
val currentList = _list.value val currentList = _list.value
if (currentList != null && newName.isNotBlank()) { if (currentList != null && newName.isNotBlank()) {
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(
viewModelScope.launch { name: String,
val timestamp = System.currentTimeMillis() fieldType: String = "STRING",
val maxOrder = _fields.value.maxOfOrNull { it.order } ?: -1 fieldOptions: String = "",
val newField = Field( ) {
id = UUID.randomUUID().toString(), viewModelScope.launch {
listId = listId, val timestamp = System.currentTimeMillis()
name = name, val maxOrder = _fields.value.maxOfOrNull { it.order } ?: -1
fieldType = fieldType, val newField =
fieldOptions = fieldOptions, Field(
order = maxOrder + 1, id = UUID.randomUUID().toString(),
createdAt = timestamp, listId = listId,
updatedAt = timestamp name = name,
) fieldType = fieldType,
fieldOptions = fieldOptions,
// Create empty ItemValue entries for this new field for all existing items order = maxOrder + 1,
val existingItems = _items.value createdAt = timestamp,
val newValues = if (existingItems.isNotEmpty()) { updatedAt = timestamp,
existingItems.map { itemWithValues -> )
ItemValue(
id = UUID.randomUUID().toString(), // Create empty ItemValue entries for this new field for all existing items
itemId = itemWithValues.item.id, val existingItems = _items.value
fieldId = newField.id, val newValues =
value = "", if (existingItems.isNotEmpty()) {
updatedAt = timestamp existingItems.map { itemWithValues ->
) ItemValue(
} id = UUID.randomUUID().toString(),
} else { itemId = itemWithValues.item.id,
emptyList() fieldId = newField.id,
} value = "",
updatedAt = timestamp,
// Insert atomically to avoid intermediate inconsistent states )
database.withTransaction { }
database.fieldDao().insertField(newField) } else {
if (newValues.isNotEmpty()) { emptyList()
database.itemValueDao().insertValues(newValues) }
}
} // Insert atomically to avoid intermediate inconsistent states
database.withTransaction {
performSync() database.fieldDao().insertField(newField)
} if (newValues.isNotEmpty()) {
} database.itemValueDao().insertValues(newValues)
}
fun deleteField(fieldId: String) { }
viewModelScope.launch {
database.fieldDao().softDeleteField(fieldId, System.currentTimeMillis()) performSync()
performSync() }
} }
}
fun deleteField(fieldId: String) {
fun updateField(fieldId: String, fieldType: String, fieldOptions: String) { viewModelScope.launch {
viewModelScope.launch { database.fieldDao().softDeleteField(fieldId, System.currentTimeMillis())
val field = database.fieldDao().getFieldById(fieldId) performSync()
if (field != null) { }
database.fieldDao().updateField( }
field.copy(
fieldType = fieldType, fun updateField(
fieldOptions = fieldOptions, fieldId: String,
updatedAt = System.currentTimeMillis() fieldType: String,
) fieldOptions: String,
) ) {
performSync() viewModelScope.launch {
} val field = database.fieldDao().getFieldById(fieldId)
} if (field != null) {
} database.fieldDao().updateField(
field.copy(
fun addItem() { fieldType = fieldType,
viewModelScope.launch { fieldOptions = fieldOptions,
val timestamp = System.currentTimeMillis() updatedAt = System.currentTimeMillis(),
val newItem = Item( ),
id = UUID.randomUUID().toString(), )
listId = listId, performSync()
createdAt = timestamp, }
updatedAt = timestamp }
) }
// Insert item and its values atomically
database.withTransaction { fun addItem() {
database.itemDao().insertItem(newItem) viewModelScope.launch {
// Create empty values for each field val timestamp = System.currentTimeMillis()
val values = _fields.value.map { field -> val newItem =
ItemValue( Item(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
itemId = newItem.id, listId = listId,
fieldId = field.id, createdAt = timestamp,
value = "", updatedAt = timestamp,
updatedAt = timestamp )
) // Insert item and its values atomically
} database.withTransaction {
if (values.isNotEmpty()) { database.itemDao().insertItem(newItem)
database.itemValueDao().insertValues(values) // Create empty values for each field
} val values =
} _fields.value.map { field ->
performSync() ItemValue(
} id = UUID.randomUUID().toString(),
} itemId = newItem.id,
fieldId = field.id,
fun addItemWithValues(fieldValues: Map<String, String>) { value = "",
viewModelScope.launch { updatedAt = timestamp,
val timestamp = System.currentTimeMillis() )
val newItem = Item( }
id = UUID.randomUUID().toString(), if (values.isNotEmpty()) {
listId = listId, database.itemValueDao().insertValues(values)
createdAt = timestamp, }
updatedAt = timestamp }
) performSync()
// Insert item and provided values atomically }
database.withTransaction { }
database.itemDao().insertItem(newItem)
// Create values for each field with the provided values fun addItemWithValues(fieldValues: Map<String, String>) {
val values = _fields.value.map { field -> viewModelScope.launch {
ItemValue( val timestamp = System.currentTimeMillis()
id = UUID.randomUUID().toString(), val newItem =
itemId = newItem.id, Item(
fieldId = field.id, id = UUID.randomUUID().toString(),
value = fieldValues[field.id] ?: "", listId = listId,
updatedAt = timestamp createdAt = timestamp,
) updatedAt = timestamp,
} )
if (values.isNotEmpty()) { // Insert item and provided values atomically
database.itemValueDao().insertValues(values) database.withTransaction {
} database.itemDao().insertItem(newItem)
} // Create values for each field with the provided values
performSync() val values =
} _fields.value.map { field ->
} ItemValue(
id = UUID.randomUUID().toString(),
fun updateItemValue(itemValueId: String, newValue: String) { itemId = newItem.id,
viewModelScope.launch { fieldId = field.id,
val itemValue = database.itemValueDao().getValueById(itemValueId) value = fieldValues[field.id] ?: "",
if (itemValue != null) { updatedAt = timestamp,
database.itemValueDao().updateValue( )
itemValue.copy( }
value = newValue, if (values.isNotEmpty()) {
updatedAt = System.currentTimeMillis() database.itemValueDao().insertValues(values)
) }
) }
performSync() performSync()
} }
} }
}
fun updateItemValue(
fun deleteItem(itemId: String) { itemValueId: String,
viewModelScope.launch { newValue: String,
database.itemDao().softDeleteItem(itemId, System.currentTimeMillis()) ) {
performSync() viewModelScope.launch {
} val itemValue = database.itemValueDao().getValueById(itemValueId)
} if (itemValue != null) {
database.itemValueDao().updateValue(
fun reorderFields(reorderedFields: List<Field>) { itemValue.copy(
viewModelScope.launch { value = newValue,
val timestamp = System.currentTimeMillis() updatedAt = System.currentTimeMillis(),
// Use DAO-level transaction for clarity ),
database.fieldDao().reorderFieldsInTransaction(reorderedFields, timestamp) )
performSync() performSync()
} }
} }
} }
fun deleteItem(itemId: String) {
viewModelScope.launch {
database.itemDao().softDeleteItem(itemId, System.currentTimeMillis())
performSync()
}
}
fun reorderFields(reorderedFields: List<Field>) {
viewModelScope.launch {
val timestamp = System.currentTimeMillis()
// Use DAO-level transaction for clarity
database.fieldDao().reorderFieldsInTransaction(reorderedFields, timestamp)
performSync()
}
}
}
@@ -1,299 +1,303 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.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.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList import com.collabtable.app.data.model.CollabList
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
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) }
val viewModel = remember { ListsViewModel(database, context) } val viewModel = remember { ListsViewModel(database, context) }
val lists by viewModel.lists.collectAsState() val lists by viewModel.lists.collectAsState()
val isLoading by viewModel.isLoading.collectAsState() val isLoading by viewModel.isLoading.collectAsState()
var showCreateDialog by remember { mutableStateOf(false) } var showCreateDialog by remember { mutableStateOf(false) }
var listToDelete by remember { mutableStateOf<CollabList?>(null) } var listToDelete by remember { mutableStateOf<CollabList?>(null) }
var listToEdit by remember { mutableStateOf<CollabList?>(null) } var listToEdit by remember { mutableStateOf<CollabList?>(null) }
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.lists)) }, title = { Text(stringResource(R.string.lists)) },
actions = { actions = {
IconButton(onClick = { viewModel.manualSync() }) { IconButton(onClick = { viewModel.manualSync() }) {
Icon(Icons.Default.Refresh, contentDescription = "Sync") Icon(Icons.Default.Refresh, contentDescription = "Sync")
} }
IconButton(onClick = onNavigateToLogs) { IconButton(onClick = onNavigateToLogs) {
Icon(Icons.Default.List, contentDescription = "Logs") Icon(Icons.Default.List, contentDescription = "Logs")
} }
IconButton(onClick = onNavigateToSettings) { IconButton(onClick = onNavigateToSettings) {
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 = {
onClick = { showCreateDialog = true } FloatingActionButton(
) { onClick = { showCreateDialog = true },
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.create_list)) ) {
} Icon(Icons.Default.Add, contentDescription = stringResource(R.string.create_list))
} }
) { padding -> },
Box( ) { padding ->
modifier = Modifier Box(
.fillMaxSize() modifier =
.padding(padding) Modifier
) { .fillMaxSize()
if (isLoading && lists.isEmpty()) { .padding(padding),
// Show loading indicator during initial sync ) {
Column( if (isLoading && lists.isEmpty()) {
modifier = Modifier.align(Alignment.Center), // Show loading indicator during initial sync
horizontalAlignment = Alignment.CenterHorizontally, Column(
verticalArrangement = Arrangement.spacedBy(16.dp) modifier = Modifier.align(Alignment.Center),
) { horizontalAlignment = Alignment.CenterHorizontally,
CircularProgressIndicator() verticalArrangement = Arrangement.spacedBy(16.dp),
Text( ) {
text = "Syncing with server...", CircularProgressIndicator()
style = MaterialTheme.typography.bodyLarge, Text(
color = MaterialTheme.colorScheme.onSurfaceVariant text = "Syncing with server...",
) style = MaterialTheme.typography.bodyLarge,
} color = MaterialTheme.colorScheme.onSurfaceVariant,
} else if (lists.isEmpty()) { )
// Show empty state }
Column( } else if (lists.isEmpty()) {
modifier = Modifier.align(Alignment.Center), // Show empty state
horizontalAlignment = Alignment.CenterHorizontally, Column(
verticalArrangement = Arrangement.spacedBy(8.dp) modifier = Modifier.align(Alignment.Center),
) { horizontalAlignment = Alignment.CenterHorizontally,
Text( verticalArrangement = Arrangement.spacedBy(8.dp),
text = stringResource(R.string.no_lists), ) {
style = MaterialTheme.typography.bodyLarge, Text(
color = MaterialTheme.colorScheme.onSurfaceVariant text = stringResource(R.string.no_lists),
) style = MaterialTheme.typography.bodyLarge,
Text( color = MaterialTheme.colorScheme.onSurfaceVariant,
text = "Tap + to create your first table", )
style = MaterialTheme.typography.bodyMedium, Text(
color = MaterialTheme.colorScheme.onSurfaceVariant text = "Tap + to create your first table",
) style = MaterialTheme.typography.bodyMedium,
} color = MaterialTheme.colorScheme.onSurfaceVariant,
} else { )
// Show lists }
LazyColumn( } else {
modifier = Modifier.fillMaxSize(), // Show lists
contentPadding = PaddingValues(16.dp), LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp) modifier = Modifier.fillMaxSize(),
) { contentPadding = PaddingValues(16.dp),
items(lists, key = { it.id }) { list -> verticalArrangement = Arrangement.spacedBy(8.dp),
ListItem( ) {
list = list, items(lists, key = { it.id }) { list ->
onListClick = { onNavigateToList(list.id) }, ListItem(
onEditClick = { listToEdit = list }, list = list,
onDeleteClick = { listToDelete = list } onListClick = { onNavigateToList(list.id) },
) onEditClick = { listToEdit = list },
} onDeleteClick = { listToDelete = list },
} )
} }
} }
} }
}
if (showCreateDialog) { }
CreateListDialog(
onDismiss = { showCreateDialog = false }, if (showCreateDialog) {
onCreate = { name -> CreateListDialog(
viewModel.createList(name) onDismiss = { showCreateDialog = false },
showCreateDialog = false onCreate = { name ->
} viewModel.createList(name)
) showCreateDialog = false
} },
)
listToEdit?.let { list -> }
RenameListDialog(
currentName = list.name, listToEdit?.let { list ->
onDismiss = { listToEdit = null }, RenameListDialog(
onRename = { newName -> currentName = list.name,
viewModel.renameList(list.id, newName) onDismiss = { listToEdit = null },
listToEdit = null onRename = { newName ->
} viewModel.renameList(list.id, newName)
) listToEdit = null
} },
)
listToDelete?.let { list -> }
AlertDialog(
onDismissRequest = { listToDelete = null }, listToDelete?.let { list ->
title = { Text(stringResource(R.string.delete_list)) }, AlertDialog(
text = { Text(stringResource(R.string.confirm_delete)) }, onDismissRequest = { listToDelete = null },
confirmButton = { title = { Text(stringResource(R.string.delete_list)) },
TextButton( text = { Text(stringResource(R.string.confirm_delete)) },
onClick = { confirmButton = {
viewModel.deleteList(list.id) TextButton(
listToDelete = null onClick = {
} viewModel.deleteList(list.id)
) { listToDelete = null
Text(stringResource(R.string.delete)) },
} ) {
}, Text(stringResource(R.string.delete))
dismissButton = { }
TextButton(onClick = { listToDelete = null }) { },
Text(stringResource(R.string.cancel)) dismissButton = {
} TextButton(onClick = { listToDelete = null }) {
} Text(stringResource(R.string.cancel))
) }
} },
} )
}
@Composable }
fun ListItem(
list: CollabList, @Composable
onListClick: () -> Unit, fun ListItem(
onEditClick: () -> Unit, list: CollabList,
onDeleteClick: () -> Unit onListClick: () -> Unit,
) { onEditClick: () -> Unit,
Card( onDeleteClick: () -> Unit,
modifier = Modifier ) {
.fillMaxWidth() Card(
.clickable(onClick = onListClick) modifier =
) { Modifier
Row( .fillMaxWidth()
modifier = Modifier .clickable(onClick = onListClick),
.fillMaxWidth() ) {
.padding(16.dp), Row(
horizontalArrangement = Arrangement.SpaceBetween, modifier =
verticalAlignment = Alignment.CenterVertically Modifier
) { .fillMaxWidth()
Column(modifier = Modifier.weight(1f)) { .padding(16.dp),
Text( horizontalArrangement = Arrangement.SpaceBetween,
text = list.name, verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.titleMedium ) {
) Column(modifier = Modifier.weight(1f)) {
Spacer(modifier = Modifier.height(4.dp)) Text(
Text( text = list.name,
text = formatDate(list.updatedAt), style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.bodySmall, )
color = MaterialTheme.colorScheme.onSurfaceVariant Spacer(modifier = Modifier.height(4.dp))
) Text(
} text = formatDate(list.updatedAt),
Row { style = MaterialTheme.typography.bodySmall,
IconButton(onClick = onEditClick) { color = MaterialTheme.colorScheme.onSurfaceVariant,
Icon( )
Icons.Default.Edit, }
contentDescription = "Edit", Row {
tint = MaterialTheme.colorScheme.primary IconButton(onClick = onEditClick) {
) Icon(
} Icons.Default.Edit,
IconButton(onClick = onDeleteClick) { contentDescription = "Edit",
Icon( tint = MaterialTheme.colorScheme.primary,
Icons.Default.Delete, )
contentDescription = stringResource(R.string.delete), }
tint = MaterialTheme.colorScheme.error IconButton(onClick = onDeleteClick) {
) Icon(
} Icons.Default.Delete,
} contentDescription = stringResource(R.string.delete),
} tint = MaterialTheme.colorScheme.error,
} )
} }
}
@Composable }
fun CreateListDialog( }
onDismiss: () -> Unit, }
onCreate: (String) -> Unit
) { @Composable
var listName by remember { mutableStateOf("") } fun CreateListDialog(
onDismiss: () -> Unit,
AlertDialog( onCreate: (String) -> Unit,
onDismissRequest = onDismiss, ) {
title = { Text(stringResource(R.string.create_list)) }, var listName by remember { mutableStateOf("") }
text = {
OutlinedTextField( AlertDialog(
value = listName, onDismissRequest = onDismiss,
onValueChange = { listName = it }, title = { Text(stringResource(R.string.create_list)) },
label = { Text(stringResource(R.string.list_name)) }, text = {
singleLine = true OutlinedTextField(
) value = listName,
}, onValueChange = { listName = it },
confirmButton = { label = { Text(stringResource(R.string.list_name)) },
TextButton( singleLine = true,
onClick = { if (listName.isNotBlank()) onCreate(listName.trim()) }, )
enabled = listName.isNotBlank() },
) { confirmButton = {
Text(stringResource(R.string.save)) TextButton(
} onClick = { if (listName.isNotBlank()) onCreate(listName.trim()) },
}, enabled = listName.isNotBlank(),
dismissButton = { ) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.save))
Text(stringResource(R.string.cancel)) }
} },
} dismissButton = {
) TextButton(onClick = onDismiss) {
} Text(stringResource(R.string.cancel))
}
@Composable },
private fun RenameListDialog( )
currentName: String, }
onDismiss: () -> Unit,
onRename: (String) -> Unit @Composable
) { private fun RenameListDialog(
var listName by remember { mutableStateOf(currentName) } currentName: String,
onDismiss: () -> Unit,
AlertDialog( onRename: (String) -> Unit,
onDismissRequest = onDismiss, ) {
title = { Text("Rename Table") }, var listName by remember { mutableStateOf(currentName) }
text = {
OutlinedTextField( AlertDialog(
value = listName, onDismissRequest = onDismiss,
onValueChange = { listName = it }, title = { Text("Rename Table") },
label = { Text(stringResource(R.string.list_name)) }, text = {
singleLine = true OutlinedTextField(
) value = listName,
}, onValueChange = { listName = it },
confirmButton = { label = { Text(stringResource(R.string.list_name)) },
TextButton( singleLine = true,
onClick = { if (listName.isNotBlank()) onRename(listName.trim()) }, )
enabled = listName.isNotBlank() && listName.trim() != currentName },
) { confirmButton = {
Text("Rename") TextButton(
} onClick = { if (listName.isNotBlank()) onRename(listName.trim()) },
}, enabled = listName.isNotBlank() && listName.trim() != currentName,
dismissButton = { ) {
TextButton(onClick = onDismiss) { Text("Rename")
Text(stringResource(R.string.cancel)) }
} },
} dismissButton = {
) TextButton(onClick = onDismiss) {
} Text(stringResource(R.string.cancel))
}
private fun formatDate(timestamp: Long): String { },
val sdf = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()) )
return sdf.format(Date(timestamp)) }
}
private fun formatDate(timestamp: Long): String {
val sdf = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault())
return sdf.format(Date(timestamp))
}
@@ -1,114 +1,118 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import android.content.Context import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList import com.collabtable.app.data.model.CollabList
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 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
import java.util.UUID 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()
private val _isLoading = MutableStateFlow(false) private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow() val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val syncRepository = SyncRepository(context) private val syncRepository = SyncRepository(context)
init { init {
loadLists() loadLists()
// Perform initial sync immediately on startup, then start periodic sync // Perform initial sync immediately on startup, then start periodic sync
viewModelScope.launch { viewModelScope.launch {
_isLoading.value = true _isLoading.value = true
performSync() performSync()
_isLoading.value = false _isLoading.value = false
startPeriodicSync() startPeriodicSync()
} }
} }
private fun loadLists() { private fun loadLists() {
viewModelScope.launch { viewModelScope.launch {
database.listDao().getAllLists().collect { listData -> database.listDao().getAllLists().collect { listData ->
_lists.value = listData _lists.value = listData
} }
} }
} }
private suspend fun startPeriodicSync() { private suspend fun startPeriodicSync() {
while (true) { while (true) {
kotlinx.coroutines.delay(5000) // Wait 5 seconds before next sync kotlinx.coroutines.delay(5000) // Wait 5 seconds before next sync
performSync() performSync()
} }
} }
private suspend fun performSync() { private suspend fun performSync() {
val result = syncRepository.performSync() val result = syncRepository.performSync()
result.onFailure { error -> result.onFailure { error ->
Logger.e("Tables", "❌ Sync failed: ${error.message}") Logger.e("Tables", "❌ Sync failed: ${error.message}")
} }
} }
fun createList(name: String) { fun createList(name: String) {
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) )
// Sync immediately after creating database.listDao().insertList(newList)
performSync() // Sync immediately after creating
} performSync()
} }
}
fun renameList(listId: String, newName: String) {
viewModelScope.launch { fun renameList(
val list = database.listDao().getListById(listId) listId: String,
if (list != null && newName.isNotBlank()) { newName: String,
Logger.i("Tables", "✏️ Renaming table: \"${list.name}\"\"$newName\"") ) {
database.listDao().updateList( viewModelScope.launch {
list.copy( val list = database.listDao().getListById(listId)
name = newName.trim(), if (list != null && newName.isNotBlank()) {
updatedAt = System.currentTimeMillis() Logger.i("Tables", "✏️ Renaming table: \"${list.name}\"\"$newName\"")
) database.listDao().updateList(
) list.copy(
// Sync immediately after renaming name = newName.trim(),
performSync() updatedAt = System.currentTimeMillis(),
} ),
} )
} // Sync immediately after renaming
performSync()
fun deleteList(listId: String) { }
viewModelScope.launch { }
val list = database.listDao().getListById(listId) }
if (list != null) {
Logger.i("Tables", "🗑️ Deleting table: \"${list.name}\"") fun deleteList(listId: String) {
database.listDao().softDeleteList(listId, System.currentTimeMillis()) viewModelScope.launch {
// Sync immediately after deleting val list = database.listDao().getListById(listId)
performSync() if (list != null) {
} Logger.i("Tables", "🗑️ Deleting table: \"${list.name}\"")
} database.listDao().softDeleteList(listId, System.currentTimeMillis())
} // Sync immediately after deleting
performSync()
fun manualSync() { }
viewModelScope.launch { }
Logger.i("Tables", "🔄 Manual sync requested") }
_isLoading.value = true
performSync() fun manualSync() {
_isLoading.value = false viewModelScope.launch {
} Logger.i("Tables", "🔄 Manual sync requested")
} _isLoading.value = true
} performSync()
_isLoading.value = false
}
}
}
@@ -1,433 +1,451 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.collabtable.app.utils.LogEntry import com.collabtable.app.utils.LogEntry
import com.collabtable.app.utils.LogLevel import com.collabtable.app.utils.LogLevel
import com.collabtable.app.utils.Logger import com.collabtable.app.utils.Logger
enum class TimeRange(val label: String, val milliseconds: Long) {
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), LAST_MINUTE("Last minute", 60_000),
LAST_MINUTE("Last minute", 60_000), LAST_5_MINUTES("Last 5 minutes", 300_000),
LAST_5_MINUTES("Last 5 minutes", 300_000), 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(onNavigateBack: () -> Unit) {
fun LogsScreen( val logs by Logger.logs.collectAsState()
onNavigateBack: () -> Unit val listState = rememberLazyListState()
) { var showFilterSheet by remember { mutableStateOf(false) }
val logs by Logger.logs.collectAsState() var filters by remember { mutableStateOf(LogFilters()) }
val listState = rememberLazyListState()
var showFilterSheet by remember { mutableStateOf(false) } // Extract all unique tags from logs
var filters by remember { mutableStateOf(LogFilters()) } val allTags =
remember(logs) {
// Extract all unique tags from logs logs.map { it.tag }.distinct().sorted()
val allTags = remember(logs) { }
logs.map { it.tag }.distinct().sorted()
} // Apply filters
val filteredLogs =
// Apply filters remember(logs, filters) {
val filteredLogs = remember(logs, filters) { val now = System.currentTimeMillis()
val now = System.currentTimeMillis() val cutoffTime = now - filters.timeRange.milliseconds
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() || filters.searchText.isEmpty() ||
log.message.contains(filters.searchText, ignoreCase = true) || log.message.contains(filters.searchText, ignoreCase = true) ||
log.tag.contains(filters.searchText, ignoreCase = true)) log.tag.contains(filters.searchText, ignoreCase = true)
} )
} }
}
// Auto-scroll to bottom when new logs arrive
LaunchedEffect(filteredLogs.size) { // Auto-scroll to bottom when new logs arrive
if (filteredLogs.isNotEmpty()) { LaunchedEffect(filteredLogs.size) {
listState.animateScrollToItem(filteredLogs.size - 1) if (filteredLogs.isNotEmpty()) {
} listState.animateScrollToItem(filteredLogs.size - 1)
} }
}
Scaffold(
topBar = { Scaffold(
TopAppBar( topBar = {
title = { Text("Logs (${filteredLogs.size}/${logs.size})") }, TopAppBar(
navigationIcon = { title = { Text("Logs (${filteredLogs.size}/${logs.size})") },
IconButton(onClick = onNavigateBack) { navigationIcon = {
Icon(Icons.Filled.ArrowBack, contentDescription = "Back") IconButton(onClick = onNavigateBack) {
} Icon(Icons.Filled.ArrowBack, contentDescription = "Back")
}, }
actions = { },
IconButton(onClick = { showFilterSheet = true }) { actions = {
Badge( IconButton(onClick = { showFilterSheet = true }) {
containerColor = if (hasActiveFilters(filters)) Badge(
MaterialTheme.colorScheme.error containerColor =
else Color.Transparent if (hasActiveFilters(filters)) {
) { MaterialTheme.colorScheme.error
Icon(Icons.Default.FilterList, contentDescription = "Filter logs") } else {
} Color.Transparent
} },
IconButton(onClick = { Logger.clear() }) { ) {
Icon(Icons.Default.Delete, contentDescription = "Clear logs") Icon(Icons.Default.FilterList, contentDescription = "Filter logs")
} }
}, }
colors = TopAppBarDefaults.topAppBarColors( IconButton(onClick = { Logger.clear() }) {
containerColor = MaterialTheme.colorScheme.primaryContainer, Icon(Icons.Default.Delete, contentDescription = "Clear logs")
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer }
) },
) colors =
} TopAppBarDefaults.topAppBarColors(
) { padding -> containerColor = MaterialTheme.colorScheme.primaryContainer,
Column( titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier ),
.fillMaxSize() )
.padding(padding) },
) { ) { padding ->
// Active filters chips Column(
if (hasActiveFilters(filters)) { modifier =
LazyRow( Modifier
modifier = Modifier .fillMaxSize()
.fillMaxWidth() .padding(padding),
.background(MaterialTheme.colorScheme.surfaceVariant) ) {
.padding(horizontal = 8.dp, vertical = 4.dp), // Active filters chips
horizontalArrangement = Arrangement.spacedBy(8.dp) if (hasActiveFilters(filters)) {
) { LazyRow(
// Severity filters modifier =
if (filters.severities.size < LogLevel.values().size) { Modifier
items(filters.severities.toList()) { level -> .fillMaxWidth()
FilterChip( .background(MaterialTheme.colorScheme.surfaceVariant)
selected = true, .padding(horizontal = 8.dp, vertical = 4.dp),
onClick = { horizontalArrangement = Arrangement.spacedBy(8.dp),
filters = filters.copy( ) {
severities = filters.severities - level // Severity filters
) if (filters.severities.size < LogLevel.values().size) {
}, items(filters.severities.toList()) { level ->
label = { Text(level.name, fontSize = 12.sp) } FilterChip(
) selected = true,
} onClick = {
} filters =
filters.copy(
// Time range filter severities = filters.severities - level,
if (filters.timeRange != TimeRange.ALL_TIME) { )
item { },
FilterChip( label = { Text(level.name, fontSize = 12.sp) },
selected = true, )
onClick = { }
filters = filters.copy(timeRange = TimeRange.ALL_TIME) }
},
label = { Text(filters.timeRange.label, fontSize = 12.sp) } // Time range filter
) if (filters.timeRange != TimeRange.ALL_TIME) {
} item {
} FilterChip(
selected = true,
// Tag filters onClick = {
items(filters.tags.toList()) { tag -> filters = filters.copy(timeRange = TimeRange.ALL_TIME)
FilterChip( },
selected = true, label = { Text(filters.timeRange.label, fontSize = 12.sp) },
onClick = { )
filters = filters.copy(tags = filters.tags - tag) }
}, }
label = { Text(tag, fontSize = 12.sp) }
) // Tag filters
} items(filters.tags.toList()) { tag ->
FilterChip(
// Clear all button selected = true,
item { onClick = {
FilterChip( filters = filters.copy(tags = filters.tags - tag)
selected = false, },
onClick = { label = { Text(tag, fontSize = 12.sp) },
filters = LogFilters() )
}, }
label = { Text("Clear All", fontSize = 12.sp) },
leadingIcon = { // Clear all button
Icon( item {
Icons.Default.Delete, FilterChip(
contentDescription = null, selected = false,
modifier = Modifier.size(16.dp) onClick = {
) filters = LogFilters()
} },
) label = { Text("Clear All", fontSize = 12.sp) },
} leadingIcon = {
} Icon(
} Icons.Default.Delete,
contentDescription = null,
// Logs list modifier = Modifier.size(16.dp),
if (filteredLogs.isEmpty()) { )
Box( },
modifier = Modifier.fillMaxSize(), )
contentAlignment = Alignment.Center }
) { }
Text( }
text = if (logs.isEmpty()) "No logs yet" else "No logs match filters",
style = MaterialTheme.typography.bodyLarge, // Logs list
color = MaterialTheme.colorScheme.onSurfaceVariant if (filteredLogs.isEmpty()) {
) Box(
} modifier = Modifier.fillMaxSize(),
} else { contentAlignment = Alignment.Center,
LazyColumn( ) {
state = listState, Text(
modifier = Modifier text = if (logs.isEmpty()) "No logs yet" else "No logs match filters",
.fillMaxSize() style = MaterialTheme.typography.bodyLarge,
.background(Color(0xFF1E1E1E)), color = MaterialTheme.colorScheme.onSurfaceVariant,
contentPadding = PaddingValues(8.dp) )
) { }
items(filteredLogs) { log -> } else {
LogItem(log) LazyColumn(
} state = listState,
} modifier =
} Modifier
} .fillMaxSize()
} .background(Color(0xFF1E1E1E)),
contentPadding = PaddingValues(8.dp),
// Filter bottom sheet ) {
if (showFilterSheet) { items(filteredLogs) { log ->
FilterBottomSheet( LogItem(log)
filters = filters, }
allTags = allTags, }
onFiltersChanged = { filters = it }, }
onDismiss = { showFilterSheet = false } }
) }
}
} // Filter bottom sheet
if (showFilterSheet) {
private fun hasActiveFilters(filters: LogFilters): Boolean { FilterBottomSheet(
return filters.severities.size < LogLevel.values().size || filters = filters,
filters.timeRange != TimeRange.ALL_TIME || allTags = allTags,
filters.tags.isNotEmpty() || onFiltersChanged = { filters = it },
filters.searchText.isNotEmpty() onDismiss = { showFilterSheet = false },
} )
}
@OptIn(ExperimentalMaterial3Api::class) }
@Composable
fun FilterBottomSheet( private fun hasActiveFilters(filters: LogFilters): Boolean {
filters: LogFilters, return filters.severities.size < LogLevel.values().size ||
allTags: List<String>, filters.timeRange != TimeRange.ALL_TIME ||
onFiltersChanged: (LogFilters) -> Unit, filters.tags.isNotEmpty() ||
onDismiss: () -> Unit filters.searchText.isNotEmpty()
) { }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@OptIn(ExperimentalMaterial3Api::class)
ModalBottomSheet( @Composable
onDismissRequest = onDismiss, fun FilterBottomSheet(
sheetState = sheetState filters: LogFilters,
) { allTags: List<String>,
Column( onFiltersChanged: (LogFilters) -> Unit,
modifier = Modifier onDismiss: () -> Unit,
.fillMaxWidth() ) {
.padding(16.dp) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
.verticalScroll(rememberScrollState())
.padding(bottom = 32.dp) ModalBottomSheet(
) { onDismissRequest = onDismiss,
Text( sheetState = sheetState,
text = "Filter Logs", ) {
style = MaterialTheme.typography.headlineSmall, Column(
fontWeight = FontWeight.Bold, modifier =
modifier = Modifier.padding(bottom = 16.dp) Modifier
) .fillMaxWidth()
.padding(16.dp)
// Severity filters .verticalScroll(rememberScrollState())
Text( .padding(bottom = 32.dp),
text = "Severity", ) {
style = MaterialTheme.typography.titleMedium, Text(
fontWeight = FontWeight.SemiBold, text = "Filter Logs",
modifier = Modifier.padding(bottom = 8.dp) style = MaterialTheme.typography.headlineSmall,
) fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp),
LogLevel.values().forEach { level -> )
Row(
modifier = Modifier // Severity filters
.fillMaxWidth() Text(
.padding(vertical = 4.dp), text = "Severity",
verticalAlignment = Alignment.CenterVertically style = MaterialTheme.typography.titleMedium,
) { fontWeight = FontWeight.SemiBold,
Checkbox( modifier = Modifier.padding(bottom = 8.dp),
checked = level in filters.severities, )
onCheckedChange = { checked ->
onFiltersChanged( LogLevel.values().forEach { level ->
filters.copy( Row(
severities = if (checked) { modifier =
filters.severities + level Modifier
} else { .fillMaxWidth()
filters.severities - level .padding(vertical = 4.dp),
} verticalAlignment = Alignment.CenterVertically,
) ) {
) Checkbox(
} checked = level in filters.severities,
) onCheckedChange = { checked ->
Text( onFiltersChanged(
text = level.name, filters.copy(
modifier = Modifier.padding(start = 8.dp) severities =
) if (checked) {
} filters.severities + level
} } else {
filters.severities - level
Divider(modifier = Modifier.padding(vertical = 16.dp)) },
),
// Time range filter )
Text( },
text = "Time Range", )
style = MaterialTheme.typography.titleMedium, Text(
fontWeight = FontWeight.SemiBold, text = level.name,
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.padding(start = 8.dp),
) )
}
TimeRange.values().forEach { range -> }
Row(
modifier = Modifier Divider(modifier = Modifier.padding(vertical = 16.dp))
.fillMaxWidth()
.padding(vertical = 4.dp), // Time range filter
verticalAlignment = Alignment.CenterVertically Text(
) { text = "Time Range",
RadioButton( style = MaterialTheme.typography.titleMedium,
selected = filters.timeRange == range, fontWeight = FontWeight.SemiBold,
onClick = { modifier = Modifier.padding(bottom = 8.dp),
onFiltersChanged(filters.copy(timeRange = range)) )
}
) TimeRange.values().forEach { range ->
Text( Row(
text = range.label, modifier =
modifier = Modifier.padding(start = 8.dp) Modifier
) .fillMaxWidth()
} .padding(vertical = 4.dp),
} verticalAlignment = Alignment.CenterVertically,
) {
Divider(modifier = Modifier.padding(vertical = 16.dp)) RadioButton(
selected = filters.timeRange == range,
// Tag filters onClick = {
Text( onFiltersChanged(filters.copy(timeRange = range))
text = "Tags (${filters.tags.size} selected)", },
style = MaterialTheme.typography.titleMedium, )
fontWeight = FontWeight.SemiBold, Text(
modifier = Modifier.padding(bottom = 8.dp) text = range.label,
) modifier = Modifier.padding(start = 8.dp),
)
if (allTags.isEmpty()) { }
Text( }
text = "No tags available",
style = MaterialTheme.typography.bodyMedium, Divider(modifier = Modifier.padding(vertical = 16.dp))
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp) // Tag filters
) Text(
} else { text = "Tags (${filters.tags.size} selected)",
Row( style = MaterialTheme.typography.titleMedium,
modifier = Modifier fontWeight = FontWeight.SemiBold,
.fillMaxWidth() modifier = Modifier.padding(bottom = 8.dp),
.padding(vertical = 4.dp), )
horizontalArrangement = Arrangement.spacedBy(8.dp)
) { if (allTags.isEmpty()) {
TextButton( Text(
onClick = { text = "No tags available",
onFiltersChanged(filters.copy(tags = allTags.toSet())) style = MaterialTheme.typography.bodyMedium,
} color = MaterialTheme.colorScheme.onSurfaceVariant,
) { modifier = Modifier.padding(vertical = 8.dp),
Text("Select All") )
} } else {
TextButton( Row(
onClick = { modifier =
onFiltersChanged(filters.copy(tags = emptySet())) Modifier
} .fillMaxWidth()
) { .padding(vertical = 4.dp),
Text("Clear All") horizontalArrangement = Arrangement.spacedBy(8.dp),
} ) {
} TextButton(
onClick = {
allTags.forEach { tag -> onFiltersChanged(filters.copy(tags = allTags.toSet()))
Row( },
modifier = Modifier ) {
.fillMaxWidth() Text("Select All")
.padding(vertical = 4.dp), }
verticalAlignment = Alignment.CenterVertically TextButton(
) { onClick = {
Checkbox( onFiltersChanged(filters.copy(tags = emptySet()))
checked = tag in filters.tags, },
onCheckedChange = { checked -> ) {
onFiltersChanged( Text("Clear All")
filters.copy( }
tags = if (checked) { }
filters.tags + tag
} else { allTags.forEach { tag ->
filters.tags - tag Row(
} modifier =
) Modifier
) .fillMaxWidth()
} .padding(vertical = 4.dp),
) verticalAlignment = Alignment.CenterVertically,
Text( ) {
text = tag, Checkbox(
modifier = Modifier.padding(start = 8.dp) checked = tag in filters.tags,
) onCheckedChange = { checked ->
} onFiltersChanged(
} filters.copy(
} tags =
if (checked) {
Divider(modifier = Modifier.padding(vertical = 16.dp)) filters.tags + tag
} else {
// Reset button filters.tags - tag
Button( },
onClick = { ),
onFiltersChanged(LogFilters()) )
}, },
modifier = Modifier.fillMaxWidth() )
) { Text(
Text("Reset All Filters") text = tag,
} modifier = Modifier.padding(start = 8.dp),
} )
} }
} }
}
@Composable
fun LogItem(log: LogEntry) { Divider(modifier = Modifier.padding(vertical = 16.dp))
val color = when (log.level) {
LogLevel.DEBUG -> Color(0xFF808080) // Reset button
LogLevel.INFO -> Color(0xFF4FC3F7) Button(
LogLevel.WARN -> Color(0xFFFFA726) onClick = {
LogLevel.ERROR -> Color(0xFFEF5350) onFiltersChanged(LogFilters())
} },
modifier = Modifier.fillMaxWidth(),
Text( ) {
text = log.toFormattedString(), Text("Reset All Filters")
color = color, }
fontFamily = FontFamily.Monospace, }
fontSize = 12.sp, }
modifier = Modifier }
.fillMaxWidth()
.padding(vertical = 2.dp) @Composable
) fun LogItem(log: LogEntry) {
} val color =
when (log.level) {
LogLevel.DEBUG -> Color(0xFF808080)
LogLevel.INFO -> Color(0xFF4FC3F7)
LogLevel.WARN -> Color(0xFFFFA726)
LogLevel.ERROR -> Color(0xFFEF5350)
}
Text(
text = log.toFormattedString(),
color = color,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
)
}
@@ -1,148 +1,152 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.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.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.collabtable.app.data.preferences.PreferencesManager 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 preferencesManager = remember { PreferencesManager.getInstance(context) }
val context = LocalContext.current val viewModel = remember { ServerSetupViewModel(preferencesManager) }
val preferencesManager = remember { PreferencesManager.getInstance(context) }
val viewModel = remember { ServerSetupViewModel(preferencesManager) } var serverUrl by remember { mutableStateOf("") }
var serverPassword by remember { mutableStateOf("") }
var serverUrl by remember { mutableStateOf("") } var passwordVisible by remember { mutableStateOf(false) }
var serverPassword by remember { mutableStateOf("") } val isValidating by viewModel.isValidating.collectAsState()
var passwordVisible by remember { mutableStateOf(false) } val validationResult by viewModel.validationResult.collectAsState()
val isValidating by viewModel.isValidating.collectAsState() val validationError by viewModel.validationError.collectAsState()
val validationResult by viewModel.validationResult.collectAsState()
val validationError by viewModel.validationError.collectAsState() LaunchedEffect(validationResult) {
if (validationResult == true) {
LaunchedEffect(validationResult) { onSetupComplete()
if (validationResult == true) { }
onSetupComplete() }
}
} Scaffold(
topBar = {
Scaffold( CenterAlignedTopAppBar(
topBar = { title = { Text("Server Setup") },
CenterAlignedTopAppBar( colors =
title = { Text("Server Setup") }, TopAppBarDefaults.centerAlignedTopAppBarColors(
colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = MaterialTheme.colorScheme.primaryContainer,
containerColor = MaterialTheme.colorScheme.primaryContainer, titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer ),
) )
) },
} ) { padding ->
) { padding -> Column(
Column( modifier =
modifier = Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
.padding(16.dp), .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))
OutlinedTextField( OutlinedTextField(
value = serverUrl, value = serverUrl,
onValueChange = { serverUrl = it }, onValueChange = { serverUrl = it },
label = { Text("Server Hostname") }, label = { Text("Server Hostname") },
placeholder = { Text("example.com or 10.0.2.2:3000") }, placeholder = { Text("example.com or 10.0.2.2:3000") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = !isValidating, enabled = !isValidating,
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 = { },
when { trailingIcon = {
isValidating -> CircularProgressIndicator( when {
modifier = Modifier.size(24.dp), isValidating ->
strokeWidth = 2.dp CircularProgressIndicator(
) modifier = Modifier.size(24.dp),
validationResult == true -> Icon( strokeWidth = 2.dp,
Icons.Default.CheckCircle, )
contentDescription = "Valid", validationResult == true ->
tint = MaterialTheme.colorScheme.primary Icon(
) Icons.Default.CheckCircle,
validationError != null -> Icon( contentDescription = "Valid",
Icons.Default.Error, tint = MaterialTheme.colorScheme.primary,
contentDescription = "Error", )
tint = MaterialTheme.colorScheme.error validationError != null ->
) Icon(
} Icons.Default.Error,
} contentDescription = "Error",
) tint = MaterialTheme.colorScheme.error,
)
OutlinedTextField( }
value = serverPassword, },
onValueChange = { serverPassword = it }, )
label = { Text("Server Password") },
placeholder = { Text("Enter server password") }, OutlinedTextField(
modifier = Modifier.fillMaxWidth(), value = serverPassword,
enabled = !isValidating, onValueChange = { serverPassword = it },
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), label = { Text("Server Password") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), placeholder = { Text("Enter server password") },
trailingIcon = { modifier = Modifier.fillMaxWidth(),
IconButton(onClick = { passwordVisible = !passwordVisible }) { enabled = !isValidating,
Icon( visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
contentDescription = if (passwordVisible) "Hide password" else "Show password" trailingIcon = {
) IconButton(onClick = { passwordVisible = !passwordVisible }) {
} Icon(
}, imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
supportingText = { Text("Server password for authentication") } contentDescription = if (passwordVisible) "Hide password" else "Show password",
) )
}
Button( },
onClick = { viewModel.validateAndSaveServerUrl(serverUrl.trim(), serverPassword.trim()) }, supportingText = { Text("Server password for authentication") },
modifier = Modifier.fillMaxWidth(), )
enabled = serverUrl.isNotBlank() && serverPassword.isNotBlank() && !isValidating
) { Button(
if (isValidating) { onClick = { viewModel.validateAndSaveServerUrl(serverUrl.trim(), serverPassword.trim()) },
CircularProgressIndicator( modifier = Modifier.fillMaxWidth(),
modifier = Modifier.size(16.dp), enabled = serverUrl.isNotBlank() && serverPassword.isNotBlank() && !isValidating,
color = MaterialTheme.colorScheme.onPrimary, ) {
strokeWidth = 2.dp if (isValidating) {
) CircularProgressIndicator(
Spacer(modifier = Modifier.width(8.dp)) modifier = Modifier.size(16.dp),
Text("Validating...") color = MaterialTheme.colorScheme.onPrimary,
} else { strokeWidth = 2.dp,
Text("Connect") )
} Spacer(modifier = Modifier.width(8.dp))
} Text("Validating...")
} else {
Text( Text("Connect")
text = "Tip: Emulator host 10.0.2.2:3000 · Physical device uses your PC IP", }
style = MaterialTheme.typography.bodySmall, }
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center Text(
) text = "Tip: Emulator host 10.0.2.2:3000 · Physical device uses your PC IP",
} style = MaterialTheme.typography.bodySmall,
} color = MaterialTheme.colorScheme.onSurfaceVariant,
} textAlign = TextAlign.Center,
)
}
}
}
@@ -1,208 +1,220 @@
package com.collabtable.app.ui.screens package com.collabtable.app.ui.screens
import androidx.lifecycle.ViewModel import android.os.NetworkOnMainThreadException
import androidx.lifecycle.viewModelScope import androidx.lifecycle.ViewModel
import com.collabtable.app.data.preferences.PreferencesManager import androidx.lifecycle.viewModelScope
import com.collabtable.app.data.api.ApiClient import com.collabtable.app.data.api.ApiClient
import kotlinx.coroutines.Dispatchers import com.collabtable.app.data.preferences.PreferencesManager
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import kotlinx.coroutines.withContext
import okhttp3.Request import okhttp3.OkHttpClient
import android.os.NetworkOnMainThreadException import okhttp3.Request
import java.io.IOException import java.io.IOException
import java.net.ConnectException import java.net.ConnectException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.net.UnknownHostException 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()
private val _validationResult = MutableStateFlow<Boolean?>(null) private val _validationResult = MutableStateFlow<Boolean?>(null)
val validationResult: StateFlow<Boolean?> = _validationResult.asStateFlow() val validationResult: StateFlow<Boolean?> = _validationResult.asStateFlow()
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) {
viewModelScope.launch { fun validateAndSaveServerUrl(
_isValidating.value = true url: String,
_validationError.value = null password: String,
_validationResult.value = null ) {
viewModelScope.launch {
try { _isValidating.value = true
// Normalize URL - add protocol if missing _validationError.value = null
var normalizedUrl = url.trim() _validationResult.value = null
// Check if URL has a protocol try {
val hasProtocol = normalizedUrl.startsWith("http://") || normalizedUrl.startsWith("https://") // Normalize URL - add protocol if missing
val hasPort = normalizedUrl.contains(":") var normalizedUrl = url.trim()
// Add http:// if no protocol specified // Check if URL has a protocol
if (!hasProtocol) { val hasProtocol = normalizedUrl.startsWith("http://") || normalizedUrl.startsWith("https://")
normalizedUrl = "http://$normalizedUrl" val hasPort = normalizedUrl.contains(":")
}
// Add http:// if no protocol specified
// Add default port if no port specified if (!hasProtocol) {
if (!hasPort || normalizedUrl.matches(Regex("^https?://.+$")) && !normalizedUrl.substringAfter("://").contains(":")) { normalizedUrl = "http://$normalizedUrl"
// Extract protocol and host }
val protocol = if (normalizedUrl.startsWith("https://")) "https" else "http"
val hostAndPath = normalizedUrl.substringAfter("://") // Add default port if no port specified
val host = if (hostAndPath.contains("/")) hostAndPath.substringBefore("/") else hostAndPath if (!hasPort || normalizedUrl.matches(Regex("^https?://.+$")) && !normalizedUrl.substringAfter("://").contains(":")) {
val path = if (hostAndPath.contains("/")) "/" + hostAndPath.substringAfter("/") else "" // Extract protocol and host
val protocol = if (normalizedUrl.startsWith("https://")) "https" else "http"
// Add default port based on protocol val hostAndPath = normalizedUrl.substringAfter("://")
val defaultPort = if (protocol == "https") "443" else "80" val host = if (hostAndPath.contains("/")) hostAndPath.substringBefore("/") else hostAndPath
normalizedUrl = "$protocol://$host:$defaultPort$path" val path = if (hostAndPath.contains("/")) "/" + hostAndPath.substringAfter("/") else ""
}
// Add default port based on protocol
// Remove trailing slash if present for consistent handling val defaultPort = if (protocol == "https") "443" else "80"
normalizedUrl = normalizedUrl.trimEnd('/') normalizedUrl = "$protocol://$host:$defaultPort$path"
}
// Add /api/ if not present
if (!normalizedUrl.contains("/api")) { // Remove trailing slash if present for consistent handling
normalizedUrl = "$normalizedUrl/api" normalizedUrl = normalizedUrl.trimEnd('/')
}
// Add /api/ if not present
// Ensure it ends with / if (!normalizedUrl.contains("/api")) {
normalizedUrl = if (normalizedUrl.endsWith("/")) normalizedUrl else "$normalizedUrl/" normalizedUrl = "$normalizedUrl/api"
}
// Validate password is not empty
if (password.isBlank()) { // Ensure it ends with /
_validationError.value = "Password cannot be empty" normalizedUrl = if (normalizedUrl.endsWith("/")) normalizedUrl else "$normalizedUrl/"
_isValidating.value = false
return@launch // Validate password is not empty
} if (password.isBlank()) {
_validationError.value = "Password cannot be empty"
// Try to reach the health endpoint (no auth required) _isValidating.value = false
val healthUrl = normalizedUrl.replace("/api/", "/health") return@launch
val healthRequest = Request.Builder() }
.url(healthUrl)
.get() // Try to reach the health endpoint (no auth required)
.build() val healthUrl = normalizedUrl.replace("/api/", "/health")
val healthRequest =
try { Request.Builder()
// Execute network call on IO dispatcher .url(healthUrl)
val healthResponse = withContext(Dispatchers.IO) { .get()
okHttpClient.newCall(healthRequest).execute() .build()
}
try {
if (!healthResponse.isSuccessful) { // Execute network call on IO dispatcher
val errorMessage = when (healthResponse.code) { val healthResponse =
404 -> "Health endpoint not found. Check server URL." withContext(Dispatchers.IO) {
500 -> "Server internal error. Check server logs." okHttpClient.newCall(healthRequest).execute()
503 -> "Service unavailable. Server may be starting up." }
else -> "Server returned HTTP ${healthResponse.code}"
} if (!healthResponse.isSuccessful) {
healthResponse.close() val errorMessage =
_validationError.value = errorMessage when (healthResponse.code) {
_isValidating.value = false 404 -> "Health endpoint not found. Check server URL."
return@launch 500 -> "Server internal error. Check server logs."
} 503 -> "Service unavailable. Server may be starting up."
healthResponse.close() else -> "Server returned HTTP ${healthResponse.code}"
}
// Try to upgrade to HTTPS if currently using HTTP healthResponse.close()
var finalUrl = normalizedUrl _validationError.value = errorMessage
if (normalizedUrl.startsWith("http://")) { _isValidating.value = false
// Replace http:// with https:// and change port 80 to 443 return@launch
var httpsUrl = normalizedUrl.replace("http://", "https://") }
if (httpsUrl.contains(":80/")) { healthResponse.close()
httpsUrl = httpsUrl.replace(":80/", ":443/")
} // Try to upgrade to HTTPS if currently using HTTP
var finalUrl = normalizedUrl
val httpsHealthUrl = httpsUrl.replace("/api/", "/health") if (normalizedUrl.startsWith("http://")) {
val httpsHealthRequest = Request.Builder() // Replace http:// with https:// and change port 80 to 443
.url(httpsHealthUrl) var httpsUrl = normalizedUrl.replace("http://", "https://")
.get() if (httpsUrl.contains(":80/")) {
.build() httpsUrl = httpsUrl.replace(":80/", ":443/")
}
try {
// Execute HTTPS check on IO dispatcher val httpsHealthUrl = httpsUrl.replace("/api/", "/health")
val httpsResponse = withContext(Dispatchers.IO) { val httpsHealthRequest =
okHttpClient.newCall(httpsHealthRequest).execute() Request.Builder()
} .url(httpsHealthUrl)
if (httpsResponse.isSuccessful) { .get()
// HTTPS works! Upgrade to it .build()
finalUrl = httpsUrl
} try {
httpsResponse.close() // Execute HTTPS check on IO dispatcher
} catch (e: Exception) { val httpsResponse =
// HTTPS doesn't work, stick with HTTP withContext(Dispatchers.IO) {
} okHttpClient.newCall(httpsHealthRequest).execute()
} }
if (httpsResponse.isSuccessful) {
// Now validate the password by making an authenticated request // HTTPS works! Upgrade to it
val testUrl = "${finalUrl}lists" finalUrl = httpsUrl
val authRequest = Request.Builder() }
.url(testUrl) httpsResponse.close()
.header("Authorization", "Bearer $password") } catch (e: Exception) {
.get() // HTTPS doesn't work, stick with HTTP
.build() }
}
// Execute auth check on IO dispatcher
val authResponse = withContext(Dispatchers.IO) { // Now validate the password by making an authenticated request
okHttpClient.newCall(authRequest).execute() val testUrl = "${finalUrl}lists"
} val authRequest =
Request.Builder()
if (authResponse.isSuccessful) { .url(testUrl)
// Password is valid, save both URL and password .header("Authorization", "Bearer $password")
preferencesManager.setServerUrl(finalUrl) .get()
preferencesManager.setServerPassword(password) .build()
// Reset sync baseline for a fresh initial sync on new server
preferencesManager.clearSyncState() // Execute auth check on IO dispatcher
preferencesManager.setIsFirstRun(false) val authResponse =
ApiClient.setBaseUrl(finalUrl) withContext(Dispatchers.IO) {
_validationResult.value = true okHttpClient.newCall(authRequest).execute()
} else if (authResponse.code == 401) { }
_validationError.value = "Invalid password. Please check and try again."
} else { if (authResponse.isSuccessful) {
val errorMessage = when (authResponse.code) { // Password is valid, save both URL and password
403 -> "Access forbidden. Check server configuration." preferencesManager.setServerUrl(finalUrl)
404 -> "API endpoint not found. Check URL path." preferencesManager.setServerPassword(password)
500 -> "Server error. Check server logs." // Reset sync baseline for a fresh initial sync on new server
503 -> "Service unavailable. Try again later." preferencesManager.clearSyncState()
else -> "Server returned HTTP ${authResponse.code}" preferencesManager.setIsFirstRun(false)
} ApiClient.setBaseUrl(finalUrl)
_validationError.value = errorMessage _validationResult.value = true
} } else if (authResponse.code == 401) {
authResponse.close() _validationError.value = "Invalid password. Please check and try again."
} catch (e: NetworkOnMainThreadException) { } else {
_validationError.value = "Network operation attempted on main thread. This is a developer error - please report this bug." val errorMessage =
} catch (e: UnknownHostException) { when (authResponse.code) {
_validationError.value = "Cannot resolve hostname. Check URL spelling and network connection." 403 -> "Access forbidden. Check server configuration."
} catch (e: ConnectException) { 404 -> "API endpoint not found. Check URL path."
_validationError.value = "Cannot connect to server. Check if server is running and URL is correct." 500 -> "Server error. Check server logs."
} catch (e: SocketTimeoutException) { 503 -> "Service unavailable. Try again later."
_validationError.value = "Connection timeout after 10 seconds. Server may be offline or unreachable." else -> "Server returned HTTP ${authResponse.code}"
} catch (e: IOException) { }
_validationError.value = "Network error: ${e.message ?: "Unable to communicate with server"}" _validationError.value = errorMessage
} catch (e: Exception) { }
_validationError.value = "Unexpected error: ${e.javaClass.simpleName} - ${e.message ?: "Unknown cause"}" authResponse.close()
} } catch (e: NetworkOnMainThreadException) {
} catch (e: IllegalArgumentException) { _validationError.value = "Network operation attempted on main thread. This is a developer error - please report this bug."
_validationError.value = "Invalid URL format: ${e.message ?: "Malformed URL"}" } catch (e: UnknownHostException) {
} catch (e: Exception) { _validationError.value = "Cannot resolve hostname. Check URL spelling and network connection."
_validationError.value = "Configuration error: ${e.javaClass.simpleName} - ${e.message ?: "Unable to process URL"}" } catch (e: ConnectException) {
} finally { _validationError.value = "Cannot connect to server. Check if server is running and URL is correct."
_isValidating.value = false } catch (e: SocketTimeoutException) {
} _validationError.value = "Connection timeout after 10 seconds. Server may be offline or unreachable."
} } catch (e: IOException) {
} _validationError.value = "Network error: ${e.message ?: "Unable to communicate with server"}"
} catch (e: Exception) {
fun clearValidationState() { _validationError.value = "Unexpected error: ${e.javaClass.simpleName} - ${e.message ?: "Unknown cause"}"
_validationResult.value = null }
_validationError.value = null } catch (e: IllegalArgumentException) {
} _validationError.value = "Invalid URL format: ${e.message ?: "Malformed URL"}"
} } catch (e: Exception) {
_validationError.value = "Configuration error: ${e.javaClass.simpleName} - ${e.message ?: "Unable to process URL"}"
} finally {
_isValidating.value = false
}
}
}
fun clearValidationState() {
_validationResult.value = null
_validationError.value = null
}
}
@@ -1,309 +1,317 @@
package com.collabtable.app.ui.screens 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.material3.* import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.runtime.* import androidx.compose.material.icons.filled.LightMode
import androidx.compose.ui.Modifier import androidx.compose.material.icons.filled.SettingsBrightness
import androidx.compose.ui.platform.LocalContext import androidx.compose.material3.*
import androidx.compose.ui.res.stringResource import androidx.compose.material3.FilterChip
import androidx.compose.ui.unit.dp import androidx.compose.runtime.*
import com.collabtable.app.R import androidx.compose.ui.Alignment
import com.collabtable.app.data.database.CollabTableDatabase import androidx.compose.ui.Modifier
import com.collabtable.app.data.api.ApiClient import androidx.compose.ui.platform.LocalContext
import com.collabtable.app.data.preferences.PreferencesManager import androidx.compose.ui.res.stringResource
import com.collabtable.app.data.repository.SyncRepository import androidx.compose.ui.unit.dp
import com.collabtable.app.utils.Logger import com.collabtable.app.R
import androidx.compose.material3.FilterChip import com.collabtable.app.data.api.ApiClient
import androidx.compose.material3.FilterChipDefaults import com.collabtable.app.data.database.CollabTableDatabase
import androidx.compose.material.icons.filled.DarkMode import com.collabtable.app.data.preferences.PreferencesManager
import androidx.compose.material.icons.filled.LightMode import com.collabtable.app.data.repository.SyncRepository
import androidx.compose.material.icons.filled.SettingsBrightness import com.collabtable.app.utils.Logger
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
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class) @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) } val syncRepository = remember { SyncRepository(context) }
val syncRepository = remember { SyncRepository(context) } val coroutineScope = rememberCoroutineScope()
val coroutineScope = rememberCoroutineScope()
val serverUrl = preferencesManager.getServerUrl()
val serverUrl = preferencesManager.getServerUrl() val themeMode by preferencesManager.themeMode.collectAsState()
val themeMode by preferencesManager.themeMode.collectAsState() val dynamicColor by preferencesManager.dynamicColor.collectAsState()
val dynamicColor by preferencesManager.dynamicColor.collectAsState() val amoledDark by preferencesManager.amoledDark.collectAsState()
val amoledDark by preferencesManager.amoledDark.collectAsState() val displayUrl = remember(serverUrl) { formatServerUrlForDisplay(serverUrl) }
val displayUrl = remember(serverUrl) { formatServerUrlForDisplay(serverUrl) } var showLeaveDialog by remember { mutableStateOf(false) }
var showLeaveDialog by remember { mutableStateOf(false) } var isLeaving by remember { mutableStateOf(false) }
var isLeaving by remember { mutableStateOf(false) }
Scaffold(
Scaffold( topBar = {
topBar = { TopAppBar(
TopAppBar( title = { Text(stringResource(R.string.settings)) },
title = { Text(stringResource(R.string.settings)) }, navigationIcon = {
navigationIcon = { IconButton(onClick = onNavigateBack) {
IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, contentDescription = "Back")
Icon(Icons.Default.ArrowBack, contentDescription = "Back") }
} },
}, colors =
colors = TopAppBarDefaults.topAppBarColors( TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer 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 = "Server Connection", Text(
style = MaterialTheme.typography.titleMedium text = "Server Connection",
) style = MaterialTheme.typography.titleMedium,
)
Card(
modifier = Modifier.fillMaxWidth(), Card(
colors = CardDefaults.cardColors( modifier = Modifier.fillMaxWidth(),
containerColor = MaterialTheme.colorScheme.surfaceVariant colors =
) CardDefaults.cardColors(
) { containerColor = MaterialTheme.colorScheme.surfaceVariant,
Column( ),
modifier = Modifier.padding(12.dp), ) {
verticalArrangement = Arrangement.spacedBy(4.dp) Column(
) { modifier = Modifier.padding(12.dp),
Text( verticalArrangement = Arrangement.spacedBy(4.dp),
text = "Connected to", ) {
style = MaterialTheme.typography.labelSmall, Text(
color = MaterialTheme.colorScheme.onSurfaceVariant text = "Connected to",
) style = MaterialTheme.typography.labelSmall,
Text( color = MaterialTheme.colorScheme.onSurfaceVariant,
text = displayUrl, )
style = MaterialTheme.typography.bodyMedium, Text(
color = MaterialTheme.colorScheme.onSurface text = displayUrl,
) style = MaterialTheme.typography.bodyMedium,
} color = MaterialTheme.colorScheme.onSurface,
} )
}
Spacer(modifier = Modifier.height(8.dp)) }
Text( Spacer(modifier = Modifier.height(8.dp))
text = "Appearance",
style = MaterialTheme.typography.titleMedium Text(
) text = "Appearance",
style = MaterialTheme.typography.titleMedium,
// Theme mode selector )
Row(
modifier = Modifier.fillMaxWidth(), // Theme mode selector
horizontalArrangement = Arrangement.spacedBy(8.dp) Row(
) { modifier = Modifier.fillMaxWidth(),
FilterChip( horizontalArrangement = Arrangement.spacedBy(8.dp),
selected = themeMode == PreferencesManager.THEME_MODE_SYSTEM, ) {
onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_SYSTEM) }, FilterChip(
label = { Text("System") }, selected = themeMode == PreferencesManager.THEME_MODE_SYSTEM,
leadingIcon = { Icon(Icons.Default.SettingsBrightness, contentDescription = null) } onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_SYSTEM) },
) label = { Text("System") },
FilterChip( leadingIcon = { Icon(Icons.Default.SettingsBrightness, contentDescription = null) },
selected = themeMode == PreferencesManager.THEME_MODE_LIGHT, )
onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_LIGHT) }, FilterChip(
label = { Text("Light") }, selected = themeMode == PreferencesManager.THEME_MODE_LIGHT,
leadingIcon = { Icon(Icons.Default.LightMode, contentDescription = null) } onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_LIGHT) },
) label = { Text("Light") },
FilterChip( leadingIcon = { Icon(Icons.Default.LightMode, contentDescription = null) },
selected = themeMode == PreferencesManager.THEME_MODE_DARK, )
onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_DARK) }, FilterChip(
label = { Text("Dark") }, selected = themeMode == PreferencesManager.THEME_MODE_DARK,
leadingIcon = { Icon(Icons.Default.DarkMode, contentDescription = null) } onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_DARK) },
) label = { Text("Dark") },
} leadingIcon = { Icon(Icons.Default.DarkMode, contentDescription = null) },
)
// Dynamic color toggle }
Row(
modifier = Modifier.fillMaxWidth(), // Dynamic color toggle
horizontalArrangement = Arrangement.SpaceBetween, Row(
verticalAlignment = Alignment.CenterVertically modifier = Modifier.fillMaxWidth(),
) { horizontalArrangement = Arrangement.SpaceBetween,
Column(modifier = Modifier.weight(1f)) { verticalAlignment = Alignment.CenterVertically,
Text("Use Material 3 colors") ) {
Text( Column(modifier = Modifier.weight(1f)) {
text = "Dynamic colors on supported devices", Text("Use Material 3 colors")
style = MaterialTheme.typography.labelSmall, Text(
color = MaterialTheme.colorScheme.onSurfaceVariant text = "Dynamic colors on supported devices",
) style = MaterialTheme.typography.labelSmall,
} color = MaterialTheme.colorScheme.onSurfaceVariant,
Switch(checked = dynamicColor, onCheckedChange = { preferencesManager.setDynamicColorEnabled(it) }) )
} }
Switch(checked = dynamicColor, onCheckedChange = { preferencesManager.setDynamicColorEnabled(it) })
// AMOLED dark toggle }
Row(
modifier = Modifier.fillMaxWidth(), // AMOLED dark toggle
horizontalArrangement = Arrangement.SpaceBetween, Row(
verticalAlignment = Alignment.CenterVertically modifier = Modifier.fillMaxWidth(),
) { horizontalArrangement = Arrangement.SpaceBetween,
Column(modifier = Modifier.weight(1f)) { verticalAlignment = Alignment.CenterVertically,
Text("AMOLED dark") ) {
Text( Column(modifier = Modifier.weight(1f)) {
text = "Pure black background in dark mode", Text("AMOLED dark")
style = MaterialTheme.typography.labelSmall, Text(
color = MaterialTheme.colorScheme.onSurfaceVariant text = "Pure black background in dark mode",
) style = MaterialTheme.typography.labelSmall,
} color = MaterialTheme.colorScheme.onSurfaceVariant,
Switch(checked = amoledDark, onCheckedChange = { preferencesManager.setAmoledDarkEnabled(it) }) )
} }
Switch(checked = amoledDark, onCheckedChange = { preferencesManager.setAmoledDarkEnabled(it) })
Button( }
onClick = { showLeaveDialog = true },
modifier = Modifier.fillMaxWidth(), Button(
enabled = !isLeaving, onClick = { showLeaveDialog = true },
colors = ButtonDefaults.buttonColors( modifier = Modifier.fillMaxWidth(),
containerColor = MaterialTheme.colorScheme.error, enabled = !isLeaving,
contentColor = MaterialTheme.colorScheme.onError colors =
) ButtonDefaults.buttonColors(
) { containerColor = MaterialTheme.colorScheme.error,
if (isLeaving) { contentColor = MaterialTheme.colorScheme.onError,
CircularProgressIndicator( ),
modifier = Modifier.size(16.dp), ) {
color = MaterialTheme.colorScheme.onError, if (isLeaving) {
strokeWidth = 2.dp CircularProgressIndicator(
) modifier = Modifier.size(16.dp),
Spacer(modifier = Modifier.width(8.dp)) color = MaterialTheme.colorScheme.onError,
Text("Leaving...") strokeWidth = 2.dp,
} else { )
Text("Leave Server") Spacer(modifier = Modifier.width(8.dp))
} Text("Leaving...")
} } else {
Text("Leave Server")
Card( }
modifier = Modifier.fillMaxWidth(), }
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer Card(
) modifier = Modifier.fillMaxWidth(),
) { colors =
Column( CardDefaults.cardColors(
modifier = Modifier.padding(12.dp), containerColor = MaterialTheme.colorScheme.secondaryContainer,
verticalArrangement = Arrangement.spacedBy(4.dp) ),
) { ) {
Text( Column(
text = "Leaving removes local data", modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.labelLarge, verticalArrangement = Arrangement.spacedBy(4.dp),
color = MaterialTheme.colorScheme.onSecondaryContainer ) {
) Text(
Text( text = "Leaving removes local data",
text = "Disconnects and clears local database", style = MaterialTheme.typography.labelLarge,
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSecondaryContainer,
color = MaterialTheme.colorScheme.onSecondaryContainer )
) Text(
Text( text = "Disconnects and clears local database",
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 = "Clears stored server URL and password",
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 = "Deletes all local data (tables, fields, items)",
text = "Returns to server setup screen", style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSecondaryContainer,
color = MaterialTheme.colorScheme.onSecondaryContainer )
) Text(
} text = "Returns to server setup screen",
} style = MaterialTheme.typography.bodySmall,
} color = MaterialTheme.colorScheme.onSecondaryContainer,
)
if (showLeaveDialog) { }
AlertDialog( }
onDismissRequest = { showLeaveDialog = false }, }
title = { Text("Leave Server?") },
text = { if (showLeaveDialog) {
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.") AlertDialog(
}, onDismissRequest = { showLeaveDialog = false },
confirmButton = { title = { Text("Leave Server?") },
TextButton( text = {
onClick = { Text(
isLeaving = true "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.",
coroutineScope.launch { )
try { },
Logger.i("Settings", "Performing final sync before leaving server") confirmButton = {
TextButton(
// Perform final sync to ensure all data is uploaded onClick = {
withContext(Dispatchers.IO) { isLeaving = true
syncRepository.performSync() coroutineScope.launch {
} try {
Logger.i("Settings", "Performing final sync before leaving server")
Logger.i("Settings", "Final sync completed, clearing local data")
// Perform final sync to ensure all data is uploaded
// Clear database in background withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { syncRepository.performSync()
CollabTableDatabase.clearDatabase(context) }
}
Logger.i("Settings", "Final sync completed, clearing local data")
// Clear preferences (URL, password, last sync)
preferencesManager.clearServerSettings() // Clear database in background
preferencesManager.setIsFirstRun(true) withContext(Dispatchers.IO) {
// Clear in-memory logs as part of local data CollabTableDatabase.clearDatabase(context)
Logger.clear() }
// Reset API client base URL to current preference (default)
ApiClient.setBaseUrl(preferencesManager.getServerUrl()) // Clear preferences (URL, password, last sync)
preferencesManager.clearServerSettings()
Logger.i("Settings", "Left server successfully") preferencesManager.setIsFirstRun(true)
// Clear in-memory logs as part of local data
showLeaveDialog = false Logger.clear()
onLeaveServer() // Reset API client base URL to current preference (default)
} catch (e: Exception) { ApiClient.setBaseUrl(preferencesManager.getServerUrl())
// Handle error if needed
Logger.e("Settings", "Error leaving server", e) Logger.i("Settings", "Left server successfully")
isLeaving = false
} showLeaveDialog = false
} onLeaveServer()
}, } catch (e: Exception) {
enabled = !isLeaving, // Handle error if needed
colors = ButtonDefaults.textButtonColors( Logger.e("Settings", "Error leaving server", e)
contentColor = MaterialTheme.colorScheme.error isLeaving = false
) }
) { }
Text("Leave Server") },
} enabled = !isLeaving,
}, colors =
dismissButton = { ButtonDefaults.textButtonColors(
TextButton(onClick = { showLeaveDialog = false }) { contentColor = MaterialTheme.colorScheme.error,
Text("Cancel") ),
} ) {
} Text("Leave Server")
) }
} },
} dismissButton = {
} TextButton(onClick = { showLeaveDialog = false }) {
Text("Cancel")
private fun formatServerUrlForDisplay(raw: String): String { }
var s = raw.trim() },
if (s.isEmpty()) return "" )
// Remove scheme }
s = s.replace(Regex("^https?://", RegexOption.IGNORE_CASE), "") }
// Remove trailing /api or /api/ }
s = s.replace(Regex("/api/?$", RegexOption.IGNORE_CASE), "")
// Trim trailing / private fun formatServerUrlForDisplay(raw: String): String {
s = s.trimEnd('/') var s = raw.trim()
// Split authority and path (in case any path remains) if (s.isEmpty()) return ""
val firstSlash = s.indexOf('/') // Remove scheme
val authority = if (firstSlash >= 0) s.substring(0, firstSlash) else s s = s.replace(Regex("^https?://", RegexOption.IGNORE_CASE), "")
val rest = if (firstSlash >= 0) s.substring(firstSlash) else "" // Remove trailing /api or /api/
// Remove default ports from authority s = s.replace(Regex("/api/?$", RegexOption.IGNORE_CASE), "")
val authorityStripped = when { // Trim trailing /
authority.endsWith(":80") -> authority.removeSuffix(":80") s = s.trimEnd('/')
authority.endsWith(":443") -> authority.removeSuffix(":443") // Split authority and path (in case any path remains)
else -> authority val firstSlash = s.indexOf('/')
} val authority = if (firstSlash >= 0) s.substring(0, firstSlash) else s
return authorityStripped + rest val rest = if (firstSlash >= 0) s.substring(firstSlash) else ""
} // Remove default ports from authority
val authorityStripped =
when {
authority.endsWith(":80") -> authority.removeSuffix(":80")
authority.endsWith(":443") -> authority.removeSuffix(":443")
else -> authority
}
return authorityStripped + rest
}
@@ -1,33 +1,33 @@
package com.collabtable.app.ui.screens 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()
private val _showSuccessMessage = MutableStateFlow(false) private val _showSuccessMessage = MutableStateFlow(false)
val showSuccessMessage: StateFlow<Boolean> = _showSuccessMessage.asStateFlow() val showSuccessMessage: StateFlow<Boolean> = _showSuccessMessage.asStateFlow()
fun updateServerUrl(url: String) { fun updateServerUrl(url: String) {
viewModelScope.launch { viewModelScope.launch {
preferencesManager.setServerUrl(url) preferencesManager.setServerUrl(url)
ApiClient.setBaseUrl(url) ApiClient.setBaseUrl(url)
_serverUrl.value = preferencesManager.getServerUrl() _serverUrl.value = preferencesManager.getServerUrl()
_showSuccessMessage.value = true _showSuccessMessage.value = true
} }
} }
fun clearSuccessMessage() { fun clearSuccessMessage() {
_showSuccessMessage.value = false _showSuccessMessage.value = false
} }
} }
@@ -1,68 +1,72 @@
package com.collabtable.app.ui.theme package com.collabtable.app.ui.theme
import android.app.Activity import android.app.Activity
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext 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(
primary = Purple40, private val LightColorScheme =
secondary = PurpleGrey40, lightColorScheme(
tertiary = Pink40 primary = Purple40,
) secondary = PurpleGrey40,
tertiary = Pink40,
@Composable )
fun CollabTableTheme(
darkTheme: Boolean = isSystemInDarkTheme(), @Composable
dynamicColor: Boolean = true, fun CollabTableTheme(
amoledDark: Boolean = false, darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit dynamicColor: Boolean = true,
) { amoledDark: Boolean = false,
var colorScheme = when { content: @Composable () -> Unit,
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { ) {
val context = LocalContext.current var colorScheme =
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) when {
} dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
darkTheme -> DarkColorScheme val context = LocalContext.current
else -> LightColorScheme if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} }
darkTheme -> DarkColorScheme
if (darkTheme && amoledDark) { else -> LightColorScheme
colorScheme = colorScheme.copy( }
background = Color.Black,
surface = Color.Black, if (darkTheme && amoledDark) {
surfaceVariant = Color.Black colorScheme =
) colorScheme.copy(
} background = Color.Black,
val view = LocalView.current surface = Color.Black,
if (!view.isInEditMode) { surfaceVariant = Color.Black,
SideEffect { )
val window = (view.context as Activity).window }
window.statusBarColor = colorScheme.primary.toArgb() val view = LocalView.current
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme if (!view.isInEditMode) {
} SideEffect {
} val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
MaterialTheme( WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
colorScheme = colorScheme, }
typography = Typography, }
content = content
) MaterialTheme(
} colorScheme = colorScheme,
typography = Typography,
content = content,
)
}
@@ -1,17 +1,19 @@
package com.collabtable.app.ui.theme package com.collabtable.app.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.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,
),
)
@@ -1,72 +1,93 @@
package com.collabtable.app.utils package com.collabtable.app.utils
import android.util.Log import android.util.Log
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 java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
data class LogEntry( 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())
val time = dateFormat.format(Date(timestamp)) val time = dateFormat.format(Date(timestamp))
return "[$time] [${level.name}] [$tag] $message" return "[$time] [${level.name}] [$tag] $message"
} }
} }
enum class LogLevel { enum class LogLevel {
DEBUG, INFO, WARN, ERROR DEBUG,
} INFO,
WARN,
object Logger { ERROR,
private val _logs = MutableStateFlow<List<LogEntry>>(emptyList()) }
val logs: StateFlow<List<LogEntry>> = _logs.asStateFlow()
object Logger {
private const val MAX_LOGS = 500 private val _logs = MutableStateFlow<List<LogEntry>>(emptyList())
val logs: StateFlow<List<LogEntry>> = _logs.asStateFlow()
fun d(tag: String, message: String) {
log(LogLevel.DEBUG, tag, message) private const val MAX_LOGS = 500
Log.d(tag, message)
} fun d(
tag: String,
fun i(tag: String, message: String) { message: String,
log(LogLevel.INFO, tag, message) ) {
Log.i(tag, message) log(LogLevel.DEBUG, tag, message)
} Log.d(tag, message)
}
fun w(tag: String, message: String) {
log(LogLevel.WARN, tag, message) fun i(
Log.w(tag, message) tag: String,
} message: String,
) {
fun e(tag: String, message: String, throwable: Throwable? = null) { log(LogLevel.INFO, tag, message)
val msg = if (throwable != null) "$message: ${throwable.message}" else message Log.i(tag, message)
log(LogLevel.ERROR, tag, msg) }
if (throwable != null) {
Log.e(tag, message, throwable) fun w(
} else { tag: String,
Log.e(tag, message) message: String,
} ) {
} log(LogLevel.WARN, tag, message)
Log.w(tag, message)
private fun log(level: LogLevel, tag: String, message: String) { }
val entry = LogEntry(
timestamp = System.currentTimeMillis(), fun e(
level = level, tag: String,
tag = tag, message: String,
message = message throwable: Throwable? = null,
) ) {
val msg = if (throwable != null) "$message: ${throwable.message}" else message
_logs.value = (_logs.value + entry).takeLast(MAX_LOGS) log(LogLevel.ERROR, tag, msg)
} if (throwable != null) {
Log.e(tag, message, throwable)
fun clear() { } else {
_logs.value = emptyList() Log.e(tag, message)
} }
} }
private fun log(
level: LogLevel,
tag: String,
message: String,
) {
val entry =
LogEntry(
timestamp = System.currentTimeMillis(),
level = level,
tag = tag,
message = message,
)
_logs.value = (_logs.value + entry).takeLast(MAX_LOGS)
}
fun clear() {
_logs.value = emptyList()
}
}