diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/MainActivity.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/MainActivity.kt index 6dd4a64..fe37e66 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/MainActivity.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/MainActivity.kt @@ -1,41 +1,42 @@ -package com.collabtable.app - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.ui.Modifier -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import com.collabtable.app.data.preferences.PreferencesManager -import com.collabtable.app.ui.navigation.AppNavigation -import com.collabtable.app.ui.theme.CollabTableTheme - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - val prefs = PreferencesManager.getInstance(this) - val themeMode by prefs.themeMode.collectAsState() - val dynamicColor by prefs.dynamicColor.collectAsState() - val amoledDark by prefs.amoledDark.collectAsState() - - val darkTheme = when (themeMode) { - PreferencesManager.THEME_MODE_LIGHT -> false - PreferencesManager.THEME_MODE_DARK -> true - else -> androidx.compose.foundation.isSystemInDarkTheme() - } - - CollabTableTheme(darkTheme = darkTheme, dynamicColor = dynamicColor, amoledDark = amoledDark) { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - AppNavigation() - } - } - } - } -} +package com.collabtable.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.collabtable.app.data.preferences.PreferencesManager +import com.collabtable.app.ui.navigation.AppNavigation +import com.collabtable.app.ui.theme.CollabTableTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + val prefs = PreferencesManager.getInstance(this) + val themeMode by prefs.themeMode.collectAsState() + val dynamicColor by prefs.dynamicColor.collectAsState() + val amoledDark by prefs.amoledDark.collectAsState() + + val darkTheme = + when (themeMode) { + PreferencesManager.THEME_MODE_LIGHT -> false + PreferencesManager.THEME_MODE_DARK -> true + else -> androidx.compose.foundation.isSystemInDarkTheme() + } + + CollabTableTheme(darkTheme = darkTheme, dynamicColor = dynamicColor, amoledDark = amoledDark) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + AppNavigation() + } + } + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt index ae7787c..5b6e7c6 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt @@ -1,73 +1,78 @@ -package com.collabtable.app.data.api - -import android.content.Context -import com.collabtable.app.data.preferences.PreferencesManager -import okhttp3.Interceptor -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory -import java.util.concurrent.TimeUnit - -object ApiClient { - private var baseUrl: String = "http://10.0.2.2:3000/api/" // Default for Android emulator - private var retrofit: Retrofit? = null - private var context: Context? = null - - private val loggingInterceptor = HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY - } - - private val authInterceptor = Interceptor { chain -> - val request = chain.request() - val password = context?.let { - PreferencesManager.getInstance(it).getServerPassword() - } - - val newRequest = if (!password.isNullOrBlank()) { - request.newBuilder() - .header("Authorization", "Bearer $password") - .build() - } else { - request - } - - chain.proceed(newRequest) - } - - private val okHttpClient = OkHttpClient.Builder() - .addInterceptor(authInterceptor) - .addInterceptor(loggingInterceptor) - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - - private fun buildRetrofit(): Retrofit { - return Retrofit.Builder() - .baseUrl(baseUrl) - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create()) - .build() - } - - fun initialize(context: Context) { - this.context = context.applicationContext - val prefs = PreferencesManager.getInstance(context) - baseUrl = prefs.getServerUrl() - retrofit = buildRetrofit() - } - - fun setBaseUrl(url: String) { - baseUrl = if (url.endsWith("/")) url else "$url/" - retrofit = buildRetrofit() - } - - val api: CollabTableApi - get() { - if (retrofit == null) { - retrofit = buildRetrofit() - } - return retrofit!!.create(CollabTableApi::class.java) - } -} +package com.collabtable.app.data.api + +import android.content.Context +import com.collabtable.app.data.preferences.PreferencesManager +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +object ApiClient { + private var baseUrl: String = "http://10.0.2.2:3000/api/" // Default for Android emulator + private var retrofit: Retrofit? = null + private var context: Context? = null + + private val loggingInterceptor = + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + private val authInterceptor = + Interceptor { chain -> + val request = chain.request() + val password = + context?.let { + PreferencesManager.getInstance(it).getServerPassword() + } + + val newRequest = + if (!password.isNullOrBlank()) { + request.newBuilder() + .header("Authorization", "Bearer $password") + .build() + } else { + request + } + + chain.proceed(newRequest) + } + + private val okHttpClient = + OkHttpClient.Builder() + .addInterceptor(authInterceptor) + .addInterceptor(loggingInterceptor) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + private fun buildRetrofit(): Retrofit { + return Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + fun initialize(context: Context) { + this.context = context.applicationContext + val prefs = PreferencesManager.getInstance(context) + baseUrl = prefs.getServerUrl() + retrofit = buildRetrofit() + } + + fun setBaseUrl(url: String) { + baseUrl = if (url.endsWith("/")) url else "$url/" + retrofit = buildRetrofit() + } + + val api: CollabTableApi + get() { + if (retrofit == null) { + retrofit = buildRetrofit() + } + return retrofit!!.create(CollabTableApi::class.java) + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/CollabTableApi.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/CollabTableApi.kt index d551cf2..3e77b48 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/CollabTableApi.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/CollabTableApi.kt @@ -1,30 +1,32 @@ -package com.collabtable.app.data.api - -import com.collabtable.app.data.model.CollabList -import com.collabtable.app.data.model.Field -import com.collabtable.app.data.model.Item -import com.collabtable.app.data.model.ItemValue -import retrofit2.Response -import retrofit2.http.Body -import retrofit2.http.POST - -data class SyncRequest( - val lastSyncTimestamp: Long, - val lists: List, - val fields: List, - val items: List, - val itemValues: List -) - -data class SyncResponse( - val lists: List, - val fields: List, - val items: List, - val itemValues: List, - val serverTimestamp: Long -) - -interface CollabTableApi { - @POST("sync") - suspend fun sync(@Body request: SyncRequest): Response -} +package com.collabtable.app.data.api + +import com.collabtable.app.data.model.CollabList +import com.collabtable.app.data.model.Field +import com.collabtable.app.data.model.Item +import com.collabtable.app.data.model.ItemValue +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST + +data class SyncRequest( + val lastSyncTimestamp: Long, + val lists: List, + val fields: List, + val items: List, + val itemValues: List, +) + +data class SyncResponse( + val lists: List, + val fields: List, + val items: List, + val itemValues: List, + val serverTimestamp: Long, +) + +interface CollabTableApi { + @POST("sync") + suspend fun sync( + @Body request: SyncRequest, + ): Response +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/WebSocketSyncClient.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/WebSocketSyncClient.kt index 90a8777..20915c7 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/WebSocketSyncClient.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/WebSocketSyncClient.kt @@ -1,143 +1,166 @@ -package com.collabtable.app.data.api - -import android.content.Context -import com.collabtable.app.data.preferences.PreferencesManager -import com.collabtable.app.utils.Logger -import com.google.gson.Gson -import com.google.gson.JsonObject -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.WebSocket -import okhttp3.WebSocketListener -import okhttp3.logging.HttpLoggingInterceptor -import java.util.UUID -import java.util.concurrent.TimeUnit - -private data class MessageEnvelope( - val id: String? = null, - val type: String, - val payload: Any? = null -) - -object WebSocketSyncClient { - private val gson = Gson() - - private val logging = HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BASIC - } - - private val client: OkHttpClient = OkHttpClient.Builder() - .addInterceptor(logging) - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .pingInterval(15, TimeUnit.SECONDS) - .build() - - private fun buildWebSocketUrl(httpApiBaseUrl: String): String { - // Expecting something like: http://host:port/api/ - var url = httpApiBaseUrl - // Ensure trailing slash removed for manipulation - if (url.endsWith("/")) url = url.dropLast(1) - - // Replace scheme - url = when { - url.startsWith("https://") -> "wss://" + url.removePrefix("https://") - url.startsWith("http://") -> "ws://" + url.removePrefix("http://") - else -> "ws://$url" // best effort - } - - // Replace trailing /api with /api/ws - return if (url.endsWith("/api")) "$url/ws" else "$url/api/ws" - } - - suspend fun sync(context: Context, request: SyncRequest): Result = withContext(Dispatchers.IO) { - val prefs = PreferencesManager.getInstance(context) - val baseUrl = prefs.getServerUrl() - val password = prefs.getServerPassword() - val wsUrl = buildWebSocketUrl(baseUrl) - val messageId = UUID.randomUUID().toString() - - val deferred = CompletableDeferred>() - var socket: WebSocket? = null - - val httpRequestBuilder = Request.Builder().url(wsUrl) - if (!password.isNullOrBlank()) { - httpRequestBuilder.addHeader("Authorization", "Bearer $password") - } - val httpRequest = httpRequestBuilder.build() - - val listener = object : WebSocketListener() { - override fun onOpen(webSocket: WebSocket, response: Response) { - // Send sync message when opened - val envelope = MessageEnvelope( - id = messageId, - type = "sync", - payload = request - ) - val json = gson.toJson(envelope) - webSocket.send(json) - } - - override fun onMessage(webSocket: WebSocket, text: String) { - try { - val root = gson.fromJson(text, JsonObject::class.java) - val type = root.get("type")?.asString - val id = root.get("id")?.asString - if (type == "syncResponse" && id == messageId) { - val payload = root.getAsJsonObject("payload") - val resp = gson.fromJson(payload, SyncResponse::class.java) - if (!deferred.isCompleted) { - deferred.complete(Result.success(resp)) - webSocket.close(1000, "done") - } - } else if (type == "error" && !deferred.isCompleted) { - val msg = root.getAsJsonObject("payload")?.get("message")?.asString ?: "WebSocket error" - deferred.complete(Result.failure(Exception(msg))) - webSocket.close(1002, "error") - } - } catch (e: Exception) { - if (!deferred.isCompleted) { - deferred.complete(Result.failure(e)) - } - } - } - - override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { - // 1008 indicates policy violation, used here for Unauthorized - if (!deferred.isCompleted) { - if (code == 1008) { - deferred.complete(Result.failure(Exception("Unauthorized (WS 1008)"))) - } else { - deferred.complete(Result.failure(Exception("WebSocket closing ($code): $reason"))) - } - } - } - - override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - if (!deferred.isCompleted) { - deferred.complete(Result.failure(t)) - } - } - } - - try { - socket = client.newWebSocket(httpRequest, listener) - // Wait up to 30s for response (WS roundtrip) - val result = withTimeout(30_000) { deferred.await() } - return@withContext result - } catch (e: Exception) { - Logger.w("WS", "Falling back to HTTP sync: ${e.message}") - return@withContext Result.failure(e) - } finally { - // OkHttp cleans up sockets automatically when no references remain - // but we try to close if still open - socket?.close(1000, null) - } - } -} +package com.collabtable.app.data.api + +import android.content.Context +import com.collabtable.app.data.preferences.PreferencesManager +import com.collabtable.app.utils.Logger +import com.google.gson.Gson +import com.google.gson.JsonObject +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.logging.HttpLoggingInterceptor +import java.util.UUID +import java.util.concurrent.TimeUnit + +private data class MessageEnvelope( + val id: String? = null, + val type: String, + val payload: Any? = null, +) + +object WebSocketSyncClient { + private val gson = Gson() + + private val logging = + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BASIC + } + + private val client: OkHttpClient = + OkHttpClient.Builder() + .addInterceptor(logging) + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .pingInterval(15, TimeUnit.SECONDS) + .build() + + private fun buildWebSocketUrl(httpApiBaseUrl: String): String { + // Expecting something like: http://host:port/api/ + var url = httpApiBaseUrl + // Ensure trailing slash removed for manipulation + if (url.endsWith("/")) url = url.dropLast(1) + + // Replace scheme + url = + when { + url.startsWith("https://") -> "wss://" + url.removePrefix("https://") + url.startsWith("http://") -> "ws://" + url.removePrefix("http://") + else -> "ws://$url" // best effort + } + + // Replace trailing /api with /api/ws + return if (url.endsWith("/api")) "$url/ws" else "$url/api/ws" + } + + suspend fun sync( + context: Context, + request: SyncRequest, + ): Result = + withContext(Dispatchers.IO) { + val prefs = PreferencesManager.getInstance(context) + val baseUrl = prefs.getServerUrl() + val password = prefs.getServerPassword() + val wsUrl = buildWebSocketUrl(baseUrl) + val messageId = UUID.randomUUID().toString() + + val deferred = CompletableDeferred>() + var socket: WebSocket? = null + + val httpRequestBuilder = Request.Builder().url(wsUrl) + if (!password.isNullOrBlank()) { + httpRequestBuilder.addHeader("Authorization", "Bearer $password") + } + val httpRequest = httpRequestBuilder.build() + + val listener = + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + // Send sync message when opened + val envelope = + MessageEnvelope( + id = messageId, + type = "sync", + payload = request, + ) + val json = gson.toJson(envelope) + webSocket.send(json) + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + try { + val root = gson.fromJson(text, JsonObject::class.java) + val type = root.get("type")?.asString + val id = root.get("id")?.asString + if (type == "syncResponse" && id == messageId) { + val payload = root.getAsJsonObject("payload") + val resp = gson.fromJson(payload, SyncResponse::class.java) + if (!deferred.isCompleted) { + deferred.complete(Result.success(resp)) + webSocket.close(1000, "done") + } + } else if (type == "error" && !deferred.isCompleted) { + val msg = root.getAsJsonObject("payload")?.get("message")?.asString ?: "WebSocket error" + deferred.complete(Result.failure(Exception(msg))) + webSocket.close(1002, "error") + } + } catch (e: Exception) { + if (!deferred.isCompleted) { + deferred.complete(Result.failure(e)) + } + } + } + + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + // 1008 indicates policy violation, used here for Unauthorized + if (!deferred.isCompleted) { + if (code == 1008) { + deferred.complete(Result.failure(Exception("Unauthorized (WS 1008)"))) + } else { + deferred.complete(Result.failure(Exception("WebSocket closing ($code): $reason"))) + } + } + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + if (!deferred.isCompleted) { + deferred.complete(Result.failure(t)) + } + } + } + + try { + socket = client.newWebSocket(httpRequest, listener) + // Wait up to 30s for response (WS roundtrip) + val result = withTimeout(30_000) { deferred.await() } + return@withContext result + } catch (e: Exception) { + Logger.w("WS", "Falling back to HTTP sync: ${e.message}") + return@withContext Result.failure(e) + } finally { + // OkHttp cleans up sockets automatically when no references remain + // but we try to close if still open + socket?.close(1000, null) + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/FieldDao.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/FieldDao.kt index 42fe727..ef52f6e 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/FieldDao.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/FieldDao.kt @@ -1,49 +1,55 @@ -package com.collabtable.app.data.dao - -import androidx.room.* -import com.collabtable.app.data.model.Field -import kotlinx.coroutines.flow.Flow - -@Dao -interface FieldDao { - @Query("SELECT * FROM fields WHERE listId = :listId AND isDeleted = 0 ORDER BY `order`") - fun getFieldsForList(listId: String): Flow> - - @Query("SELECT * FROM fields WHERE id = :fieldId") - suspend fun getFieldById(fieldId: String): Field? - - @Query("SELECT id FROM fields") - suspend fun getAllFieldIds(): List - - @Upsert - suspend fun insertField(field: Field) - - @Upsert - suspend fun insertFields(fields: List) - - @Update - suspend fun updateField(field: Field) - - @Query("UPDATE fields SET isDeleted = 1, updatedAt = :timestamp WHERE id = :fieldId") - suspend fun softDeleteField(fieldId: String, timestamp: Long) - - @Query("DELETE FROM fields WHERE id = :fieldId") - suspend fun deleteField(fieldId: String) - - // Get all fields updated since timestamp, including deleted ones for sync - @Query("SELECT * FROM fields WHERE updatedAt >= :since") - suspend fun getFieldsUpdatedSince(since: Long): List - - // Reorder fields in a single transaction for clarity and consistency - @Transaction - suspend fun reorderFieldsInTransaction(reorderedFields: List, timestamp: Long) { - reorderedFields.forEachIndexed { index, field -> - updateField( - field.copy( - order = index, - updatedAt = timestamp - ) - ) - } - } -} +package com.collabtable.app.data.dao + +import androidx.room.* +import com.collabtable.app.data.model.Field +import kotlinx.coroutines.flow.Flow + +@Dao +interface FieldDao { + @Query("SELECT * FROM fields WHERE listId = :listId AND isDeleted = 0 ORDER BY `order`") + fun getFieldsForList(listId: String): Flow> + + @Query("SELECT * FROM fields WHERE id = :fieldId") + suspend fun getFieldById(fieldId: String): Field? + + @Query("SELECT id FROM fields") + suspend fun getAllFieldIds(): List + + @Upsert + suspend fun insertField(field: Field) + + @Upsert + suspend fun insertFields(fields: List) + + @Update + suspend fun updateField(field: Field) + + @Query("UPDATE fields SET isDeleted = 1, updatedAt = :timestamp WHERE id = :fieldId") + suspend fun softDeleteField( + fieldId: String, + timestamp: Long, + ) + + @Query("DELETE FROM fields WHERE id = :fieldId") + suspend fun deleteField(fieldId: String) + + // Get all fields updated since timestamp, including deleted ones for sync + @Query("SELECT * FROM fields WHERE updatedAt >= :since") + suspend fun getFieldsUpdatedSince(since: Long): List + + // Reorder fields in a single transaction for clarity and consistency + @Transaction + suspend fun reorderFieldsInTransaction( + reorderedFields: List, + timestamp: Long, + ) { + reorderedFields.forEachIndexed { index, field -> + updateField( + field.copy( + order = index, + updatedAt = timestamp, + ), + ) + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ItemDao.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ItemDao.kt index e074c59..ab14ea6 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ItemDao.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ItemDao.kt @@ -1,41 +1,44 @@ -package com.collabtable.app.data.dao - -import androidx.room.* -import com.collabtable.app.data.model.Item -import com.collabtable.app.data.model.ItemWithValues -import kotlinx.coroutines.flow.Flow - -@Dao -interface ItemDao { - @Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY updatedAt DESC") - fun getItemsForList(listId: String): Flow> - - @Transaction - @Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY updatedAt DESC") - fun getItemsWithValuesForList(listId: String): Flow> - - @Query("SELECT * FROM items WHERE id = :itemId") - suspend fun getItemById(itemId: String): Item? - - @Query("SELECT id FROM items") - suspend fun getAllItemIds(): List - - @Upsert - suspend fun insertItem(item: Item) - - @Upsert - suspend fun insertItems(items: List) - - @Update - suspend fun updateItem(item: Item) - - @Query("UPDATE items SET isDeleted = 1, updatedAt = :timestamp WHERE id = :itemId") - suspend fun softDeleteItem(itemId: String, timestamp: Long) - - @Query("DELETE FROM items WHERE id = :itemId") - suspend fun deleteItem(itemId: String) - - // Get all items updated since timestamp, including deleted ones for sync - @Query("SELECT * FROM items WHERE updatedAt >= :since") - suspend fun getItemsUpdatedSince(since: Long): List -} +package com.collabtable.app.data.dao + +import androidx.room.* +import com.collabtable.app.data.model.Item +import com.collabtable.app.data.model.ItemWithValues +import kotlinx.coroutines.flow.Flow + +@Dao +interface ItemDao { + @Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY updatedAt DESC") + fun getItemsForList(listId: String): Flow> + + @Transaction + @Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY updatedAt DESC") + fun getItemsWithValuesForList(listId: String): Flow> + + @Query("SELECT * FROM items WHERE id = :itemId") + suspend fun getItemById(itemId: String): Item? + + @Query("SELECT id FROM items") + suspend fun getAllItemIds(): List + + @Upsert + suspend fun insertItem(item: Item) + + @Upsert + suspend fun insertItems(items: List) + + @Update + suspend fun updateItem(item: Item) + + @Query("UPDATE items SET isDeleted = 1, updatedAt = :timestamp WHERE id = :itemId") + suspend fun softDeleteItem( + itemId: String, + timestamp: Long, + ) + + @Query("DELETE FROM items WHERE id = :itemId") + suspend fun deleteItem(itemId: String) + + // Get all items updated since timestamp, including deleted ones for sync + @Query("SELECT * FROM items WHERE updatedAt >= :since") + suspend fun getItemsUpdatedSince(since: Long): List +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ListDao.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ListDao.kt index 6456c5f..876d4c2 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ListDao.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ListDao.kt @@ -1,41 +1,44 @@ -package com.collabtable.app.data.dao - -import androidx.room.* -import com.collabtable.app.data.model.CollabList -import com.collabtable.app.data.model.ListWithFields -import kotlinx.coroutines.flow.Flow - -@Dao -interface ListDao { - @Query("SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC") - fun getAllLists(): Flow> - - @Transaction - @Query("SELECT * FROM lists WHERE id = :listId AND isDeleted = 0") - fun getListWithFields(listId: String): Flow - - @Query("SELECT * FROM lists WHERE id = :listId") - suspend fun getListById(listId: String): CollabList? - - @Query("SELECT id FROM lists") - suspend fun getAllListIds(): List - - @Upsert - suspend fun insertList(list: CollabList) - - @Upsert - suspend fun insertLists(lists: List) - - @Update - suspend fun updateList(list: CollabList) - - @Query("UPDATE lists SET isDeleted = 1, updatedAt = :timestamp WHERE id = :listId") - suspend fun softDeleteList(listId: String, timestamp: Long) - - @Query("DELETE FROM lists WHERE id = :listId") - suspend fun deleteList(listId: String) - - // Get all lists updated since timestamp, including deleted ones for sync - @Query("SELECT * FROM lists WHERE updatedAt >= :since") - suspend fun getListsUpdatedSince(since: Long): List -} +package com.collabtable.app.data.dao + +import androidx.room.* +import com.collabtable.app.data.model.CollabList +import com.collabtable.app.data.model.ListWithFields +import kotlinx.coroutines.flow.Flow + +@Dao +interface ListDao { + @Query("SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC") + fun getAllLists(): Flow> + + @Transaction + @Query("SELECT * FROM lists WHERE id = :listId AND isDeleted = 0") + fun getListWithFields(listId: String): Flow + + @Query("SELECT * FROM lists WHERE id = :listId") + suspend fun getListById(listId: String): CollabList? + + @Query("SELECT id FROM lists") + suspend fun getAllListIds(): List + + @Upsert + suspend fun insertList(list: CollabList) + + @Upsert + suspend fun insertLists(lists: List) + + @Update + suspend fun updateList(list: CollabList) + + @Query("UPDATE lists SET isDeleted = 1, updatedAt = :timestamp WHERE id = :listId") + suspend fun softDeleteList( + listId: String, + timestamp: Long, + ) + + @Query("DELETE FROM lists WHERE id = :listId") + suspend fun deleteList(listId: String) + + // Get all lists updated since timestamp, including deleted ones for sync + @Query("SELECT * FROM lists WHERE updatedAt >= :since") + suspend fun getListsUpdatedSince(since: Long): List +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt index 11b331a..ea7313c 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt @@ -1,61 +1,66 @@ -package com.collabtable.app.data.database - -import android.content.Context -import androidx.room.Database -import androidx.room.Room -import androidx.room.RoomDatabase -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase -import com.collabtable.app.data.dao.* -import com.collabtable.app.data.model.* - -val MIGRATION_1_2 = object : Migration(1, 2) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE fields ADD COLUMN fieldType TEXT NOT NULL DEFAULT 'STRING'") - database.execSQL("ALTER TABLE fields ADD COLUMN fieldOptions TEXT NOT NULL DEFAULT ''") - } -} - -@Database( - entities = [ - CollabList::class, - Field::class, - Item::class, - ItemValue::class - ], - version = 2, - exportSchema = false -) -abstract class CollabTableDatabase : RoomDatabase() { - abstract fun listDao(): ListDao - abstract fun fieldDao(): FieldDao - abstract fun itemDao(): ItemDao - abstract fun itemValueDao(): ItemValueDao - - companion object { - @Volatile - private var INSTANCE: CollabTableDatabase? = null - - fun getDatabase(context: Context): CollabTableDatabase { - return INSTANCE ?: synchronized(this) { - val instance = Room.databaseBuilder( - context.applicationContext, - CollabTableDatabase::class.java, - "collab_table_database" - ) - .addMigrations(MIGRATION_1_2) - .build() - INSTANCE = instance - instance - } - } - - fun clearDatabase(context: Context) { - synchronized(this) { - INSTANCE?.close() - context.deleteDatabase("collab_table_database") - INSTANCE = null - } - } - } -} +package com.collabtable.app.data.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.collabtable.app.data.dao.* +import com.collabtable.app.data.model.* + +val MIGRATION_1_2 = + object : Migration(1, 2) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE fields ADD COLUMN fieldType TEXT NOT NULL DEFAULT 'STRING'") + database.execSQL("ALTER TABLE fields ADD COLUMN fieldOptions TEXT NOT NULL DEFAULT ''") + } + } + +@Database( + entities = [ + CollabList::class, + Field::class, + Item::class, + ItemValue::class, + ], + version = 2, + exportSchema = false, +) +abstract class CollabTableDatabase : RoomDatabase() { + abstract fun listDao(): ListDao + + abstract fun fieldDao(): FieldDao + + abstract fun itemDao(): ItemDao + + abstract fun itemValueDao(): ItemValueDao + + companion object { + @Volatile + private var INSTANCE: CollabTableDatabase? = null + + fun getDatabase(context: Context): CollabTableDatabase { + return INSTANCE ?: synchronized(this) { + val instance = + Room.databaseBuilder( + context.applicationContext, + CollabTableDatabase::class.java, + "collab_table_database", + ) + .addMigrations(MIGRATION_1_2) + .build() + INSTANCE = instance + instance + } + } + + fun clearDatabase(context: Context) { + synchronized(this) { + INSTANCE?.close() + context.deleteDatabase("collab_table_database") + INSTANCE = null + } + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/CollabList.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/CollabList.kt index c394786..dcb68e8 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/CollabList.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/CollabList.kt @@ -1,13 +1,13 @@ -package com.collabtable.app.data.model - -import androidx.room.Entity -import androidx.room.PrimaryKey - -@Entity(tableName = "lists") -data class CollabList( - @PrimaryKey val id: String, - val name: String, - val createdAt: Long, - val updatedAt: Long, - val isDeleted: Boolean = false -) +package com.collabtable.app.data.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "lists") +data class CollabList( + @PrimaryKey val id: String, + val name: String, + val createdAt: Long, + val updatedAt: Long, + val isDeleted: Boolean = false, +) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Field.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Field.kt index 69ed842..dcf29be 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Field.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Field.kt @@ -1,116 +1,116 @@ -package com.collabtable.app.data.model - -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.Index -import androidx.room.PrimaryKey - -enum class FieldType { - // Text types - TEXT, - MULTILINE_TEXT, - - // Number types - NUMBER, - CURRENCY, - PERCENTAGE, - - // Selection types - DROPDOWN, - AUTOCOMPLETE, - CHECKBOX, - SWITCH, - - // Link types - URL, - EMAIL, - PHONE, - - // Date/Time types - DATE, - TIME, - DATETIME, - DURATION, - - // Media types - IMAGE, - FILE, - BARCODE, - SIGNATURE, - - // Other types - RATING, - COLOR, - LOCATION -} - -@Entity( - tableName = "fields", - foreignKeys = [ - ForeignKey( - entity = CollabList::class, - parentColumns = ["id"], - childColumns = ["listId"], - onDelete = ForeignKey.CASCADE - ) - ], - indices = [Index("listId")] -) -data class Field( - @PrimaryKey val id: String, - val listId: String, - val name: String, - val fieldType: String = "TEXT", - val fieldOptions: String = "", // JSON string for dropdown options, currency symbol, etc. - val order: Int, - val createdAt: Long, - val updatedAt: Long, - val isDeleted: Boolean = false -) { - fun getType(): FieldType { - return try { - FieldType.valueOf(fieldType) - } catch (e: Exception) { - // Handle legacy field types - when (fieldType) { - "STRING" -> FieldType.TEXT - "PRICE" -> FieldType.CURRENCY - "AMOUNT" -> FieldType.NUMBER - "SIZE" -> FieldType.TEXT - else -> FieldType.TEXT - } - } - } - - fun getDropdownOptions(): List { - return if (fieldType == "DROPDOWN" && fieldOptions.isNotBlank()) { - fieldOptions.split("|") - } else { - emptyList() - } - } - - fun getCurrency(): String { - return if ((fieldType == "CURRENCY" || fieldType == "PRICE") && fieldOptions.isNotBlank()) { - fieldOptions - } else { - "CHF" - } - } - - fun getMaxRating(): Int { - return if (fieldType == "RATING" && fieldOptions.isNotBlank()) { - fieldOptions.toIntOrNull() ?: 5 - } else { - 5 - } - } - - fun getAutocompleteOptions(): List { - return if (fieldType == "AUTOCOMPLETE" && fieldOptions.isNotBlank()) { - fieldOptions.split("|") - } else { - emptyList() - } - } -} +package com.collabtable.app.data.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +enum class FieldType { + // Text types + TEXT, + MULTILINE_TEXT, + + // Number types + NUMBER, + CURRENCY, + PERCENTAGE, + + // Selection types + DROPDOWN, + AUTOCOMPLETE, + CHECKBOX, + SWITCH, + + // Link types + URL, + EMAIL, + PHONE, + + // Date/Time types + DATE, + TIME, + DATETIME, + DURATION, + + // Media types + IMAGE, + FILE, + BARCODE, + SIGNATURE, + + // Other types + RATING, + COLOR, + LOCATION, +} + +@Entity( + tableName = "fields", + foreignKeys = [ + ForeignKey( + entity = CollabList::class, + parentColumns = ["id"], + childColumns = ["listId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("listId")], +) +data class Field( + @PrimaryKey val id: String, + val listId: String, + val name: String, + val fieldType: String = "TEXT", + val fieldOptions: String = "", // JSON string for dropdown options, currency symbol, etc. + val order: Int, + val createdAt: Long, + val updatedAt: Long, + val isDeleted: Boolean = false, +) { + fun getType(): FieldType { + return try { + FieldType.valueOf(fieldType) + } catch (e: Exception) { + // Handle legacy field types + when (fieldType) { + "STRING" -> FieldType.TEXT + "PRICE" -> FieldType.CURRENCY + "AMOUNT" -> FieldType.NUMBER + "SIZE" -> FieldType.TEXT + else -> FieldType.TEXT + } + } + } + + fun getDropdownOptions(): List { + return if (fieldType == "DROPDOWN" && fieldOptions.isNotBlank()) { + fieldOptions.split("|") + } else { + emptyList() + } + } + + fun getCurrency(): String { + return if ((fieldType == "CURRENCY" || fieldType == "PRICE") && fieldOptions.isNotBlank()) { + fieldOptions + } else { + "CHF" + } + } + + fun getMaxRating(): Int { + return if (fieldType == "RATING" && fieldOptions.isNotBlank()) { + fieldOptions.toIntOrNull() ?: 5 + } else { + 5 + } + } + + fun getAutocompleteOptions(): List { + return if (fieldType == "AUTOCOMPLETE" && fieldOptions.isNotBlank()) { + fieldOptions.split("|") + } else { + emptyList() + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Item.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Item.kt index 3277cc8..8950ac8 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Item.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Item.kt @@ -1,26 +1,26 @@ -package com.collabtable.app.data.model - -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.Index -import androidx.room.PrimaryKey - -@Entity( - tableName = "items", - foreignKeys = [ - ForeignKey( - entity = CollabList::class, - parentColumns = ["id"], - childColumns = ["listId"], - onDelete = ForeignKey.CASCADE - ) - ], - indices = [Index("listId")] -) -data class Item( - @PrimaryKey val id: String, - val listId: String, - val createdAt: Long, - val updatedAt: Long, - val isDeleted: Boolean = false -) +package com.collabtable.app.data.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "items", + foreignKeys = [ + ForeignKey( + entity = CollabList::class, + parentColumns = ["id"], + childColumns = ["listId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("listId")], +) +data class Item( + @PrimaryKey val id: String, + val listId: String, + val createdAt: Long, + val updatedAt: Long, + val isDeleted: Boolean = false, +) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ItemValue.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ItemValue.kt index a43dcd3..99c7ed9 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ItemValue.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ItemValue.kt @@ -1,32 +1,32 @@ -package com.collabtable.app.data.model - -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.Index -import androidx.room.PrimaryKey - -@Entity( - tableName = "item_values", - foreignKeys = [ - ForeignKey( - entity = Item::class, - parentColumns = ["id"], - childColumns = ["itemId"], - onDelete = ForeignKey.CASCADE - ), - ForeignKey( - entity = Field::class, - parentColumns = ["id"], - childColumns = ["fieldId"], - onDelete = ForeignKey.CASCADE - ) - ], - indices = [Index("itemId"), Index("fieldId")] -) -data class ItemValue( - @PrimaryKey val id: String, - val itemId: String, - val fieldId: String, - val value: String, - val updatedAt: Long -) +package com.collabtable.app.data.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "item_values", + foreignKeys = [ + ForeignKey( + entity = Item::class, + parentColumns = ["id"], + childColumns = ["itemId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = Field::class, + parentColumns = ["id"], + childColumns = ["fieldId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("itemId"), Index("fieldId")], +) +data class ItemValue( + @PrimaryKey val id: String, + val itemId: String, + val fieldId: String, + val value: String, + val updatedAt: Long, +) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ItemWithValues.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ItemWithValues.kt index 4399122..bdc1247 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ItemWithValues.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ItemWithValues.kt @@ -1,13 +1,13 @@ -package com.collabtable.app.data.model - -import androidx.room.Embedded -import androidx.room.Relation - -data class ItemWithValues( - @Embedded val item: Item, - @Relation( - parentColumn = "id", - entityColumn = "itemId" - ) - val values: List -) +package com.collabtable.app.data.model + +import androidx.room.Embedded +import androidx.room.Relation + +data class ItemWithValues( + @Embedded val item: Item, + @Relation( + parentColumn = "id", + entityColumn = "itemId", + ) + val values: List, +) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ListWithFields.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ListWithFields.kt index 26a2181..37165d6 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ListWithFields.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ListWithFields.kt @@ -1,13 +1,13 @@ -package com.collabtable.app.data.model - -import androidx.room.Embedded -import androidx.room.Relation - -data class ListWithFields( - @Embedded val list: CollabList, - @Relation( - parentColumn = "id", - entityColumn = "listId" - ) - val fields: List -) +package com.collabtable.app.data.model + +import androidx.room.Embedded +import androidx.room.Relation + +data class ListWithFields( + @Embedded val list: CollabList, + @Relation( + parentColumn = "id", + entityColumn = "listId", + ) + val fields: List, +) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt index 2d77cc7..6e9e4f1 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt @@ -1,130 +1,131 @@ -package com.collabtable.app.data.preferences - -import android.content.Context -import android.content.SharedPreferences -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -class PreferencesManager(context: Context) { - private val prefs: SharedPreferences = - context.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE) - - private val _serverUrl = MutableStateFlow(getServerUrl()) - val serverUrl: StateFlow = _serverUrl.asStateFlow() - - private val _themeMode = MutableStateFlow(getThemeMode()) - val themeMode: StateFlow = _themeMode.asStateFlow() - - private val _dynamicColor = MutableStateFlow(isDynamicColorEnabled()) - val dynamicColor: StateFlow = _dynamicColor.asStateFlow() - - private val _amoledDark = MutableStateFlow(isAmoledDarkEnabled()) - val amoledDark: StateFlow = _amoledDark.asStateFlow() - - fun getServerUrl(): String { - return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL - } - - fun setServerUrl(url: String) { - val raw = url.trim() - if (raw.isBlank()) { - // Remove the key entirely to fall back to default URL on reads - prefs.edit().remove(KEY_SERVER_URL).apply() - _serverUrl.value = getServerUrl() - return - } - val cleanUrl = raw.let { if (it.endsWith("/")) it else "$it/" } - prefs.edit().putString(KEY_SERVER_URL, cleanUrl).apply() - _serverUrl.value = cleanUrl - } - - fun isFirstRun(): Boolean { - return prefs.getBoolean(KEY_FIRST_RUN, true) - } - - fun setIsFirstRun(isFirstRun: Boolean) { - prefs.edit().putBoolean(KEY_FIRST_RUN, isFirstRun).apply() - } - - fun getServerPassword(): String? { - return prefs.getString(KEY_SERVER_PASSWORD, null) - } - - fun setServerPassword(password: String) { - if (password.isBlank()) { - prefs.edit().remove(KEY_SERVER_PASSWORD).apply() - } else { - prefs.edit().putString(KEY_SERVER_PASSWORD, password).apply() - } - } - - // Clear sync-related state so the next sync starts from scratch - fun clearSyncState() { - prefs.edit().remove(KEY_LAST_SYNC_TIMESTAMP).apply() - } - - fun clearServerSettings() { - prefs.edit() - .remove(KEY_SERVER_URL) - .remove(KEY_SERVER_PASSWORD) - .remove(KEY_LAST_SYNC_TIMESTAMP) - .apply() - _serverUrl.value = getServerUrl() - } - - // Theme settings - fun getThemeMode(): String { - return prefs.getString(KEY_THEME_MODE, THEME_MODE_SYSTEM) ?: THEME_MODE_SYSTEM - } - - fun setThemeMode(mode: String) { - val normalized = when (mode.lowercase()) { - THEME_MODE_LIGHT, THEME_MODE_DARK, THEME_MODE_SYSTEM -> mode.lowercase() - else -> THEME_MODE_SYSTEM - } - prefs.edit().putString(KEY_THEME_MODE, normalized).apply() - _themeMode.value = normalized - } - - fun isDynamicColorEnabled(): Boolean { - return prefs.getBoolean(KEY_DYNAMIC_COLOR, true) - } - - fun setDynamicColorEnabled(enabled: Boolean) { - prefs.edit().putBoolean(KEY_DYNAMIC_COLOR, enabled).apply() - _dynamicColor.value = enabled - } - - fun isAmoledDarkEnabled(): Boolean { - return prefs.getBoolean(KEY_AMOLED_DARK, false) - } - - fun setAmoledDarkEnabled(enabled: Boolean) { - prefs.edit().putBoolean(KEY_AMOLED_DARK, enabled).apply() - _amoledDark.value = enabled - } - - companion object { - private const val KEY_SERVER_URL = "server_url" - private const val KEY_FIRST_RUN = "first_run" - private const val KEY_SERVER_PASSWORD = "server_password" - private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp" - private const val KEY_THEME_MODE = "theme_mode" // system|light|dark - private const val KEY_DYNAMIC_COLOR = "dynamic_color" - private const val KEY_AMOLED_DARK = "amoled_dark" - private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/" - const val THEME_MODE_SYSTEM = "system" - const val THEME_MODE_LIGHT = "light" - const val THEME_MODE_DARK = "dark" - - @Volatile - private var instance: PreferencesManager? = null - - fun getInstance(context: Context): PreferencesManager { - return instance ?: synchronized(this) { - instance ?: PreferencesManager(context.applicationContext).also { instance = it } - } - } - } -} +package com.collabtable.app.data.preferences + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class PreferencesManager(context: Context) { + private val prefs: SharedPreferences = + context.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE) + + private val _serverUrl = MutableStateFlow(getServerUrl()) + val serverUrl: StateFlow = _serverUrl.asStateFlow() + + private val _themeMode = MutableStateFlow(getThemeMode()) + val themeMode: StateFlow = _themeMode.asStateFlow() + + private val _dynamicColor = MutableStateFlow(isDynamicColorEnabled()) + val dynamicColor: StateFlow = _dynamicColor.asStateFlow() + + private val _amoledDark = MutableStateFlow(isAmoledDarkEnabled()) + val amoledDark: StateFlow = _amoledDark.asStateFlow() + + fun getServerUrl(): String { + return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL + } + + fun setServerUrl(url: String) { + val raw = url.trim() + if (raw.isBlank()) { + // Remove the key entirely to fall back to default URL on reads + prefs.edit().remove(KEY_SERVER_URL).apply() + _serverUrl.value = getServerUrl() + return + } + val cleanUrl = raw.let { if (it.endsWith("/")) it else "$it/" } + prefs.edit().putString(KEY_SERVER_URL, cleanUrl).apply() + _serverUrl.value = cleanUrl + } + + fun isFirstRun(): Boolean { + return prefs.getBoolean(KEY_FIRST_RUN, true) + } + + fun setIsFirstRun(isFirstRun: Boolean) { + prefs.edit().putBoolean(KEY_FIRST_RUN, isFirstRun).apply() + } + + fun getServerPassword(): String? { + return prefs.getString(KEY_SERVER_PASSWORD, null) + } + + fun setServerPassword(password: String) { + if (password.isBlank()) { + prefs.edit().remove(KEY_SERVER_PASSWORD).apply() + } else { + prefs.edit().putString(KEY_SERVER_PASSWORD, password).apply() + } + } + + // Clear sync-related state so the next sync starts from scratch + fun clearSyncState() { + prefs.edit().remove(KEY_LAST_SYNC_TIMESTAMP).apply() + } + + fun clearServerSettings() { + prefs.edit() + .remove(KEY_SERVER_URL) + .remove(KEY_SERVER_PASSWORD) + .remove(KEY_LAST_SYNC_TIMESTAMP) + .apply() + _serverUrl.value = getServerUrl() + } + + // Theme settings + fun getThemeMode(): String { + return prefs.getString(KEY_THEME_MODE, THEME_MODE_SYSTEM) ?: THEME_MODE_SYSTEM + } + + fun setThemeMode(mode: String) { + val normalized = + when (mode.lowercase()) { + THEME_MODE_LIGHT, THEME_MODE_DARK, THEME_MODE_SYSTEM -> mode.lowercase() + else -> THEME_MODE_SYSTEM + } + prefs.edit().putString(KEY_THEME_MODE, normalized).apply() + _themeMode.value = normalized + } + + fun isDynamicColorEnabled(): Boolean { + return prefs.getBoolean(KEY_DYNAMIC_COLOR, true) + } + + fun setDynamicColorEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_DYNAMIC_COLOR, enabled).apply() + _dynamicColor.value = enabled + } + + fun isAmoledDarkEnabled(): Boolean { + return prefs.getBoolean(KEY_AMOLED_DARK, false) + } + + fun setAmoledDarkEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_AMOLED_DARK, enabled).apply() + _amoledDark.value = enabled + } + + companion object { + private const val KEY_SERVER_URL = "server_url" + private const val KEY_FIRST_RUN = "first_run" + private const val KEY_SERVER_PASSWORD = "server_password" + private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp" + private const val KEY_THEME_MODE = "theme_mode" // system|light|dark + private const val KEY_DYNAMIC_COLOR = "dynamic_color" + private const val KEY_AMOLED_DARK = "amoled_dark" + private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/" + const val THEME_MODE_SYSTEM = "system" + const val THEME_MODE_LIGHT = "light" + const val THEME_MODE_DARK = "dark" + + @Volatile + private var instance: PreferencesManager? = null + + fun getInstance(context: Context): PreferencesManager { + return instance ?: synchronized(this) { + instance ?: PreferencesManager(context.applicationContext).also { instance = it } + } + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt index 0bf493e..45af0f6 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt @@ -1,146 +1,150 @@ -package com.collabtable.app.data.repository - -import android.content.Context -import com.collabtable.app.data.api.ApiClient -import com.collabtable.app.data.api.WebSocketSyncClient -import com.collabtable.app.data.api.SyncRequest -import com.collabtable.app.data.database.CollabTableDatabase -import com.collabtable.app.utils.Logger -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import androidx.room.withTransaction - -class SyncRepository(context: Context) { - private val appContext = context.applicationContext - private val database = CollabTableDatabase.getDatabase(appContext) - private val api = ApiClient.api - private val prefs = appContext.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE) - - private fun getLastSyncTimestamp(): Long { - return prefs.getLong("last_sync_timestamp", 0) - } - - private fun setLastSyncTimestamp(timestamp: Long) { - prefs.edit().putLong("last_sync_timestamp", timestamp).apply() - } - - suspend fun performSync(): Result = withContext(Dispatchers.IO) { - try { - val lastSync = getLastSyncTimestamp() - val isInitialSync = lastSync == 0L - if (isInitialSync) { - Logger.i("Sync", "[SYNC] Starting initial sync with server") - } - - // Gather local changes since last sync - val localLists = database.listDao().getListsUpdatedSince(lastSync) - val localFields = database.fieldDao().getFieldsUpdatedSince(lastSync) - val localItems = database.itemDao().getItemsUpdatedSince(lastSync) - val localValues = database.itemValueDao().getValuesUpdatedSince(lastSync) - - // Only log send when there are actual local changes - val localTotal = localLists.size + localFields.size + localItems.size + localValues.size - if (localTotal > 0) { - Logger.i( - "Sync", - "[OUT] Sending changes (lists=${localLists.size}, fields=${localFields.size}, items=${localItems.size}, values=${localValues.size})" - ) - } - - // Send to server and get updates - val syncRequest = SyncRequest( - lastSyncTimestamp = lastSync, - lists = localLists, - fields = localFields, - items = localItems, - itemValues = localValues - ) - - // Try WebSocket first; on failure, fall back to HTTP - val wsResult = WebSocketSyncClient.sync(appContext, syncRequest) - val syncResponse = if (wsResult.isSuccess) { - wsResult.getOrThrow() - } else { - val response = api.sync(syncRequest) - if (!response.isSuccessful) { - if (response.code() == 401) { - // Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error. - Logger.e("Sync", "[ERROR] Unauthorized (401). Check server password in Settings.") - setLastSyncTimestamp(0) - return@withContext Result.failure(Exception("Unauthorized (401). Please verify server password.")) - } - Logger.e("Sync", "[ERROR] Sync failed: HTTP ${response.code()}") - return@withContext Result.failure(Exception("Sync failed: ${response.code()}")) - } - response.body()!! - } - // Only log receive when there are actual server changes - val inTotal = syncResponse.lists.size + syncResponse.fields.size + syncResponse.items.size + syncResponse.itemValues.size - if (inTotal > 0) { - Logger.i( - "Sync", - "[IN] Received changes (lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, items=${syncResponse.items.size}, values=${syncResponse.itemValues.size})" - ) - } - - // Apply server changes to local database atomically in correct order - database.withTransaction { - // 1) Upsert lists first - database.listDao().insertLists(syncResponse.lists) - - // Build set of existing list ids to guard child inserts - val existingListIds = database.listDao().getAllListIds().toSet() - - // 2) Filter and upsert fields whose parent list exists - val filteredFields = syncResponse.fields.filter { f -> f.listId in existingListIds } - if (filteredFields.size != syncResponse.fields.size) { - val dropped = syncResponse.fields.size - filteredFields.size - Logger.w("Sync", "Dropping $dropped field(s) with missing parent list to avoid FK errors") - } - if (filteredFields.isNotEmpty()) { - database.fieldDao().insertFields(filteredFields) - } - - // 3) Filter and upsert items whose parent list exists - val filteredItems = syncResponse.items.filter { i -> i.listId in existingListIds } - if (filteredItems.size != syncResponse.items.size) { - val dropped = syncResponse.items.size - filteredItems.size - Logger.w("Sync", "Dropping $dropped item(s) with missing parent list to avoid FK errors") - } - if (filteredItems.isNotEmpty()) { - database.itemDao().insertItems(filteredItems) - } - - // 4) Filter item values to only those whose parents (item and field) exist locally - val existingItemIds = database.itemDao().getAllItemIds().toSet() - val existingFieldIds = database.fieldDao().getAllFieldIds().toSet() - val filteredValues = syncResponse.itemValues.filter { v -> - v.itemId in existingItemIds && v.fieldId in existingFieldIds - } - if (filteredValues.size != syncResponse.itemValues.size) { - val dropped = syncResponse.itemValues.size - filteredValues.size - Logger.w("Sync", "Dropping $dropped item value(s) with missing parents to avoid FK errors") - } - if (filteredValues.isNotEmpty()) { - database.itemValueDao().insertValues(filteredValues) - } - } - - // Update last sync timestamp - setLastSyncTimestamp(syncResponse.serverTimestamp) - - if (isInitialSync) { - Logger.i("Sync", "[SYNC] Initial sync completed") - } - - return@withContext Result.success(Unit) - } catch (e: Exception) { - // If WS indicated unauthorized, align behavior with HTTP path - if (e.message?.contains("Unauthorized") == true) { - setLastSyncTimestamp(0) - } - Logger.e("Sync", "[ERROR] Sync error: ${e.message}") - return@withContext Result.failure(e) - } - } -} +package com.collabtable.app.data.repository + +import android.content.Context +import androidx.room.withTransaction +import com.collabtable.app.data.api.ApiClient +import com.collabtable.app.data.api.SyncRequest +import com.collabtable.app.data.api.WebSocketSyncClient +import com.collabtable.app.data.database.CollabTableDatabase +import com.collabtable.app.utils.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class SyncRepository(context: Context) { + private val appContext = context.applicationContext + private val database = CollabTableDatabase.getDatabase(appContext) + private val api = ApiClient.api + private val prefs = appContext.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE) + + private fun getLastSyncTimestamp(): Long { + return prefs.getLong("last_sync_timestamp", 0) + } + + private fun setLastSyncTimestamp(timestamp: Long) { + prefs.edit().putLong("last_sync_timestamp", timestamp).apply() + } + + suspend fun performSync(): Result = + withContext(Dispatchers.IO) { + try { + val lastSync = getLastSyncTimestamp() + val isInitialSync = lastSync == 0L + if (isInitialSync) { + Logger.i("Sync", "[SYNC] Starting initial sync with server") + } + + // Gather local changes since last sync + val localLists = database.listDao().getListsUpdatedSince(lastSync) + val localFields = database.fieldDao().getFieldsUpdatedSince(lastSync) + val localItems = database.itemDao().getItemsUpdatedSince(lastSync) + val localValues = database.itemValueDao().getValuesUpdatedSince(lastSync) + + // Only log send when there are actual local changes + val localTotal = localLists.size + localFields.size + localItems.size + localValues.size + if (localTotal > 0) { + Logger.i( + "Sync", + "[OUT] Sending changes (lists=${localLists.size}, fields=${localFields.size}, items=${localItems.size}, values=${localValues.size})", + ) + } + + // Send to server and get updates + val syncRequest = + SyncRequest( + lastSyncTimestamp = lastSync, + lists = localLists, + fields = localFields, + items = localItems, + itemValues = localValues, + ) + + // Try WebSocket first; on failure, fall back to HTTP + val wsResult = WebSocketSyncClient.sync(appContext, syncRequest) + val syncResponse = + if (wsResult.isSuccess) { + wsResult.getOrThrow() + } else { + val response = api.sync(syncRequest) + if (!response.isSuccessful) { + if (response.code() == 401) { + // Unauthorized: likely bad/missing password. Reset sync baseline and surface a clear error. + Logger.e("Sync", "[ERROR] Unauthorized (401). Check server password in Settings.") + setLastSyncTimestamp(0) + return@withContext Result.failure(Exception("Unauthorized (401). Please verify server password.")) + } + Logger.e("Sync", "[ERROR] Sync failed: HTTP ${response.code()}") + return@withContext Result.failure(Exception("Sync failed: ${response.code()}")) + } + response.body()!! + } + // Only log receive when there are actual server changes + val inTotal = syncResponse.lists.size + syncResponse.fields.size + syncResponse.items.size + syncResponse.itemValues.size + if (inTotal > 0) { + Logger.i( + "Sync", + "[IN] Received changes (lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, items=${syncResponse.items.size}, values=${syncResponse.itemValues.size})", + ) + } + + // Apply server changes to local database atomically in correct order + database.withTransaction { + // 1) Upsert lists first + database.listDao().insertLists(syncResponse.lists) + + // Build set of existing list ids to guard child inserts + val existingListIds = database.listDao().getAllListIds().toSet() + + // 2) Filter and upsert fields whose parent list exists + val filteredFields = syncResponse.fields.filter { f -> f.listId in existingListIds } + if (filteredFields.size != syncResponse.fields.size) { + val dropped = syncResponse.fields.size - filteredFields.size + Logger.w("Sync", "Dropping $dropped field(s) with missing parent list to avoid FK errors") + } + if (filteredFields.isNotEmpty()) { + database.fieldDao().insertFields(filteredFields) + } + + // 3) Filter and upsert items whose parent list exists + val filteredItems = syncResponse.items.filter { i -> i.listId in existingListIds } + if (filteredItems.size != syncResponse.items.size) { + val dropped = syncResponse.items.size - filteredItems.size + Logger.w("Sync", "Dropping $dropped item(s) with missing parent list to avoid FK errors") + } + if (filteredItems.isNotEmpty()) { + database.itemDao().insertItems(filteredItems) + } + + // 4) Filter item values to only those whose parents (item and field) exist locally + val existingItemIds = database.itemDao().getAllItemIds().toSet() + val existingFieldIds = database.fieldDao().getAllFieldIds().toSet() + val filteredValues = + syncResponse.itemValues.filter { v -> + v.itemId in existingItemIds && v.fieldId in existingFieldIds + } + if (filteredValues.size != syncResponse.itemValues.size) { + val dropped = syncResponse.itemValues.size - filteredValues.size + Logger.w("Sync", "Dropping $dropped item value(s) with missing parents to avoid FK errors") + } + if (filteredValues.isNotEmpty()) { + database.itemValueDao().insertValues(filteredValues) + } + } + + // Update last sync timestamp + setLastSyncTimestamp(syncResponse.serverTimestamp) + + if (isInitialSync) { + Logger.i("Sync", "[SYNC] Initial sync completed") + } + + return@withContext Result.success(Unit) + } catch (e: Exception) { + // If WS indicated unauthorized, align behavior with HTTP path + if (e.message?.contains("Unauthorized") == true) { + setLastSyncTimestamp(0) + } + Logger.e("Sync", "[ERROR] Sync error: ${e.message}") + return@withContext Result.failure(e) + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/navigation/AppNavigation.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/navigation/AppNavigation.kt index f898f85..df68076 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/navigation/AppNavigation.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/navigation/AppNavigation.kt @@ -1,89 +1,90 @@ -package com.collabtable.app.ui.navigation - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import androidx.navigation.NavType -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument -import com.collabtable.app.data.preferences.PreferencesManager -import com.collabtable.app.ui.screens.ListDetailScreen -import com.collabtable.app.ui.screens.ListsScreen -import com.collabtable.app.ui.screens.LogsScreen -import com.collabtable.app.ui.screens.ServerSetupScreen -import com.collabtable.app.ui.screens.SettingsScreen - -@Composable -fun AppNavigation() { - val navController = rememberNavController() - val context = LocalContext.current - val preferencesManager = remember { PreferencesManager.getInstance(context) } - - // Determine start destination based on first run - val startDestination = if (preferencesManager.isFirstRun()) { - "server_setup" - } else { - "lists" - } - - NavHost( - navController = navController, - startDestination = startDestination - ) { - composable("server_setup") { - ServerSetupScreen( - onSetupComplete = { - // Navigate to lists and clear back stack - navController.navigate("lists") { - popUpTo("server_setup") { inclusive = true } - } - } - ) - } - - composable("lists") { - ListsScreen( - onNavigateToList = { listId -> - navController.navigate("list/$listId") - }, - onNavigateToSettings = { - navController.navigate("settings") - }, - onNavigateToLogs = { - navController.navigate("logs") - } - ) - } - - composable( - route = "list/{listId}", - arguments = listOf(navArgument("listId") { type = NavType.StringType }) - ) { backStackEntry -> - val listId = backStackEntry.arguments?.getString("listId") ?: return@composable - ListDetailScreen( - listId = listId, - onNavigateBack = { navController.popBackStack() } - ) - } - - composable("settings") { - SettingsScreen( - onNavigateBack = { navController.popBackStack() }, - onLeaveServer = { - // Navigate back to server setup and clear entire back stack - navController.navigate("server_setup") { - popUpTo(0) { inclusive = true } - } - } - ) - } - - composable("logs") { - LogsScreen( - onNavigateBack = { navController.popBackStack() } - ) - } - } -} +package com.collabtable.app.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.collabtable.app.data.preferences.PreferencesManager +import com.collabtable.app.ui.screens.ListDetailScreen +import com.collabtable.app.ui.screens.ListsScreen +import com.collabtable.app.ui.screens.LogsScreen +import com.collabtable.app.ui.screens.ServerSetupScreen +import com.collabtable.app.ui.screens.SettingsScreen + +@Composable +fun AppNavigation() { + val navController = rememberNavController() + val context = LocalContext.current + val preferencesManager = remember { PreferencesManager.getInstance(context) } + + // Determine start destination based on first run + val startDestination = + if (preferencesManager.isFirstRun()) { + "server_setup" + } else { + "lists" + } + + NavHost( + navController = navController, + startDestination = startDestination, + ) { + composable("server_setup") { + ServerSetupScreen( + onSetupComplete = { + // Navigate to lists and clear back stack + navController.navigate("lists") { + popUpTo("server_setup") { inclusive = true } + } + }, + ) + } + + composable("lists") { + ListsScreen( + onNavigateToList = { listId -> + navController.navigate("list/$listId") + }, + onNavigateToSettings = { + navController.navigate("settings") + }, + onNavigateToLogs = { + navController.navigate("logs") + }, + ) + } + + composable( + route = "list/{listId}", + arguments = listOf(navArgument("listId") { type = NavType.StringType }), + ) { backStackEntry -> + val listId = backStackEntry.arguments?.getString("listId") ?: return@composable + ListDetailScreen( + listId = listId, + onNavigateBack = { navController.popBackStack() }, + ) + } + + composable("settings") { + SettingsScreen( + onNavigateBack = { navController.popBackStack() }, + onLeaveServer = { + // Navigate back to server setup and clear entire back stack + navController.navigate("server_setup") { + popUpTo(0) { inclusive = true } + } + }, + ) + } + + composable("logs") { + LogsScreen( + onNavigateBack = { navController.popBackStack() }, + ) + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt index 2d5f31a..914eafa 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt @@ -1,3456 +1,3604 @@ -package com.collabtable.app.ui.screens - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.ClickableText -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDialog -import androidx.compose.material3.TimePicker -import androidx.compose.material3.rememberDatePickerState -import androidx.compose.material3.rememberTimePickerState -import androidx.compose.runtime.* -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.runtime.toMutableStateList -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.collabtable.app.R -import com.collabtable.app.data.database.CollabTableDatabase -import com.collabtable.app.data.model.Field -import com.collabtable.app.data.model.ItemValue -import com.collabtable.app.data.model.ItemWithValues -import java.text.SimpleDateFormat -import java.util.* - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) -@Composable -fun ListDetailScreen( - listId: String, - onNavigateBack: () -> Unit -) { - val context = LocalContext.current - val database = remember { CollabTableDatabase.getDatabase(context) } - val viewModel = remember { ListDetailViewModel(database, listId, context) } - - val list by viewModel.list.collectAsState() - val fields by viewModel.fields.collectAsState() - val items by viewModel.items.collectAsState() - - // Use derivedStateOf to create stable references - val stableFields by remember { derivedStateOf { fields } } - val stableItems by remember { derivedStateOf { items } } - - var showManageColumnsDialog by remember { mutableStateOf(false) } - var showAddItemDialog by remember { mutableStateOf(false) } - var itemToEdit by remember { mutableStateOf(null) } - var showSortDialog by remember { mutableStateOf(false) } - var showGroupDialog by remember { mutableStateOf(false) } - var showFilterDialog by remember { mutableStateOf(false) } - var showRenameListDialog by remember { mutableStateOf(false) } - - // Filter/Sort state - var sortField by remember { mutableStateOf(null) } - var sortAscending by remember { mutableStateOf(true) } - var groupByField by remember { mutableStateOf(null) } - var filterField by remember { mutableStateOf(null) } - var filterValue by remember { mutableStateOf("") } - - // Shared scroll state for synchronized horizontal scrolling - val horizontalScrollState = rememberScrollState() - - // Field widths state (resizable columns) - val fieldWidths = remember { mutableStateMapOf() } - - // Initialize field widths - LaunchedEffect(stableFields) { - stableFields.forEach { field -> - if (!fieldWidths.containsKey(field.id)) { - fieldWidths[field.id] = 150.dp - } - } - } - - // Apply filtering, sorting, and grouping - val processedItems = remember(stableItems, stableFields, filterField, filterValue, sortField, sortAscending, groupByField) { - var result = stableItems - - // Apply filter - if (filterField != null && filterValue.isNotBlank()) { - result = result.filter { itemWithValues -> - val value = itemWithValues.values.find { it.fieldId == filterField!!.id }?.value ?: "" - value.contains(filterValue, ignoreCase = true) - } - } - - // Apply sort - if (sortField != null) { - result = result.sortedWith(compareBy { itemWithValues -> - val value = itemWithValues.values.find { it.fieldId == sortField!!.id }?.value ?: "" - if (sortAscending) value else value - }) - if (!sortAscending) { - result = result.reversed() - } - } - - result - } - - // Group items if groupByField is set - val groupedItems = remember(processedItems, groupByField) { - if (groupByField != null) { - processedItems.groupBy { itemWithValues -> - itemWithValues.values.find { it.fieldId == groupByField!!.id }?.value ?: "(Empty)" - } - } else { - mapOf("" to processedItems) - } - } - - Scaffold( - topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.clickable { showRenameListDialog = true }, - verticalAlignment = Alignment.CenterVertically - ) { - Text(list?.name ?: "") - Spacer(modifier = Modifier.width(4.dp)) - Icon( - Icons.Default.Edit, - contentDescription = "Rename", - modifier = Modifier.size(18.dp) - ) - } - }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") - } - }, - actions = { - IconButton(onClick = { showManageColumnsDialog = true }) { - Icon(Icons.Default.Settings, contentDescription = "Manage Columns") - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - ) - }, - floatingActionButton = { - FloatingActionButton( - onClick = { showAddItemDialog = true } - ) { - Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_item)) - } - } - ) { padding -> - if (stableFields.isEmpty()) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding), - contentAlignment = Alignment.Center - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = "No fields yet. Add fields to get started!", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { showManageColumnsDialog = true }) { - Icon(Icons.Default.Add, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.add_field)) - } - } - } - } else { - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - ) { - // Filter/Sort/Group Controls - Always visible above table - Row( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Sort button - FilterChip( - selected = sortField != null, - onClick = { showSortDialog = true }, - label = { - Text( - if (sortField != null) - "Sort: ${sortField!!.name} ${if (sortAscending) "โ†‘" else "โ†“"}" - else - "Sort" - ) - }, - leadingIcon = { - Icon( - Icons.Default.Search, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - }, - trailingIcon = if (sortField != null) { - { - IconButton( - onClick = { - sortField = null - sortAscending = true - }, - modifier = Modifier.size(18.dp) - ) { - Icon( - Icons.Default.Close, - contentDescription = "Clear", - modifier = Modifier.size(16.dp) - ) - } - } - } else null - ) - - // Group button - FilterChip( - selected = groupByField != null, - onClick = { showGroupDialog = true }, - label = { - Text( - if (groupByField != null) - "Group: ${groupByField!!.name}" - else - "Group" - ) - }, - leadingIcon = { - Icon( - Icons.Default.List, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - }, - trailingIcon = if (groupByField != null) { - { - IconButton( - onClick = { groupByField = null }, - modifier = Modifier.size(18.dp) - ) { - Icon( - Icons.Default.Close, - contentDescription = "Clear", - modifier = Modifier.size(16.dp) - ) - } - } - } else null - ) - - // Filter button - FilterChip( - selected = filterField != null, - onClick = { showFilterDialog = true }, - label = { - Text( - if (filterField != null) - "Filter: ${filterField!!.name}" - else - "Filter" - ) - }, - leadingIcon = { - Icon( - Icons.Default.Add, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - }, - trailingIcon = if (filterField != null) { - { - IconButton( - onClick = { - filterField = null - filterValue = "" - }, - modifier = Modifier.size(18.dp) - ) { - Icon( - Icons.Default.Close, - contentDescription = "Clear", - modifier = Modifier.size(16.dp) - ) - } - } - } else null - ) - } - - // Field headers with long-press to delete and resize handles - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(horizontalScrollState), - verticalAlignment = Alignment.CenterVertically - ) { - stableFields.forEach { field -> - key(field.id) { - FieldHeader( - field = field, - width = fieldWidths[field.id] ?: 150.dp, - onWidthChange = { delta -> - val currentWidth = fieldWidths[field.id] ?: 150.dp - val newWidth = (currentWidth.value + delta).coerceIn(100f, 400f) - fieldWidths[field.id] = newWidth.dp - } - ) - } - } - } - - // Items list with synchronized scrolling - if (stableItems.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(R.string.no_items), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } else if (processedItems.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = "No items match the current filter", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - TextButton(onClick = { - filterField = null - filterValue = "" - }) { - Text("Clear Filter") - } - } - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize() - ) { - groupedItems.entries.forEach { (groupName, groupItems) -> - // Show group header if grouping is enabled - if (groupByField != null) { - item(key = "group_$groupName") { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.secondaryContainer - ) { - Text( - text = "$groupName (${groupItems.size})", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(12.dp), - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - } - } - } - - // Show items in the group - items( - items = groupItems, - key = { it.item.id } - ) { itemWithValues -> - ItemRow( - fields = stableFields, - fieldWidths = fieldWidths, - itemWithValues = itemWithValues, - scrollState = horizontalScrollState, - onClick = { itemToEdit = itemWithValues } - ) - } - } - } - } - } - } - } - - if (showManageColumnsDialog) { - ManageColumnsDialog( - fields = stableFields, - onDismiss = { showManageColumnsDialog = false }, - onAddField = { name, fieldType, fieldOptions -> - viewModel.addField(name, fieldType, fieldOptions) - }, - onUpdateField = { fieldId, fieldType, fieldOptions -> - viewModel.updateField(fieldId, fieldType, fieldOptions) - }, - onDeleteField = { fieldId -> - viewModel.deleteField(fieldId) - }, - onReorderFields = { reorderedFields -> - viewModel.reorderFields(reorderedFields) - } - ) - } - - if (showAddItemDialog) { - AddItemDialog( - fields = stableFields, - onDismiss = { showAddItemDialog = false }, - onAdd = { fieldValues -> - viewModel.addItemWithValues(fieldValues) - showAddItemDialog = false - } - ) - } - - // Sort Dialog - if (showSortDialog) { - SortDialog( - fields = stableFields, - currentSortField = sortField, - currentSortAscending = sortAscending, - onDismiss = { showSortDialog = false }, - onApply = { newSortField, newSortAscending -> - sortField = newSortField - sortAscending = newSortAscending - showSortDialog = false - } - ) - } - - // Group Dialog - if (showGroupDialog) { - GroupDialog( - fields = stableFields, - currentGroupByField = groupByField, - onDismiss = { showGroupDialog = false }, - onApply = { newGroupByField -> - groupByField = newGroupByField - showGroupDialog = false - } - ) - } - - // Filter Dialog - if (showFilterDialog) { - FilterDialog( - fields = stableFields, - currentFilterField = filterField, - currentFilterValue = filterValue, - onDismiss = { showFilterDialog = false }, - onApply = { newFilterField, newFilterValue -> - filterField = newFilterField - filterValue = newFilterValue - showFilterDialog = false - } - ) - } - - // Rename List Dialog - if (showRenameListDialog) { - list?.let { currentList -> - RenameListDialog( - currentName = currentList.name, - onDismiss = { showRenameListDialog = false }, - onRename = { newName -> - viewModel.renameList(newName) - showRenameListDialog = false - } - ) - } - } - - itemToEdit?.let { itemWithValues -> - EditItemDialog( - fields = stableFields, - itemWithValues = itemWithValues, - onDismiss = { itemToEdit = null }, - onUpdate = { fieldValues -> - fieldValues.forEach { (valueId, newValue) -> - viewModel.updateItemValue(valueId, newValue) - } - itemToEdit = null - }, - onDelete = { - viewModel.deleteItem(itemWithValues.item.id) - itemToEdit = null - } - ) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun FieldHeader( - field: Field, - width: Dp, - onWidthChange: (Float) -> Unit -) { - val density = LocalDensity.current - - Box( - modifier = Modifier - .width(width) - .border(1.dp, MaterialTheme.colorScheme.outline) - ) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.primaryContainer - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = field.name, - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.weight(1f) - ) - - // Resize handle - Box( - modifier = Modifier - .size(24.dp) - .pointerInput(Unit) { - detectDragGestures { change, dragAmount -> - change.consume() - with(density) { - onWidthChange(dragAmount.x.toDp().value) - } - } - } - ) { - Icon( - Icons.Default.Menu, - contentDescription = "Resize", - modifier = Modifier - .size(16.dp) - .align(Alignment.Center), - tint = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) - ) - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ItemRow( - fields: List, - fieldWidths: Map, - itemWithValues: ItemWithValues, - scrollState: androidx.compose.foundation.ScrollState, - onClick: () -> Unit -) { - // Map values by fieldId to ensure correct value-column alignment regardless of original order - val valuesByFieldId = remember(itemWithValues.values) { - itemWithValues.values.associateBy { it.fieldId } - } - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .horizontalScroll(scrollState), - verticalAlignment = Alignment.CenterVertically - ) { - fields.forEach { field -> - key(field.id) { - val value = valuesByFieldId[field.id] - val fieldWidth = fieldWidths[field.id] ?: 150.dp - - Box( - modifier = Modifier - .width(fieldWidth) - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.outline - ) - .padding(8.dp) - ) { - when (field.getType()) { - com.collabtable.app.data.model.FieldType.TEXT -> { - Text( - text = value?.value ?: "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - - com.collabtable.app.data.model.FieldType.MULTILINE_TEXT -> { - Text( - text = value?.value ?: "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - - com.collabtable.app.data.model.FieldType.NUMBER -> { - Text( - text = value?.value ?: "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - - com.collabtable.app.data.model.FieldType.CURRENCY -> { - Text( - text = if (value?.value.isNullOrBlank()) "" - else "${field.getCurrency()}${value?.value}", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - - com.collabtable.app.data.model.FieldType.PERCENTAGE -> { - Text( - text = if (value?.value.isNullOrBlank()) "" else "${value?.value}%", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - - com.collabtable.app.data.model.FieldType.DROPDOWN -> { - Text( - text = value?.value ?: "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - - com.collabtable.app.data.model.FieldType.CHECKBOX -> { - Text( - text = if (value?.value == "true") "โœ“" else "", - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - - com.collabtable.app.data.model.FieldType.URL -> { - val uriHandler = LocalUriHandler.current - val urlValue = value?.value ?: "" - if (urlValue.isNotBlank()) { - ClickableText( - text = buildAnnotatedString { - withStyle( - style = SpanStyle( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline - ) - ) { - append(urlValue) - } - }, - onClick = { - try { - uriHandler.openUri(urlValue) - } catch (e: Exception) { - // Handle invalid URL - } - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - style = MaterialTheme.typography.bodyMedium - ) - } else { - Text( - text = "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - } - - com.collabtable.app.data.model.FieldType.EMAIL -> { - val uriHandler = LocalUriHandler.current - val emailValue = value?.value ?: "" - if (emailValue.isNotBlank()) { - ClickableText( - text = buildAnnotatedString { - withStyle( - style = SpanStyle( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline - ) - ) { - append(emailValue) - } - }, - onClick = { - try { - uriHandler.openUri("mailto:$emailValue") - } catch (e: Exception) { - // Handle invalid email - } - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - style = MaterialTheme.typography.bodyMedium - ) - } else { - Text( - text = "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - } - - com.collabtable.app.data.model.FieldType.PHONE -> { - val uriHandler = LocalUriHandler.current - val phoneValue = value?.value ?: "" - if (phoneValue.isNotBlank()) { - ClickableText( - text = buildAnnotatedString { - withStyle( - style = SpanStyle( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline - ) - ) { - append(phoneValue) - } - }, - onClick = { - try { - uriHandler.openUri("tel:$phoneValue") - } catch (e: Exception) { - // Handle invalid phone - } - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - style = MaterialTheme.typography.bodyMedium - ) - } else { - Text( - text = "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - } - - com.collabtable.app.data.model.FieldType.DATE -> { - Text( - text = value?.value ?: "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - - com.collabtable.app.data.model.FieldType.TIME -> { - Text( - text = value?.value ?: "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - - com.collabtable.app.data.model.FieldType.DATETIME -> { - Text( - text = value?.value ?: "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - - // New field types - com.collabtable.app.data.model.FieldType.SWITCH -> { - Text( - text = if (value?.value == "true") "ON" else "OFF", - style = MaterialTheme.typography.bodyMedium, - color = if (value?.value == "true") - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - - com.collabtable.app.data.model.FieldType.AUTOCOMPLETE -> { - Text( - text = value?.value ?: "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - - com.collabtable.app.data.model.FieldType.DURATION -> { - Text( - text = value?.value ?: "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - - com.collabtable.app.data.model.FieldType.RATING -> { - val rating = value?.value?.toIntOrNull() ?: 0 - val maxRating = field.getMaxRating() - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) { - repeat(maxRating) { index -> - Icon( - imageVector = Icons.Default.Star, - contentDescription = null, - tint = if (index < rating) - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.size(16.dp) - ) - } - } - } - - com.collabtable.app.data.model.FieldType.COLOR -> { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (value?.value?.isNotBlank() == true) { - Box( - modifier = Modifier - .size(20.dp) - .background( - color = try { - androidx.compose.ui.graphics.Color( - android.graphics.Color.parseColor(value.value) - ) - } catch (e: Exception) { - MaterialTheme.colorScheme.surface - }, - shape = RoundedCornerShape(4.dp) - ) - .border( - 1.dp, - MaterialTheme.colorScheme.outline, - RoundedCornerShape(4.dp) - ) - ) - Spacer(modifier = Modifier.width(8.dp)) - } - Text( - text = value?.value ?: "", - style = MaterialTheme.typography.bodyMedium - ) - } - } - - com.collabtable.app.data.model.FieldType.LOCATION -> { - Text( - text = value?.value ?: "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - - com.collabtable.app.data.model.FieldType.IMAGE -> { - if (value?.value?.isNotBlank() == true) { - Text( - text = "๐Ÿ–ผ๏ธ ${value.value}", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } else { - Text( - text = "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - } - - com.collabtable.app.data.model.FieldType.FILE -> { - if (value?.value?.isNotBlank() == true) { - Text( - text = "๐Ÿ“Ž ${value.value}", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } else { - Text( - text = "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - } - - com.collabtable.app.data.model.FieldType.BARCODE -> { - Text( - text = value?.value ?: "", - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace - ), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - - com.collabtable.app.data.model.FieldType.SIGNATURE -> { - if (value?.value?.isNotBlank() == true) { - Text( - text = "โœ๏ธ Signed", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } else { - Text( - text = "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - } - } - } - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AddFieldDialog( - onDismiss: () -> Unit, - onAdd: (String, String, String) -> Unit -) { - var fieldName by remember { mutableStateOf("") } - var selectedFieldType by remember { mutableStateOf("TEXT") } - var dropdownOptions by remember { mutableStateOf("") } - var currency by remember { mutableStateOf("$") } - var expanded by remember { mutableStateOf(false) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.add_field)) }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - OutlinedTextField( - value = fieldName, - onValueChange = { fieldName = it }, - label = { Text(stringResource(R.string.field_name)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - // Field Type Selector - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded } - ) { - OutlinedTextField( - value = when(selectedFieldType) { - "TEXT" -> "Text" - "MULTILINE_TEXT" -> "Multi-line Text" - "NUMBER" -> "Number" - "CURRENCY" -> "Currency" - "PERCENTAGE" -> "Percentage" - "DROPDOWN" -> "Dropdown" - "CHECKBOX" -> "Checkbox" - "URL" -> "URL" - "EMAIL" -> "Email" - "PHONE" -> "Phone" - "DATE" -> "Date" - "TIME" -> "Time" - "DATETIME" -> "Date & Time" - else -> "Text" - }, - onValueChange = {}, - readOnly = true, - label = { Text("Field Type") }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - modifier = Modifier - .menuAnchor() - .fillMaxWidth() - ) - - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - // Text types - DropdownMenuItem( - text = { Text("Text") }, - onClick = { - selectedFieldType = "TEXT" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Multi-line Text") }, - onClick = { - selectedFieldType = "MULTILINE_TEXT" - expanded = false - } - ) - - // Number types - DropdownMenuItem( - text = { Text("Number") }, - onClick = { - selectedFieldType = "NUMBER" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Currency") }, - onClick = { - selectedFieldType = "CURRENCY" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Percentage") }, - onClick = { - selectedFieldType = "PERCENTAGE" - expanded = false - } - ) - - // Selection types - DropdownMenuItem( - text = { Text("Dropdown") }, - onClick = { - selectedFieldType = "DROPDOWN" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Autocomplete") }, - onClick = { - selectedFieldType = "AUTOCOMPLETE" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Checkbox") }, - onClick = { - selectedFieldType = "CHECKBOX" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Switch") }, - onClick = { - selectedFieldType = "SWITCH" - expanded = false - } - ) - - // Link types - DropdownMenuItem( - text = { Text("URL") }, - onClick = { - selectedFieldType = "URL" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Email") }, - onClick = { - selectedFieldType = "EMAIL" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Phone") }, - onClick = { - selectedFieldType = "PHONE" - expanded = false - } - ) - - // Date/Time types - DropdownMenuItem( - text = { Text("Date") }, - onClick = { - selectedFieldType = "DATE" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Time") }, - onClick = { - selectedFieldType = "TIME" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Date & Time") }, - onClick = { - selectedFieldType = "DATETIME" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Duration") }, - onClick = { - selectedFieldType = "DURATION" - expanded = false - } - ) - - // Media types - DropdownMenuItem( - text = { Text("Image") }, - onClick = { - selectedFieldType = "IMAGE" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("File") }, - onClick = { - selectedFieldType = "FILE" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Barcode") }, - onClick = { - selectedFieldType = "BARCODE" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Signature") }, - onClick = { - selectedFieldType = "SIGNATURE" - expanded = false - } - ) - - // Other types - DropdownMenuItem( - text = { Text("Rating") }, - onClick = { - selectedFieldType = "RATING" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Color") }, - onClick = { - selectedFieldType = "COLOR" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Location") }, - onClick = { - selectedFieldType = "LOCATION" - expanded = false - } - ) - } - } - - // Currency-specific options - if (selectedFieldType == "CURRENCY") { - OutlinedTextField( - value = currency, - onValueChange = { currency = it }, - label = { Text("Currency Symbol") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("$, โ‚ฌ, ยฃ, etc.") } - ) - } - - // Dropdown-specific options - if (selectedFieldType == "DROPDOWN") { - OutlinedTextField( - value = dropdownOptions, - onValueChange = { dropdownOptions = it }, - label = { Text("Options (comma-separated)") }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Option 1, Option 2, Option 3") }, - supportingText = { Text("Enter dropdown choices separated by commas") } - ) - } - - // Autocomplete-specific options - if (selectedFieldType == "AUTOCOMPLETE") { - OutlinedTextField( - value = dropdownOptions, - onValueChange = { dropdownOptions = it }, - label = { Text("Options (comma-separated)") }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Option 1, Option 2, Option 3") }, - supportingText = { Text("Enter autocomplete suggestions separated by commas") } - ) - } - - // Rating-specific options - if (selectedFieldType == "RATING") { - OutlinedTextField( - value = currency, - onValueChange = { currency = it }, - label = { Text("Max Rating") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("5") }, - supportingText = { Text("Maximum number of stars (default: 5)") } - ) - } - } - }, - confirmButton = { - TextButton( - onClick = { - if (fieldName.isNotBlank()) { - val options = when(selectedFieldType) { - "CURRENCY" -> currency.trim() - "DROPDOWN" -> dropdownOptions.split(",") - .map { it.trim() } - .filter { it.isNotBlank() } - .joinToString("|") - "AUTOCOMPLETE" -> dropdownOptions.split(",") - .map { it.trim() } - .filter { it.isNotBlank() } - .joinToString("|") - "RATING" -> currency.trim().ifBlank { "5" } - else -> "" - } - onAdd(fieldName.trim(), selectedFieldType, options) - } - }, - enabled = fieldName.isNotBlank() && - (selectedFieldType !in listOf("DROPDOWN", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()) - ) { - Text(stringResource(R.string.add)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) - } - } - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EditFieldDialog( - field: Field, - onDismiss: () -> Unit, - onUpdate: (String, String) -> Unit -) { - var selectedFieldType by remember { mutableStateOf(field.fieldType ?: "TEXT") } - var dropdownOptions by remember { mutableStateOf(field.getDropdownOptions().joinToString(", ")) } - var currency by remember { mutableStateOf(field.getCurrency()) } - var expanded by remember { mutableStateOf(false) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Edit Field: ${field.name}") }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Field Type Selector - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded } - ) { - OutlinedTextField( - value = when(selectedFieldType) { - "TEXT", "STRING" -> "Text" - "MULTILINE_TEXT" -> "Multi-line Text" - "NUMBER" -> "Number" - "CURRENCY", "PRICE" -> "Currency" - "PERCENTAGE" -> "Percentage" - "DROPDOWN" -> "Dropdown" - "CHECKBOX" -> "Checkbox" - "URL" -> "URL" - "EMAIL" -> "Email" - "PHONE" -> "Phone" - "DATE" -> "Date" - "TIME" -> "Time" - "DATETIME" -> "Date & Time" - else -> "Text" - }, - onValueChange = {}, - readOnly = true, - label = { Text("Field Type") }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - modifier = Modifier - .menuAnchor() - .fillMaxWidth() - ) - - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - // Text types - DropdownMenuItem( - text = { Text("Text") }, - onClick = { - selectedFieldType = "TEXT" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Multi-line Text") }, - onClick = { - selectedFieldType = "MULTILINE_TEXT" - expanded = false - } - ) - - // Number types - DropdownMenuItem( - text = { Text("Number") }, - onClick = { - selectedFieldType = "NUMBER" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Currency") }, - onClick = { - selectedFieldType = "CURRENCY" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Percentage") }, - onClick = { - selectedFieldType = "PERCENTAGE" - expanded = false - } - ) - - // Selection types - DropdownMenuItem( - text = { Text("Dropdown") }, - onClick = { - selectedFieldType = "DROPDOWN" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Autocomplete") }, - onClick = { - selectedFieldType = "AUTOCOMPLETE" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Checkbox") }, - onClick = { - selectedFieldType = "CHECKBOX" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Switch") }, - onClick = { - selectedFieldType = "SWITCH" - expanded = false - } - ) - - // Link types - DropdownMenuItem( - text = { Text("URL") }, - onClick = { - selectedFieldType = "URL" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Email") }, - onClick = { - selectedFieldType = "EMAIL" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Phone") }, - onClick = { - selectedFieldType = "PHONE" - expanded = false - } - ) - - // Date/Time types - DropdownMenuItem( - text = { Text("Date") }, - onClick = { - selectedFieldType = "DATE" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Time") }, - onClick = { - selectedFieldType = "TIME" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Date & Time") }, - onClick = { - selectedFieldType = "DATETIME" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Duration") }, - onClick = { - selectedFieldType = "DURATION" - expanded = false - } - ) - - // Media types - DropdownMenuItem( - text = { Text("Image") }, - onClick = { - selectedFieldType = "IMAGE" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("File") }, - onClick = { - selectedFieldType = "FILE" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Barcode") }, - onClick = { - selectedFieldType = "BARCODE" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Signature") }, - onClick = { - selectedFieldType = "SIGNATURE" - expanded = false - } - ) - - // Other types - DropdownMenuItem( - text = { Text("Rating") }, - onClick = { - selectedFieldType = "RATING" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Color") }, - onClick = { - selectedFieldType = "COLOR" - expanded = false - } - ) - DropdownMenuItem( - text = { Text("Location") }, - onClick = { - selectedFieldType = "LOCATION" - expanded = false - } - ) - } - } - - // Currency-specific options - if (selectedFieldType == "CURRENCY" || selectedFieldType == "PRICE") { - OutlinedTextField( - value = currency, - onValueChange = { currency = it }, - label = { Text("Currency Symbol") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("$, โ‚ฌ, ยฃ, etc.") } - ) - } - - // Dropdown-specific options - if (selectedFieldType == "DROPDOWN") { - OutlinedTextField( - value = dropdownOptions, - onValueChange = { dropdownOptions = it }, - label = { Text("Options (comma-separated)") }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Option 1, Option 2, Option 3") }, - supportingText = { Text("Enter dropdown choices separated by commas") } - ) - } - - // Autocomplete-specific options - if (selectedFieldType == "AUTOCOMPLETE") { - OutlinedTextField( - value = dropdownOptions, - onValueChange = { dropdownOptions = it }, - label = { Text("Options (comma-separated)") }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Option 1, Option 2, Option 3") }, - supportingText = { Text("Enter autocomplete suggestions separated by commas") } - ) - } - - // Rating-specific options - if (selectedFieldType == "RATING") { - OutlinedTextField( - value = currency, - onValueChange = { currency = it }, - label = { Text("Max Rating") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("5") }, - supportingText = { Text("Maximum number of stars (default: 5)") } - ) - } - } - }, - confirmButton = { - TextButton( - onClick = { - val options = when(selectedFieldType) { - "CURRENCY", "PRICE" -> currency.trim() - "DROPDOWN" -> dropdownOptions.split(",") - .map { it.trim() } - .filter { it.isNotBlank() } - .joinToString("|") - "AUTOCOMPLETE" -> dropdownOptions.split(",") - .map { it.trim() } - .filter { it.isNotBlank() } - .joinToString("|") - "RATING" -> currency.trim().ifBlank { "5" } - else -> "" - } - onUpdate(selectedFieldType, options) - }, - enabled = selectedFieldType !in listOf("DROPDOWN", "AUTOCOMPLETE") || dropdownOptions.isNotBlank() - ) { - Text("Update") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) - } - } - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AddItemDialog( - fields: List, - onDismiss: () -> Unit, - onAdd: (Map) -> Unit -) { - val fieldValues = remember { mutableStateMapOf() } - - // Initialize all field values to empty strings - LaunchedEffect(fields) { - fields.forEach { field -> - if (!fieldValues.containsKey(field.id)) { - fieldValues[field.id] = "" - } - } - } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.add_item)) }, - text = { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth() - ) { - items(fields, key = { it.id }) { field -> - FieldInput( - field = field, - value = fieldValues[field.id].orEmpty(), - onValueChange = { newValue -> - fieldValues[field.id] = newValue - } - ) - } - } - }, - confirmButton = { - TextButton( - onClick = { onAdd(fieldValues.toMap()) } - ) { - Text(stringResource(R.string.add)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) - } - } - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun FieldInput( - field: Field, - value: String, - onValueChange: (String) -> Unit -) { - when (field.getType()) { - // Text types - com.collabtable.app.data.model.FieldType.TEXT -> { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(field.name) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - } - - com.collabtable.app.data.model.FieldType.MULTILINE_TEXT -> { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(field.name) }, - modifier = Modifier.fillMaxWidth(), - singleLine = false, - minLines = 3, - maxLines = 6 - ) - } - - // Number types - com.collabtable.app.data.model.FieldType.NUMBER -> { - OutlinedTextField( - value = value, - onValueChange = { newValue -> - val filtered = newValue.filter { it.isDigit() || it == '.' || it == '-' } - onValueChange(filtered) - }, - label = { Text(field.name) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - placeholder = { Text("0") } - ) - } - - com.collabtable.app.data.model.FieldType.CURRENCY -> { - OutlinedTextField( - value = value, - onValueChange = { newValue -> - val filtered = newValue.filter { it.isDigit() || it == '.' } - onValueChange(filtered) - }, - label = { Text(field.name) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - leadingIcon = { Text(field.getCurrency()) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - placeholder = { Text("0.00") } - ) - } - - com.collabtable.app.data.model.FieldType.PERCENTAGE -> { - OutlinedTextField( - value = value, - onValueChange = { newValue -> - val filtered = newValue.filter { it.isDigit() || it == '.' } - onValueChange(filtered) - }, - label = { Text(field.name) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - trailingIcon = { Text("%") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - placeholder = { Text("0") } - ) - } - - // Selection types - com.collabtable.app.data.model.FieldType.CHECKBOX -> { - var checked by remember { mutableStateOf(value == "true") } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = checked, - onCheckedChange = { - checked = it - onValueChange(it.toString()) - } - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = field.name, - style = MaterialTheme.typography.bodyLarge - ) - } - } - - com.collabtable.app.data.model.FieldType.DROPDOWN -> { - val options = field.getDropdownOptions() - var dropdownExpanded by remember { mutableStateOf(false) } - - ExposedDropdownMenuBox( - expanded = dropdownExpanded, - onExpandedChange = { dropdownExpanded = !dropdownExpanded } - ) { - OutlinedTextField( - value = value, - onValueChange = {}, - readOnly = true, - label = { Text(field.name) }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded) - }, - modifier = Modifier - .menuAnchor() - .fillMaxWidth() - ) - - ExposedDropdownMenu( - expanded = dropdownExpanded, - onDismissRequest = { dropdownExpanded = false } - ) { - options.forEach { option -> - DropdownMenuItem( - text = { Text(option) }, - onClick = { - onValueChange(option) - dropdownExpanded = false - } - ) - } - } - } - } - - com.collabtable.app.data.model.FieldType.URL -> { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(field.name) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - placeholder = { Text("https://example.com") } - ) - } - - com.collabtable.app.data.model.FieldType.EMAIL -> { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(field.name) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), - placeholder = { Text("email@example.com") } - ) - } - - com.collabtable.app.data.model.FieldType.PHONE -> { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(field.name) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), - placeholder = { Text("+1 (555) 123-4567") } - ) - } - - // Date/Time types - com.collabtable.app.data.model.FieldType.DATE -> { - val dateFormat = remember { SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) } - val calendar = remember { Calendar.getInstance() } - var showDatePicker by remember { mutableStateOf(false) } - - OutlinedTextField( - value = value, - onValueChange = {}, - readOnly = true, - label = { Text(field.name) }, - modifier = Modifier - .fillMaxWidth() - .clickable { showDatePicker = true }, - singleLine = true, - placeholder = { Text("Select date") }, - trailingIcon = { - IconButton(onClick = { showDatePicker = true }) { - Icon(Icons.Default.DateRange, contentDescription = "Pick date") - } - } - ) - - if (showDatePicker) { - val datePickerState = rememberDatePickerState( - initialSelectedDateMillis = if (value.isBlank()) { - System.currentTimeMillis() - } else { - try { - dateFormat.parse(value)?.time - } catch (e: Exception) { - System.currentTimeMillis() - } - } - ) - - DatePickerDialog( - onDismissRequest = { showDatePicker = false }, - confirmButton = { - TextButton(onClick = { - datePickerState.selectedDateMillis?.let { millis -> - calendar.timeInMillis = millis - val formattedDate = dateFormat.format(calendar.time) - onValueChange(formattedDate) - } - showDatePicker = false - }) { - Text("OK") - } - }, - dismissButton = { - TextButton(onClick = { showDatePicker = false }) { - Text("Cancel") - } - } - ) { - DatePicker(state = datePickerState) - } - } - } - - com.collabtable.app.data.model.FieldType.TIME -> { - val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } - val calendar = remember { Calendar.getInstance() } - var showTimePicker by remember { mutableStateOf(false) } - - OutlinedTextField( - value = value, - onValueChange = {}, - readOnly = true, - label = { Text(field.name) }, - modifier = Modifier - .fillMaxWidth() - .clickable { showTimePicker = true }, - singleLine = true, - placeholder = { Text("Select time") }, - trailingIcon = { - IconButton(onClick = { showTimePicker = true }) { - Icon(Icons.Default.AccountBox, contentDescription = "Pick time") - } - } - ) - - if (showTimePicker) { - val timePickerState = rememberTimePickerState( - initialHour = if (value.isBlank()) { - Calendar.getInstance().get(Calendar.HOUR_OF_DAY) - } else { - try { - timeFormat.parse(value)?.let { - calendar.time = it - calendar.get(Calendar.HOUR_OF_DAY) - } ?: 12 - } catch (e: Exception) { - 12 - } - }, - initialMinute = if (value.isBlank()) { - Calendar.getInstance().get(Calendar.MINUTE) - } else { - try { - timeFormat.parse(value)?.let { - calendar.time = it - calendar.get(Calendar.MINUTE) - } ?: 0 - } catch (e: Exception) { - 0 - } - } - ) - - AlertDialog( - onDismissRequest = { showTimePicker = false }, - confirmButton = { - TextButton(onClick = { - calendar.set(Calendar.HOUR_OF_DAY, timePickerState.hour) - calendar.set(Calendar.MINUTE, timePickerState.minute) - val formattedTime = timeFormat.format(calendar.time) - onValueChange(formattedTime) - showTimePicker = false - }) { - Text("OK") - } - }, - dismissButton = { - TextButton(onClick = { showTimePicker = false }) { - Text("Cancel") - } - }, - text = { - TimePicker(state = timePickerState) - } - ) - } - } - - com.collabtable.app.data.model.FieldType.DATETIME -> { - val dateFormat = remember { SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) } - val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } - val calendar = remember { Calendar.getInstance() } - var showDatePicker by remember { mutableStateOf(false) } - var showTimePicker by remember { mutableStateOf(false) } - var tempDate by remember { mutableStateOf("") } - - OutlinedTextField( - value = value, - onValueChange = {}, - readOnly = true, - label = { Text(field.name) }, - modifier = Modifier - .fillMaxWidth() - .clickable { showDatePicker = true }, - singleLine = true, - placeholder = { Text("Select date & time") }, - trailingIcon = { - IconButton(onClick = { showDatePicker = true }) { - Icon(Icons.Default.DateRange, contentDescription = "Pick date & time") - } - } - ) - - if (showDatePicker) { - val datePickerState = rememberDatePickerState( - initialSelectedDateMillis = System.currentTimeMillis() - ) - - DatePickerDialog( - onDismissRequest = { showDatePicker = false }, - confirmButton = { - TextButton(onClick = { - datePickerState.selectedDateMillis?.let { millis -> - calendar.timeInMillis = millis - tempDate = dateFormat.format(calendar.time) - } - showDatePicker = false - showTimePicker = true - }) { - Text("Next") - } - }, - dismissButton = { - TextButton(onClick = { showDatePicker = false }) { - Text("Cancel") - } - } - ) { - DatePicker(state = datePickerState) - } - } - - if (showTimePicker) { - val timePickerState = rememberTimePickerState( - initialHour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY), - initialMinute = Calendar.getInstance().get(Calendar.MINUTE) - ) - - AlertDialog( - onDismissRequest = { showTimePicker = false }, - confirmButton = { - TextButton(onClick = { - calendar.set(Calendar.HOUR_OF_DAY, timePickerState.hour) - calendar.set(Calendar.MINUTE, timePickerState.minute) - val time = timeFormat.format(calendar.time) - val dateTime = "$tempDate $time" - onValueChange(dateTime) - showTimePicker = false - }) { - Text("OK") - } - }, - dismissButton = { - TextButton(onClick = { showTimePicker = false }) { - Text("Cancel") - } - }, - text = { - TimePicker(state = timePickerState) - } - ) - } - } - - // New field types - com.collabtable.app.data.model.FieldType.SWITCH -> { - var checked by remember { mutableStateOf(value == "true") } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = field.name, - style = MaterialTheme.typography.bodyLarge - ) - Switch( - checked = checked, - onCheckedChange = { - checked = it - onValueChange(it.toString()) - } - ) - } - } - - com.collabtable.app.data.model.FieldType.AUTOCOMPLETE -> { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(field.name) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text("Start typing...") }, - supportingText = { - val options = field.getAutocompleteOptions() - if (options.isNotEmpty()) { - Text("Suggestions: ${options.take(3).joinToString(", ")}") - } else null - } - ) - } - - com.collabtable.app.data.model.FieldType.DURATION -> { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(field.name) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text("2:30 (hours:minutes)") }, - supportingText = { Text("Format: HH:MM") } - ) - } - - com.collabtable.app.data.model.FieldType.RATING -> { - val maxRating = field.getMaxRating() - var rating by remember { mutableStateOf(value.toIntOrNull() ?: 0) } - - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = field.name, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(bottom = 8.dp) - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start - ) { - for (i in 1..maxRating) { - IconButton( - onClick = { - rating = i - onValueChange(i.toString()) - } - ) { - Icon( - imageVector = if (i <= rating) Icons.Default.Star else Icons.Default.Star, - contentDescription = "Star $i", - tint = if (i <= rating) - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.outlineVariant - ) - } - } - } - } - } - - com.collabtable.app.data.model.FieldType.COLOR -> { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(field.name) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text("#FF5733 or red") }, - leadingIcon = { - // Show color preview if valid - if (value.isNotBlank()) { - Box( - modifier = Modifier - .size(24.dp) - .background( - color = try { - androidx.compose.ui.graphics.Color(android.graphics.Color.parseColor(value)) - } catch (e: Exception) { - MaterialTheme.colorScheme.surface - }, - shape = RoundedCornerShape(4.dp) - ) - .border( - 1.dp, - MaterialTheme.colorScheme.outline, - RoundedCornerShape(4.dp) - ) - ) - } - } - ) - } - - com.collabtable.app.data.model.FieldType.LOCATION -> { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(field.name) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text("Address or coordinates") } - ) - } - - com.collabtable.app.data.model.FieldType.IMAGE -> { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(field.name) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text("Image URL") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri) - ) - } - - com.collabtable.app.data.model.FieldType.FILE -> { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(field.name) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text("File path or URL") } - ) - } - - com.collabtable.app.data.model.FieldType.BARCODE -> { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(field.name) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text("Scan or enter barcode") } - ) - } - - com.collabtable.app.data.model.FieldType.SIGNATURE -> { - OutlinedTextField( - value = value, - onValueChange = {}, - readOnly = true, - label = { Text(field.name) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text("Tap to sign") } - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ManageColumnsDialog( - fields: List, - onDismiss: () -> Unit, - onAddField: (String, String, String) -> Unit, - onUpdateField: (String, String, String) -> Unit, - onDeleteField: (String) -> Unit, - onReorderFields: (List) -> Unit -) { - var showAddField by remember { mutableStateOf(false) } - var fieldToEdit by remember { mutableStateOf(null) } - var fieldToDelete by remember { mutableStateOf(null) } - - // Use derivedStateOf to keep reorderedFields in sync with fields - val reorderedFields = remember(fields) { - mutableStateListOf().apply { - clear() - addAll(fields) - } - } - - // Watch for changes in fields and update reorderedFields - LaunchedEffect(fields) { - val currentIds = reorderedFields.map { it.id }.toSet() - val newIds = fields.map { it.id }.toSet() - - // Add new fields - fields.filter { it.id !in currentIds }.forEach { newField -> - reorderedFields.add(newField) - } - - // Remove deleted fields - reorderedFields.removeAll { it.id !in newIds } - - // Update existing fields (in case of edits) - fields.forEach { updatedField -> - val index = reorderedFields.indexOfFirst { it.id == updatedField.id } - if (index >= 0 && reorderedFields[index] != updatedField) { - reorderedFields[index] = updatedField - } - } - } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Card( - modifier = Modifier - .fillMaxWidth(0.95f) - .fillMaxHeight(0.8f), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) { - Column( - modifier = Modifier.fillMaxSize() - ) { - // Header - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Manage Columns", - style = MaterialTheme.typography.headlineSmall - ) - IconButton(onClick = onDismiss) { - Icon(Icons.Default.Close, contentDescription = "Close") - } - } - - Divider() - - // Column list - LazyColumn( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - itemsIndexed( - items = reorderedFields, - key = { _, field -> field.id } - ) { index, field -> - ColumnItem( - field = field, - onEdit = { fieldToEdit = field }, - onDelete = { fieldToDelete = field }, - onMoveUp = { - if (index > 0) { - val item = reorderedFields.removeAt(index) - reorderedFields.add(index - 1, item) - } - }, - onMoveDown = { - if (index < reorderedFields.size - 1) { - val item = reorderedFields.removeAt(index) - reorderedFields.add(index + 1, item) - } - }, - canMoveUp = index > 0, - canMoveDown = index < reorderedFields.size - 1 - ) - } - } - - Divider() - - // Add button - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Button( - onClick = { showAddField = true }, - modifier = Modifier.weight(1f) - ) { - Icon(Icons.Default.Add, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text("Add Column") - } - - Spacer(modifier = Modifier.width(8.dp)) - - Button( - onClick = { - onReorderFields(reorderedFields.toList()) - onDismiss() - } - ) { - Text("Done") - } - } - } - } - } - - if (showAddField) { - AddFieldDialog( - onDismiss = { showAddField = false }, - onAdd = { name, fieldType, fieldOptions -> - onAddField(name, fieldType, fieldOptions) - showAddField = false - } - ) - } - - fieldToEdit?.let { field -> - EditFieldDialog( - field = field, - onDismiss = { fieldToEdit = null }, - onUpdate = { fieldType, fieldOptions -> - onUpdateField(field.id, fieldType, fieldOptions) - fieldToEdit = null - } - ) - } - - fieldToDelete?.let { field -> - AlertDialog( - onDismissRequest = { fieldToDelete = null }, - title = { Text("Delete Column") }, - text = { Text("Are you sure you want to delete '${field.name}'? All data in this column will be lost.") }, - confirmButton = { - TextButton( - onClick = { - onDeleteField(field.id) - fieldToDelete = null - } - ) { - Text("Delete") - } - }, - dismissButton = { - TextButton(onClick = { fieldToDelete = null }) { - Text("Cancel") - } - } - ) - } -} - -@Composable -fun ColumnItem( - field: Field, - onEdit: () -> Unit, - onDelete: () -> Unit, - onMoveUp: () -> Unit, - onMoveDown: () -> Unit, - canMoveUp: Boolean, - canMoveDown: Boolean -) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - // Move up/down buttons - Column { - IconButton( - onClick = onMoveUp, - enabled = canMoveUp, - modifier = Modifier.size(32.dp) - ) { - Icon( - Icons.Default.KeyboardArrowUp, - contentDescription = "Move up", - tint = if (canMoveUp) - MaterialTheme.colorScheme.onSurfaceVariant - else - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) - ) - } - IconButton( - onClick = onMoveDown, - enabled = canMoveDown, - modifier = Modifier.size(32.dp) - ) { - Icon( - Icons.Default.KeyboardArrowDown, - contentDescription = "Move down", - tint = if (canMoveDown) - MaterialTheme.colorScheme.onSurfaceVariant - else - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) - ) - } - } - - Spacer(modifier = Modifier.width(12.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = field.name, - style = MaterialTheme.typography.titleMedium - ) - Text( - text = when (field.getType()) { - // Text types - com.collabtable.app.data.model.FieldType.TEXT -> "Text" - com.collabtable.app.data.model.FieldType.MULTILINE_TEXT -> "Multiline Text" - - // Number types - com.collabtable.app.data.model.FieldType.NUMBER -> "Number" - com.collabtable.app.data.model.FieldType.CURRENCY -> "Currency (${field.getCurrency()})" - com.collabtable.app.data.model.FieldType.PERCENTAGE -> "Percentage" - - // Selection types - com.collabtable.app.data.model.FieldType.CHECKBOX -> "Checkbox" - com.collabtable.app.data.model.FieldType.SWITCH -> "Switch" - com.collabtable.app.data.model.FieldType.DROPDOWN -> "Dropdown (${field.getDropdownOptions().size} options)" - com.collabtable.app.data.model.FieldType.AUTOCOMPLETE -> "Autocomplete (${field.getAutocompleteOptions().size} options)" - - // Link types - com.collabtable.app.data.model.FieldType.URL -> "URL" - com.collabtable.app.data.model.FieldType.EMAIL -> "Email" - com.collabtable.app.data.model.FieldType.PHONE -> "Phone" - - // Date/Time types - com.collabtable.app.data.model.FieldType.DATE -> "Date" - com.collabtable.app.data.model.FieldType.TIME -> "Time" - com.collabtable.app.data.model.FieldType.DATETIME -> "Date & Time" - com.collabtable.app.data.model.FieldType.DURATION -> "Duration" - - // Media types - com.collabtable.app.data.model.FieldType.IMAGE -> "Image" - com.collabtable.app.data.model.FieldType.FILE -> "File" - com.collabtable.app.data.model.FieldType.BARCODE -> "Barcode" - com.collabtable.app.data.model.FieldType.SIGNATURE -> "Signature" - - // Other types - com.collabtable.app.data.model.FieldType.RATING -> "Rating (${field.getMaxRating()} stars)" - com.collabtable.app.data.model.FieldType.COLOR -> "Color" - com.collabtable.app.data.model.FieldType.LOCATION -> "Location" - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - // Edit button - IconButton(onClick = onEdit) { - Icon( - Icons.Default.Edit, - contentDescription = "Edit", - tint = MaterialTheme.colorScheme.primary - ) - } - - // Delete button - IconButton(onClick = onDelete) { - Icon( - Icons.Default.Delete, - contentDescription = "Delete", - tint = MaterialTheme.colorScheme.error - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EditItemDialog( - fields: List, - itemWithValues: ItemWithValues, - onDismiss: () -> Unit, - onUpdate: (Map) -> Unit, - onDelete: () -> Unit -) { - val fieldValues = remember { mutableStateMapOf() } - var showDeleteConfirmation by remember { mutableStateOf(false) } - // Map values by field to avoid linear lookups and ensure consistent mapping when fields reorder - val valuesByFieldId = remember(itemWithValues.values) { - itemWithValues.values.associateBy { it.fieldId } - } - - // Initialize field values from existing item - LaunchedEffect(itemWithValues) { - itemWithValues.values.forEach { itemValue -> - fieldValues[itemValue.id] = itemValue.value - } - } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Card( - modifier = Modifier - .fillMaxWidth(0.95f) - .fillMaxHeight(0.85f), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) { - Column( - modifier = Modifier.fillMaxSize() - ) { - // Header - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Edit Item", - style = MaterialTheme.typography.headlineSmall - ) - IconButton(onClick = onDismiss) { - Icon(Icons.Default.Close, contentDescription = "Close") - } - } - - Divider() - - // Fields list - LazyColumn( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(fields, key = { it.id }) { field -> - val itemValue = valuesByFieldId[field.id] - if (itemValue != null) { - FieldInput( - field = field, - value = fieldValues[itemValue.id].orEmpty(), - onValueChange = { newValue -> - fieldValues[itemValue.id] = newValue - } - ) - } - } - } - - Divider() - - // Action buttons - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Button( - onClick = { showDeleteConfirmation = true }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ) - ) { - Icon(Icons.Default.Delete, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text("Delete") - } - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = onDismiss) { - Text("Cancel") - } - - Button( - onClick = { - onUpdate(fieldValues.toMap()) - } - ) { - Text("Save") - } - } - } - } - } - } - - if (showDeleteConfirmation) { - AlertDialog( - onDismissRequest = { showDeleteConfirmation = false }, - title = { Text("Delete Item") }, - text = { Text("Are you sure you want to delete this item? This action cannot be undone.") }, - confirmButton = { - TextButton( - onClick = { - onDelete() - showDeleteConfirmation = false - } - ) { - Text("Delete") - } - }, - dismissButton = { - TextButton(onClick = { showDeleteConfirmation = false }) { - Text("Cancel") - } - } - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun FilterSortDialog( - fields: List, - currentSortField: Field?, - currentSortAscending: Boolean, - currentGroupByField: Field?, - currentFilterField: Field?, - currentFilterValue: String, - onDismiss: () -> Unit, - onApply: (sortField: Field?, sortAscending: Boolean, groupByField: Field?, filterField: Field?, filterValue: String) -> Unit, - onClearAll: () -> Unit -) { - var sortField by remember { mutableStateOf(currentSortField) } - var sortAscending by remember { mutableStateOf(currentSortAscending) } - var groupByField by remember { mutableStateOf(currentGroupByField) } - var filterField by remember { mutableStateOf(currentFilterField) } - var filterValue by remember { mutableStateOf(currentFilterValue) } - - var sortFieldExpanded by remember { mutableStateOf(false) } - var groupByFieldExpanded by remember { mutableStateOf(false) } - var filterFieldExpanded by remember { mutableStateOf(false) } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Card( - modifier = Modifier - .fillMaxWidth(0.95f) - .fillMaxHeight(0.7f), - shape = RoundedCornerShape(16.dp) - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { - // Header - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Filter, Sort & Group", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - IconButton(onClick = onDismiss) { - Icon(Icons.Default.Close, contentDescription = "Close") - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - // Content - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - // Sort Section - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = "Sort By", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - - ExposedDropdownMenuBox( - expanded = sortFieldExpanded, - onExpandedChange = { sortFieldExpanded = it } - ) { - OutlinedTextField( - value = sortField?.name ?: "None", - onValueChange = {}, - readOnly = true, - label = { Text("Sort Field") }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = sortFieldExpanded) }, - modifier = Modifier - .fillMaxWidth() - .menuAnchor() - ) - - ExposedDropdownMenu( - expanded = sortFieldExpanded, - onDismissRequest = { sortFieldExpanded = false } - ) { - DropdownMenuItem( - text = { Text("None") }, - onClick = { - sortField = null - sortFieldExpanded = false - } - ) - fields.forEach { field -> - DropdownMenuItem( - text = { Text(field.name) }, - onClick = { - sortField = field - sortFieldExpanded = false - } - ) - } - } - } - - if (sortField != null) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text("Order:", modifier = Modifier.padding(end = 8.dp)) - FilterChip( - selected = sortAscending, - onClick = { sortAscending = true }, - label = { Text("Ascending") }, - leadingIcon = if (sortAscending) { - { Icon(Icons.Default.Check, contentDescription = null) } - } else null - ) - Spacer(modifier = Modifier.width(8.dp)) - FilterChip( - selected = !sortAscending, - onClick = { sortAscending = false }, - label = { Text("Descending") }, - leadingIcon = if (!sortAscending) { - { Icon(Icons.Default.Check, contentDescription = null) } - } else null - ) - } - } - } - - Divider() - - // Group By Section - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = "Group By", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - - ExposedDropdownMenuBox( - expanded = groupByFieldExpanded, - onExpandedChange = { groupByFieldExpanded = it } - ) { - OutlinedTextField( - value = groupByField?.name ?: "None", - onValueChange = {}, - readOnly = true, - label = { Text("Group By Field") }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = groupByFieldExpanded) }, - modifier = Modifier - .fillMaxWidth() - .menuAnchor() - ) - - ExposedDropdownMenu( - expanded = groupByFieldExpanded, - onDismissRequest = { groupByFieldExpanded = false } - ) { - DropdownMenuItem( - text = { Text("None") }, - onClick = { - groupByField = null - groupByFieldExpanded = false - } - ) - fields.forEach { field -> - DropdownMenuItem( - text = { Text(field.name) }, - onClick = { - groupByField = field - groupByFieldExpanded = false - } - ) - } - } - } - } - - Divider() - - // Filter Section - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = "Filter", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - - ExposedDropdownMenuBox( - expanded = filterFieldExpanded, - onExpandedChange = { filterFieldExpanded = it } - ) { - OutlinedTextField( - value = filterField?.name ?: "None", - onValueChange = {}, - readOnly = true, - label = { Text("Filter Field") }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = filterFieldExpanded) }, - modifier = Modifier - .fillMaxWidth() - .menuAnchor() - ) - - ExposedDropdownMenu( - expanded = filterFieldExpanded, - onDismissRequest = { filterFieldExpanded = false } - ) { - DropdownMenuItem( - text = { Text("None") }, - onClick = { - filterField = null - filterValue = "" - filterFieldExpanded = false - } - ) - fields.forEach { field -> - DropdownMenuItem( - text = { Text(field.name) }, - onClick = { - filterField = field - filterFieldExpanded = false - } - ) - } - } - } - - if (filterField != null) { - OutlinedTextField( - value = filterValue, - onValueChange = { filterValue = it }, - label = { Text("Filter Value") }, - placeholder = { Text("Enter text to filter...") }, - modifier = Modifier.fillMaxWidth() - ) - } - } - } - - Divider() - - // Action buttons - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - OutlinedButton(onClick = onClearAll) { - Text("Clear All") - } - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = onDismiss) { - Text("Cancel") - } - - Button( - onClick = { - onApply(sortField, sortAscending, groupByField, filterField, filterValue) - } - ) { - Text("Apply") - } - } - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun SortDialog( - fields: List, - currentSortField: Field?, - currentSortAscending: Boolean, - onDismiss: () -> Unit, - onApply: (sortField: Field?, sortAscending: Boolean) -> Unit -) { - var sortField by remember { mutableStateOf(currentSortField) } - var sortAscending by remember { mutableStateOf(currentSortAscending) } - var sortFieldExpanded by remember { mutableStateOf(false) } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Card( - modifier = Modifier - .fillMaxWidth(0.9f) - .wrapContentHeight(), - shape = RoundedCornerShape(16.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - // Header - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Sort Items", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - IconButton(onClick = onDismiss) { - Icon(Icons.Default.Close, contentDescription = "Close") - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - // Sort Field Selection - ExposedDropdownMenuBox( - expanded = sortFieldExpanded, - onExpandedChange = { sortFieldExpanded = it } - ) { - OutlinedTextField( - value = sortField?.name ?: "None", - onValueChange = {}, - readOnly = true, - label = { Text("Sort Field") }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = sortFieldExpanded) }, - modifier = Modifier - .fillMaxWidth() - .menuAnchor() - ) - - ExposedDropdownMenu( - expanded = sortFieldExpanded, - onDismissRequest = { sortFieldExpanded = false } - ) { - DropdownMenuItem( - text = { Text("None") }, - onClick = { - sortField = null - sortFieldExpanded = false - } - ) - fields.forEach { field -> - DropdownMenuItem( - text = { Text(field.name) }, - onClick = { - sortField = field - sortFieldExpanded = false - } - ) - } - } - } - - // Sort Order Selection - if (sortField != null) { - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Order", - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(bottom = 8.dp) - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - FilterChip( - selected = sortAscending, - onClick = { sortAscending = true }, - label = { Text("Ascending โ†‘") }, - modifier = Modifier.weight(1f), - leadingIcon = if (sortAscending) { - { Icon(Icons.Default.Check, contentDescription = null) } - } else null - ) - FilterChip( - selected = !sortAscending, - onClick = { sortAscending = false }, - label = { Text("Descending โ†“") }, - modifier = Modifier.weight(1f), - leadingIcon = if (!sortAscending) { - { Icon(Icons.Default.Check, contentDescription = null) } - } else null - ) - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - // Action Buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedButton(onClick = onDismiss) { - Text("Cancel") - } - Spacer(modifier = Modifier.width(8.dp)) - Button( - onClick = { onApply(sortField, sortAscending) } - ) { - Text("Apply") - } - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun GroupDialog( - fields: List, - currentGroupByField: Field?, - onDismiss: () -> Unit, - onApply: (groupByField: Field?) -> Unit -) { - var groupByField by remember { mutableStateOf(currentGroupByField) } - var groupByFieldExpanded by remember { mutableStateOf(false) } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Card( - modifier = Modifier - .fillMaxWidth(0.9f) - .wrapContentHeight(), - shape = RoundedCornerShape(16.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - // Header - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Group Items", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - IconButton(onClick = onDismiss) { - Icon(Icons.Default.Close, contentDescription = "Close") - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = "Group items by a field to organize them into categories", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Group By Field Selection - ExposedDropdownMenuBox( - expanded = groupByFieldExpanded, - onExpandedChange = { groupByFieldExpanded = it } - ) { - OutlinedTextField( - value = groupByField?.name ?: "None", - onValueChange = {}, - readOnly = true, - label = { Text("Group By Field") }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = groupByFieldExpanded) }, - modifier = Modifier - .fillMaxWidth() - .menuAnchor() - ) - - ExposedDropdownMenu( - expanded = groupByFieldExpanded, - onDismissRequest = { groupByFieldExpanded = false } - ) { - DropdownMenuItem( - text = { Text("None") }, - onClick = { - groupByField = null - groupByFieldExpanded = false - } - ) - fields.forEach { field -> - DropdownMenuItem( - text = { Text(field.name) }, - onClick = { - groupByField = field - groupByFieldExpanded = false - } - ) - } - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - // Action Buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedButton(onClick = onDismiss) { - Text("Cancel") - } - Spacer(modifier = Modifier.width(8.dp)) - Button( - onClick = { onApply(groupByField) } - ) { - Text("Apply") - } - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun FilterDialog( - fields: List, - currentFilterField: Field?, - currentFilterValue: String, - onDismiss: () -> Unit, - onApply: (filterField: Field?, filterValue: String) -> Unit -) { - var filterField by remember { mutableStateOf(currentFilterField) } - var filterValue by remember { mutableStateOf(currentFilterValue) } - var filterFieldExpanded by remember { mutableStateOf(false) } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Card( - modifier = Modifier - .fillMaxWidth(0.9f) - .wrapContentHeight(), - shape = RoundedCornerShape(16.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - // Header - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Filter Items", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - IconButton(onClick = onDismiss) { - Icon(Icons.Default.Close, contentDescription = "Close") - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = "Show only items that contain specific text in a field", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Filter Field Selection - ExposedDropdownMenuBox( - expanded = filterFieldExpanded, - onExpandedChange = { filterFieldExpanded = it } - ) { - OutlinedTextField( - value = filterField?.name ?: "None", - onValueChange = {}, - readOnly = true, - label = { Text("Filter Field") }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = filterFieldExpanded) }, - modifier = Modifier - .fillMaxWidth() - .menuAnchor() - ) - - ExposedDropdownMenu( - expanded = filterFieldExpanded, - onDismissRequest = { filterFieldExpanded = false } - ) { - DropdownMenuItem( - text = { Text("None") }, - onClick = { - filterField = null - filterFieldExpanded = false - } - ) - fields.forEach { field -> - DropdownMenuItem( - text = { Text(field.name) }, - onClick = { - filterField = field - filterFieldExpanded = false - } - ) - } - } - } - - // Filter Value Input - if (filterField != null) { - Spacer(modifier = Modifier.height(16.dp)) - OutlinedTextField( - value = filterValue, - onValueChange = { filterValue = it }, - label = { Text("Filter Value") }, - placeholder = { Text("Enter text to filter...") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - } - - Spacer(modifier = Modifier.height(24.dp)) - - // Action Buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedButton(onClick = onDismiss) { - Text("Cancel") - } - Spacer(modifier = Modifier.width(8.dp)) - Button( - onClick = { onApply(filterField, filterValue) }, - enabled = filterField == null || filterValue.isNotBlank() - ) { - Text("Apply") - } - } - } - } - } -} - -@Composable -private fun RenameListDialog( - currentName: String, - onDismiss: () -> Unit, - onRename: (String) -> Unit -) { - var newName by remember { mutableStateOf(currentName) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text( - text = "Rename List", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - }, - text = { - Column { - Text( - text = "Enter a new name for this list", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 16.dp) - ) - OutlinedTextField( - value = newName, - onValueChange = { newName = it }, - label = { Text("List Name") }, - placeholder = { Text("Enter list name...") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - } - }, - confirmButton = { - Button( - onClick = { onRename(newName) }, - enabled = newName.isNotBlank() && newName.trim() != currentName - ) { - Text("Rename") - } - }, - dismissButton = { - OutlinedButton(onClick = onDismiss) { - Text("Cancel") - } - } - ) -} +package com.collabtable.app.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.collabtable.app.R +import com.collabtable.app.data.database.CollabTableDatabase +import com.collabtable.app.data.model.Field +import com.collabtable.app.data.model.ItemWithValues +import java.text.SimpleDateFormat +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun ListDetailScreen( + listId: String, + onNavigateBack: () -> Unit, +) { + val context = LocalContext.current + val database = remember { CollabTableDatabase.getDatabase(context) } + val viewModel = remember { ListDetailViewModel(database, listId, context) } + + val list by viewModel.list.collectAsState() + val fields by viewModel.fields.collectAsState() + val items by viewModel.items.collectAsState() + + // Use derivedStateOf to create stable references + val stableFields by remember { derivedStateOf { fields } } + val stableItems by remember { derivedStateOf { items } } + + var showManageColumnsDialog by remember { mutableStateOf(false) } + var showAddItemDialog by remember { mutableStateOf(false) } + var itemToEdit by remember { mutableStateOf(null) } + var showSortDialog by remember { mutableStateOf(false) } + var showGroupDialog by remember { mutableStateOf(false) } + var showFilterDialog by remember { mutableStateOf(false) } + var showRenameListDialog by remember { mutableStateOf(false) } + + // Filter/Sort state + var sortField by remember { mutableStateOf(null) } + var sortAscending by remember { mutableStateOf(true) } + var groupByField by remember { mutableStateOf(null) } + var filterField by remember { mutableStateOf(null) } + var filterValue by remember { mutableStateOf("") } + + // Shared scroll state for synchronized horizontal scrolling + val horizontalScrollState = rememberScrollState() + + // Field widths state (resizable columns) + val fieldWidths = remember { mutableStateMapOf() } + + // Initialize field widths + LaunchedEffect(stableFields) { + stableFields.forEach { field -> + if (!fieldWidths.containsKey(field.id)) { + fieldWidths[field.id] = 150.dp + } + } + } + + // Apply filtering, sorting, and grouping + val processedItems = + remember(stableItems, stableFields, filterField, filterValue, sortField, sortAscending, groupByField) { + var result = stableItems + + // Apply filter + if (filterField != null && filterValue.isNotBlank()) { + result = + result.filter { itemWithValues -> + val value = itemWithValues.values.find { it.fieldId == filterField!!.id }?.value ?: "" + value.contains(filterValue, ignoreCase = true) + } + } + + // Apply sort + if (sortField != null) { + result = + result.sortedWith( + compareBy { itemWithValues -> + val value = itemWithValues.values.find { it.fieldId == sortField!!.id }?.value ?: "" + if (sortAscending) value else value + }, + ) + if (!sortAscending) { + result = result.reversed() + } + } + + result + } + + // Group items if groupByField is set + val groupedItems = + remember(processedItems, groupByField) { + if (groupByField != null) { + processedItems.groupBy { itemWithValues -> + itemWithValues.values.find { it.fieldId == groupByField!!.id }?.value ?: "(Empty)" + } + } else { + mapOf("" to processedItems) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Row( + modifier = Modifier.clickable { showRenameListDialog = true }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(list?.name ?: "") + Spacer(modifier = Modifier.width(4.dp)) + Icon( + Icons.Default.Edit, + contentDescription = "Rename", + modifier = Modifier.size(18.dp), + ) + } + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { showManageColumnsDialog = true }) { + Icon(Icons.Default.Settings, contentDescription = "Manage Columns") + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { showAddItemDialog = true }, + ) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_item)) + } + }, + ) { padding -> + if (stableFields.isEmpty()) { + Box( + modifier = + Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "No fields yet. Add fields to get started!", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { showManageColumnsDialog = true }) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.add_field)) + } + } + } + } else { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding), + ) { + // Filter/Sort/Group Controls - Always visible above table + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Sort button + FilterChip( + selected = sortField != null, + onClick = { showSortDialog = true }, + label = { + Text( + if (sortField != null) { + "Sort: ${sortField!!.name} ${if (sortAscending) "โ†‘" else "โ†“"}" + } else { + "Sort" + }, + ) + }, + leadingIcon = { + Icon( + Icons.Default.Search, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + }, + trailingIcon = + if (sortField != null) { + { + IconButton( + onClick = { + sortField = null + sortAscending = true + }, + modifier = Modifier.size(18.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = "Clear", + modifier = Modifier.size(16.dp), + ) + } + } + } else { + null + }, + ) + + // Group button + FilterChip( + selected = groupByField != null, + onClick = { showGroupDialog = true }, + label = { + Text( + if (groupByField != null) { + "Group: ${groupByField!!.name}" + } else { + "Group" + }, + ) + }, + leadingIcon = { + Icon( + Icons.Default.List, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + }, + trailingIcon = + if (groupByField != null) { + { + IconButton( + onClick = { groupByField = null }, + modifier = Modifier.size(18.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = "Clear", + modifier = Modifier.size(16.dp), + ) + } + } + } else { + null + }, + ) + + // Filter button + FilterChip( + selected = filterField != null, + onClick = { showFilterDialog = true }, + label = { + Text( + if (filterField != null) { + "Filter: ${filterField!!.name}" + } else { + "Filter" + }, + ) + }, + leadingIcon = { + Icon( + Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + }, + trailingIcon = + if (filterField != null) { + { + IconButton( + onClick = { + filterField = null + filterValue = "" + }, + modifier = Modifier.size(18.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = "Clear", + modifier = Modifier.size(16.dp), + ) + } + } + } else { + null + }, + ) + } + + // Field headers with long-press to delete and resize handles + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(horizontalScrollState), + verticalAlignment = Alignment.CenterVertically, + ) { + stableFields.forEach { field -> + key(field.id) { + FieldHeader( + field = field, + width = fieldWidths[field.id] ?: 150.dp, + onWidthChange = { delta -> + val currentWidth = fieldWidths[field.id] ?: 150.dp + val newWidth = (currentWidth.value + delta).coerceIn(100f, 400f) + fieldWidths[field.id] = newWidth.dp + }, + ) + } + } + } + + // Items list with synchronized scrolling + if (stableItems.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.no_items), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else if (processedItems.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "No items match the current filter", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = { + filterField = null + filterValue = "" + }) { + Text("Clear Filter") + } + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + groupedItems.entries.forEach { (groupName, groupItems) -> + // Show group header if grouping is enabled + if (groupByField != null) { + item(key = "group_$groupName") { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + text = "$groupName (${groupItems.size})", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + + // Show items in the group + items( + items = groupItems, + key = { it.item.id }, + ) { itemWithValues -> + ItemRow( + fields = stableFields, + fieldWidths = fieldWidths, + itemWithValues = itemWithValues, + scrollState = horizontalScrollState, + onClick = { itemToEdit = itemWithValues }, + ) + } + } + } + } + } + } + } + + if (showManageColumnsDialog) { + ManageColumnsDialog( + fields = stableFields, + onDismiss = { showManageColumnsDialog = false }, + onAddField = { name, fieldType, fieldOptions -> + viewModel.addField(name, fieldType, fieldOptions) + }, + onUpdateField = { fieldId, fieldType, fieldOptions -> + viewModel.updateField(fieldId, fieldType, fieldOptions) + }, + onDeleteField = { fieldId -> + viewModel.deleteField(fieldId) + }, + onReorderFields = { reorderedFields -> + viewModel.reorderFields(reorderedFields) + }, + ) + } + + if (showAddItemDialog) { + AddItemDialog( + fields = stableFields, + onDismiss = { showAddItemDialog = false }, + onAdd = { fieldValues -> + viewModel.addItemWithValues(fieldValues) + showAddItemDialog = false + }, + ) + } + + // Sort Dialog + if (showSortDialog) { + SortDialog( + fields = stableFields, + currentSortField = sortField, + currentSortAscending = sortAscending, + onDismiss = { showSortDialog = false }, + onApply = { newSortField, newSortAscending -> + sortField = newSortField + sortAscending = newSortAscending + showSortDialog = false + }, + ) + } + + // Group Dialog + if (showGroupDialog) { + GroupDialog( + fields = stableFields, + currentGroupByField = groupByField, + onDismiss = { showGroupDialog = false }, + onApply = { newGroupByField -> + groupByField = newGroupByField + showGroupDialog = false + }, + ) + } + + // Filter Dialog + if (showFilterDialog) { + FilterDialog( + fields = stableFields, + currentFilterField = filterField, + currentFilterValue = filterValue, + onDismiss = { showFilterDialog = false }, + onApply = { newFilterField, newFilterValue -> + filterField = newFilterField + filterValue = newFilterValue + showFilterDialog = false + }, + ) + } + + // Rename List Dialog + if (showRenameListDialog) { + list?.let { currentList -> + RenameListDialog( + currentName = currentList.name, + onDismiss = { showRenameListDialog = false }, + onRename = { newName -> + viewModel.renameList(newName) + showRenameListDialog = false + }, + ) + } + } + + itemToEdit?.let { itemWithValues -> + EditItemDialog( + fields = stableFields, + itemWithValues = itemWithValues, + onDismiss = { itemToEdit = null }, + onUpdate = { fieldValues -> + fieldValues.forEach { (valueId, newValue) -> + viewModel.updateItemValue(valueId, newValue) + } + itemToEdit = null + }, + onDelete = { + viewModel.deleteItem(itemWithValues.item.id) + itemToEdit = null + }, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun FieldHeader( + field: Field, + width: Dp, + onWidthChange: (Float) -> Unit, +) { + val density = LocalDensity.current + + Box( + modifier = + Modifier + .width(width) + .border(1.dp, MaterialTheme.colorScheme.outline), + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = field.name, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.weight(1f), + ) + + // Resize handle + Box( + modifier = + Modifier + .size(24.dp) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + with(density) { + onWidthChange(dragAmount.x.toDp().value) + } + } + }, + ) { + Icon( + Icons.Default.Menu, + contentDescription = "Resize", + modifier = + Modifier + .size(16.dp) + .align(Alignment.Center), + tint = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f), + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ItemRow( + fields: List, + fieldWidths: Map, + itemWithValues: ItemWithValues, + scrollState: androidx.compose.foundation.ScrollState, + onClick: () -> Unit, +) { + // Map values by fieldId to ensure correct value-column alignment regardless of original order + val valuesByFieldId = + remember(itemWithValues.values) { + itemWithValues.values.associateBy { it.fieldId } + } + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .horizontalScroll(scrollState), + verticalAlignment = Alignment.CenterVertically, + ) { + fields.forEach { field -> + key(field.id) { + val value = valuesByFieldId[field.id] + val fieldWidth = fieldWidths[field.id] ?: 150.dp + + Box( + modifier = + Modifier + .width(fieldWidth) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + ) + .padding(8.dp), + ) { + when (field.getType()) { + com.collabtable.app.data.model.FieldType.TEXT -> { + Text( + text = value?.value ?: "", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + + com.collabtable.app.data.model.FieldType.MULTILINE_TEXT -> { + Text( + text = value?.value ?: "", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + + com.collabtable.app.data.model.FieldType.NUMBER -> { + Text( + text = value?.value ?: "", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + + com.collabtable.app.data.model.FieldType.CURRENCY -> { + Text( + text = + if (value?.value.isNullOrBlank()) { + "" + } else { + "${field.getCurrency()}${value?.value}" + }, + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + + com.collabtable.app.data.model.FieldType.PERCENTAGE -> { + Text( + text = if (value?.value.isNullOrBlank()) "" else "${value?.value}%", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + + com.collabtable.app.data.model.FieldType.DROPDOWN -> { + Text( + text = value?.value ?: "", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + + com.collabtable.app.data.model.FieldType.CHECKBOX -> { + Text( + text = if (value?.value == "true") "โœ“" else "", + style = MaterialTheme.typography.bodyLarge, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + + com.collabtable.app.data.model.FieldType.URL -> { + val uriHandler = LocalUriHandler.current + val urlValue = value?.value ?: "" + if (urlValue.isNotBlank()) { + ClickableText( + text = + buildAnnotatedString { + withStyle( + style = + SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + ) { + append(urlValue) + } + }, + onClick = { + try { + uriHandler.openUri(urlValue) + } catch (e: Exception) { + // Handle invalid URL + } + }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } else { + Text( + text = "", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + } + + com.collabtable.app.data.model.FieldType.EMAIL -> { + val uriHandler = LocalUriHandler.current + val emailValue = value?.value ?: "" + if (emailValue.isNotBlank()) { + ClickableText( + text = + buildAnnotatedString { + withStyle( + style = + SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + ) { + append(emailValue) + } + }, + onClick = { + try { + uriHandler.openUri("mailto:$emailValue") + } catch (e: Exception) { + // Handle invalid email + } + }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } else { + Text( + text = "", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + } + + com.collabtable.app.data.model.FieldType.PHONE -> { + val uriHandler = LocalUriHandler.current + val phoneValue = value?.value ?: "" + if (phoneValue.isNotBlank()) { + ClickableText( + text = + buildAnnotatedString { + withStyle( + style = + SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + ) { + append(phoneValue) + } + }, + onClick = { + try { + uriHandler.openUri("tel:$phoneValue") + } catch (e: Exception) { + // Handle invalid phone + } + }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } else { + Text( + text = "", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + } + + com.collabtable.app.data.model.FieldType.DATE -> { + Text( + text = value?.value ?: "", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + + com.collabtable.app.data.model.FieldType.TIME -> { + Text( + text = value?.value ?: "", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + + com.collabtable.app.data.model.FieldType.DATETIME -> { + Text( + text = value?.value ?: "", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + + // New field types + com.collabtable.app.data.model.FieldType.SWITCH -> { + Text( + text = if (value?.value == "true") "ON" else "OFF", + style = MaterialTheme.typography.bodyMedium, + color = + if (value?.value == "true") { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + + com.collabtable.app.data.model.FieldType.AUTOCOMPLETE -> { + Text( + text = value?.value ?: "", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + + com.collabtable.app.data.model.FieldType.DURATION -> { + Text( + text = value?.value ?: "", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + + com.collabtable.app.data.model.FieldType.RATING -> { + val rating = value?.value?.toIntOrNull() ?: 0 + val maxRating = field.getMaxRating() + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) { + repeat(maxRating) { index -> + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + tint = + if (index < rating) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + }, + modifier = Modifier.size(16.dp), + ) + } + } + } + + com.collabtable.app.data.model.FieldType.COLOR -> { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (value?.value?.isNotBlank() == true) { + Box( + modifier = + Modifier + .size(20.dp) + .background( + color = + try { + androidx.compose.ui.graphics.Color( + android.graphics.Color.parseColor(value.value), + ) + } catch (e: Exception) { + MaterialTheme.colorScheme.surface + }, + shape = RoundedCornerShape(4.dp), + ) + .border( + 1.dp, + MaterialTheme.colorScheme.outline, + RoundedCornerShape(4.dp), + ), + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = value?.value ?: "", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + com.collabtable.app.data.model.FieldType.LOCATION -> { + Text( + text = value?.value ?: "", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + + com.collabtable.app.data.model.FieldType.IMAGE -> { + if (value?.value?.isNotBlank() == true) { + Text( + text = "๐Ÿ–ผ๏ธ ${value.value}", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } else { + Text( + text = "", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + } + + com.collabtable.app.data.model.FieldType.FILE -> { + if (value?.value?.isNotBlank() == true) { + Text( + text = "๐Ÿ“Ž ${value.value}", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } else { + Text( + text = "", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + } + + com.collabtable.app.data.model.FieldType.BARCODE -> { + Text( + text = value?.value ?: "", + style = + MaterialTheme.typography.bodyMedium.copy( + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + ), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + + com.collabtable.app.data.model.FieldType.SIGNATURE -> { + if (value?.value?.isNotBlank() == true) { + Text( + text = "โœ๏ธ Signed", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } else { + Text( + text = "", + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddFieldDialog( + onDismiss: () -> Unit, + onAdd: (String, String, String) -> Unit, +) { + var fieldName by remember { mutableStateOf("") } + var selectedFieldType by remember { mutableStateOf("TEXT") } + var dropdownOptions by remember { mutableStateOf("") } + var currency by remember { mutableStateOf("$") } + var expanded by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.add_field)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = fieldName, + onValueChange = { fieldName = it }, + label = { Text(stringResource(R.string.field_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + // Field Type Selector + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + ) { + OutlinedTextField( + value = + when (selectedFieldType) { + "TEXT" -> "Text" + "MULTILINE_TEXT" -> "Multi-line Text" + "NUMBER" -> "Number" + "CURRENCY" -> "Currency" + "PERCENTAGE" -> "Percentage" + "DROPDOWN" -> "Dropdown" + "CHECKBOX" -> "Checkbox" + "URL" -> "URL" + "EMAIL" -> "Email" + "PHONE" -> "Phone" + "DATE" -> "Date" + "TIME" -> "Time" + "DATETIME" -> "Date & Time" + else -> "Text" + }, + onValueChange = {}, + readOnly = true, + label = { Text("Field Type") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = + Modifier + .menuAnchor() + .fillMaxWidth(), + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + // Text types + DropdownMenuItem( + text = { Text("Text") }, + onClick = { + selectedFieldType = "TEXT" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Multi-line Text") }, + onClick = { + selectedFieldType = "MULTILINE_TEXT" + expanded = false + }, + ) + + // Number types + DropdownMenuItem( + text = { Text("Number") }, + onClick = { + selectedFieldType = "NUMBER" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Currency") }, + onClick = { + selectedFieldType = "CURRENCY" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Percentage") }, + onClick = { + selectedFieldType = "PERCENTAGE" + expanded = false + }, + ) + + // Selection types + DropdownMenuItem( + text = { Text("Dropdown") }, + onClick = { + selectedFieldType = "DROPDOWN" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Autocomplete") }, + onClick = { + selectedFieldType = "AUTOCOMPLETE" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Checkbox") }, + onClick = { + selectedFieldType = "CHECKBOX" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Switch") }, + onClick = { + selectedFieldType = "SWITCH" + expanded = false + }, + ) + + // Link types + DropdownMenuItem( + text = { Text("URL") }, + onClick = { + selectedFieldType = "URL" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Email") }, + onClick = { + selectedFieldType = "EMAIL" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Phone") }, + onClick = { + selectedFieldType = "PHONE" + expanded = false + }, + ) + + // Date/Time types + DropdownMenuItem( + text = { Text("Date") }, + onClick = { + selectedFieldType = "DATE" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Time") }, + onClick = { + selectedFieldType = "TIME" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Date & Time") }, + onClick = { + selectedFieldType = "DATETIME" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Duration") }, + onClick = { + selectedFieldType = "DURATION" + expanded = false + }, + ) + + // Media types + DropdownMenuItem( + text = { Text("Image") }, + onClick = { + selectedFieldType = "IMAGE" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("File") }, + onClick = { + selectedFieldType = "FILE" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Barcode") }, + onClick = { + selectedFieldType = "BARCODE" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Signature") }, + onClick = { + selectedFieldType = "SIGNATURE" + expanded = false + }, + ) + + // Other types + DropdownMenuItem( + text = { Text("Rating") }, + onClick = { + selectedFieldType = "RATING" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Color") }, + onClick = { + selectedFieldType = "COLOR" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Location") }, + onClick = { + selectedFieldType = "LOCATION" + expanded = false + }, + ) + } + } + + // Currency-specific options + if (selectedFieldType == "CURRENCY") { + OutlinedTextField( + value = currency, + onValueChange = { currency = it }, + label = { Text("Currency Symbol") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("$, โ‚ฌ, ยฃ, etc.") }, + ) + } + + // Dropdown-specific options + if (selectedFieldType == "DROPDOWN") { + OutlinedTextField( + value = dropdownOptions, + onValueChange = { dropdownOptions = it }, + label = { Text("Options (comma-separated)") }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Option 1, Option 2, Option 3") }, + supportingText = { Text("Enter dropdown choices separated by commas") }, + ) + } + + // Autocomplete-specific options + if (selectedFieldType == "AUTOCOMPLETE") { + OutlinedTextField( + value = dropdownOptions, + onValueChange = { dropdownOptions = it }, + label = { Text("Options (comma-separated)") }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Option 1, Option 2, Option 3") }, + supportingText = { Text("Enter autocomplete suggestions separated by commas") }, + ) + } + + // Rating-specific options + if (selectedFieldType == "RATING") { + OutlinedTextField( + value = currency, + onValueChange = { currency = it }, + label = { Text("Max Rating") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("5") }, + supportingText = { Text("Maximum number of stars (default: 5)") }, + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + if (fieldName.isNotBlank()) { + val options = + when (selectedFieldType) { + "CURRENCY" -> currency.trim() + "DROPDOWN" -> + dropdownOptions.split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + .joinToString("|") + "AUTOCOMPLETE" -> + dropdownOptions.split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + .joinToString("|") + "RATING" -> currency.trim().ifBlank { "5" } + else -> "" + } + onAdd(fieldName.trim(), selectedFieldType, options) + } + }, + enabled = + fieldName.isNotBlank() && + (selectedFieldType !in listOf("DROPDOWN", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()), + ) { + Text(stringResource(R.string.add)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditFieldDialog( + field: Field, + onDismiss: () -> Unit, + onUpdate: (String, String) -> Unit, +) { + var selectedFieldType by remember { mutableStateOf(field.fieldType ?: "TEXT") } + var dropdownOptions by remember { mutableStateOf(field.getDropdownOptions().joinToString(", ")) } + var currency by remember { mutableStateOf(field.getCurrency()) } + var expanded by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Edit Field: ${field.name}") }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Field Type Selector + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + ) { + OutlinedTextField( + value = + when (selectedFieldType) { + "TEXT", "STRING" -> "Text" + "MULTILINE_TEXT" -> "Multi-line Text" + "NUMBER" -> "Number" + "CURRENCY", "PRICE" -> "Currency" + "PERCENTAGE" -> "Percentage" + "DROPDOWN" -> "Dropdown" + "CHECKBOX" -> "Checkbox" + "URL" -> "URL" + "EMAIL" -> "Email" + "PHONE" -> "Phone" + "DATE" -> "Date" + "TIME" -> "Time" + "DATETIME" -> "Date & Time" + else -> "Text" + }, + onValueChange = {}, + readOnly = true, + label = { Text("Field Type") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = + Modifier + .menuAnchor() + .fillMaxWidth(), + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + // Text types + DropdownMenuItem( + text = { Text("Text") }, + onClick = { + selectedFieldType = "TEXT" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Multi-line Text") }, + onClick = { + selectedFieldType = "MULTILINE_TEXT" + expanded = false + }, + ) + + // Number types + DropdownMenuItem( + text = { Text("Number") }, + onClick = { + selectedFieldType = "NUMBER" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Currency") }, + onClick = { + selectedFieldType = "CURRENCY" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Percentage") }, + onClick = { + selectedFieldType = "PERCENTAGE" + expanded = false + }, + ) + + // Selection types + DropdownMenuItem( + text = { Text("Dropdown") }, + onClick = { + selectedFieldType = "DROPDOWN" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Autocomplete") }, + onClick = { + selectedFieldType = "AUTOCOMPLETE" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Checkbox") }, + onClick = { + selectedFieldType = "CHECKBOX" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Switch") }, + onClick = { + selectedFieldType = "SWITCH" + expanded = false + }, + ) + + // Link types + DropdownMenuItem( + text = { Text("URL") }, + onClick = { + selectedFieldType = "URL" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Email") }, + onClick = { + selectedFieldType = "EMAIL" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Phone") }, + onClick = { + selectedFieldType = "PHONE" + expanded = false + }, + ) + + // Date/Time types + DropdownMenuItem( + text = { Text("Date") }, + onClick = { + selectedFieldType = "DATE" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Time") }, + onClick = { + selectedFieldType = "TIME" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Date & Time") }, + onClick = { + selectedFieldType = "DATETIME" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Duration") }, + onClick = { + selectedFieldType = "DURATION" + expanded = false + }, + ) + + // Media types + DropdownMenuItem( + text = { Text("Image") }, + onClick = { + selectedFieldType = "IMAGE" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("File") }, + onClick = { + selectedFieldType = "FILE" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Barcode") }, + onClick = { + selectedFieldType = "BARCODE" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Signature") }, + onClick = { + selectedFieldType = "SIGNATURE" + expanded = false + }, + ) + + // Other types + DropdownMenuItem( + text = { Text("Rating") }, + onClick = { + selectedFieldType = "RATING" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Color") }, + onClick = { + selectedFieldType = "COLOR" + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Location") }, + onClick = { + selectedFieldType = "LOCATION" + expanded = false + }, + ) + } + } + + // Currency-specific options + if (selectedFieldType == "CURRENCY" || selectedFieldType == "PRICE") { + OutlinedTextField( + value = currency, + onValueChange = { currency = it }, + label = { Text("Currency Symbol") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("$, โ‚ฌ, ยฃ, etc.") }, + ) + } + + // Dropdown-specific options + if (selectedFieldType == "DROPDOWN") { + OutlinedTextField( + value = dropdownOptions, + onValueChange = { dropdownOptions = it }, + label = { Text("Options (comma-separated)") }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Option 1, Option 2, Option 3") }, + supportingText = { Text("Enter dropdown choices separated by commas") }, + ) + } + + // Autocomplete-specific options + if (selectedFieldType == "AUTOCOMPLETE") { + OutlinedTextField( + value = dropdownOptions, + onValueChange = { dropdownOptions = it }, + label = { Text("Options (comma-separated)") }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Option 1, Option 2, Option 3") }, + supportingText = { Text("Enter autocomplete suggestions separated by commas") }, + ) + } + + // Rating-specific options + if (selectedFieldType == "RATING") { + OutlinedTextField( + value = currency, + onValueChange = { currency = it }, + label = { Text("Max Rating") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("5") }, + supportingText = { Text("Maximum number of stars (default: 5)") }, + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + val options = + when (selectedFieldType) { + "CURRENCY", "PRICE" -> currency.trim() + "DROPDOWN" -> + dropdownOptions.split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + .joinToString("|") + "AUTOCOMPLETE" -> + dropdownOptions.split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + .joinToString("|") + "RATING" -> currency.trim().ifBlank { "5" } + else -> "" + } + onUpdate(selectedFieldType, options) + }, + enabled = selectedFieldType !in listOf("DROPDOWN", "AUTOCOMPLETE") || dropdownOptions.isNotBlank(), + ) { + Text("Update") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddItemDialog( + fields: List, + onDismiss: () -> Unit, + onAdd: (Map) -> Unit, +) { + val fieldValues = remember { mutableStateMapOf() } + + // Initialize all field values to empty strings + LaunchedEffect(fields) { + fields.forEach { field -> + if (!fieldValues.containsKey(field.id)) { + fieldValues[field.id] = "" + } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.add_item)) }, + text = { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + items(fields, key = { it.id }) { field -> + FieldInput( + field = field, + value = fieldValues[field.id].orEmpty(), + onValueChange = { newValue -> + fieldValues[field.id] = newValue + }, + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { onAdd(fieldValues.toMap()) }, + ) { + Text(stringResource(R.string.add)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FieldInput( + field: Field, + value: String, + onValueChange: (String) -> Unit, +) { + when (field.getType()) { + // Text types + com.collabtable.app.data.model.FieldType.TEXT -> { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + + com.collabtable.app.data.model.FieldType.MULTILINE_TEXT -> { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = false, + minLines = 3, + maxLines = 6, + ) + } + + // Number types + com.collabtable.app.data.model.FieldType.NUMBER -> { + OutlinedTextField( + value = value, + onValueChange = { newValue -> + val filtered = newValue.filter { it.isDigit() || it == '.' || it == '-' } + onValueChange(filtered) + }, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + placeholder = { Text("0") }, + ) + } + + com.collabtable.app.data.model.FieldType.CURRENCY -> { + OutlinedTextField( + value = value, + onValueChange = { newValue -> + val filtered = newValue.filter { it.isDigit() || it == '.' } + onValueChange(filtered) + }, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + leadingIcon = { Text(field.getCurrency()) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + placeholder = { Text("0.00") }, + ) + } + + com.collabtable.app.data.model.FieldType.PERCENTAGE -> { + OutlinedTextField( + value = value, + onValueChange = { newValue -> + val filtered = newValue.filter { it.isDigit() || it == '.' } + onValueChange(filtered) + }, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + trailingIcon = { Text("%") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + placeholder = { Text("0") }, + ) + } + + // Selection types + com.collabtable.app.data.model.FieldType.CHECKBOX -> { + var checked by remember { mutableStateOf(value == "true") } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = checked, + onCheckedChange = { + checked = it + onValueChange(it.toString()) + }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = field.name, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + + com.collabtable.app.data.model.FieldType.DROPDOWN -> { + val options = field.getDropdownOptions() + var dropdownExpanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = dropdownExpanded, + onExpandedChange = { dropdownExpanded = !dropdownExpanded }, + ) { + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(field.name) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded) + }, + modifier = + Modifier + .menuAnchor() + .fillMaxWidth(), + ) + + ExposedDropdownMenu( + expanded = dropdownExpanded, + onDismissRequest = { dropdownExpanded = false }, + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + onValueChange(option) + dropdownExpanded = false + }, + ) + } + } + } + } + + com.collabtable.app.data.model.FieldType.URL -> { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + placeholder = { Text("https://example.com") }, + ) + } + + com.collabtable.app.data.model.FieldType.EMAIL -> { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + placeholder = { Text("email@example.com") }, + ) + } + + com.collabtable.app.data.model.FieldType.PHONE -> { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + placeholder = { Text("+1 (555) 123-4567") }, + ) + } + + // Date/Time types + com.collabtable.app.data.model.FieldType.DATE -> { + val dateFormat = remember { SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) } + val calendar = remember { Calendar.getInstance() } + var showDatePicker by remember { mutableStateOf(false) } + + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(field.name) }, + modifier = + Modifier + .fillMaxWidth() + .clickable { showDatePicker = true }, + singleLine = true, + placeholder = { Text("Select date") }, + trailingIcon = { + IconButton(onClick = { showDatePicker = true }) { + Icon(Icons.Default.DateRange, contentDescription = "Pick date") + } + }, + ) + + if (showDatePicker) { + val datePickerState = + rememberDatePickerState( + initialSelectedDateMillis = + if (value.isBlank()) { + System.currentTimeMillis() + } else { + try { + dateFormat.parse(value)?.time + } catch (e: Exception) { + System.currentTimeMillis() + } + }, + ) + + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + datePickerState.selectedDateMillis?.let { millis -> + calendar.timeInMillis = millis + val formattedDate = dateFormat.format(calendar.time) + onValueChange(formattedDate) + } + showDatePicker = false + }) { + Text("OK") + } + }, + dismissButton = { + TextButton(onClick = { showDatePicker = false }) { + Text("Cancel") + } + }, + ) { + DatePicker(state = datePickerState) + } + } + } + + com.collabtable.app.data.model.FieldType.TIME -> { + val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } + val calendar = remember { Calendar.getInstance() } + var showTimePicker by remember { mutableStateOf(false) } + + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(field.name) }, + modifier = + Modifier + .fillMaxWidth() + .clickable { showTimePicker = true }, + singleLine = true, + placeholder = { Text("Select time") }, + trailingIcon = { + IconButton(onClick = { showTimePicker = true }) { + Icon(Icons.Default.AccountBox, contentDescription = "Pick time") + } + }, + ) + + if (showTimePicker) { + val timePickerState = + rememberTimePickerState( + initialHour = + if (value.isBlank()) { + Calendar.getInstance().get(Calendar.HOUR_OF_DAY) + } else { + try { + timeFormat.parse(value)?.let { + calendar.time = it + calendar.get(Calendar.HOUR_OF_DAY) + } ?: 12 + } catch (e: Exception) { + 12 + } + }, + initialMinute = + if (value.isBlank()) { + Calendar.getInstance().get(Calendar.MINUTE) + } else { + try { + timeFormat.parse(value)?.let { + calendar.time = it + calendar.get(Calendar.MINUTE) + } ?: 0 + } catch (e: Exception) { + 0 + } + }, + ) + + AlertDialog( + onDismissRequest = { showTimePicker = false }, + confirmButton = { + TextButton(onClick = { + calendar.set(Calendar.HOUR_OF_DAY, timePickerState.hour) + calendar.set(Calendar.MINUTE, timePickerState.minute) + val formattedTime = timeFormat.format(calendar.time) + onValueChange(formattedTime) + showTimePicker = false + }) { + Text("OK") + } + }, + dismissButton = { + TextButton(onClick = { showTimePicker = false }) { + Text("Cancel") + } + }, + text = { + TimePicker(state = timePickerState) + }, + ) + } + } + + com.collabtable.app.data.model.FieldType.DATETIME -> { + val dateFormat = remember { SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) } + val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } + val calendar = remember { Calendar.getInstance() } + var showDatePicker by remember { mutableStateOf(false) } + var showTimePicker by remember { mutableStateOf(false) } + var tempDate by remember { mutableStateOf("") } + + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(field.name) }, + modifier = + Modifier + .fillMaxWidth() + .clickable { showDatePicker = true }, + singleLine = true, + placeholder = { Text("Select date & time") }, + trailingIcon = { + IconButton(onClick = { showDatePicker = true }) { + Icon(Icons.Default.DateRange, contentDescription = "Pick date & time") + } + }, + ) + + if (showDatePicker) { + val datePickerState = + rememberDatePickerState( + initialSelectedDateMillis = System.currentTimeMillis(), + ) + + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + datePickerState.selectedDateMillis?.let { millis -> + calendar.timeInMillis = millis + tempDate = dateFormat.format(calendar.time) + } + showDatePicker = false + showTimePicker = true + }) { + Text("Next") + } + }, + dismissButton = { + TextButton(onClick = { showDatePicker = false }) { + Text("Cancel") + } + }, + ) { + DatePicker(state = datePickerState) + } + } + + if (showTimePicker) { + val timePickerState = + rememberTimePickerState( + initialHour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY), + initialMinute = Calendar.getInstance().get(Calendar.MINUTE), + ) + + AlertDialog( + onDismissRequest = { showTimePicker = false }, + confirmButton = { + TextButton(onClick = { + calendar.set(Calendar.HOUR_OF_DAY, timePickerState.hour) + calendar.set(Calendar.MINUTE, timePickerState.minute) + val time = timeFormat.format(calendar.time) + val dateTime = "$tempDate $time" + onValueChange(dateTime) + showTimePicker = false + }) { + Text("OK") + } + }, + dismissButton = { + TextButton(onClick = { showTimePicker = false }) { + Text("Cancel") + } + }, + text = { + TimePicker(state = timePickerState) + }, + ) + } + } + + // New field types + com.collabtable.app.data.model.FieldType.SWITCH -> { + var checked by remember { mutableStateOf(value == "true") } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = field.name, + style = MaterialTheme.typography.bodyLarge, + ) + Switch( + checked = checked, + onCheckedChange = { + checked = it + onValueChange(it.toString()) + }, + ) + } + } + + com.collabtable.app.data.model.FieldType.AUTOCOMPLETE -> { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("Start typing...") }, + supportingText = { + val options = field.getAutocompleteOptions() + if (options.isNotEmpty()) { + Text("Suggestions: ${options.take(3).joinToString(", ")}") + } else { + null + } + }, + ) + } + + com.collabtable.app.data.model.FieldType.DURATION -> { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("2:30 (hours:minutes)") }, + supportingText = { Text("Format: HH:MM") }, + ) + } + + com.collabtable.app.data.model.FieldType.RATING -> { + val maxRating = field.getMaxRating() + var rating by remember { mutableStateOf(value.toIntOrNull() ?: 0) } + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = field.name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + ) { + for (i in 1..maxRating) { + IconButton( + onClick = { + rating = i + onValueChange(i.toString()) + }, + ) { + Icon( + imageVector = if (i <= rating) Icons.Default.Star else Icons.Default.Star, + contentDescription = "Star $i", + tint = + if (i <= rating) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + }, + ) + } + } + } + } + } + + com.collabtable.app.data.model.FieldType.COLOR -> { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("#FF5733 or red") }, + leadingIcon = { + // Show color preview if valid + if (value.isNotBlank()) { + Box( + modifier = + Modifier + .size(24.dp) + .background( + color = + try { + androidx.compose.ui.graphics.Color(android.graphics.Color.parseColor(value)) + } catch (e: Exception) { + MaterialTheme.colorScheme.surface + }, + shape = RoundedCornerShape(4.dp), + ) + .border( + 1.dp, + MaterialTheme.colorScheme.outline, + RoundedCornerShape(4.dp), + ), + ) + } + }, + ) + } + + com.collabtable.app.data.model.FieldType.LOCATION -> { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("Address or coordinates") }, + ) + } + + com.collabtable.app.data.model.FieldType.IMAGE -> { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("Image URL") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + ) + } + + com.collabtable.app.data.model.FieldType.FILE -> { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("File path or URL") }, + ) + } + + com.collabtable.app.data.model.FieldType.BARCODE -> { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("Scan or enter barcode") }, + ) + } + + com.collabtable.app.data.model.FieldType.SIGNATURE -> { + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("Tap to sign") }, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManageColumnsDialog( + fields: List, + onDismiss: () -> Unit, + onAddField: (String, String, String) -> Unit, + onUpdateField: (String, String, String) -> Unit, + onDeleteField: (String) -> Unit, + onReorderFields: (List) -> Unit, +) { + var showAddField by remember { mutableStateOf(false) } + var fieldToEdit by remember { mutableStateOf(null) } + var fieldToDelete by remember { mutableStateOf(null) } + + // Use derivedStateOf to keep reorderedFields in sync with fields + val reorderedFields = + remember(fields) { + mutableStateListOf().apply { + clear() + addAll(fields) + } + } + + // Watch for changes in fields and update reorderedFields + LaunchedEffect(fields) { + val currentIds = reorderedFields.map { it.id }.toSet() + val newIds = fields.map { it.id }.toSet() + + // Add new fields + fields.filter { it.id !in currentIds }.forEach { newField -> + reorderedFields.add(newField) + } + + // Remove deleted fields + reorderedFields.removeAll { it.id !in newIds } + + // Update existing fields (in case of edits) + fields.forEach { updatedField -> + val index = reorderedFields.indexOfFirst { it.id == updatedField.id } + if (index >= 0 && reorderedFields[index] != updatedField) { + reorderedFields[index] = updatedField + } + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Card( + modifier = + Modifier + .fillMaxWidth(0.95f) + .fillMaxHeight(0.8f), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + // Header + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Manage Columns", + style = MaterialTheme.typography.headlineSmall, + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + + Divider() + + // Column list + LazyColumn( + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + itemsIndexed( + items = reorderedFields, + key = { _, field -> field.id }, + ) { index, field -> + ColumnItem( + field = field, + onEdit = { fieldToEdit = field }, + onDelete = { fieldToDelete = field }, + onMoveUp = { + if (index > 0) { + val item = reorderedFields.removeAt(index) + reorderedFields.add(index - 1, item) + } + }, + onMoveDown = { + if (index < reorderedFields.size - 1) { + val item = reorderedFields.removeAt(index) + reorderedFields.add(index + 1, item) + } + }, + canMoveUp = index > 0, + canMoveDown = index < reorderedFields.size - 1, + ) + } + } + + Divider() + + // Add button + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Button( + onClick = { showAddField = true }, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Add Column") + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + onReorderFields(reorderedFields.toList()) + onDismiss() + }, + ) { + Text("Done") + } + } + } + } + } + + if (showAddField) { + AddFieldDialog( + onDismiss = { showAddField = false }, + onAdd = { name, fieldType, fieldOptions -> + onAddField(name, fieldType, fieldOptions) + showAddField = false + }, + ) + } + + fieldToEdit?.let { field -> + EditFieldDialog( + field = field, + onDismiss = { fieldToEdit = null }, + onUpdate = { fieldType, fieldOptions -> + onUpdateField(field.id, fieldType, fieldOptions) + fieldToEdit = null + }, + ) + } + + fieldToDelete?.let { field -> + AlertDialog( + onDismissRequest = { fieldToDelete = null }, + title = { Text("Delete Column") }, + text = { Text("Are you sure you want to delete '${field.name}'? All data in this column will be lost.") }, + confirmButton = { + TextButton( + onClick = { + onDeleteField(field.id) + fieldToDelete = null + }, + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { fieldToDelete = null }) { + Text("Cancel") + } + }, + ) + } +} + +@Composable +fun ColumnItem( + field: Field, + onEdit: () -> Unit, + onDelete: () -> Unit, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + canMoveUp: Boolean, + canMoveDown: Boolean, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + // Move up/down buttons + Column { + IconButton( + onClick = onMoveUp, + enabled = canMoveUp, + modifier = Modifier.size(32.dp), + ) { + Icon( + Icons.Default.KeyboardArrowUp, + contentDescription = "Move up", + tint = + if (canMoveUp) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + }, + ) + } + IconButton( + onClick = onMoveDown, + enabled = canMoveDown, + modifier = Modifier.size(32.dp), + ) { + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = "Move down", + tint = + if (canMoveDown) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + }, + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = field.name, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = + when (field.getType()) { + // Text types + com.collabtable.app.data.model.FieldType.TEXT -> "Text" + com.collabtable.app.data.model.FieldType.MULTILINE_TEXT -> "Multiline Text" + + // Number types + com.collabtable.app.data.model.FieldType.NUMBER -> "Number" + com.collabtable.app.data.model.FieldType.CURRENCY -> "Currency (${field.getCurrency()})" + com.collabtable.app.data.model.FieldType.PERCENTAGE -> "Percentage" + + // Selection types + com.collabtable.app.data.model.FieldType.CHECKBOX -> "Checkbox" + com.collabtable.app.data.model.FieldType.SWITCH -> "Switch" + com.collabtable.app.data.model.FieldType.DROPDOWN -> "Dropdown (${field.getDropdownOptions().size} options)" + com.collabtable.app.data.model.FieldType.AUTOCOMPLETE -> "Autocomplete (${field.getAutocompleteOptions().size} options)" + + // Link types + com.collabtable.app.data.model.FieldType.URL -> "URL" + com.collabtable.app.data.model.FieldType.EMAIL -> "Email" + com.collabtable.app.data.model.FieldType.PHONE -> "Phone" + + // Date/Time types + com.collabtable.app.data.model.FieldType.DATE -> "Date" + com.collabtable.app.data.model.FieldType.TIME -> "Time" + com.collabtable.app.data.model.FieldType.DATETIME -> "Date & Time" + com.collabtable.app.data.model.FieldType.DURATION -> "Duration" + + // Media types + com.collabtable.app.data.model.FieldType.IMAGE -> "Image" + com.collabtable.app.data.model.FieldType.FILE -> "File" + com.collabtable.app.data.model.FieldType.BARCODE -> "Barcode" + com.collabtable.app.data.model.FieldType.SIGNATURE -> "Signature" + + // Other types + com.collabtable.app.data.model.FieldType.RATING -> "Rating (${field.getMaxRating()} stars)" + com.collabtable.app.data.model.FieldType.COLOR -> "Color" + com.collabtable.app.data.model.FieldType.LOCATION -> "Location" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Edit button + IconButton(onClick = onEdit) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit", + tint = MaterialTheme.colorScheme.primary, + ) + } + + // Delete button + IconButton(onClick = onDelete) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditItemDialog( + fields: List, + itemWithValues: ItemWithValues, + onDismiss: () -> Unit, + onUpdate: (Map) -> Unit, + onDelete: () -> Unit, +) { + val fieldValues = remember { mutableStateMapOf() } + var showDeleteConfirmation by remember { mutableStateOf(false) } + // Map values by field to avoid linear lookups and ensure consistent mapping when fields reorder + val valuesByFieldId = + remember(itemWithValues.values) { + itemWithValues.values.associateBy { it.fieldId } + } + + // Initialize field values from existing item + LaunchedEffect(itemWithValues) { + itemWithValues.values.forEach { itemValue -> + fieldValues[itemValue.id] = itemValue.value + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Card( + modifier = + Modifier + .fillMaxWidth(0.95f) + .fillMaxHeight(0.85f), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + // Header + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Edit Item", + style = MaterialTheme.typography.headlineSmall, + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + + Divider() + + // Fields list + LazyColumn( + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(fields, key = { it.id }) { field -> + val itemValue = valuesByFieldId[field.id] + if (itemValue != null) { + FieldInput( + field = field, + value = fieldValues[itemValue.id].orEmpty(), + onValueChange = { newValue -> + fieldValues[itemValue.id] = newValue + }, + ) + } + } + } + + Divider() + + // Action buttons + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Button( + onClick = { showDeleteConfirmation = true }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Icon(Icons.Default.Delete, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Delete") + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + + Button( + onClick = { + onUpdate(fieldValues.toMap()) + }, + ) { + Text("Save") + } + } + } + } + } + } + + if (showDeleteConfirmation) { + AlertDialog( + onDismissRequest = { showDeleteConfirmation = false }, + title = { Text("Delete Item") }, + text = { Text("Are you sure you want to delete this item? This action cannot be undone.") }, + confirmButton = { + TextButton( + onClick = { + onDelete() + showDeleteConfirmation = false + }, + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirmation = false }) { + Text("Cancel") + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FilterSortDialog( + fields: List, + currentSortField: Field?, + currentSortAscending: Boolean, + currentGroupByField: Field?, + currentFilterField: Field?, + currentFilterValue: String, + onDismiss: () -> Unit, + onApply: (sortField: Field?, sortAscending: Boolean, groupByField: Field?, filterField: Field?, filterValue: String) -> Unit, + onClearAll: () -> Unit, +) { + var sortField by remember { mutableStateOf(currentSortField) } + var sortAscending by remember { mutableStateOf(currentSortAscending) } + var groupByField by remember { mutableStateOf(currentGroupByField) } + var filterField by remember { mutableStateOf(currentFilterField) } + var filterValue by remember { mutableStateOf(currentFilterValue) } + + var sortFieldExpanded by remember { mutableStateOf(false) } + var groupByFieldExpanded by remember { mutableStateOf(false) } + var filterFieldExpanded by remember { mutableStateOf(false) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Card( + modifier = + Modifier + .fillMaxWidth(0.95f) + .fillMaxHeight(0.7f), + shape = RoundedCornerShape(16.dp), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Filter, Sort & Group", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Content + Column( + modifier = + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + // Sort Section + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Sort By", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + ExposedDropdownMenuBox( + expanded = sortFieldExpanded, + onExpandedChange = { sortFieldExpanded = it }, + ) { + OutlinedTextField( + value = sortField?.name ?: "None", + onValueChange = {}, + readOnly = true, + label = { Text("Sort Field") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = sortFieldExpanded) }, + modifier = + Modifier + .fillMaxWidth() + .menuAnchor(), + ) + + ExposedDropdownMenu( + expanded = sortFieldExpanded, + onDismissRequest = { sortFieldExpanded = false }, + ) { + DropdownMenuItem( + text = { Text("None") }, + onClick = { + sortField = null + sortFieldExpanded = false + }, + ) + fields.forEach { field -> + DropdownMenuItem( + text = { Text(field.name) }, + onClick = { + sortField = field + sortFieldExpanded = false + }, + ) + } + } + } + + if (sortField != null) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Order:", modifier = Modifier.padding(end = 8.dp)) + FilterChip( + selected = sortAscending, + onClick = { sortAscending = true }, + label = { Text("Ascending") }, + leadingIcon = + if (sortAscending) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, + ) + Spacer(modifier = Modifier.width(8.dp)) + FilterChip( + selected = !sortAscending, + onClick = { sortAscending = false }, + label = { Text("Descending") }, + leadingIcon = + if (!sortAscending) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, + ) + } + } + } + + Divider() + + // Group By Section + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Group By", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + ExposedDropdownMenuBox( + expanded = groupByFieldExpanded, + onExpandedChange = { groupByFieldExpanded = it }, + ) { + OutlinedTextField( + value = groupByField?.name ?: "None", + onValueChange = {}, + readOnly = true, + label = { Text("Group By Field") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = groupByFieldExpanded) }, + modifier = + Modifier + .fillMaxWidth() + .menuAnchor(), + ) + + ExposedDropdownMenu( + expanded = groupByFieldExpanded, + onDismissRequest = { groupByFieldExpanded = false }, + ) { + DropdownMenuItem( + text = { Text("None") }, + onClick = { + groupByField = null + groupByFieldExpanded = false + }, + ) + fields.forEach { field -> + DropdownMenuItem( + text = { Text(field.name) }, + onClick = { + groupByField = field + groupByFieldExpanded = false + }, + ) + } + } + } + } + + Divider() + + // Filter Section + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Filter", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + ExposedDropdownMenuBox( + expanded = filterFieldExpanded, + onExpandedChange = { filterFieldExpanded = it }, + ) { + OutlinedTextField( + value = filterField?.name ?: "None", + onValueChange = {}, + readOnly = true, + label = { Text("Filter Field") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = filterFieldExpanded) }, + modifier = + Modifier + .fillMaxWidth() + .menuAnchor(), + ) + + ExposedDropdownMenu( + expanded = filterFieldExpanded, + onDismissRequest = { filterFieldExpanded = false }, + ) { + DropdownMenuItem( + text = { Text("None") }, + onClick = { + filterField = null + filterValue = "" + filterFieldExpanded = false + }, + ) + fields.forEach { field -> + DropdownMenuItem( + text = { Text(field.name) }, + onClick = { + filterField = field + filterFieldExpanded = false + }, + ) + } + } + } + + if (filterField != null) { + OutlinedTextField( + value = filterValue, + onValueChange = { filterValue = it }, + label = { Text("Filter Value") }, + placeholder = { Text("Enter text to filter...") }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + Divider() + + // Action buttons + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + OutlinedButton(onClick = onClearAll) { + Text("Clear All") + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + + Button( + onClick = { + onApply(sortField, sortAscending, groupByField, filterField, filterValue) + }, + ) { + Text("Apply") + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SortDialog( + fields: List, + currentSortField: Field?, + currentSortAscending: Boolean, + onDismiss: () -> Unit, + onApply: (sortField: Field?, sortAscending: Boolean) -> Unit, +) { + var sortField by remember { mutableStateOf(currentSortField) } + var sortAscending by remember { mutableStateOf(currentSortAscending) } + var sortFieldExpanded by remember { mutableStateOf(false) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Card( + modifier = + Modifier + .fillMaxWidth(0.9f) + .wrapContentHeight(), + shape = RoundedCornerShape(16.dp), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Sort Items", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Sort Field Selection + ExposedDropdownMenuBox( + expanded = sortFieldExpanded, + onExpandedChange = { sortFieldExpanded = it }, + ) { + OutlinedTextField( + value = sortField?.name ?: "None", + onValueChange = {}, + readOnly = true, + label = { Text("Sort Field") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = sortFieldExpanded) }, + modifier = + Modifier + .fillMaxWidth() + .menuAnchor(), + ) + + ExposedDropdownMenu( + expanded = sortFieldExpanded, + onDismissRequest = { sortFieldExpanded = false }, + ) { + DropdownMenuItem( + text = { Text("None") }, + onClick = { + sortField = null + sortFieldExpanded = false + }, + ) + fields.forEach { field -> + DropdownMenuItem( + text = { Text(field.name) }, + onClick = { + sortField = field + sortFieldExpanded = false + }, + ) + } + } + } + + // Sort Order Selection + if (sortField != null) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Order", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(bottom = 8.dp), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = sortAscending, + onClick = { sortAscending = true }, + label = { Text("Ascending โ†‘") }, + modifier = Modifier.weight(1f), + leadingIcon = + if (sortAscending) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, + ) + FilterChip( + selected = !sortAscending, + onClick = { sortAscending = false }, + label = { Text("Descending โ†“") }, + modifier = Modifier.weight(1f), + leadingIcon = + if (!sortAscending) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Action Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { onApply(sortField, sortAscending) }, + ) { + Text("Apply") + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun GroupDialog( + fields: List, + currentGroupByField: Field?, + onDismiss: () -> Unit, + onApply: (groupByField: Field?) -> Unit, +) { + var groupByField by remember { mutableStateOf(currentGroupByField) } + var groupByFieldExpanded by remember { mutableStateOf(false) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Card( + modifier = + Modifier + .fillMaxWidth(0.9f) + .wrapContentHeight(), + shape = RoundedCornerShape(16.dp), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Group Items", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Group items by a field to organize them into categories", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Group By Field Selection + ExposedDropdownMenuBox( + expanded = groupByFieldExpanded, + onExpandedChange = { groupByFieldExpanded = it }, + ) { + OutlinedTextField( + value = groupByField?.name ?: "None", + onValueChange = {}, + readOnly = true, + label = { Text("Group By Field") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = groupByFieldExpanded) }, + modifier = + Modifier + .fillMaxWidth() + .menuAnchor(), + ) + + ExposedDropdownMenu( + expanded = groupByFieldExpanded, + onDismissRequest = { groupByFieldExpanded = false }, + ) { + DropdownMenuItem( + text = { Text("None") }, + onClick = { + groupByField = null + groupByFieldExpanded = false + }, + ) + fields.forEach { field -> + DropdownMenuItem( + text = { Text(field.name) }, + onClick = { + groupByField = field + groupByFieldExpanded = false + }, + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Action Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { onApply(groupByField) }, + ) { + Text("Apply") + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FilterDialog( + fields: List, + currentFilterField: Field?, + currentFilterValue: String, + onDismiss: () -> Unit, + onApply: (filterField: Field?, filterValue: String) -> Unit, +) { + var filterField by remember { mutableStateOf(currentFilterField) } + var filterValue by remember { mutableStateOf(currentFilterValue) } + var filterFieldExpanded by remember { mutableStateOf(false) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Card( + modifier = + Modifier + .fillMaxWidth(0.9f) + .wrapContentHeight(), + shape = RoundedCornerShape(16.dp), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Filter Items", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Show only items that contain specific text in a field", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Filter Field Selection + ExposedDropdownMenuBox( + expanded = filterFieldExpanded, + onExpandedChange = { filterFieldExpanded = it }, + ) { + OutlinedTextField( + value = filterField?.name ?: "None", + onValueChange = {}, + readOnly = true, + label = { Text("Filter Field") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = filterFieldExpanded) }, + modifier = + Modifier + .fillMaxWidth() + .menuAnchor(), + ) + + ExposedDropdownMenu( + expanded = filterFieldExpanded, + onDismissRequest = { filterFieldExpanded = false }, + ) { + DropdownMenuItem( + text = { Text("None") }, + onClick = { + filterField = null + filterFieldExpanded = false + }, + ) + fields.forEach { field -> + DropdownMenuItem( + text = { Text(field.name) }, + onClick = { + filterField = field + filterFieldExpanded = false + }, + ) + } + } + } + + // Filter Value Input + if (filterField != null) { + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = filterValue, + onValueChange = { filterValue = it }, + label = { Text("Filter Value") }, + placeholder = { Text("Enter text to filter...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Action Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { onApply(filterField, filterValue) }, + enabled = filterField == null || filterValue.isNotBlank(), + ) { + Text("Apply") + } + } + } + } + } +} + +@Composable +private fun RenameListDialog( + currentName: String, + onDismiss: () -> Unit, + onRename: (String) -> Unit, +) { + var newName by remember { mutableStateOf(currentName) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = "Rename List", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + }, + text = { + Column { + Text( + text = "Enter a new name for this list", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp), + ) + OutlinedTextField( + value = newName, + onValueChange = { newName = it }, + label = { Text("List Name") }, + placeholder = { Text("Enter list name...") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + Button( + onClick = { onRename(newName) }, + enabled = newName.isNotBlank() && newName.trim() != currentName, + ) { + Text("Rename") + } + }, + dismissButton = { + OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt index 400204a..7d004e1 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt @@ -1,246 +1,263 @@ -package com.collabtable.app.ui.screens - -import android.content.Context -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.room.withTransaction -import com.collabtable.app.data.database.CollabTableDatabase -import com.collabtable.app.data.model.* -import com.collabtable.app.data.repository.SyncRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.launch -import java.util.UUID - -class ListDetailViewModel( - private val database: CollabTableDatabase, - private val listId: String, - private val context: Context -) : ViewModel() { - private val _list = MutableStateFlow(null) - val list: StateFlow = _list.asStateFlow() - - private val _fields = MutableStateFlow>(emptyList()) - val fields: StateFlow> = _fields.asStateFlow() - - private val _items = MutableStateFlow>(emptyList()) - val items: StateFlow> = _items.asStateFlow() - - private val syncRepository = SyncRepository(context) - - init { - loadListData() - startPeriodicSync() - } - - private fun loadListData() { - viewModelScope.launch { - database.listDao().getListWithFields(listId) - .debounce(75) - .collect { listWithFields -> - _list.value = listWithFields?.list - } - } - - viewModelScope.launch { - database.itemDao().getItemsWithValuesForList(listId).collect { itemsData -> - _items.value = itemsData - } - } - - viewModelScope.launch { - database.fieldDao().getFieldsForList(listId) - .debounce(75) - .collect { fieldsData -> - _fields.value = fieldsData - } - } - } - - private fun startPeriodicSync() { - viewModelScope.launch { - while (true) { - performSync() - kotlinx.coroutines.delay(5000) // Sync every 5 seconds - } - } - } - - private suspend fun performSync() { - syncRepository.performSync() - } - - fun renameList(newName: String) { - viewModelScope.launch { - val currentList = _list.value - if (currentList != null && newName.isNotBlank()) { - database.listDao().updateList( - currentList.copy( - name = newName.trim(), - updatedAt = System.currentTimeMillis() - ) - ) - performSync() - } - } - } - - fun addField(name: String, fieldType: String = "STRING", fieldOptions: String = "") { - viewModelScope.launch { - val timestamp = System.currentTimeMillis() - val maxOrder = _fields.value.maxOfOrNull { it.order } ?: -1 - val newField = Field( - id = UUID.randomUUID().toString(), - listId = listId, - name = name, - fieldType = fieldType, - fieldOptions = fieldOptions, - order = maxOrder + 1, - createdAt = timestamp, - updatedAt = timestamp - ) - - // Create empty ItemValue entries for this new field for all existing items - val existingItems = _items.value - val newValues = if (existingItems.isNotEmpty()) { - existingItems.map { itemWithValues -> - ItemValue( - id = UUID.randomUUID().toString(), - itemId = itemWithValues.item.id, - fieldId = newField.id, - value = "", - updatedAt = timestamp - ) - } - } else { - emptyList() - } - - // Insert atomically to avoid intermediate inconsistent states - database.withTransaction { - database.fieldDao().insertField(newField) - if (newValues.isNotEmpty()) { - database.itemValueDao().insertValues(newValues) - } - } - - performSync() - } - } - - fun deleteField(fieldId: String) { - viewModelScope.launch { - database.fieldDao().softDeleteField(fieldId, System.currentTimeMillis()) - performSync() - } - } - - fun updateField(fieldId: String, fieldType: String, fieldOptions: String) { - viewModelScope.launch { - val field = database.fieldDao().getFieldById(fieldId) - if (field != null) { - database.fieldDao().updateField( - field.copy( - fieldType = fieldType, - fieldOptions = fieldOptions, - updatedAt = System.currentTimeMillis() - ) - ) - performSync() - } - } - } - - fun addItem() { - viewModelScope.launch { - val timestamp = System.currentTimeMillis() - val newItem = Item( - id = UUID.randomUUID().toString(), - listId = listId, - createdAt = timestamp, - updatedAt = timestamp - ) - // Insert item and its values atomically - database.withTransaction { - database.itemDao().insertItem(newItem) - // Create empty values for each field - val values = _fields.value.map { field -> - ItemValue( - id = UUID.randomUUID().toString(), - itemId = newItem.id, - fieldId = field.id, - value = "", - updatedAt = timestamp - ) - } - if (values.isNotEmpty()) { - database.itemValueDao().insertValues(values) - } - } - performSync() - } - } - - fun addItemWithValues(fieldValues: Map) { - viewModelScope.launch { - val timestamp = System.currentTimeMillis() - val newItem = Item( - id = UUID.randomUUID().toString(), - listId = listId, - createdAt = timestamp, - updatedAt = timestamp - ) - // Insert item and provided values atomically - database.withTransaction { - database.itemDao().insertItem(newItem) - // Create values for each field with the provided values - val values = _fields.value.map { field -> - ItemValue( - id = UUID.randomUUID().toString(), - itemId = newItem.id, - fieldId = field.id, - value = fieldValues[field.id] ?: "", - updatedAt = timestamp - ) - } - if (values.isNotEmpty()) { - database.itemValueDao().insertValues(values) - } - } - performSync() - } - } - - fun updateItemValue(itemValueId: String, newValue: String) { - viewModelScope.launch { - val itemValue = database.itemValueDao().getValueById(itemValueId) - if (itemValue != null) { - database.itemValueDao().updateValue( - itemValue.copy( - value = newValue, - updatedAt = System.currentTimeMillis() - ) - ) - performSync() - } - } - } - - fun deleteItem(itemId: String) { - viewModelScope.launch { - database.itemDao().softDeleteItem(itemId, System.currentTimeMillis()) - performSync() - } - } - - fun reorderFields(reorderedFields: List) { - viewModelScope.launch { - val timestamp = System.currentTimeMillis() - // Use DAO-level transaction for clarity - database.fieldDao().reorderFieldsInTransaction(reorderedFields, timestamp) - performSync() - } - } -} +package com.collabtable.app.ui.screens + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.room.withTransaction +import com.collabtable.app.data.database.CollabTableDatabase +import com.collabtable.app.data.model.* +import com.collabtable.app.data.repository.SyncRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch +import java.util.UUID + +class ListDetailViewModel( + private val database: CollabTableDatabase, + private val listId: String, + private val context: Context, +) : ViewModel() { + private val _list = MutableStateFlow(null) + val list: StateFlow = _list.asStateFlow() + + private val _fields = MutableStateFlow>(emptyList()) + val fields: StateFlow> = _fields.asStateFlow() + + private val _items = MutableStateFlow>(emptyList()) + val items: StateFlow> = _items.asStateFlow() + + private val syncRepository = SyncRepository(context) + + init { + loadListData() + startPeriodicSync() + } + + private fun loadListData() { + viewModelScope.launch { + database.listDao().getListWithFields(listId) + .debounce(75) + .collect { listWithFields -> + _list.value = listWithFields?.list + } + } + + viewModelScope.launch { + database.itemDao().getItemsWithValuesForList(listId).collect { itemsData -> + _items.value = itemsData + } + } + + viewModelScope.launch { + database.fieldDao().getFieldsForList(listId) + .debounce(75) + .collect { fieldsData -> + _fields.value = fieldsData + } + } + } + + private fun startPeriodicSync() { + viewModelScope.launch { + while (true) { + performSync() + kotlinx.coroutines.delay(5000) // Sync every 5 seconds + } + } + } + + private suspend fun performSync() { + syncRepository.performSync() + } + + fun renameList(newName: String) { + viewModelScope.launch { + val currentList = _list.value + if (currentList != null && newName.isNotBlank()) { + database.listDao().updateList( + currentList.copy( + name = newName.trim(), + updatedAt = System.currentTimeMillis(), + ), + ) + performSync() + } + } + } + + fun addField( + name: String, + fieldType: String = "STRING", + fieldOptions: String = "", + ) { + viewModelScope.launch { + val timestamp = System.currentTimeMillis() + val maxOrder = _fields.value.maxOfOrNull { it.order } ?: -1 + val newField = + Field( + id = UUID.randomUUID().toString(), + listId = listId, + name = name, + fieldType = fieldType, + fieldOptions = fieldOptions, + order = maxOrder + 1, + createdAt = timestamp, + updatedAt = timestamp, + ) + + // Create empty ItemValue entries for this new field for all existing items + val existingItems = _items.value + val newValues = + if (existingItems.isNotEmpty()) { + existingItems.map { itemWithValues -> + ItemValue( + id = UUID.randomUUID().toString(), + itemId = itemWithValues.item.id, + fieldId = newField.id, + value = "", + updatedAt = timestamp, + ) + } + } else { + emptyList() + } + + // Insert atomically to avoid intermediate inconsistent states + database.withTransaction { + database.fieldDao().insertField(newField) + if (newValues.isNotEmpty()) { + database.itemValueDao().insertValues(newValues) + } + } + + performSync() + } + } + + fun deleteField(fieldId: String) { + viewModelScope.launch { + database.fieldDao().softDeleteField(fieldId, System.currentTimeMillis()) + performSync() + } + } + + fun updateField( + fieldId: String, + fieldType: String, + fieldOptions: String, + ) { + viewModelScope.launch { + val field = database.fieldDao().getFieldById(fieldId) + if (field != null) { + database.fieldDao().updateField( + field.copy( + fieldType = fieldType, + fieldOptions = fieldOptions, + updatedAt = System.currentTimeMillis(), + ), + ) + performSync() + } + } + } + + fun addItem() { + viewModelScope.launch { + val timestamp = System.currentTimeMillis() + val newItem = + Item( + id = UUID.randomUUID().toString(), + listId = listId, + createdAt = timestamp, + updatedAt = timestamp, + ) + // Insert item and its values atomically + database.withTransaction { + database.itemDao().insertItem(newItem) + // Create empty values for each field + val values = + _fields.value.map { field -> + ItemValue( + id = UUID.randomUUID().toString(), + itemId = newItem.id, + fieldId = field.id, + value = "", + updatedAt = timestamp, + ) + } + if (values.isNotEmpty()) { + database.itemValueDao().insertValues(values) + } + } + performSync() + } + } + + fun addItemWithValues(fieldValues: Map) { + viewModelScope.launch { + val timestamp = System.currentTimeMillis() + val newItem = + Item( + id = UUID.randomUUID().toString(), + listId = listId, + createdAt = timestamp, + updatedAt = timestamp, + ) + // Insert item and provided values atomically + database.withTransaction { + database.itemDao().insertItem(newItem) + // Create values for each field with the provided values + val values = + _fields.value.map { field -> + ItemValue( + id = UUID.randomUUID().toString(), + itemId = newItem.id, + fieldId = field.id, + value = fieldValues[field.id] ?: "", + updatedAt = timestamp, + ) + } + if (values.isNotEmpty()) { + database.itemValueDao().insertValues(values) + } + } + performSync() + } + } + + fun updateItemValue( + itemValueId: String, + newValue: String, + ) { + viewModelScope.launch { + val itemValue = database.itemValueDao().getValueById(itemValueId) + if (itemValue != null) { + database.itemValueDao().updateValue( + itemValue.copy( + value = newValue, + updatedAt = System.currentTimeMillis(), + ), + ) + performSync() + } + } + } + + fun deleteItem(itemId: String) { + viewModelScope.launch { + database.itemDao().softDeleteItem(itemId, System.currentTimeMillis()) + performSync() + } + } + + fun reorderFields(reorderedFields: List) { + viewModelScope.launch { + val timestamp = System.currentTimeMillis() + // Use DAO-level transaction for clarity + database.fieldDao().reorderFieldsInTransaction(reorderedFields, timestamp) + performSync() + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt index 5cceda5..d1b0c8b 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt @@ -1,299 +1,303 @@ -package com.collabtable.app.ui.screens - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.List -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.collabtable.app.R -import com.collabtable.app.data.database.CollabTableDatabase -import com.collabtable.app.data.model.CollabList -import java.text.SimpleDateFormat -import java.util.* - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ListsScreen( - onNavigateToList: (String) -> Unit, - onNavigateToSettings: () -> Unit, - onNavigateToLogs: () -> Unit = {} -) { - val context = LocalContext.current - val database = remember { CollabTableDatabase.getDatabase(context) } - val viewModel = remember { ListsViewModel(database, context) } - - val lists by viewModel.lists.collectAsState() - val isLoading by viewModel.isLoading.collectAsState() - - var showCreateDialog by remember { mutableStateOf(false) } - var listToDelete by remember { mutableStateOf(null) } - var listToEdit by remember { mutableStateOf(null) } - - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.lists)) }, - actions = { - IconButton(onClick = { viewModel.manualSync() }) { - Icon(Icons.Default.Refresh, contentDescription = "Sync") - } - IconButton(onClick = onNavigateToLogs) { - Icon(Icons.Default.List, contentDescription = "Logs") - } - IconButton(onClick = onNavigateToSettings) { - Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings)) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - ) - }, - floatingActionButton = { - FloatingActionButton( - onClick = { showCreateDialog = true } - ) { - Icon(Icons.Default.Add, contentDescription = stringResource(R.string.create_list)) - } - } - ) { padding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - ) { - if (isLoading && lists.isEmpty()) { - // Show loading indicator during initial sync - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - CircularProgressIndicator() - Text( - text = "Syncing with server...", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } else if (lists.isEmpty()) { - // Show empty state - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = stringResource(R.string.no_lists), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = "Tap + to create your first table", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } else { - // Show lists - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(lists, key = { it.id }) { list -> - ListItem( - list = list, - onListClick = { onNavigateToList(list.id) }, - onEditClick = { listToEdit = list }, - onDeleteClick = { listToDelete = list } - ) - } - } - } - } - } - - if (showCreateDialog) { - CreateListDialog( - onDismiss = { showCreateDialog = false }, - onCreate = { name -> - viewModel.createList(name) - showCreateDialog = false - } - ) - } - - listToEdit?.let { list -> - RenameListDialog( - currentName = list.name, - onDismiss = { listToEdit = null }, - onRename = { newName -> - viewModel.renameList(list.id, newName) - listToEdit = null - } - ) - } - - listToDelete?.let { list -> - AlertDialog( - onDismissRequest = { listToDelete = null }, - title = { Text(stringResource(R.string.delete_list)) }, - text = { Text(stringResource(R.string.confirm_delete)) }, - confirmButton = { - TextButton( - onClick = { - viewModel.deleteList(list.id) - listToDelete = null - } - ) { - Text(stringResource(R.string.delete)) - } - }, - dismissButton = { - TextButton(onClick = { listToDelete = null }) { - Text(stringResource(R.string.cancel)) - } - } - ) - } -} - -@Composable -fun ListItem( - list: CollabList, - onListClick: () -> Unit, - onEditClick: () -> Unit, - onDeleteClick: () -> Unit -) { - Card( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onListClick) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = list.name, - style = MaterialTheme.typography.titleMedium - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = formatDate(list.updatedAt), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Row { - IconButton(onClick = onEditClick) { - Icon( - Icons.Default.Edit, - contentDescription = "Edit", - tint = MaterialTheme.colorScheme.primary - ) - } - IconButton(onClick = onDeleteClick) { - Icon( - Icons.Default.Delete, - contentDescription = stringResource(R.string.delete), - tint = MaterialTheme.colorScheme.error - ) - } - } - } - } -} - -@Composable -fun CreateListDialog( - onDismiss: () -> Unit, - onCreate: (String) -> Unit -) { - var listName by remember { mutableStateOf("") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.create_list)) }, - text = { - OutlinedTextField( - value = listName, - onValueChange = { listName = it }, - label = { Text(stringResource(R.string.list_name)) }, - singleLine = true - ) - }, - confirmButton = { - TextButton( - onClick = { if (listName.isNotBlank()) onCreate(listName.trim()) }, - enabled = listName.isNotBlank() - ) { - Text(stringResource(R.string.save)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) - } - } - ) -} - -@Composable -private fun RenameListDialog( - currentName: String, - onDismiss: () -> Unit, - onRename: (String) -> Unit -) { - var listName by remember { mutableStateOf(currentName) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Rename Table") }, - text = { - OutlinedTextField( - value = listName, - onValueChange = { listName = it }, - label = { Text(stringResource(R.string.list_name)) }, - singleLine = true - ) - }, - confirmButton = { - TextButton( - onClick = { if (listName.isNotBlank()) onRename(listName.trim()) }, - enabled = listName.isNotBlank() && listName.trim() != currentName - ) { - Text("Rename") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) - } - } - ) -} - -private fun formatDate(timestamp: Long): String { - val sdf = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()) - return sdf.format(Date(timestamp)) -} +package com.collabtable.app.ui.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.collabtable.app.R +import com.collabtable.app.data.database.CollabTableDatabase +import com.collabtable.app.data.model.CollabList +import java.text.SimpleDateFormat +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ListsScreen( + onNavigateToList: (String) -> Unit, + onNavigateToSettings: () -> Unit, + onNavigateToLogs: () -> Unit = {}, +) { + val context = LocalContext.current + val database = remember { CollabTableDatabase.getDatabase(context) } + val viewModel = remember { ListsViewModel(database, context) } + + val lists by viewModel.lists.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + + var showCreateDialog by remember { mutableStateOf(false) } + var listToDelete by remember { mutableStateOf(null) } + var listToEdit by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.lists)) }, + actions = { + IconButton(onClick = { viewModel.manualSync() }) { + Icon(Icons.Default.Refresh, contentDescription = "Sync") + } + IconButton(onClick = onNavigateToLogs) { + Icon(Icons.Default.List, contentDescription = "Logs") + } + IconButton(onClick = onNavigateToSettings) { + Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings)) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { showCreateDialog = true }, + ) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.create_list)) + } + }, + ) { padding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(padding), + ) { + if (isLoading && lists.isEmpty()) { + // Show loading indicator during initial sync + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator() + Text( + text = "Syncing with server...", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else if (lists.isEmpty()) { + // Show empty state + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.no_lists), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Tap + to create your first table", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + // Show lists + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(lists, key = { it.id }) { list -> + ListItem( + list = list, + onListClick = { onNavigateToList(list.id) }, + onEditClick = { listToEdit = list }, + onDeleteClick = { listToDelete = list }, + ) + } + } + } + } + } + + if (showCreateDialog) { + CreateListDialog( + onDismiss = { showCreateDialog = false }, + onCreate = { name -> + viewModel.createList(name) + showCreateDialog = false + }, + ) + } + + listToEdit?.let { list -> + RenameListDialog( + currentName = list.name, + onDismiss = { listToEdit = null }, + onRename = { newName -> + viewModel.renameList(list.id, newName) + listToEdit = null + }, + ) + } + + listToDelete?.let { list -> + AlertDialog( + onDismissRequest = { listToDelete = null }, + title = { Text(stringResource(R.string.delete_list)) }, + text = { Text(stringResource(R.string.confirm_delete)) }, + confirmButton = { + TextButton( + onClick = { + viewModel.deleteList(list.id) + listToDelete = null + }, + ) { + Text(stringResource(R.string.delete)) + } + }, + dismissButton = { + TextButton(onClick = { listToDelete = null }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } +} + +@Composable +fun ListItem( + list: CollabList, + onListClick: () -> Unit, + onEditClick: () -> Unit, + onDeleteClick: () -> Unit, +) { + Card( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onListClick), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = list.name, + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatDate(list.updatedAt), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row { + IconButton(onClick = onEditClick) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit", + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = onDeleteClick) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.delete), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +@Composable +fun CreateListDialog( + onDismiss: () -> Unit, + onCreate: (String) -> Unit, +) { + var listName by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.create_list)) }, + text = { + OutlinedTextField( + value = listName, + onValueChange = { listName = it }, + label = { Text(stringResource(R.string.list_name)) }, + singleLine = true, + ) + }, + confirmButton = { + TextButton( + onClick = { if (listName.isNotBlank()) onCreate(listName.trim()) }, + enabled = listName.isNotBlank(), + ) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} + +@Composable +private fun RenameListDialog( + currentName: String, + onDismiss: () -> Unit, + onRename: (String) -> Unit, +) { + var listName by remember { mutableStateOf(currentName) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Rename Table") }, + text = { + OutlinedTextField( + value = listName, + onValueChange = { listName = it }, + label = { Text(stringResource(R.string.list_name)) }, + singleLine = true, + ) + }, + confirmButton = { + TextButton( + onClick = { if (listName.isNotBlank()) onRename(listName.trim()) }, + enabled = listName.isNotBlank() && listName.trim() != currentName, + ) { + Text("Rename") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} + +private fun formatDate(timestamp: Long): String { + val sdf = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()) + return sdf.format(Date(timestamp)) +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt index 6a2ff82..ad5bcfa 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt @@ -1,114 +1,118 @@ -package com.collabtable.app.ui.screens - -import android.content.Context -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.collabtable.app.data.database.CollabTableDatabase -import com.collabtable.app.data.model.CollabList -import com.collabtable.app.data.repository.SyncRepository -import com.collabtable.app.utils.Logger -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import java.util.UUID - -class ListsViewModel( - private val database: CollabTableDatabase, - private val context: Context -) : ViewModel() { - private val _lists = MutableStateFlow>(emptyList()) - val lists: StateFlow> = _lists.asStateFlow() - - private val _isLoading = MutableStateFlow(false) - val isLoading: StateFlow = _isLoading.asStateFlow() - - private val syncRepository = SyncRepository(context) - - init { - loadLists() - // Perform initial sync immediately on startup, then start periodic sync - viewModelScope.launch { - _isLoading.value = true - performSync() - _isLoading.value = false - startPeriodicSync() - } - } - - private fun loadLists() { - viewModelScope.launch { - database.listDao().getAllLists().collect { listData -> - _lists.value = listData - } - } - } - - private suspend fun startPeriodicSync() { - while (true) { - kotlinx.coroutines.delay(5000) // Wait 5 seconds before next sync - performSync() - } - } - - private suspend fun performSync() { - val result = syncRepository.performSync() - result.onFailure { error -> - Logger.e("Tables", "โŒ Sync failed: ${error.message}") - } - } - - fun createList(name: String) { - viewModelScope.launch { - Logger.i("Tables", "โž• Creating table: \"$name\"") - val timestamp = System.currentTimeMillis() - val newList = CollabList( - id = UUID.randomUUID().toString(), - name = name, - createdAt = timestamp, - updatedAt = timestamp - ) - database.listDao().insertList(newList) - // Sync immediately after creating - performSync() - } - } - - fun renameList(listId: String, newName: String) { - viewModelScope.launch { - val list = database.listDao().getListById(listId) - if (list != null && newName.isNotBlank()) { - Logger.i("Tables", "โœ๏ธ Renaming table: \"${list.name}\" โ†’ \"$newName\"") - database.listDao().updateList( - list.copy( - name = newName.trim(), - updatedAt = System.currentTimeMillis() - ) - ) - // Sync immediately after renaming - performSync() - } - } - } - - fun deleteList(listId: String) { - viewModelScope.launch { - val list = database.listDao().getListById(listId) - if (list != null) { - Logger.i("Tables", "๐Ÿ—‘๏ธ Deleting table: \"${list.name}\"") - database.listDao().softDeleteList(listId, System.currentTimeMillis()) - // Sync immediately after deleting - performSync() - } - } - } - - fun manualSync() { - viewModelScope.launch { - Logger.i("Tables", "๐Ÿ”„ Manual sync requested") - _isLoading.value = true - performSync() - _isLoading.value = false - } - } -} +package com.collabtable.app.ui.screens + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.collabtable.app.data.database.CollabTableDatabase +import com.collabtable.app.data.model.CollabList +import com.collabtable.app.data.repository.SyncRepository +import com.collabtable.app.utils.Logger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.UUID + +class ListsViewModel( + private val database: CollabTableDatabase, + private val context: Context, +) : ViewModel() { + private val _lists = MutableStateFlow>(emptyList()) + val lists: StateFlow> = _lists.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val syncRepository = SyncRepository(context) + + init { + loadLists() + // Perform initial sync immediately on startup, then start periodic sync + viewModelScope.launch { + _isLoading.value = true + performSync() + _isLoading.value = false + startPeriodicSync() + } + } + + private fun loadLists() { + viewModelScope.launch { + database.listDao().getAllLists().collect { listData -> + _lists.value = listData + } + } + } + + private suspend fun startPeriodicSync() { + while (true) { + kotlinx.coroutines.delay(5000) // Wait 5 seconds before next sync + performSync() + } + } + + private suspend fun performSync() { + val result = syncRepository.performSync() + result.onFailure { error -> + Logger.e("Tables", "โŒ Sync failed: ${error.message}") + } + } + + fun createList(name: String) { + viewModelScope.launch { + Logger.i("Tables", "โž• Creating table: \"$name\"") + val timestamp = System.currentTimeMillis() + val newList = + CollabList( + id = UUID.randomUUID().toString(), + name = name, + createdAt = timestamp, + updatedAt = timestamp, + ) + database.listDao().insertList(newList) + // Sync immediately after creating + performSync() + } + } + + fun renameList( + listId: String, + newName: String, + ) { + viewModelScope.launch { + val list = database.listDao().getListById(listId) + if (list != null && newName.isNotBlank()) { + Logger.i("Tables", "โœ๏ธ Renaming table: \"${list.name}\" โ†’ \"$newName\"") + database.listDao().updateList( + list.copy( + name = newName.trim(), + updatedAt = System.currentTimeMillis(), + ), + ) + // Sync immediately after renaming + performSync() + } + } + } + + fun deleteList(listId: String) { + viewModelScope.launch { + val list = database.listDao().getListById(listId) + if (list != null) { + Logger.i("Tables", "๐Ÿ—‘๏ธ Deleting table: \"${list.name}\"") + database.listDao().softDeleteList(listId, System.currentTimeMillis()) + // Sync immediately after deleting + performSync() + } + } + } + + fun manualSync() { + viewModelScope.launch { + Logger.i("Tables", "๐Ÿ”„ Manual sync requested") + _isLoading.value = true + performSync() + _isLoading.value = false + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/LogsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/LogsScreen.kt index 662c073..03f103b 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/LogsScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/LogsScreen.kt @@ -1,433 +1,451 @@ -package com.collabtable.app.ui.screens - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.FilterList -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.collabtable.app.utils.LogEntry -import com.collabtable.app.utils.LogLevel -import com.collabtable.app.utils.Logger - - -enum class TimeRange(val label: String, val milliseconds: Long) { - LAST_10_SECONDS("Last 10 seconds", 10_000), - LAST_30_SECONDS("Last 30 seconds", 30_000), - LAST_MINUTE("Last minute", 60_000), - LAST_5_MINUTES("Last 5 minutes", 300_000), - LAST_15_MINUTES("Last 15 minutes", 900_000), - LAST_HOUR("Last hour", 3_600_000), - LAST_24_HOURS("Last 24 hours", 86_400_000), - ALL_TIME("All time", Long.MAX_VALUE) -} - -data class LogFilters( - val severities: Set = LogLevel.values().toSet(), - val timeRange: TimeRange = TimeRange.ALL_TIME, - val tags: Set = emptySet(), - val searchText: String = "" -) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun LogsScreen( - onNavigateBack: () -> Unit -) { - val logs by Logger.logs.collectAsState() - val listState = rememberLazyListState() - var showFilterSheet by remember { mutableStateOf(false) } - var filters by remember { mutableStateOf(LogFilters()) } - - // Extract all unique tags from logs - val allTags = remember(logs) { - logs.map { it.tag }.distinct().sorted() - } - - // Apply filters - val filteredLogs = remember(logs, filters) { - val now = System.currentTimeMillis() - val cutoffTime = now - filters.timeRange.milliseconds - - logs.filter { log -> - // Severity filter - log.level in filters.severities && - // Time range filter - log.timestamp >= cutoffTime && - // Tag filter (empty means show all) - (filters.tags.isEmpty() || log.tag in filters.tags) && - // Search text filter - (filters.searchText.isEmpty() || - log.message.contains(filters.searchText, ignoreCase = true) || - log.tag.contains(filters.searchText, ignoreCase = true)) - } - } - - // Auto-scroll to bottom when new logs arrive - LaunchedEffect(filteredLogs.size) { - if (filteredLogs.isNotEmpty()) { - listState.animateScrollToItem(filteredLogs.size - 1) - } - } - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Logs (${filteredLogs.size}/${logs.size})") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.Filled.ArrowBack, contentDescription = "Back") - } - }, - actions = { - IconButton(onClick = { showFilterSheet = true }) { - Badge( - containerColor = if (hasActiveFilters(filters)) - MaterialTheme.colorScheme.error - else Color.Transparent - ) { - Icon(Icons.Default.FilterList, contentDescription = "Filter logs") - } - } - IconButton(onClick = { Logger.clear() }) { - Icon(Icons.Default.Delete, contentDescription = "Clear logs") - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - ) - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - ) { - // Active filters chips - if (hasActiveFilters(filters)) { - LazyRow( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - // Severity filters - if (filters.severities.size < LogLevel.values().size) { - items(filters.severities.toList()) { level -> - FilterChip( - selected = true, - onClick = { - filters = filters.copy( - severities = filters.severities - level - ) - }, - label = { Text(level.name, fontSize = 12.sp) } - ) - } - } - - // Time range filter - if (filters.timeRange != TimeRange.ALL_TIME) { - item { - FilterChip( - selected = true, - onClick = { - filters = filters.copy(timeRange = TimeRange.ALL_TIME) - }, - label = { Text(filters.timeRange.label, fontSize = 12.sp) } - ) - } - } - - // Tag filters - items(filters.tags.toList()) { tag -> - FilterChip( - selected = true, - onClick = { - filters = filters.copy(tags = filters.tags - tag) - }, - label = { Text(tag, fontSize = 12.sp) } - ) - } - - // Clear all button - item { - FilterChip( - selected = false, - onClick = { - filters = LogFilters() - }, - label = { Text("Clear All", fontSize = 12.sp) }, - leadingIcon = { - Icon( - Icons.Default.Delete, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - } - ) - } - } - } - - // Logs list - if (filteredLogs.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = if (logs.isEmpty()) "No logs yet" else "No logs match filters", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } else { - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxSize() - .background(Color(0xFF1E1E1E)), - contentPadding = PaddingValues(8.dp) - ) { - items(filteredLogs) { log -> - LogItem(log) - } - } - } - } - } - - // Filter bottom sheet - if (showFilterSheet) { - FilterBottomSheet( - filters = filters, - allTags = allTags, - onFiltersChanged = { filters = it }, - onDismiss = { showFilterSheet = false } - ) - } -} - -private fun hasActiveFilters(filters: LogFilters): Boolean { - return filters.severities.size < LogLevel.values().size || - filters.timeRange != TimeRange.ALL_TIME || - filters.tags.isNotEmpty() || - filters.searchText.isNotEmpty() -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun FilterBottomSheet( - filters: LogFilters, - allTags: List, - onFiltersChanged: (LogFilters) -> Unit, - onDismiss: () -> Unit -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .verticalScroll(rememberScrollState()) - .padding(bottom = 32.dp) - ) { - Text( - text = "Filter Logs", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 16.dp) - ) - - // Severity filters - Text( - text = "Severity", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(bottom = 8.dp) - ) - - LogLevel.values().forEach { level -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = level in filters.severities, - onCheckedChange = { checked -> - onFiltersChanged( - filters.copy( - severities = if (checked) { - filters.severities + level - } else { - filters.severities - level - } - ) - ) - } - ) - Text( - text = level.name, - modifier = Modifier.padding(start = 8.dp) - ) - } - } - - Divider(modifier = Modifier.padding(vertical = 16.dp)) - - // Time range filter - Text( - text = "Time Range", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(bottom = 8.dp) - ) - - TimeRange.values().forEach { range -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = filters.timeRange == range, - onClick = { - onFiltersChanged(filters.copy(timeRange = range)) - } - ) - Text( - text = range.label, - modifier = Modifier.padding(start = 8.dp) - ) - } - } - - Divider(modifier = Modifier.padding(vertical = 16.dp)) - - // Tag filters - Text( - text = "Tags (${filters.tags.size} selected)", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(bottom = 8.dp) - ) - - if (allTags.isEmpty()) { - Text( - text = "No tags available", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 8.dp) - ) - } else { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - TextButton( - onClick = { - onFiltersChanged(filters.copy(tags = allTags.toSet())) - } - ) { - Text("Select All") - } - TextButton( - onClick = { - onFiltersChanged(filters.copy(tags = emptySet())) - } - ) { - Text("Clear All") - } - } - - allTags.forEach { tag -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = tag in filters.tags, - onCheckedChange = { checked -> - onFiltersChanged( - filters.copy( - tags = if (checked) { - filters.tags + tag - } else { - filters.tags - tag - } - ) - ) - } - ) - Text( - text = tag, - modifier = Modifier.padding(start = 8.dp) - ) - } - } - } - - Divider(modifier = Modifier.padding(vertical = 16.dp)) - - // Reset button - Button( - onClick = { - onFiltersChanged(LogFilters()) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Reset All Filters") - } - } - } -} - -@Composable -fun LogItem(log: LogEntry) { - val color = when (log.level) { - LogLevel.DEBUG -> Color(0xFF808080) - LogLevel.INFO -> Color(0xFF4FC3F7) - LogLevel.WARN -> Color(0xFFFFA726) - LogLevel.ERROR -> Color(0xFFEF5350) - } - - Text( - text = log.toFormattedString(), - color = color, - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp) - ) -} +package com.collabtable.app.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.collabtable.app.utils.LogEntry +import com.collabtable.app.utils.LogLevel +import com.collabtable.app.utils.Logger + +enum class TimeRange(val label: String, val milliseconds: Long) { + LAST_10_SECONDS("Last 10 seconds", 10_000), + LAST_30_SECONDS("Last 30 seconds", 30_000), + LAST_MINUTE("Last minute", 60_000), + LAST_5_MINUTES("Last 5 minutes", 300_000), + LAST_15_MINUTES("Last 15 minutes", 900_000), + LAST_HOUR("Last hour", 3_600_000), + LAST_24_HOURS("Last 24 hours", 86_400_000), + ALL_TIME("All time", Long.MAX_VALUE), +} + +data class LogFilters( + val severities: Set = LogLevel.values().toSet(), + val timeRange: TimeRange = TimeRange.ALL_TIME, + val tags: Set = emptySet(), + val searchText: String = "", +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LogsScreen(onNavigateBack: () -> Unit) { + val logs by Logger.logs.collectAsState() + val listState = rememberLazyListState() + var showFilterSheet by remember { mutableStateOf(false) } + var filters by remember { mutableStateOf(LogFilters()) } + + // Extract all unique tags from logs + val allTags = + remember(logs) { + logs.map { it.tag }.distinct().sorted() + } + + // Apply filters + val filteredLogs = + remember(logs, filters) { + val now = System.currentTimeMillis() + val cutoffTime = now - filters.timeRange.milliseconds + + logs.filter { log -> + // Severity filter + log.level in filters.severities && + // Time range filter + log.timestamp >= cutoffTime && + // Tag filter (empty means show all) + (filters.tags.isEmpty() || log.tag in filters.tags) && + // Search text filter + ( + filters.searchText.isEmpty() || + log.message.contains(filters.searchText, ignoreCase = true) || + log.tag.contains(filters.searchText, ignoreCase = true) + ) + } + } + + // Auto-scroll to bottom when new logs arrive + LaunchedEffect(filteredLogs.size) { + if (filteredLogs.isNotEmpty()) { + listState.animateScrollToItem(filteredLogs.size - 1) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Logs (${filteredLogs.size}/${logs.size})") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { showFilterSheet = true }) { + Badge( + containerColor = + if (hasActiveFilters(filters)) { + MaterialTheme.colorScheme.error + } else { + Color.Transparent + }, + ) { + Icon(Icons.Default.FilterList, contentDescription = "Filter logs") + } + } + IconButton(onClick = { Logger.clear() }) { + Icon(Icons.Default.Delete, contentDescription = "Clear logs") + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding), + ) { + // Active filters chips + if (hasActiveFilters(filters)) { + LazyRow( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Severity filters + if (filters.severities.size < LogLevel.values().size) { + items(filters.severities.toList()) { level -> + FilterChip( + selected = true, + onClick = { + filters = + filters.copy( + severities = filters.severities - level, + ) + }, + label = { Text(level.name, fontSize = 12.sp) }, + ) + } + } + + // Time range filter + if (filters.timeRange != TimeRange.ALL_TIME) { + item { + FilterChip( + selected = true, + onClick = { + filters = filters.copy(timeRange = TimeRange.ALL_TIME) + }, + label = { Text(filters.timeRange.label, fontSize = 12.sp) }, + ) + } + } + + // Tag filters + items(filters.tags.toList()) { tag -> + FilterChip( + selected = true, + onClick = { + filters = filters.copy(tags = filters.tags - tag) + }, + label = { Text(tag, fontSize = 12.sp) }, + ) + } + + // Clear all button + item { + FilterChip( + selected = false, + onClick = { + filters = LogFilters() + }, + label = { Text("Clear All", fontSize = 12.sp) }, + leadingIcon = { + Icon( + Icons.Default.Delete, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + ) + } + } + } + + // Logs list + if (filteredLogs.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (logs.isEmpty()) "No logs yet" else "No logs match filters", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + state = listState, + modifier = + Modifier + .fillMaxSize() + .background(Color(0xFF1E1E1E)), + contentPadding = PaddingValues(8.dp), + ) { + items(filteredLogs) { log -> + LogItem(log) + } + } + } + } + } + + // Filter bottom sheet + if (showFilterSheet) { + FilterBottomSheet( + filters = filters, + allTags = allTags, + onFiltersChanged = { filters = it }, + onDismiss = { showFilterSheet = false }, + ) + } +} + +private fun hasActiveFilters(filters: LogFilters): Boolean { + return filters.severities.size < LogLevel.values().size || + filters.timeRange != TimeRange.ALL_TIME || + filters.tags.isNotEmpty() || + filters.searchText.isNotEmpty() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilterBottomSheet( + filters: LogFilters, + allTags: List, + onFiltersChanged: (LogFilters) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp) + .verticalScroll(rememberScrollState()) + .padding(bottom = 32.dp), + ) { + Text( + text = "Filter Logs", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp), + ) + + // Severity filters + Text( + text = "Severity", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 8.dp), + ) + + LogLevel.values().forEach { level -> + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = level in filters.severities, + onCheckedChange = { checked -> + onFiltersChanged( + filters.copy( + severities = + if (checked) { + filters.severities + level + } else { + filters.severities - level + }, + ), + ) + }, + ) + Text( + text = level.name, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + + Divider(modifier = Modifier.padding(vertical = 16.dp)) + + // Time range filter + Text( + text = "Time Range", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 8.dp), + ) + + TimeRange.values().forEach { range -> + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = filters.timeRange == range, + onClick = { + onFiltersChanged(filters.copy(timeRange = range)) + }, + ) + Text( + text = range.label, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + + Divider(modifier = Modifier.padding(vertical = 16.dp)) + + // Tag filters + Text( + text = "Tags (${filters.tags.size} selected)", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 8.dp), + ) + + if (allTags.isEmpty()) { + Text( + text = "No tags available", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + } else { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton( + onClick = { + onFiltersChanged(filters.copy(tags = allTags.toSet())) + }, + ) { + Text("Select All") + } + TextButton( + onClick = { + onFiltersChanged(filters.copy(tags = emptySet())) + }, + ) { + Text("Clear All") + } + } + + allTags.forEach { tag -> + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = tag in filters.tags, + onCheckedChange = { checked -> + onFiltersChanged( + filters.copy( + tags = + if (checked) { + filters.tags + tag + } else { + filters.tags - tag + }, + ), + ) + }, + ) + Text( + text = tag, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + + Divider(modifier = Modifier.padding(vertical = 16.dp)) + + // Reset button + Button( + onClick = { + onFiltersChanged(LogFilters()) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Reset All Filters") + } + } + } +} + +@Composable +fun LogItem(log: LogEntry) { + val color = + when (log.level) { + LogLevel.DEBUG -> Color(0xFF808080) + LogLevel.INFO -> Color(0xFF4FC3F7) + LogLevel.WARN -> Color(0xFFFFA726) + LogLevel.ERROR -> Color(0xFFEF5350) + } + + Text( + text = log.toFormattedString(), + color = color, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + ) +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt index 58a1554..e4d21b4 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt @@ -1,148 +1,152 @@ -package com.collabtable.app.ui.screens - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Error -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.collabtable.app.data.preferences.PreferencesManager - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ServerSetupScreen( - onSetupComplete: () -> Unit -) { - val context = LocalContext.current - val preferencesManager = remember { PreferencesManager.getInstance(context) } - val viewModel = remember { ServerSetupViewModel(preferencesManager) } - - var serverUrl by remember { mutableStateOf("") } - var serverPassword by remember { mutableStateOf("") } - var passwordVisible by remember { mutableStateOf(false) } - val isValidating by viewModel.isValidating.collectAsState() - val validationResult by viewModel.validationResult.collectAsState() - val validationError by viewModel.validationError.collectAsState() - - LaunchedEffect(validationResult) { - if (validationResult == true) { - onSetupComplete() - } - } - - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { Text("Server Setup") }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - ) - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Spacer(modifier = Modifier.height(4.dp)) - - OutlinedTextField( - value = serverUrl, - onValueChange = { serverUrl = it }, - label = { Text("Server Hostname") }, - placeholder = { Text("example.com or 10.0.2.2:3000") }, - modifier = Modifier.fillMaxWidth(), - enabled = !isValidating, - isError = validationError != null, - supportingText = { - when { - validationError != null -> Text( - text = validationError ?: "", - color = MaterialTheme.colorScheme.error - ) - else -> Text("Hostname only. Port optional (80/443 default). No http://, no /api/") - } - }, - trailingIcon = { - when { - isValidating -> CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp - ) - validationResult == true -> Icon( - Icons.Default.CheckCircle, - contentDescription = "Valid", - tint = MaterialTheme.colorScheme.primary - ) - validationError != null -> Icon( - Icons.Default.Error, - contentDescription = "Error", - tint = MaterialTheme.colorScheme.error - ) - } - } - ) - - OutlinedTextField( - value = serverPassword, - onValueChange = { serverPassword = it }, - label = { Text("Server Password") }, - placeholder = { Text("Enter server password") }, - modifier = Modifier.fillMaxWidth(), - enabled = !isValidating, - visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - trailingIcon = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon( - imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff, - contentDescription = if (passwordVisible) "Hide password" else "Show password" - ) - } - }, - supportingText = { Text("Server password for authentication") } - ) - - Button( - onClick = { viewModel.validateAndSaveServerUrl(serverUrl.trim(), serverPassword.trim()) }, - modifier = Modifier.fillMaxWidth(), - enabled = serverUrl.isNotBlank() && serverPassword.isNotBlank() && !isValidating - ) { - if (isValidating) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Validating...") - } else { - Text("Connect") - } - } - - Text( - text = "Tip: Emulator host 10.0.2.2:3000 ยท Physical device uses your PC IP", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } - } -} +package com.collabtable.app.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.collabtable.app.data.preferences.PreferencesManager + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ServerSetupScreen(onSetupComplete: () -> Unit) { + val context = LocalContext.current + val preferencesManager = remember { PreferencesManager.getInstance(context) } + val viewModel = remember { ServerSetupViewModel(preferencesManager) } + + var serverUrl by remember { mutableStateOf("") } + var serverPassword by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + val isValidating by viewModel.isValidating.collectAsState() + val validationResult by viewModel.validationResult.collectAsState() + val validationError by viewModel.validationError.collectAsState() + + LaunchedEffect(validationResult) { + if (validationResult == true) { + onSetupComplete() + } + } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text("Server Setup") }, + colors = + TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Spacer(modifier = Modifier.height(4.dp)) + + OutlinedTextField( + value = serverUrl, + onValueChange = { serverUrl = it }, + label = { Text("Server Hostname") }, + placeholder = { Text("example.com or 10.0.2.2:3000") }, + modifier = Modifier.fillMaxWidth(), + enabled = !isValidating, + isError = validationError != null, + supportingText = { + when { + validationError != null -> + Text( + text = validationError ?: "", + color = MaterialTheme.colorScheme.error, + ) + else -> Text("Hostname only. Port optional (80/443 default). No http://, no /api/") + } + }, + trailingIcon = { + when { + isValidating -> + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + validationResult == true -> + Icon( + Icons.Default.CheckCircle, + contentDescription = "Valid", + tint = MaterialTheme.colorScheme.primary, + ) + validationError != null -> + Icon( + Icons.Default.Error, + contentDescription = "Error", + tint = MaterialTheme.colorScheme.error, + ) + } + }, + ) + + OutlinedTextField( + value = serverPassword, + onValueChange = { serverPassword = it }, + label = { Text("Server Password") }, + placeholder = { Text("Enter server password") }, + modifier = Modifier.fillMaxWidth(), + enabled = !isValidating, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff, + contentDescription = if (passwordVisible) "Hide password" else "Show password", + ) + } + }, + supportingText = { Text("Server password for authentication") }, + ) + + Button( + onClick = { viewModel.validateAndSaveServerUrl(serverUrl.trim(), serverPassword.trim()) }, + modifier = Modifier.fillMaxWidth(), + enabled = serverUrl.isNotBlank() && serverPassword.isNotBlank() && !isValidating, + ) { + if (isValidating) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Validating...") + } else { + Text("Connect") + } + } + + Text( + text = "Tip: Emulator host 10.0.2.2:3000 ยท Physical device uses your PC IP", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt index e5f5713..0750598 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt @@ -1,208 +1,220 @@ -package com.collabtable.app.ui.screens - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.collabtable.app.data.preferences.PreferencesManager -import com.collabtable.app.data.api.ApiClient -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request -import android.os.NetworkOnMainThreadException -import java.io.IOException -import java.net.ConnectException -import java.net.SocketTimeoutException -import java.net.UnknownHostException -import java.util.concurrent.TimeUnit - -class ServerSetupViewModel( - private val preferencesManager: PreferencesManager -) : ViewModel() { - private val _isValidating = MutableStateFlow(false) - val isValidating: StateFlow = _isValidating.asStateFlow() - - private val _validationResult = MutableStateFlow(null) - val validationResult: StateFlow = _validationResult.asStateFlow() - - private val _validationError = MutableStateFlow(null) - val validationError: StateFlow = _validationError.asStateFlow() - - private val okHttpClient = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(10, TimeUnit.SECONDS) - .build() - - fun validateAndSaveServerUrl(url: String, password: String) { - viewModelScope.launch { - _isValidating.value = true - _validationError.value = null - _validationResult.value = null - - try { - // Normalize URL - add protocol if missing - var normalizedUrl = url.trim() - - // Check if URL has a protocol - val hasProtocol = normalizedUrl.startsWith("http://") || normalizedUrl.startsWith("https://") - val hasPort = normalizedUrl.contains(":") - - // Add http:// if no protocol specified - if (!hasProtocol) { - normalizedUrl = "http://$normalizedUrl" - } - - // Add default port if no port specified - if (!hasPort || normalizedUrl.matches(Regex("^https?://.+$")) && !normalizedUrl.substringAfter("://").contains(":")) { - // Extract protocol and host - val protocol = if (normalizedUrl.startsWith("https://")) "https" else "http" - val hostAndPath = normalizedUrl.substringAfter("://") - val host = if (hostAndPath.contains("/")) hostAndPath.substringBefore("/") else hostAndPath - val path = if (hostAndPath.contains("/")) "/" + hostAndPath.substringAfter("/") else "" - - // Add default port based on protocol - val defaultPort = if (protocol == "https") "443" else "80" - normalizedUrl = "$protocol://$host:$defaultPort$path" - } - - // Remove trailing slash if present for consistent handling - normalizedUrl = normalizedUrl.trimEnd('/') - - // Add /api/ if not present - if (!normalizedUrl.contains("/api")) { - normalizedUrl = "$normalizedUrl/api" - } - - // Ensure it ends with / - normalizedUrl = if (normalizedUrl.endsWith("/")) normalizedUrl else "$normalizedUrl/" - - // Validate password is not empty - if (password.isBlank()) { - _validationError.value = "Password cannot be empty" - _isValidating.value = false - return@launch - } - - // Try to reach the health endpoint (no auth required) - val healthUrl = normalizedUrl.replace("/api/", "/health") - val healthRequest = Request.Builder() - .url(healthUrl) - .get() - .build() - - try { - // Execute network call on IO dispatcher - val healthResponse = withContext(Dispatchers.IO) { - okHttpClient.newCall(healthRequest).execute() - } - - if (!healthResponse.isSuccessful) { - val errorMessage = when (healthResponse.code) { - 404 -> "Health endpoint not found. Check server URL." - 500 -> "Server internal error. Check server logs." - 503 -> "Service unavailable. Server may be starting up." - else -> "Server returned HTTP ${healthResponse.code}" - } - healthResponse.close() - _validationError.value = errorMessage - _isValidating.value = false - return@launch - } - healthResponse.close() - - // Try to upgrade to HTTPS if currently using HTTP - var finalUrl = normalizedUrl - if (normalizedUrl.startsWith("http://")) { - // Replace http:// with https:// and change port 80 to 443 - var httpsUrl = normalizedUrl.replace("http://", "https://") - if (httpsUrl.contains(":80/")) { - httpsUrl = httpsUrl.replace(":80/", ":443/") - } - - val httpsHealthUrl = httpsUrl.replace("/api/", "/health") - val httpsHealthRequest = Request.Builder() - .url(httpsHealthUrl) - .get() - .build() - - try { - // Execute HTTPS check on IO dispatcher - val httpsResponse = withContext(Dispatchers.IO) { - okHttpClient.newCall(httpsHealthRequest).execute() - } - if (httpsResponse.isSuccessful) { - // HTTPS works! Upgrade to it - finalUrl = httpsUrl - } - httpsResponse.close() - } catch (e: Exception) { - // HTTPS doesn't work, stick with HTTP - } - } - - // Now validate the password by making an authenticated request - val testUrl = "${finalUrl}lists" - val authRequest = Request.Builder() - .url(testUrl) - .header("Authorization", "Bearer $password") - .get() - .build() - - // Execute auth check on IO dispatcher - val authResponse = withContext(Dispatchers.IO) { - okHttpClient.newCall(authRequest).execute() - } - - if (authResponse.isSuccessful) { - // Password is valid, save both URL and password - preferencesManager.setServerUrl(finalUrl) - preferencesManager.setServerPassword(password) - // Reset sync baseline for a fresh initial sync on new server - preferencesManager.clearSyncState() - preferencesManager.setIsFirstRun(false) - ApiClient.setBaseUrl(finalUrl) - _validationResult.value = true - } else if (authResponse.code == 401) { - _validationError.value = "Invalid password. Please check and try again." - } else { - val errorMessage = when (authResponse.code) { - 403 -> "Access forbidden. Check server configuration." - 404 -> "API endpoint not found. Check URL path." - 500 -> "Server error. Check server logs." - 503 -> "Service unavailable. Try again later." - else -> "Server returned HTTP ${authResponse.code}" - } - _validationError.value = errorMessage - } - authResponse.close() - } catch (e: NetworkOnMainThreadException) { - _validationError.value = "Network operation attempted on main thread. This is a developer error - please report this bug." - } catch (e: UnknownHostException) { - _validationError.value = "Cannot resolve hostname. Check URL spelling and network connection." - } catch (e: ConnectException) { - _validationError.value = "Cannot connect to server. Check if server is running and URL is correct." - } catch (e: SocketTimeoutException) { - _validationError.value = "Connection timeout after 10 seconds. Server may be offline or unreachable." - } catch (e: IOException) { - _validationError.value = "Network error: ${e.message ?: "Unable to communicate with server"}" - } catch (e: Exception) { - _validationError.value = "Unexpected error: ${e.javaClass.simpleName} - ${e.message ?: "Unknown cause"}" - } - } catch (e: IllegalArgumentException) { - _validationError.value = "Invalid URL format: ${e.message ?: "Malformed URL"}" - } catch (e: Exception) { - _validationError.value = "Configuration error: ${e.javaClass.simpleName} - ${e.message ?: "Unable to process URL"}" - } finally { - _isValidating.value = false - } - } - } - - fun clearValidationState() { - _validationResult.value = null - _validationError.value = null - } -} +package com.collabtable.app.ui.screens + +import android.os.NetworkOnMainThreadException +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.collabtable.app.data.api.ApiClient +import com.collabtable.app.data.preferences.PreferencesManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.util.concurrent.TimeUnit + +class ServerSetupViewModel( + private val preferencesManager: PreferencesManager, +) : ViewModel() { + private val _isValidating = MutableStateFlow(false) + val isValidating: StateFlow = _isValidating.asStateFlow() + + private val _validationResult = MutableStateFlow(null) + val validationResult: StateFlow = _validationResult.asStateFlow() + + private val _validationError = MutableStateFlow(null) + val validationError: StateFlow = _validationError.asStateFlow() + + private val okHttpClient = + OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build() + + fun validateAndSaveServerUrl( + url: String, + password: String, + ) { + viewModelScope.launch { + _isValidating.value = true + _validationError.value = null + _validationResult.value = null + + try { + // Normalize URL - add protocol if missing + var normalizedUrl = url.trim() + + // Check if URL has a protocol + val hasProtocol = normalizedUrl.startsWith("http://") || normalizedUrl.startsWith("https://") + val hasPort = normalizedUrl.contains(":") + + // Add http:// if no protocol specified + if (!hasProtocol) { + normalizedUrl = "http://$normalizedUrl" + } + + // Add default port if no port specified + if (!hasPort || normalizedUrl.matches(Regex("^https?://.+$")) && !normalizedUrl.substringAfter("://").contains(":")) { + // Extract protocol and host + val protocol = if (normalizedUrl.startsWith("https://")) "https" else "http" + val hostAndPath = normalizedUrl.substringAfter("://") + val host = if (hostAndPath.contains("/")) hostAndPath.substringBefore("/") else hostAndPath + val path = if (hostAndPath.contains("/")) "/" + hostAndPath.substringAfter("/") else "" + + // Add default port based on protocol + val defaultPort = if (protocol == "https") "443" else "80" + normalizedUrl = "$protocol://$host:$defaultPort$path" + } + + // Remove trailing slash if present for consistent handling + normalizedUrl = normalizedUrl.trimEnd('/') + + // Add /api/ if not present + if (!normalizedUrl.contains("/api")) { + normalizedUrl = "$normalizedUrl/api" + } + + // Ensure it ends with / + normalizedUrl = if (normalizedUrl.endsWith("/")) normalizedUrl else "$normalizedUrl/" + + // Validate password is not empty + if (password.isBlank()) { + _validationError.value = "Password cannot be empty" + _isValidating.value = false + return@launch + } + + // Try to reach the health endpoint (no auth required) + val healthUrl = normalizedUrl.replace("/api/", "/health") + val healthRequest = + Request.Builder() + .url(healthUrl) + .get() + .build() + + try { + // Execute network call on IO dispatcher + val healthResponse = + withContext(Dispatchers.IO) { + okHttpClient.newCall(healthRequest).execute() + } + + if (!healthResponse.isSuccessful) { + val errorMessage = + when (healthResponse.code) { + 404 -> "Health endpoint not found. Check server URL." + 500 -> "Server internal error. Check server logs." + 503 -> "Service unavailable. Server may be starting up." + else -> "Server returned HTTP ${healthResponse.code}" + } + healthResponse.close() + _validationError.value = errorMessage + _isValidating.value = false + return@launch + } + healthResponse.close() + + // Try to upgrade to HTTPS if currently using HTTP + var finalUrl = normalizedUrl + if (normalizedUrl.startsWith("http://")) { + // Replace http:// with https:// and change port 80 to 443 + var httpsUrl = normalizedUrl.replace("http://", "https://") + if (httpsUrl.contains(":80/")) { + httpsUrl = httpsUrl.replace(":80/", ":443/") + } + + val httpsHealthUrl = httpsUrl.replace("/api/", "/health") + val httpsHealthRequest = + Request.Builder() + .url(httpsHealthUrl) + .get() + .build() + + try { + // Execute HTTPS check on IO dispatcher + val httpsResponse = + withContext(Dispatchers.IO) { + okHttpClient.newCall(httpsHealthRequest).execute() + } + if (httpsResponse.isSuccessful) { + // HTTPS works! Upgrade to it + finalUrl = httpsUrl + } + httpsResponse.close() + } catch (e: Exception) { + // HTTPS doesn't work, stick with HTTP + } + } + + // Now validate the password by making an authenticated request + val testUrl = "${finalUrl}lists" + val authRequest = + Request.Builder() + .url(testUrl) + .header("Authorization", "Bearer $password") + .get() + .build() + + // Execute auth check on IO dispatcher + val authResponse = + withContext(Dispatchers.IO) { + okHttpClient.newCall(authRequest).execute() + } + + if (authResponse.isSuccessful) { + // Password is valid, save both URL and password + preferencesManager.setServerUrl(finalUrl) + preferencesManager.setServerPassword(password) + // Reset sync baseline for a fresh initial sync on new server + preferencesManager.clearSyncState() + preferencesManager.setIsFirstRun(false) + ApiClient.setBaseUrl(finalUrl) + _validationResult.value = true + } else if (authResponse.code == 401) { + _validationError.value = "Invalid password. Please check and try again." + } else { + val errorMessage = + when (authResponse.code) { + 403 -> "Access forbidden. Check server configuration." + 404 -> "API endpoint not found. Check URL path." + 500 -> "Server error. Check server logs." + 503 -> "Service unavailable. Try again later." + else -> "Server returned HTTP ${authResponse.code}" + } + _validationError.value = errorMessage + } + authResponse.close() + } catch (e: NetworkOnMainThreadException) { + _validationError.value = "Network operation attempted on main thread. This is a developer error - please report this bug." + } catch (e: UnknownHostException) { + _validationError.value = "Cannot resolve hostname. Check URL spelling and network connection." + } catch (e: ConnectException) { + _validationError.value = "Cannot connect to server. Check if server is running and URL is correct." + } catch (e: SocketTimeoutException) { + _validationError.value = "Connection timeout after 10 seconds. Server may be offline or unreachable." + } catch (e: IOException) { + _validationError.value = "Network error: ${e.message ?: "Unable to communicate with server"}" + } catch (e: Exception) { + _validationError.value = "Unexpected error: ${e.javaClass.simpleName} - ${e.message ?: "Unknown cause"}" + } + } catch (e: IllegalArgumentException) { + _validationError.value = "Invalid URL format: ${e.message ?: "Malformed URL"}" + } catch (e: Exception) { + _validationError.value = "Configuration error: ${e.javaClass.simpleName} - ${e.message ?: "Unable to process URL"}" + } finally { + _isValidating.value = false + } + } + } + + fun clearValidationState() { + _validationResult.value = null + _validationError.value = null + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt index b520652..235d233 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt @@ -1,309 +1,317 @@ -package com.collabtable.app.ui.screens - -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.collabtable.app.R -import com.collabtable.app.data.database.CollabTableDatabase -import com.collabtable.app.data.api.ApiClient -import com.collabtable.app.data.preferences.PreferencesManager -import com.collabtable.app.data.repository.SyncRepository -import com.collabtable.app.utils.Logger -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults -import androidx.compose.material.icons.filled.DarkMode -import androidx.compose.material.icons.filled.LightMode -import androidx.compose.material.icons.filled.SettingsBrightness -import androidx.compose.ui.Alignment -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SettingsScreen( - onNavigateBack: () -> Unit, - onLeaveServer: () -> Unit = {} -) { - val context = LocalContext.current - val preferencesManager = remember { PreferencesManager.getInstance(context) } - val syncRepository = remember { SyncRepository(context) } - val coroutineScope = rememberCoroutineScope() - - val serverUrl = preferencesManager.getServerUrl() - val themeMode by preferencesManager.themeMode.collectAsState() - val dynamicColor by preferencesManager.dynamicColor.collectAsState() - val amoledDark by preferencesManager.amoledDark.collectAsState() - val displayUrl = remember(serverUrl) { formatServerUrlForDisplay(serverUrl) } - var showLeaveDialog by remember { mutableStateOf(false) } - var isLeaving by remember { mutableStateOf(false) } - - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.settings)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - ) - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = "Server Connection", - style = MaterialTheme.typography.titleMedium - ) - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = "Connected to", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = displayUrl, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = "Appearance", - style = MaterialTheme.typography.titleMedium - ) - - // Theme mode selector - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - FilterChip( - selected = themeMode == PreferencesManager.THEME_MODE_SYSTEM, - onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_SYSTEM) }, - label = { Text("System") }, - leadingIcon = { Icon(Icons.Default.SettingsBrightness, contentDescription = null) } - ) - FilterChip( - selected = themeMode == PreferencesManager.THEME_MODE_LIGHT, - onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_LIGHT) }, - label = { Text("Light") }, - leadingIcon = { Icon(Icons.Default.LightMode, contentDescription = null) } - ) - FilterChip( - selected = themeMode == PreferencesManager.THEME_MODE_DARK, - onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_DARK) }, - label = { Text("Dark") }, - leadingIcon = { Icon(Icons.Default.DarkMode, contentDescription = null) } - ) - } - - // Dynamic color toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Use Material 3 colors") - Text( - text = "Dynamic colors on supported devices", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch(checked = dynamicColor, onCheckedChange = { preferencesManager.setDynamicColorEnabled(it) }) - } - - // AMOLED dark toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text("AMOLED dark") - Text( - text = "Pure black background in dark mode", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch(checked = amoledDark, onCheckedChange = { preferencesManager.setAmoledDarkEnabled(it) }) - } - - Button( - onClick = { showLeaveDialog = true }, - modifier = Modifier.fillMaxWidth(), - enabled = !isLeaving, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError - ) - ) { - if (isLeaving) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - color = MaterialTheme.colorScheme.onError, - strokeWidth = 2.dp - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Leaving...") - } else { - Text("Leave Server") - } - } - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer - ) - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = "Leaving removes local data", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - Text( - text = "Disconnects and clears local database", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - Text( - text = "Clears stored server URL and password", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - Text( - text = "Deletes all local data (tables, fields, items)", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - Text( - text = "Returns to server setup screen", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - } - } - } - - if (showLeaveDialog) { - AlertDialog( - onDismissRequest = { showLeaveDialog = false }, - title = { Text("Leave Server?") }, - text = { - Text("All your data will be synced to the server before disconnecting. After leaving, all local data will be deleted and you'll need to set up a new connection. This action cannot be undone.") - }, - confirmButton = { - TextButton( - onClick = { - isLeaving = true - coroutineScope.launch { - try { - Logger.i("Settings", "Performing final sync before leaving server") - - // Perform final sync to ensure all data is uploaded - withContext(Dispatchers.IO) { - syncRepository.performSync() - } - - Logger.i("Settings", "Final sync completed, clearing local data") - - // Clear database in background - withContext(Dispatchers.IO) { - CollabTableDatabase.clearDatabase(context) - } - - // Clear preferences (URL, password, last sync) - preferencesManager.clearServerSettings() - preferencesManager.setIsFirstRun(true) - // Clear in-memory logs as part of local data - Logger.clear() - // Reset API client base URL to current preference (default) - ApiClient.setBaseUrl(preferencesManager.getServerUrl()) - - Logger.i("Settings", "Left server successfully") - - showLeaveDialog = false - onLeaveServer() - } catch (e: Exception) { - // Handle error if needed - Logger.e("Settings", "Error leaving server", e) - isLeaving = false - } - } - }, - enabled = !isLeaving, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Text("Leave Server") - } - }, - dismissButton = { - TextButton(onClick = { showLeaveDialog = false }) { - Text("Cancel") - } - } - ) - } - } -} - -private fun formatServerUrlForDisplay(raw: String): String { - var s = raw.trim() - if (s.isEmpty()) return "" - // Remove scheme - s = s.replace(Regex("^https?://", RegexOption.IGNORE_CASE), "") - // Remove trailing /api or /api/ - s = s.replace(Regex("/api/?$", RegexOption.IGNORE_CASE), "") - // Trim trailing / - s = s.trimEnd('/') - // Split authority and path (in case any path remains) - val firstSlash = s.indexOf('/') - val authority = if (firstSlash >= 0) s.substring(0, firstSlash) else s - val rest = if (firstSlash >= 0) s.substring(firstSlash) else "" - // Remove default ports from authority - val authorityStripped = when { - authority.endsWith(":80") -> authority.removeSuffix(":80") - authority.endsWith(":443") -> authority.removeSuffix(":443") - else -> authority - } - return authorityStripped + rest -} +package com.collabtable.app.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.SettingsBrightness +import androidx.compose.material3.* +import androidx.compose.material3.FilterChip +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.collabtable.app.R +import com.collabtable.app.data.api.ApiClient +import com.collabtable.app.data.database.CollabTableDatabase +import com.collabtable.app.data.preferences.PreferencesManager +import com.collabtable.app.data.repository.SyncRepository +import com.collabtable.app.utils.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onNavigateBack: () -> Unit, + onLeaveServer: () -> Unit = {}, +) { + val context = LocalContext.current + val preferencesManager = remember { PreferencesManager.getInstance(context) } + val syncRepository = remember { SyncRepository(context) } + val coroutineScope = rememberCoroutineScope() + + val serverUrl = preferencesManager.getServerUrl() + val themeMode by preferencesManager.themeMode.collectAsState() + val dynamicColor by preferencesManager.dynamicColor.collectAsState() + val amoledDark by preferencesManager.amoledDark.collectAsState() + val displayUrl = remember(serverUrl) { formatServerUrlForDisplay(serverUrl) } + var showLeaveDialog by remember { mutableStateOf(false) } + var isLeaving by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.settings)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Server Connection", + style = MaterialTheme.typography.titleMedium, + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Connected to", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = displayUrl, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Appearance", + style = MaterialTheme.typography.titleMedium, + ) + + // Theme mode selector + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = themeMode == PreferencesManager.THEME_MODE_SYSTEM, + onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_SYSTEM) }, + label = { Text("System") }, + leadingIcon = { Icon(Icons.Default.SettingsBrightness, contentDescription = null) }, + ) + FilterChip( + selected = themeMode == PreferencesManager.THEME_MODE_LIGHT, + onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_LIGHT) }, + label = { Text("Light") }, + leadingIcon = { Icon(Icons.Default.LightMode, contentDescription = null) }, + ) + FilterChip( + selected = themeMode == PreferencesManager.THEME_MODE_DARK, + onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_DARK) }, + label = { Text("Dark") }, + leadingIcon = { Icon(Icons.Default.DarkMode, contentDescription = null) }, + ) + } + + // Dynamic color toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Use Material 3 colors") + Text( + text = "Dynamic colors on supported devices", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = dynamicColor, onCheckedChange = { preferencesManager.setDynamicColorEnabled(it) }) + } + + // AMOLED dark toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("AMOLED dark") + Text( + text = "Pure black background in dark mode", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = amoledDark, onCheckedChange = { preferencesManager.setAmoledDarkEnabled(it) }) + } + + Button( + onClick = { showLeaveDialog = true }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLeaving, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + if (isLeaving) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = MaterialTheme.colorScheme.onError, + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Leaving...") + } else { + Text("Leave Server") + } + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Leaving removes local data", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Text( + text = "Disconnects and clears local database", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Text( + text = "Clears stored server URL and password", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Text( + text = "Deletes all local data (tables, fields, items)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Text( + text = "Returns to server setup screen", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + + if (showLeaveDialog) { + AlertDialog( + onDismissRequest = { showLeaveDialog = false }, + title = { Text("Leave Server?") }, + text = { + Text( + "All your data will be synced to the server before disconnecting. After leaving, all local data will be deleted and you'll need to set up a new connection. This action cannot be undone.", + ) + }, + confirmButton = { + TextButton( + onClick = { + isLeaving = true + coroutineScope.launch { + try { + Logger.i("Settings", "Performing final sync before leaving server") + + // Perform final sync to ensure all data is uploaded + withContext(Dispatchers.IO) { + syncRepository.performSync() + } + + Logger.i("Settings", "Final sync completed, clearing local data") + + // Clear database in background + withContext(Dispatchers.IO) { + CollabTableDatabase.clearDatabase(context) + } + + // Clear preferences (URL, password, last sync) + preferencesManager.clearServerSettings() + preferencesManager.setIsFirstRun(true) + // Clear in-memory logs as part of local data + Logger.clear() + // Reset API client base URL to current preference (default) + ApiClient.setBaseUrl(preferencesManager.getServerUrl()) + + Logger.i("Settings", "Left server successfully") + + showLeaveDialog = false + onLeaveServer() + } catch (e: Exception) { + // Handle error if needed + Logger.e("Settings", "Error leaving server", e) + isLeaving = false + } + } + }, + enabled = !isLeaving, + colors = + ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Leave Server") + } + }, + dismissButton = { + TextButton(onClick = { showLeaveDialog = false }) { + Text("Cancel") + } + }, + ) + } + } +} + +private fun formatServerUrlForDisplay(raw: String): String { + var s = raw.trim() + if (s.isEmpty()) return "" + // Remove scheme + s = s.replace(Regex("^https?://", RegexOption.IGNORE_CASE), "") + // Remove trailing /api or /api/ + s = s.replace(Regex("/api/?$", RegexOption.IGNORE_CASE), "") + // Trim trailing / + s = s.trimEnd('/') + // Split authority and path (in case any path remains) + val firstSlash = s.indexOf('/') + val authority = if (firstSlash >= 0) s.substring(0, firstSlash) else s + val rest = if (firstSlash >= 0) s.substring(firstSlash) else "" + // Remove default ports from authority + val authorityStripped = + when { + authority.endsWith(":80") -> authority.removeSuffix(":80") + authority.endsWith(":443") -> authority.removeSuffix(":443") + else -> authority + } + return authorityStripped + rest +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsViewModel.kt index 2a5b7f0..0ea7063 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsViewModel.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsViewModel.kt @@ -1,33 +1,33 @@ -package com.collabtable.app.ui.screens - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.collabtable.app.data.preferences.PreferencesManager -import com.collabtable.app.data.api.ApiClient -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -class SettingsViewModel( - private val preferencesManager: PreferencesManager -) : ViewModel() { - private val _serverUrl = MutableStateFlow(preferencesManager.getServerUrl()) - val serverUrl: StateFlow = _serverUrl.asStateFlow() - - private val _showSuccessMessage = MutableStateFlow(false) - val showSuccessMessage: StateFlow = _showSuccessMessage.asStateFlow() - - fun updateServerUrl(url: String) { - viewModelScope.launch { - preferencesManager.setServerUrl(url) - ApiClient.setBaseUrl(url) - _serverUrl.value = preferencesManager.getServerUrl() - _showSuccessMessage.value = true - } - } - - fun clearSuccessMessage() { - _showSuccessMessage.value = false - } -} +package com.collabtable.app.ui.screens + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.collabtable.app.data.api.ApiClient +import com.collabtable.app.data.preferences.PreferencesManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class SettingsViewModel( + private val preferencesManager: PreferencesManager, +) : ViewModel() { + private val _serverUrl = MutableStateFlow(preferencesManager.getServerUrl()) + val serverUrl: StateFlow = _serverUrl.asStateFlow() + + private val _showSuccessMessage = MutableStateFlow(false) + val showSuccessMessage: StateFlow = _showSuccessMessage.asStateFlow() + + fun updateServerUrl(url: String) { + viewModelScope.launch { + preferencesManager.setServerUrl(url) + ApiClient.setBaseUrl(url) + _serverUrl.value = preferencesManager.getServerUrl() + _showSuccessMessage.value = true + } + } + + fun clearSuccessMessage() { + _showSuccessMessage.value = false + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Theme.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Theme.kt index a7e779d..74373cf 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Theme.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Theme.kt @@ -1,68 +1,72 @@ -package com.collabtable.app.ui.theme - -import android.app.Activity -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.core.view.WindowCompat - -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 -) - -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 -) - -@Composable -fun CollabTableTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - dynamicColor: Boolean = true, - amoledDark: Boolean = false, - content: @Composable () -> Unit -) { - var colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - darkTheme -> DarkColorScheme - else -> LightColorScheme - } - - if (darkTheme && amoledDark) { - colorScheme = colorScheme.copy( - background = Color.Black, - surface = Color.Black, - surfaceVariant = Color.Black - ) - } - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - window.statusBarColor = colorScheme.primary.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme - } - } - - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) -} +package com.collabtable.app.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = + darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80, + ) + +private val LightColorScheme = + lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, + ) + +@Composable +fun CollabTableTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + amoledDark: Boolean = false, + content: @Composable () -> Unit, +) { + var colorScheme = + when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + if (darkTheme && amoledDark) { + colorScheme = + colorScheme.copy( + background = Color.Black, + surface = Color.Black, + surfaceVariant = Color.Black, + ) + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Type.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Type.kt index ff93a87..678d639 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Type.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Type.kt @@ -1,17 +1,19 @@ -package com.collabtable.app.ui.theme - -import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp - -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) -) +package com.collabtable.app.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = + Typography( + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + ) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/utils/Logger.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/utils/Logger.kt index 776fe1d..fad1987 100644 --- a/CollabTableAndroid/app/src/main/java/com/collabtable/app/utils/Logger.kt +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/utils/Logger.kt @@ -1,72 +1,93 @@ -package com.collabtable.app.utils - -import android.util.Log -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import java.text.SimpleDateFormat -import java.util.* - -data class LogEntry( - val timestamp: Long, - val level: LogLevel, - val tag: String, - val message: String -) { - fun toFormattedString(): String { - val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()) - val time = dateFormat.format(Date(timestamp)) - return "[$time] [${level.name}] [$tag] $message" - } -} - -enum class LogLevel { - DEBUG, INFO, WARN, ERROR -} - -object Logger { - private val _logs = MutableStateFlow>(emptyList()) - val logs: StateFlow> = _logs.asStateFlow() - - private const val MAX_LOGS = 500 - - fun d(tag: String, message: String) { - log(LogLevel.DEBUG, tag, message) - Log.d(tag, message) - } - - fun i(tag: String, message: String) { - log(LogLevel.INFO, tag, message) - Log.i(tag, message) - } - - fun w(tag: String, message: String) { - log(LogLevel.WARN, tag, message) - Log.w(tag, message) - } - - fun e(tag: String, message: String, throwable: Throwable? = null) { - val msg = if (throwable != null) "$message: ${throwable.message}" else message - log(LogLevel.ERROR, tag, msg) - if (throwable != null) { - Log.e(tag, message, throwable) - } else { - Log.e(tag, message) - } - } - - private fun log(level: LogLevel, tag: String, message: String) { - val entry = LogEntry( - timestamp = System.currentTimeMillis(), - level = level, - tag = tag, - message = message - ) - - _logs.value = (_logs.value + entry).takeLast(MAX_LOGS) - } - - fun clear() { - _logs.value = emptyList() - } -} +package com.collabtable.app.utils + +import android.util.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.text.SimpleDateFormat +import java.util.* + +data class LogEntry( + val timestamp: Long, + val level: LogLevel, + val tag: String, + val message: String, +) { + fun toFormattedString(): String { + val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()) + val time = dateFormat.format(Date(timestamp)) + return "[$time] [${level.name}] [$tag] $message" + } +} + +enum class LogLevel { + DEBUG, + INFO, + WARN, + ERROR, +} + +object Logger { + private val _logs = MutableStateFlow>(emptyList()) + val logs: StateFlow> = _logs.asStateFlow() + + private const val MAX_LOGS = 500 + + fun d( + tag: String, + message: String, + ) { + log(LogLevel.DEBUG, tag, message) + Log.d(tag, message) + } + + fun i( + tag: String, + message: String, + ) { + log(LogLevel.INFO, tag, message) + Log.i(tag, message) + } + + fun w( + tag: String, + message: String, + ) { + log(LogLevel.WARN, tag, message) + Log.w(tag, message) + } + + fun e( + tag: String, + message: String, + throwable: Throwable? = null, + ) { + val msg = if (throwable != null) "$message: ${throwable.message}" else message + log(LogLevel.ERROR, tag, msg) + if (throwable != null) { + Log.e(tag, message, throwable) + } else { + Log.e(tag, message) + } + } + + private fun log( + level: LogLevel, + tag: String, + message: String, + ) { + val entry = + LogEntry( + timestamp = System.currentTimeMillis(), + level = level, + tag = tag, + message = message, + ) + + _logs.value = (_logs.value + entry).takeLast(MAX_LOGS) + } + + fun clear() { + _logs.value = emptyList() + } +}