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