Refactor SettingsScreen and related components for improved readability and maintainability
- Updated SettingsScreen.kt to enhance UI structure and organization. - Refactored SettingsViewModel.kt for better state management. - Improved theme handling in Theme.kt with clearer structure. - Enhanced typography definitions in Type.kt for consistency. - Updated Logger.kt to improve logging functionality and clarity.
This commit is contained in:
@@ -6,9 +6,9 @@ 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 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
|
||||
@@ -22,16 +22,17 @@ class MainActivity : ComponentActivity() {
|
||||
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()
|
||||
}
|
||||
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
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
AppNavigation()
|
||||
}
|
||||
|
||||
@@ -14,34 +14,39 @@ object ApiClient {
|
||||
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()
|
||||
private val loggingInterceptor =
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
val newRequest = if (!password.isNullOrBlank()) {
|
||||
request.newBuilder()
|
||||
.header("Authorization", "Bearer $password")
|
||||
.build()
|
||||
} else {
|
||||
request
|
||||
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)
|
||||
}
|
||||
|
||||
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 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()
|
||||
|
||||
@@ -13,7 +13,7 @@ data class SyncRequest(
|
||||
val lists: List<CollabList>,
|
||||
val fields: List<Field>,
|
||||
val items: List<Item>,
|
||||
val itemValues: List<ItemValue>
|
||||
val itemValues: List<ItemValue>,
|
||||
)
|
||||
|
||||
data class SyncResponse(
|
||||
@@ -21,10 +21,12 @@ data class SyncResponse(
|
||||
val fields: List<Field>,
|
||||
val items: List<Item>,
|
||||
val itemValues: List<ItemValue>,
|
||||
val serverTimestamp: Long
|
||||
val serverTimestamp: Long,
|
||||
)
|
||||
|
||||
interface CollabTableApi {
|
||||
@POST("sync")
|
||||
suspend fun sync(@Body request: SyncRequest): Response<SyncResponse>
|
||||
suspend fun sync(
|
||||
@Body request: SyncRequest,
|
||||
): Response<SyncResponse>
|
||||
}
|
||||
|
||||
+111
-88
@@ -21,23 +21,25 @@ import java.util.concurrent.TimeUnit
|
||||
private data class MessageEnvelope(
|
||||
val id: String? = null,
|
||||
val type: String,
|
||||
val payload: Any? = null
|
||||
val payload: Any? = null,
|
||||
)
|
||||
|
||||
object WebSocketSyncClient {
|
||||
private val gson = Gson()
|
||||
|
||||
private val logging = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
}
|
||||
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 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/
|
||||
@@ -46,98 +48,119 @@ object WebSocketSyncClient {
|
||||
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
|
||||
}
|
||||
url =
|
||||
when {
|
||||
url.startsWith("https://") -> "wss://" + url.removePrefix("https://")
|
||||
url.startsWith("http://") -> "ws://" + url.removePrefix("http://")
|
||||
else -> "ws://$url" // best effort
|
||||
}
|
||||
|
||||
// Replace trailing /api with /api/ws
|
||||
return if (url.endsWith("/api")) "$url/ws" else "$url/api/ws"
|
||||
}
|
||||
|
||||
suspend fun sync(context: Context, request: SyncRequest): Result<SyncResponse> = withContext(Dispatchers.IO) {
|
||||
val prefs = PreferencesManager.getInstance(context)
|
||||
val baseUrl = prefs.getServerUrl()
|
||||
val password = prefs.getServerPassword()
|
||||
val wsUrl = buildWebSocketUrl(baseUrl)
|
||||
val messageId = UUID.randomUUID().toString()
|
||||
suspend fun sync(
|
||||
context: Context,
|
||||
request: SyncRequest,
|
||||
): Result<SyncResponse> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefs = PreferencesManager.getInstance(context)
|
||||
val baseUrl = prefs.getServerUrl()
|
||||
val password = prefs.getServerPassword()
|
||||
val wsUrl = buildWebSocketUrl(baseUrl)
|
||||
val messageId = UUID.randomUUID().toString()
|
||||
|
||||
val deferred = CompletableDeferred<Result<SyncResponse>>()
|
||||
var socket: WebSocket? = null
|
||||
val deferred = CompletableDeferred<Result<SyncResponse>>()
|
||||
var socket: WebSocket? = null
|
||||
|
||||
val httpRequestBuilder = Request.Builder().url(wsUrl)
|
||||
if (!password.isNullOrBlank()) {
|
||||
httpRequestBuilder.addHeader("Authorization", "Bearer $password")
|
||||
}
|
||||
val httpRequest = httpRequestBuilder.build()
|
||||
|
||||
val listener = object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
// Send sync message when opened
|
||||
val envelope = MessageEnvelope(
|
||||
id = messageId,
|
||||
type = "sync",
|
||||
payload = request
|
||||
)
|
||||
val json = gson.toJson(envelope)
|
||||
webSocket.send(json)
|
||||
val httpRequestBuilder = Request.Builder().url(wsUrl)
|
||||
if (!password.isNullOrBlank()) {
|
||||
httpRequestBuilder.addHeader("Authorization", "Bearer $password")
|
||||
}
|
||||
val httpRequest = httpRequestBuilder.build()
|
||||
|
||||
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")
|
||||
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))
|
||||
}
|
||||
}
|
||||
} 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 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
socket = client.newWebSocket(httpRequest, listener)
|
||||
// Wait up to 30s for response (WS roundtrip)
|
||||
val result = withTimeout(30_000) { deferred.await() }
|
||||
return@withContext result
|
||||
} catch (e: Exception) {
|
||||
Logger.w("WS", "Falling back to HTTP sync: ${e.message}")
|
||||
return@withContext Result.failure(e)
|
||||
} finally {
|
||||
// OkHttp cleans up sockets automatically when no references remain
|
||||
// but we try to close if still open
|
||||
socket?.close(1000, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,10 @@ interface FieldDao {
|
||||
suspend fun updateField(field: Field)
|
||||
|
||||
@Query("UPDATE fields SET isDeleted = 1, updatedAt = :timestamp WHERE id = :fieldId")
|
||||
suspend fun softDeleteField(fieldId: String, timestamp: Long)
|
||||
suspend fun softDeleteField(
|
||||
fieldId: String,
|
||||
timestamp: Long,
|
||||
)
|
||||
|
||||
@Query("DELETE FROM fields WHERE id = :fieldId")
|
||||
suspend fun deleteField(fieldId: String)
|
||||
@@ -36,13 +39,16 @@ interface FieldDao {
|
||||
|
||||
// Reorder fields in a single transaction for clarity and consistency
|
||||
@Transaction
|
||||
suspend fun reorderFieldsInTransaction(reorderedFields: List<Field>, timestamp: Long) {
|
||||
suspend fun reorderFieldsInTransaction(
|
||||
reorderedFields: List<Field>,
|
||||
timestamp: Long,
|
||||
) {
|
||||
reorderedFields.forEachIndexed { index, field ->
|
||||
updateField(
|
||||
field.copy(
|
||||
order = index,
|
||||
updatedAt = timestamp
|
||||
)
|
||||
updatedAt = timestamp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,10 @@ interface ItemDao {
|
||||
suspend fun updateItem(item: Item)
|
||||
|
||||
@Query("UPDATE items SET isDeleted = 1, updatedAt = :timestamp WHERE id = :itemId")
|
||||
suspend fun softDeleteItem(itemId: String, timestamp: Long)
|
||||
suspend fun softDeleteItem(
|
||||
itemId: String,
|
||||
timestamp: Long,
|
||||
)
|
||||
|
||||
@Query("DELETE FROM items WHERE id = :itemId")
|
||||
suspend fun deleteItem(itemId: String)
|
||||
|
||||
@@ -30,7 +30,10 @@ interface ListDao {
|
||||
suspend fun updateList(list: CollabList)
|
||||
|
||||
@Query("UPDATE lists SET isDeleted = 1, updatedAt = :timestamp WHERE id = :listId")
|
||||
suspend fun softDeleteList(listId: String, timestamp: Long)
|
||||
suspend fun softDeleteList(
|
||||
listId: String,
|
||||
timestamp: Long,
|
||||
)
|
||||
|
||||
@Query("DELETE FROM lists WHERE id = :listId")
|
||||
suspend fun deleteList(listId: String)
|
||||
|
||||
+19
-14
@@ -9,27 +9,31 @@ 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 ''")
|
||||
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
|
||||
ItemValue::class,
|
||||
],
|
||||
version = 2,
|
||||
exportSchema = false
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class CollabTableDatabase : RoomDatabase() {
|
||||
abstract fun listDao(): ListDao
|
||||
|
||||
abstract fun fieldDao(): FieldDao
|
||||
|
||||
abstract fun itemDao(): ItemDao
|
||||
|
||||
abstract fun itemValueDao(): ItemValueDao
|
||||
|
||||
companion object {
|
||||
@@ -38,13 +42,14 @@ abstract class CollabTableDatabase : RoomDatabase() {
|
||||
|
||||
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()
|
||||
val instance =
|
||||
Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
CollabTableDatabase::class.java,
|
||||
"collab_table_database",
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2)
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@ data class CollabList(
|
||||
val name: String,
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long,
|
||||
val isDeleted: Boolean = false
|
||||
val isDeleted: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ enum class FieldType {
|
||||
// Other types
|
||||
RATING,
|
||||
COLOR,
|
||||
LOCATION
|
||||
LOCATION,
|
||||
}
|
||||
|
||||
@Entity(
|
||||
@@ -51,10 +51,10 @@ enum class FieldType {
|
||||
entity = CollabList::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["listId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("listId")]
|
||||
indices = [Index("listId")],
|
||||
)
|
||||
data class Field(
|
||||
@PrimaryKey val id: String,
|
||||
@@ -65,7 +65,7 @@ data class Field(
|
||||
val order: Int,
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long,
|
||||
val isDeleted: Boolean = false
|
||||
val isDeleted: Boolean = false,
|
||||
) {
|
||||
fun getType(): FieldType {
|
||||
return try {
|
||||
|
||||
@@ -12,15 +12,15 @@ import androidx.room.PrimaryKey
|
||||
entity = CollabList::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["listId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("listId")]
|
||||
indices = [Index("listId")],
|
||||
)
|
||||
data class Item(
|
||||
@PrimaryKey val id: String,
|
||||
val listId: String,
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long,
|
||||
val isDeleted: Boolean = false
|
||||
val isDeleted: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -12,21 +12,21 @@ import androidx.room.PrimaryKey
|
||||
entity = Item::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["itemId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
ForeignKey(
|
||||
entity = Field::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["fieldId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("itemId"), Index("fieldId")]
|
||||
indices = [Index("itemId"), Index("fieldId")],
|
||||
)
|
||||
data class ItemValue(
|
||||
@PrimaryKey val id: String,
|
||||
val itemId: String,
|
||||
val fieldId: String,
|
||||
val value: String,
|
||||
val updatedAt: Long
|
||||
val updatedAt: Long,
|
||||
)
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ data class ItemWithValues(
|
||||
@Embedded val item: Item,
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "itemId"
|
||||
entityColumn = "itemId",
|
||||
)
|
||||
val values: List<ItemValue>
|
||||
val values: List<ItemValue>,
|
||||
)
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ data class ListWithFields(
|
||||
@Embedded val list: CollabList,
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "listId"
|
||||
entityColumn = "listId",
|
||||
)
|
||||
val fields: List<Field>
|
||||
val fields: List<Field>,
|
||||
)
|
||||
|
||||
+12
-11
@@ -79,10 +79,11 @@ class PreferencesManager(context: Context) {
|
||||
}
|
||||
|
||||
fun setThemeMode(mode: String) {
|
||||
val normalized = when (mode.lowercase()) {
|
||||
THEME_MODE_LIGHT, THEME_MODE_DARK, THEME_MODE_SYSTEM -> mode.lowercase()
|
||||
else -> THEME_MODE_SYSTEM
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -109,14 +110,14 @@ class PreferencesManager(context: Context) {
|
||||
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 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"
|
||||
const val THEME_MODE_SYSTEM = "system"
|
||||
const val THEME_MODE_LIGHT = "light"
|
||||
const val THEME_MODE_DARK = "dark"
|
||||
|
||||
@Volatile
|
||||
private var instance: PreferencesManager? = null
|
||||
|
||||
+117
-113
@@ -1,14 +1,14 @@
|
||||
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.WebSocketSyncClient
|
||||
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
|
||||
import androidx.room.withTransaction
|
||||
|
||||
class SyncRepository(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
@@ -24,123 +24,127 @@ class SyncRepository(context: Context) {
|
||||
prefs.edit().putLong("last_sync_timestamp", timestamp).apply()
|
||||
}
|
||||
|
||||
suspend fun performSync(): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val lastSync = getLastSyncTimestamp()
|
||||
val isInitialSync = lastSync == 0L
|
||||
if (isInitialSync) {
|
||||
Logger.i("Sync", "[SYNC] Starting initial sync with server")
|
||||
}
|
||||
suspend fun performSync(): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val lastSync = getLastSyncTimestamp()
|
||||
val isInitialSync = lastSync == 0L
|
||||
if (isInitialSync) {
|
||||
Logger.i("Sync", "[SYNC] Starting initial sync with server")
|
||||
}
|
||||
|
||||
// Gather local changes since last sync
|
||||
val localLists = database.listDao().getListsUpdatedSince(lastSync)
|
||||
val localFields = database.fieldDao().getFieldsUpdatedSince(lastSync)
|
||||
val localItems = database.itemDao().getItemsUpdatedSince(lastSync)
|
||||
val localValues = database.itemValueDao().getValuesUpdatedSince(lastSync)
|
||||
// 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})"
|
||||
)
|
||||
}
|
||||
// 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
|
||||
)
|
||||
// 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."))
|
||||
// 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()!!
|
||||
}
|
||||
Logger.e("Sync", "[ERROR] Sync failed: HTTP ${response.code()}")
|
||||
return@withContext Result.failure(Exception("Sync failed: ${response.code()}"))
|
||||
// Only log receive when there are actual server changes
|
||||
val inTotal = syncResponse.lists.size + syncResponse.fields.size + syncResponse.items.size + syncResponse.itemValues.size
|
||||
if (inTotal > 0) {
|
||||
Logger.i(
|
||||
"Sync",
|
||||
"[IN] Received changes (lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, items=${syncResponse.items.size}, values=${syncResponse.itemValues.size})",
|
||||
)
|
||||
}
|
||||
response.body()!!
|
||||
|
||||
// Apply server changes to local database atomically in correct order
|
||||
database.withTransaction {
|
||||
// 1) Upsert lists first
|
||||
database.listDao().insertLists(syncResponse.lists)
|
||||
|
||||
// Build set of existing list ids to guard child inserts
|
||||
val existingListIds = database.listDao().getAllListIds().toSet()
|
||||
|
||||
// 2) Filter and upsert fields whose parent list exists
|
||||
val filteredFields = syncResponse.fields.filter { f -> f.listId in existingListIds }
|
||||
if (filteredFields.size != syncResponse.fields.size) {
|
||||
val dropped = syncResponse.fields.size - filteredFields.size
|
||||
Logger.w("Sync", "Dropping $dropped field(s) with missing parent list to avoid FK errors")
|
||||
}
|
||||
if (filteredFields.isNotEmpty()) {
|
||||
database.fieldDao().insertFields(filteredFields)
|
||||
}
|
||||
|
||||
// 3) Filter and upsert items whose parent list exists
|
||||
val filteredItems = syncResponse.items.filter { i -> i.listId in existingListIds }
|
||||
if (filteredItems.size != syncResponse.items.size) {
|
||||
val dropped = syncResponse.items.size - filteredItems.size
|
||||
Logger.w("Sync", "Dropping $dropped item(s) with missing parent list to avoid FK errors")
|
||||
}
|
||||
if (filteredItems.isNotEmpty()) {
|
||||
database.itemDao().insertItems(filteredItems)
|
||||
}
|
||||
|
||||
// 4) Filter item values to only those whose parents (item and field) exist locally
|
||||
val existingItemIds = database.itemDao().getAllItemIds().toSet()
|
||||
val existingFieldIds = database.fieldDao().getAllFieldIds().toSet()
|
||||
val filteredValues =
|
||||
syncResponse.itemValues.filter { v ->
|
||||
v.itemId in existingItemIds && v.fieldId in existingFieldIds
|
||||
}
|
||||
if (filteredValues.size != syncResponse.itemValues.size) {
|
||||
val dropped = syncResponse.itemValues.size - filteredValues.size
|
||||
Logger.w("Sync", "Dropping $dropped item value(s) with missing parents to avoid FK errors")
|
||||
}
|
||||
if (filteredValues.isNotEmpty()) {
|
||||
database.itemValueDao().insertValues(filteredValues)
|
||||
}
|
||||
}
|
||||
|
||||
// Update last sync timestamp
|
||||
setLastSyncTimestamp(syncResponse.serverTimestamp)
|
||||
|
||||
if (isInitialSync) {
|
||||
Logger.i("Sync", "[SYNC] Initial sync completed")
|
||||
}
|
||||
|
||||
return@withContext Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
// If WS indicated unauthorized, align behavior with HTTP path
|
||||
if (e.message?.contains("Unauthorized") == true) {
|
||||
setLastSyncTimestamp(0)
|
||||
}
|
||||
Logger.e("Sync", "[ERROR] Sync error: ${e.message}")
|
||||
return@withContext Result.failure(e)
|
||||
}
|
||||
// Only log receive when there are actual server changes
|
||||
val inTotal = syncResponse.lists.size + syncResponse.fields.size + syncResponse.items.size + syncResponse.itemValues.size
|
||||
if (inTotal > 0) {
|
||||
Logger.i(
|
||||
"Sync",
|
||||
"[IN] Received changes (lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, items=${syncResponse.items.size}, values=${syncResponse.itemValues.size})"
|
||||
)
|
||||
}
|
||||
|
||||
// Apply server changes to local database atomically in correct order
|
||||
database.withTransaction {
|
||||
// 1) Upsert lists first
|
||||
database.listDao().insertLists(syncResponse.lists)
|
||||
|
||||
// Build set of existing list ids to guard child inserts
|
||||
val existingListIds = database.listDao().getAllListIds().toSet()
|
||||
|
||||
// 2) Filter and upsert fields whose parent list exists
|
||||
val filteredFields = syncResponse.fields.filter { f -> f.listId in existingListIds }
|
||||
if (filteredFields.size != syncResponse.fields.size) {
|
||||
val dropped = syncResponse.fields.size - filteredFields.size
|
||||
Logger.w("Sync", "Dropping $dropped field(s) with missing parent list to avoid FK errors")
|
||||
}
|
||||
if (filteredFields.isNotEmpty()) {
|
||||
database.fieldDao().insertFields(filteredFields)
|
||||
}
|
||||
|
||||
// 3) Filter and upsert items whose parent list exists
|
||||
val filteredItems = syncResponse.items.filter { i -> i.listId in existingListIds }
|
||||
if (filteredItems.size != syncResponse.items.size) {
|
||||
val dropped = syncResponse.items.size - filteredItems.size
|
||||
Logger.w("Sync", "Dropping $dropped item(s) with missing parent list to avoid FK errors")
|
||||
}
|
||||
if (filteredItems.isNotEmpty()) {
|
||||
database.itemDao().insertItems(filteredItems)
|
||||
}
|
||||
|
||||
// 4) Filter item values to only those whose parents (item and field) exist locally
|
||||
val existingItemIds = database.itemDao().getAllItemIds().toSet()
|
||||
val existingFieldIds = database.fieldDao().getAllFieldIds().toSet()
|
||||
val filteredValues = syncResponse.itemValues.filter { v ->
|
||||
v.itemId in existingItemIds && v.fieldId in existingFieldIds
|
||||
}
|
||||
if (filteredValues.size != syncResponse.itemValues.size) {
|
||||
val dropped = syncResponse.itemValues.size - filteredValues.size
|
||||
Logger.w("Sync", "Dropping $dropped item value(s) with missing parents to avoid FK errors")
|
||||
}
|
||||
if (filteredValues.isNotEmpty()) {
|
||||
database.itemValueDao().insertValues(filteredValues)
|
||||
}
|
||||
}
|
||||
|
||||
// Update last sync timestamp
|
||||
setLastSyncTimestamp(syncResponse.serverTimestamp)
|
||||
|
||||
if (isInitialSync) {
|
||||
Logger.i("Sync", "[SYNC] Initial sync completed")
|
||||
}
|
||||
|
||||
return@withContext Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
// If WS indicated unauthorized, align behavior with HTTP path
|
||||
if (e.message?.contains("Unauthorized") == true) {
|
||||
setLastSyncTimestamp(0)
|
||||
}
|
||||
Logger.e("Sync", "[ERROR] Sync error: ${e.message}")
|
||||
return@withContext Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-12
@@ -22,15 +22,16 @@ fun AppNavigation() {
|
||||
val preferencesManager = remember { PreferencesManager.getInstance(context) }
|
||||
|
||||
// Determine start destination based on first run
|
||||
val startDestination = if (preferencesManager.isFirstRun()) {
|
||||
"server_setup"
|
||||
} else {
|
||||
"lists"
|
||||
}
|
||||
val startDestination =
|
||||
if (preferencesManager.isFirstRun()) {
|
||||
"server_setup"
|
||||
} else {
|
||||
"lists"
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination
|
||||
startDestination = startDestination,
|
||||
) {
|
||||
composable("server_setup") {
|
||||
ServerSetupScreen(
|
||||
@@ -39,7 +40,7 @@ fun AppNavigation() {
|
||||
navController.navigate("lists") {
|
||||
popUpTo("server_setup") { inclusive = true }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,18 +54,18 @@ fun AppNavigation() {
|
||||
},
|
||||
onNavigateToLogs = {
|
||||
navController.navigate("logs")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "list/{listId}",
|
||||
arguments = listOf(navArgument("listId") { type = NavType.StringType })
|
||||
arguments = listOf(navArgument("listId") { type = NavType.StringType }),
|
||||
) { backStackEntry ->
|
||||
val listId = backStackEntry.arguments?.getString("listId") ?: return@composable
|
||||
ListDetailScreen(
|
||||
listId = listId,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,13 +77,13 @@ fun AppNavigation() {
|
||||
navController.navigate("server_setup") {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable("logs") {
|
||||
LogsScreen(
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+969
-821
File diff suppressed because it is too large
Load Diff
+81
-64
@@ -17,7 +17,7 @@ import java.util.UUID
|
||||
class ListDetailViewModel(
|
||||
private val database: CollabTableDatabase,
|
||||
private val listId: String,
|
||||
private val context: Context
|
||||
private val context: Context,
|
||||
) : ViewModel() {
|
||||
private val _list = MutableStateFlow<CollabList?>(null)
|
||||
val list: StateFlow<CollabList?> = _list.asStateFlow()
|
||||
@@ -40,8 +40,8 @@ class ListDetailViewModel(
|
||||
database.listDao().getListWithFields(listId)
|
||||
.debounce(75)
|
||||
.collect { listWithFields ->
|
||||
_list.value = listWithFields?.list
|
||||
}
|
||||
_list.value = listWithFields?.list
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
@@ -79,44 +79,50 @@ class ListDetailViewModel(
|
||||
database.listDao().updateList(
|
||||
currentList.copy(
|
||||
name = newName.trim(),
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
performSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addField(name: String, fieldType: String = "STRING", fieldOptions: String = "") {
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
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()
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// Insert atomically to avoid intermediate inconsistent states
|
||||
database.withTransaction {
|
||||
@@ -137,7 +143,11 @@ class ListDetailViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateField(fieldId: String, fieldType: String, fieldOptions: String) {
|
||||
fun updateField(
|
||||
fieldId: String,
|
||||
fieldType: String,
|
||||
fieldOptions: String,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val field = database.fieldDao().getFieldById(fieldId)
|
||||
if (field != null) {
|
||||
@@ -145,8 +155,8 @@ class ListDetailViewModel(
|
||||
field.copy(
|
||||
fieldType = fieldType,
|
||||
fieldOptions = fieldOptions,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
performSync()
|
||||
}
|
||||
@@ -156,25 +166,27 @@ class ListDetailViewModel(
|
||||
fun addItem() {
|
||||
viewModelScope.launch {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val newItem = Item(
|
||||
id = UUID.randomUUID().toString(),
|
||||
listId = listId,
|
||||
createdAt = timestamp,
|
||||
updatedAt = timestamp
|
||||
)
|
||||
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
|
||||
)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -186,25 +198,27 @@ class ListDetailViewModel(
|
||||
fun addItemWithValues(fieldValues: Map<String, String>) {
|
||||
viewModelScope.launch {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val newItem = Item(
|
||||
id = UUID.randomUUID().toString(),
|
||||
listId = listId,
|
||||
createdAt = timestamp,
|
||||
updatedAt = timestamp
|
||||
)
|
||||
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
|
||||
)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -213,15 +227,18 @@ class ListDetailViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateItemValue(itemValueId: String, newValue: String) {
|
||||
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()
|
||||
)
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
performSync()
|
||||
}
|
||||
|
||||
+46
-42
@@ -29,7 +29,7 @@ import java.util.*
|
||||
fun ListsScreen(
|
||||
onNavigateToList: (String) -> Unit,
|
||||
onNavigateToSettings: () -> Unit,
|
||||
onNavigateToLogs: () -> Unit = {}
|
||||
onNavigateToLogs: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val database = remember { CollabTableDatabase.getDatabase(context) }
|
||||
@@ -57,37 +57,39 @@ fun ListsScreen(
|
||||
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = { showCreateDialog = true }
|
||||
onClick = { showCreateDialog = true },
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.create_list))
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
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)
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
text = "Syncing with server...",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else if (lists.isEmpty()) {
|
||||
@@ -95,17 +97,17 @@ fun ListsScreen(
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_lists),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "Tap + to create your first table",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -113,14 +115,14 @@ fun ListsScreen(
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.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 }
|
||||
onDeleteClick = { listToDelete = list },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -134,7 +136,7 @@ fun ListsScreen(
|
||||
onCreate = { name ->
|
||||
viewModel.createList(name)
|
||||
showCreateDialog = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -145,7 +147,7 @@ fun ListsScreen(
|
||||
onRename = { newName ->
|
||||
viewModel.renameList(list.id, newName)
|
||||
listToEdit = null
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -159,7 +161,7 @@ fun ListsScreen(
|
||||
onClick = {
|
||||
viewModel.deleteList(list.id)
|
||||
listToDelete = null
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.delete))
|
||||
}
|
||||
@@ -168,7 +170,7 @@ fun ListsScreen(
|
||||
TextButton(onClick = { listToDelete = null }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -178,30 +180,32 @@ fun ListItem(
|
||||
list: CollabList,
|
||||
onListClick: () -> Unit,
|
||||
onEditClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
onDeleteClick: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onListClick)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onListClick),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = list.name,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = formatDate(list.updatedAt),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Row {
|
||||
@@ -209,14 +213,14 @@ fun ListItem(
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "Edit",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDeleteClick) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.delete),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -227,7 +231,7 @@ fun ListItem(
|
||||
@Composable
|
||||
fun CreateListDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (String) -> Unit
|
||||
onCreate: (String) -> Unit,
|
||||
) {
|
||||
var listName by remember { mutableStateOf("") }
|
||||
|
||||
@@ -239,13 +243,13 @@ fun CreateListDialog(
|
||||
value = listName,
|
||||
onValueChange = { listName = it },
|
||||
label = { Text(stringResource(R.string.list_name)) },
|
||||
singleLine = true
|
||||
singleLine = true,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { if (listName.isNotBlank()) onCreate(listName.trim()) },
|
||||
enabled = listName.isNotBlank()
|
||||
enabled = listName.isNotBlank(),
|
||||
) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
@@ -254,7 +258,7 @@ fun CreateListDialog(
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -262,25 +266,25 @@ fun CreateListDialog(
|
||||
private fun RenameListDialog(
|
||||
currentName: String,
|
||||
onDismiss: () -> Unit,
|
||||
onRename: (String) -> Unit
|
||||
onRename: (String) -> Unit,
|
||||
) {
|
||||
var listName by remember { mutableStateOf(currentName) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Rename Table") },
|
||||
title = { Text("Rename Table") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = listName,
|
||||
onValueChange = { listName = it },
|
||||
label = { Text(stringResource(R.string.list_name)) },
|
||||
singleLine = true
|
||||
singleLine = true,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { if (listName.isNotBlank()) onRename(listName.trim()) },
|
||||
enabled = listName.isNotBlank() && listName.trim() != currentName
|
||||
enabled = listName.isNotBlank() && listName.trim() != currentName,
|
||||
) {
|
||||
Text("Rename")
|
||||
}
|
||||
@@ -289,7 +293,7 @@ private fun RenameListDialog(
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+14
-10
@@ -15,7 +15,7 @@ import java.util.UUID
|
||||
|
||||
class ListsViewModel(
|
||||
private val database: CollabTableDatabase,
|
||||
private val context: Context
|
||||
private val context: Context,
|
||||
) : ViewModel() {
|
||||
private val _lists = MutableStateFlow<List<CollabList>>(emptyList())
|
||||
val lists: StateFlow<List<CollabList>> = _lists.asStateFlow()
|
||||
@@ -62,19 +62,23 @@ class ListsViewModel(
|
||||
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
|
||||
)
|
||||
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) {
|
||||
fun renameList(
|
||||
listId: String,
|
||||
newName: String,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val list = database.listDao().getListById(listId)
|
||||
if (list != null && newName.isNotBlank()) {
|
||||
@@ -82,8 +86,8 @@ class ListsViewModel(
|
||||
database.listDao().updateList(
|
||||
list.copy(
|
||||
name = newName.trim(),
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
// Sync immediately after renaming
|
||||
performSync()
|
||||
|
||||
+134
-116
@@ -25,7 +25,6 @@ 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),
|
||||
@@ -34,49 +33,51 @@ enum class TimeRange(val label: String, val milliseconds: Long) {
|
||||
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)
|
||||
ALL_TIME("All time", Long.MAX_VALUE),
|
||||
}
|
||||
|
||||
data class LogFilters(
|
||||
val severities: Set<LogLevel> = LogLevel.values().toSet(),
|
||||
val timeRange: TimeRange = TimeRange.ALL_TIME,
|
||||
val tags: Set<String> = emptySet(),
|
||||
val searchText: String = ""
|
||||
val searchText: String = "",
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LogsScreen(
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
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()
|
||||
}
|
||||
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
|
||||
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))
|
||||
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) {
|
||||
@@ -97,9 +98,12 @@ fun LogsScreen(
|
||||
actions = {
|
||||
IconButton(onClick = { showFilterSheet = true }) {
|
||||
Badge(
|
||||
containerColor = if (hasActiveFilters(filters))
|
||||
MaterialTheme.colorScheme.error
|
||||
else Color.Transparent
|
||||
containerColor =
|
||||
if (hasActiveFilters(filters)) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
) {
|
||||
Icon(Icons.Default.FilterList, contentDescription = "Filter logs")
|
||||
}
|
||||
@@ -108,26 +112,29 @@ fun LogsScreen(
|
||||
Icon(Icons.Default.Delete, contentDescription = "Clear logs")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
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)
|
||||
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) {
|
||||
@@ -135,11 +142,12 @@ fun LogsScreen(
|
||||
FilterChip(
|
||||
selected = true,
|
||||
onClick = {
|
||||
filters = filters.copy(
|
||||
severities = filters.severities - level
|
||||
)
|
||||
filters =
|
||||
filters.copy(
|
||||
severities = filters.severities - level,
|
||||
)
|
||||
},
|
||||
label = { Text(level.name, fontSize = 12.sp) }
|
||||
label = { Text(level.name, fontSize = 12.sp) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -152,7 +160,7 @@ fun LogsScreen(
|
||||
onClick = {
|
||||
filters = filters.copy(timeRange = TimeRange.ALL_TIME)
|
||||
},
|
||||
label = { Text(filters.timeRange.label, fontSize = 12.sp) }
|
||||
label = { Text(filters.timeRange.label, fontSize = 12.sp) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -164,7 +172,7 @@ fun LogsScreen(
|
||||
onClick = {
|
||||
filters = filters.copy(tags = filters.tags - tag)
|
||||
},
|
||||
label = { Text(tag, fontSize = 12.sp) }
|
||||
label = { Text(tag, fontSize = 12.sp) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -180,9 +188,9 @@ fun LogsScreen(
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -192,21 +200,22 @@ fun LogsScreen(
|
||||
if (filteredLogs.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = if (logs.isEmpty()) "No logs yet" else "No logs match filters",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF1E1E1E)),
|
||||
contentPadding = PaddingValues(8.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF1E1E1E)),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
) {
|
||||
items(filteredLogs) { log ->
|
||||
LogItem(log)
|
||||
@@ -222,16 +231,16 @@ fun LogsScreen(
|
||||
filters = filters,
|
||||
allTags = allTags,
|
||||
onFiltersChanged = { filters = it },
|
||||
onDismiss = { showFilterSheet = false }
|
||||
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()
|
||||
filters.timeRange != TimeRange.ALL_TIME ||
|
||||
filters.tags.isNotEmpty() ||
|
||||
filters.searchText.isNotEmpty()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -240,26 +249,27 @@ fun FilterBottomSheet(
|
||||
filters: LogFilters,
|
||||
allTags: List<String>,
|
||||
onFiltersChanged: (LogFilters) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 32.dp)
|
||||
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)
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
|
||||
// Severity filters
|
||||
@@ -267,33 +277,35 @@ fun FilterBottomSheet(
|
||||
text = "Severity",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
LogLevel.values().forEach { level ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
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
|
||||
}
|
||||
)
|
||||
severities =
|
||||
if (checked) {
|
||||
filters.severities + level
|
||||
} else {
|
||||
filters.severities - level
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = level.name,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -305,25 +317,26 @@ fun FilterBottomSheet(
|
||||
text = "Time Range",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
TimeRange.values().forEach { range ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
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)
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -335,7 +348,7 @@ fun FilterBottomSheet(
|
||||
text = "Tags (${filters.tags.size} selected)",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
if (allTags.isEmpty()) {
|
||||
@@ -343,26 +356,27 @@ fun FilterBottomSheet(
|
||||
text = "No tags available",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
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")
|
||||
}
|
||||
@@ -370,28 +384,30 @@ fun FilterBottomSheet(
|
||||
|
||||
allTags.forEach { tag ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
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
|
||||
}
|
||||
)
|
||||
tags =
|
||||
if (checked) {
|
||||
filters.tags + tag
|
||||
} else {
|
||||
filters.tags - tag
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = tag,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -404,7 +420,7 @@ fun FilterBottomSheet(
|
||||
onClick = {
|
||||
onFiltersChanged(LogFilters())
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Reset All Filters")
|
||||
}
|
||||
@@ -414,20 +430,22 @@ fun FilterBottomSheet(
|
||||
|
||||
@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)
|
||||
}
|
||||
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)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
+41
-37
@@ -21,9 +21,7 @@ import com.collabtable.app.data.preferences.PreferencesManager
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ServerSetupScreen(
|
||||
onSetupComplete: () -> Unit
|
||||
) {
|
||||
fun ServerSetupScreen(onSetupComplete: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val preferencesManager = remember { PreferencesManager.getInstance(context) }
|
||||
val viewModel = remember { ServerSetupViewModel(preferencesManager) }
|
||||
@@ -45,20 +43,22 @@ fun ServerSetupScreen(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("Server Setup") },
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
colors =
|
||||
TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
@@ -72,31 +72,35 @@ fun ServerSetupScreen(
|
||||
isError = validationError != null,
|
||||
supportingText = {
|
||||
when {
|
||||
validationError != null -> Text(
|
||||
text = validationError ?: "",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
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
|
||||
)
|
||||
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(
|
||||
@@ -112,23 +116,23 @@ fun ServerSetupScreen(
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
|
||||
contentDescription = if (passwordVisible) "Hide password" else "Show password"
|
||||
contentDescription = if (passwordVisible) "Hide password" else "Show password",
|
||||
)
|
||||
}
|
||||
},
|
||||
supportingText = { Text("Server password for authentication") }
|
||||
supportingText = { Text("Server password for authentication") },
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.validateAndSaveServerUrl(serverUrl.trim(), serverPassword.trim()) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = serverUrl.isNotBlank() && serverPassword.isNotBlank() && !isValidating
|
||||
enabled = serverUrl.isNotBlank() && serverPassword.isNotBlank() && !isValidating,
|
||||
) {
|
||||
if (isValidating) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Validating...")
|
||||
@@ -141,7 +145,7 @@ fun ServerSetupScreen(
|
||||
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
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+55
-43
@@ -1,9 +1,10 @@
|
||||
package com.collabtable.app.ui.screens
|
||||
|
||||
import android.os.NetworkOnMainThreadException
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.collabtable.app.data.preferences.PreferencesManager
|
||||
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
|
||||
@@ -12,7 +13,6 @@ 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
|
||||
@@ -20,7 +20,7 @@ import java.net.UnknownHostException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ServerSetupViewModel(
|
||||
private val preferencesManager: PreferencesManager
|
||||
private val preferencesManager: PreferencesManager,
|
||||
) : ViewModel() {
|
||||
private val _isValidating = MutableStateFlow(false)
|
||||
val isValidating: StateFlow<Boolean> = _isValidating.asStateFlow()
|
||||
@@ -31,12 +31,16 @@ class ServerSetupViewModel(
|
||||
private val _validationError = MutableStateFlow<String?>(null)
|
||||
val validationError: StateFlow<String?> = _validationError.asStateFlow()
|
||||
|
||||
private val okHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val okHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun validateAndSaveServerUrl(url: String, password: String) {
|
||||
fun validateAndSaveServerUrl(
|
||||
url: String,
|
||||
password: String,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_isValidating.value = true
|
||||
_validationError.value = null
|
||||
@@ -88,24 +92,27 @@ class ServerSetupViewModel(
|
||||
|
||||
// Try to reach the health endpoint (no auth required)
|
||||
val healthUrl = normalizedUrl.replace("/api/", "/health")
|
||||
val healthRequest = Request.Builder()
|
||||
.url(healthUrl)
|
||||
.get()
|
||||
.build()
|
||||
val healthRequest =
|
||||
Request.Builder()
|
||||
.url(healthUrl)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
try {
|
||||
// Execute network call on IO dispatcher
|
||||
val healthResponse = withContext(Dispatchers.IO) {
|
||||
okHttpClient.newCall(healthRequest).execute()
|
||||
}
|
||||
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}"
|
||||
}
|
||||
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
|
||||
@@ -123,16 +130,18 @@ class ServerSetupViewModel(
|
||||
}
|
||||
|
||||
val httpsHealthUrl = httpsUrl.replace("/api/", "/health")
|
||||
val httpsHealthRequest = Request.Builder()
|
||||
.url(httpsHealthUrl)
|
||||
.get()
|
||||
.build()
|
||||
val httpsHealthRequest =
|
||||
Request.Builder()
|
||||
.url(httpsHealthUrl)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
try {
|
||||
// Execute HTTPS check on IO dispatcher
|
||||
val httpsResponse = withContext(Dispatchers.IO) {
|
||||
okHttpClient.newCall(httpsHealthRequest).execute()
|
||||
}
|
||||
val httpsResponse =
|
||||
withContext(Dispatchers.IO) {
|
||||
okHttpClient.newCall(httpsHealthRequest).execute()
|
||||
}
|
||||
if (httpsResponse.isSuccessful) {
|
||||
// HTTPS works! Upgrade to it
|
||||
finalUrl = httpsUrl
|
||||
@@ -145,16 +154,18 @@ class ServerSetupViewModel(
|
||||
|
||||
// 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()
|
||||
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()
|
||||
}
|
||||
val authResponse =
|
||||
withContext(Dispatchers.IO) {
|
||||
okHttpClient.newCall(authRequest).execute()
|
||||
}
|
||||
|
||||
if (authResponse.isSuccessful) {
|
||||
// Password is valid, save both URL and password
|
||||
@@ -168,13 +179,14 @@ class ServerSetupViewModel(
|
||||
} 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}"
|
||||
}
|
||||
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()
|
||||
|
||||
+66
-58
@@ -3,24 +3,23 @@ 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.database.CollabTableDatabase
|
||||
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 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
|
||||
@@ -29,7 +28,7 @@ import kotlinx.coroutines.withContext
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onLeaveServer: () -> Unit = {}
|
||||
onLeaveServer: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val preferencesManager = remember { PreferencesManager.getInstance(context) }
|
||||
@@ -53,44 +52,47 @@ fun SettingsScreen(
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
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)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Server Connection",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Connected to",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = displayUrl,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -99,31 +101,31 @@ fun SettingsScreen(
|
||||
|
||||
Text(
|
||||
text = "Appearance",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
// Theme mode selector
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
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) }
|
||||
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) }
|
||||
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) }
|
||||
leadingIcon = { Icon(Icons.Default.DarkMode, contentDescription = null) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -131,14 +133,14 @@ fun SettingsScreen(
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
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
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = dynamicColor, onCheckedChange = { preferencesManager.setDynamicColorEnabled(it) })
|
||||
@@ -148,14 +150,14 @@ fun SettingsScreen(
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
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
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = amoledDark, onCheckedChange = { preferencesManager.setAmoledDarkEnabled(it) })
|
||||
@@ -165,16 +167,17 @@ fun SettingsScreen(
|
||||
onClick = { showLeaveDialog = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isLeaving,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
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
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Leaving...")
|
||||
@@ -185,38 +188,39 @@ fun SettingsScreen(
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
)
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Leaving removes local data",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = "Disconnects and clears local database",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = "Clears stored server URL and password",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = "Deletes all local data (tables, fields, items)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = "Returns to server setup screen",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -227,7 +231,9 @@ fun SettingsScreen(
|
||||
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.")
|
||||
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(
|
||||
@@ -269,9 +275,10 @@ fun SettingsScreen(
|
||||
}
|
||||
},
|
||||
enabled = !isLeaving,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
colors =
|
||||
ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Text("Leave Server")
|
||||
}
|
||||
@@ -280,7 +287,7 @@ fun SettingsScreen(
|
||||
TextButton(onClick = { showLeaveDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -300,10 +307,11 @@ private fun formatServerUrlForDisplay(raw: String): String {
|
||||
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
|
||||
}
|
||||
val authorityStripped =
|
||||
when {
|
||||
authority.endsWith(":80") -> authority.removeSuffix(":80")
|
||||
authority.endsWith(":443") -> authority.removeSuffix(":443")
|
||||
else -> authority
|
||||
}
|
||||
return authorityStripped + rest
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,15 +2,15 @@ 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 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
|
||||
private val preferencesManager: PreferencesManager,
|
||||
) : ViewModel() {
|
||||
private val _serverUrl = MutableStateFlow(preferencesManager.getServerUrl())
|
||||
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
|
||||
|
||||
@@ -16,40 +16,44 @@ 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 DarkColorScheme =
|
||||
darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80,
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
)
|
||||
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
|
||||
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)
|
||||
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
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
if (darkTheme && amoledDark) {
|
||||
colorScheme = colorScheme.copy(
|
||||
background = Color.Black,
|
||||
surface = Color.Black,
|
||||
surfaceVariant = Color.Black
|
||||
)
|
||||
colorScheme =
|
||||
colorScheme.copy(
|
||||
background = Color.Black,
|
||||
surface = Color.Black,
|
||||
surfaceVariant = Color.Black,
|
||||
)
|
||||
}
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
@@ -63,6 +67,6 @@ fun CollabTableTheme(
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
val Typography =
|
||||
Typography(
|
||||
bodyLarge =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ data class LogEntry(
|
||||
val timestamp: Long,
|
||||
val level: LogLevel,
|
||||
val tag: String,
|
||||
val message: String
|
||||
val message: String,
|
||||
) {
|
||||
fun toFormattedString(): String {
|
||||
val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||
@@ -21,7 +21,10 @@ data class LogEntry(
|
||||
}
|
||||
|
||||
enum class LogLevel {
|
||||
DEBUG, INFO, WARN, ERROR
|
||||
DEBUG,
|
||||
INFO,
|
||||
WARN,
|
||||
ERROR,
|
||||
}
|
||||
|
||||
object Logger {
|
||||
@@ -30,22 +33,35 @@ object Logger {
|
||||
|
||||
private const val MAX_LOGS = 500
|
||||
|
||||
fun d(tag: String, message: String) {
|
||||
fun d(
|
||||
tag: String,
|
||||
message: String,
|
||||
) {
|
||||
log(LogLevel.DEBUG, tag, message)
|
||||
Log.d(tag, message)
|
||||
}
|
||||
|
||||
fun i(tag: String, message: String) {
|
||||
fun i(
|
||||
tag: String,
|
||||
message: String,
|
||||
) {
|
||||
log(LogLevel.INFO, tag, message)
|
||||
Log.i(tag, message)
|
||||
}
|
||||
|
||||
fun w(tag: String, message: String) {
|
||||
fun w(
|
||||
tag: String,
|
||||
message: String,
|
||||
) {
|
||||
log(LogLevel.WARN, tag, message)
|
||||
Log.w(tag, message)
|
||||
}
|
||||
|
||||
fun e(tag: String, message: String, throwable: Throwable? = null) {
|
||||
fun e(
|
||||
tag: String,
|
||||
message: String,
|
||||
throwable: Throwable? = null,
|
||||
) {
|
||||
val msg = if (throwable != null) "$message: ${throwable.message}" else message
|
||||
log(LogLevel.ERROR, tag, msg)
|
||||
if (throwable != null) {
|
||||
@@ -55,13 +71,18 @@ object Logger {
|
||||
}
|
||||
}
|
||||
|
||||
private fun log(level: LogLevel, tag: String, message: String) {
|
||||
val entry = LogEntry(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
level = level,
|
||||
tag = tag,
|
||||
message = 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user