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