Refactor database access to use a unified dbAdapter for SQLite and PostgreSQL
- Replaced direct database calls with dbAdapter methods in fieldRoutes, itemRoutes, listRoutes, syncRoutes, and webRoutes. - Implemented a new db.ts file to handle database interactions for both SQLite and PostgreSQL. - Added WebSocket support for real-time synchronization in WebSocketSyncClient and ws.ts. - Updated TypeScript configuration to include WebSocket types. - Created a new WebSocket server for handling sync requests and responses. - Ensured compatibility with both SQLite and PostgreSQL by using parameterized queries.
This commit is contained in:
@@ -7,6 +7,9 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.collabtable.app.data.preferences.PreferencesManager
|
||||
import com.collabtable.app.ui.navigation.AppNavigation
|
||||
import com.collabtable.app.ui.theme.CollabTableTheme
|
||||
|
||||
@@ -14,7 +17,18 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
CollabTableTheme {
|
||||
val prefs = PreferencesManager.getInstance(this)
|
||||
val themeMode by prefs.themeMode.collectAsState()
|
||||
val dynamicColor by prefs.dynamicColor.collectAsState()
|
||||
val amoledDark by prefs.amoledDark.collectAsState()
|
||||
|
||||
val darkTheme = when (themeMode) {
|
||||
PreferencesManager.THEME_MODE_LIGHT -> false
|
||||
PreferencesManager.THEME_MODE_DARK -> true
|
||||
else -> androidx.compose.foundation.isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
CollabTableTheme(darkTheme = darkTheme, dynamicColor = dynamicColor, amoledDark = amoledDark) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package com.collabtable.app.data.api
|
||||
|
||||
import android.content.Context
|
||||
import com.collabtable.app.data.preferences.PreferencesManager
|
||||
import com.collabtable.app.utils.Logger
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonObject
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private data class MessageEnvelope(
|
||||
val id: String? = null,
|
||||
val type: String,
|
||||
val payload: Any? = null
|
||||
)
|
||||
|
||||
object WebSocketSyncClient {
|
||||
private val gson = Gson()
|
||||
|
||||
private val logging = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
}
|
||||
|
||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor(logging)
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private fun buildWebSocketUrl(httpApiBaseUrl: String): String {
|
||||
// Expecting something like: http://host:port/api/
|
||||
var url = httpApiBaseUrl
|
||||
// Ensure trailing slash removed for manipulation
|
||||
if (url.endsWith("/")) url = url.dropLast(1)
|
||||
|
||||
// Replace scheme
|
||||
url = when {
|
||||
url.startsWith("https://") -> "wss://" + url.removePrefix("https://")
|
||||
url.startsWith("http://") -> "ws://" + url.removePrefix("http://")
|
||||
else -> "ws://$url" // best effort
|
||||
}
|
||||
|
||||
// Replace trailing /api with /api/ws
|
||||
return if (url.endsWith("/api")) "$url/ws" else "$url/api/ws"
|
||||
}
|
||||
|
||||
suspend fun sync(context: Context, request: SyncRequest): Result<SyncResponse> = withContext(Dispatchers.IO) {
|
||||
val prefs = PreferencesManager.getInstance(context)
|
||||
val baseUrl = prefs.getServerUrl()
|
||||
val password = prefs.getServerPassword()
|
||||
val wsUrl = buildWebSocketUrl(baseUrl)
|
||||
val messageId = UUID.randomUUID().toString()
|
||||
|
||||
val deferred = CompletableDeferred<Result<SyncResponse>>()
|
||||
var socket: WebSocket? = null
|
||||
|
||||
val httpRequestBuilder = Request.Builder().url(wsUrl)
|
||||
if (!password.isNullOrBlank()) {
|
||||
httpRequestBuilder.addHeader("Authorization", "Bearer $password")
|
||||
}
|
||||
val httpRequest = httpRequestBuilder.build()
|
||||
|
||||
val listener = object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
// Send sync message when opened
|
||||
val envelope = MessageEnvelope(
|
||||
id = messageId,
|
||||
type = "sync",
|
||||
payload = request
|
||||
)
|
||||
val json = gson.toJson(envelope)
|
||||
webSocket.send(json)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
try {
|
||||
val root = gson.fromJson(text, JsonObject::class.java)
|
||||
val type = root.get("type")?.asString
|
||||
val id = root.get("id")?.asString
|
||||
if (type == "syncResponse" && id == messageId) {
|
||||
val payload = root.getAsJsonObject("payload")
|
||||
val resp = gson.fromJson(payload, SyncResponse::class.java)
|
||||
if (!deferred.isCompleted) {
|
||||
deferred.complete(Result.success(resp))
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
} else if (type == "error" && !deferred.isCompleted) {
|
||||
val msg = root.getAsJsonObject("payload")?.get("message")?.asString ?: "WebSocket error"
|
||||
deferred.complete(Result.failure(Exception(msg)))
|
||||
webSocket.close(1002, "error")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (!deferred.isCompleted) {
|
||||
deferred.complete(Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
// 1008 indicates policy violation, used here for Unauthorized
|
||||
if (!deferred.isCompleted) {
|
||||
if (code == 1008) {
|
||||
deferred.complete(Result.failure(Exception("Unauthorized (WS 1008)")))
|
||||
} else {
|
||||
deferred.complete(Result.failure(Exception("WebSocket closing ($code): $reason")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
if (!deferred.isCompleted) {
|
||||
deferred.complete(Result.failure(t))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
socket = client.newWebSocket(httpRequest, listener)
|
||||
// Wait up to 15s for response
|
||||
val result = withTimeout(15_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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -13,6 +13,15 @@ class PreferencesManager(context: Context) {
|
||||
private val _serverUrl = MutableStateFlow(getServerUrl())
|
||||
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
|
||||
|
||||
private val _themeMode = MutableStateFlow(getThemeMode())
|
||||
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
|
||||
|
||||
private val _dynamicColor = MutableStateFlow(isDynamicColorEnabled())
|
||||
val dynamicColor: StateFlow<Boolean> = _dynamicColor.asStateFlow()
|
||||
|
||||
private val _amoledDark = MutableStateFlow(isAmoledDarkEnabled())
|
||||
val amoledDark: StateFlow<Boolean> = _amoledDark.asStateFlow()
|
||||
|
||||
fun getServerUrl(): String {
|
||||
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL
|
||||
}
|
||||
@@ -64,12 +73,50 @@ class PreferencesManager(context: Context) {
|
||||
_serverUrl.value = getServerUrl()
|
||||
}
|
||||
|
||||
// Theme settings
|
||||
fun getThemeMode(): String {
|
||||
return prefs.getString(KEY_THEME_MODE, THEME_MODE_SYSTEM) ?: THEME_MODE_SYSTEM
|
||||
}
|
||||
|
||||
fun setThemeMode(mode: String) {
|
||||
val normalized = when (mode.lowercase()) {
|
||||
THEME_MODE_LIGHT, THEME_MODE_DARK, THEME_MODE_SYSTEM -> mode.lowercase()
|
||||
else -> THEME_MODE_SYSTEM
|
||||
}
|
||||
prefs.edit().putString(KEY_THEME_MODE, normalized).apply()
|
||||
_themeMode.value = normalized
|
||||
}
|
||||
|
||||
fun isDynamicColorEnabled(): Boolean {
|
||||
return prefs.getBoolean(KEY_DYNAMIC_COLOR, true)
|
||||
}
|
||||
|
||||
fun setDynamicColorEnabled(enabled: Boolean) {
|
||||
prefs.edit().putBoolean(KEY_DYNAMIC_COLOR, enabled).apply()
|
||||
_dynamicColor.value = enabled
|
||||
}
|
||||
|
||||
fun isAmoledDarkEnabled(): Boolean {
|
||||
return prefs.getBoolean(KEY_AMOLED_DARK, false)
|
||||
}
|
||||
|
||||
fun setAmoledDarkEnabled(enabled: Boolean) {
|
||||
prefs.edit().putBoolean(KEY_AMOLED_DARK, enabled).apply()
|
||||
_amoledDark.value = enabled
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_SERVER_URL = "server_url"
|
||||
private const val KEY_FIRST_RUN = "first_run"
|
||||
private const val KEY_SERVER_PASSWORD = "server_password"
|
||||
private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp"
|
||||
private const val KEY_THEME_MODE = "theme_mode" // system|light|dark
|
||||
private const val KEY_DYNAMIC_COLOR = "dynamic_color"
|
||||
private const val KEY_AMOLED_DARK = "amoled_dark"
|
||||
private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/"
|
||||
const val THEME_MODE_SYSTEM = "system"
|
||||
const val THEME_MODE_LIGHT = "light"
|
||||
const val THEME_MODE_DARK = "dark"
|
||||
|
||||
@Volatile
|
||||
private var instance: PreferencesManager? = null
|
||||
|
||||
+80
-72
@@ -2,6 +2,7 @@ package com.collabtable.app.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.collabtable.app.data.api.ApiClient
|
||||
import com.collabtable.app.data.api.WebSocketSyncClient
|
||||
import com.collabtable.app.data.api.SyncRequest
|
||||
import com.collabtable.app.data.database.CollabTableDatabase
|
||||
import com.collabtable.app.utils.Logger
|
||||
@@ -55,82 +56,89 @@ class SyncRepository(context: Context) {
|
||||
itemValues = localValues
|
||||
)
|
||||
|
||||
val response = api.sync(syncRequest)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val syncResponse = response.body()!!
|
||||
|
||||
// Only log receive when there are actual server changes
|
||||
val inTotal = syncResponse.lists.size + syncResponse.fields.size + syncResponse.items.size + syncResponse.itemValues.size
|
||||
if (inTotal > 0) {
|
||||
Logger.i(
|
||||
"Sync",
|
||||
"[IN] Received changes (lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, items=${syncResponse.items.size}, values=${syncResponse.itemValues.size})"
|
||||
)
|
||||
}
|
||||
|
||||
// Apply server changes to local database atomically in correct order
|
||||
database.withTransaction {
|
||||
// 1) Upsert lists first
|
||||
database.listDao().insertLists(syncResponse.lists)
|
||||
|
||||
// Build set of existing list ids to guard child inserts
|
||||
val existingListIds = database.listDao().getAllListIds().toSet()
|
||||
|
||||
// 2) Filter and upsert fields whose parent list exists
|
||||
val filteredFields = syncResponse.fields.filter { f -> f.listId in existingListIds }
|
||||
if (filteredFields.size != syncResponse.fields.size) {
|
||||
val dropped = syncResponse.fields.size - filteredFields.size
|
||||
Logger.w("Sync", "Dropping $dropped field(s) with missing parent list to avoid FK errors")
|
||||
}
|
||||
if (filteredFields.isNotEmpty()) {
|
||||
database.fieldDao().insertFields(filteredFields)
|
||||
}
|
||||
|
||||
// 3) Filter and upsert items whose parent list exists
|
||||
val filteredItems = syncResponse.items.filter { i -> i.listId in existingListIds }
|
||||
if (filteredItems.size != syncResponse.items.size) {
|
||||
val dropped = syncResponse.items.size - filteredItems.size
|
||||
Logger.w("Sync", "Dropping $dropped item(s) with missing parent list to avoid FK errors")
|
||||
}
|
||||
if (filteredItems.isNotEmpty()) {
|
||||
database.itemDao().insertItems(filteredItems)
|
||||
}
|
||||
|
||||
// 4) Filter item values to only those whose parents (item and field) exist locally
|
||||
val existingItemIds = database.itemDao().getAllItemIds().toSet()
|
||||
val existingFieldIds = database.fieldDao().getAllFieldIds().toSet()
|
||||
val filteredValues = syncResponse.itemValues.filter { v ->
|
||||
v.itemId in existingItemIds && v.fieldId in existingFieldIds
|
||||
}
|
||||
if (filteredValues.size != syncResponse.itemValues.size) {
|
||||
val dropped = syncResponse.itemValues.size - filteredValues.size
|
||||
Logger.w("Sync", "Dropping $dropped item value(s) with missing parents to avoid FK errors")
|
||||
}
|
||||
if (filteredValues.isNotEmpty()) {
|
||||
database.itemValueDao().insertValues(filteredValues)
|
||||
}
|
||||
}
|
||||
|
||||
// Update last sync timestamp
|
||||
setLastSyncTimestamp(syncResponse.serverTimestamp)
|
||||
|
||||
if (isInitialSync) {
|
||||
Logger.i("Sync", "[SYNC] Initial sync completed")
|
||||
}
|
||||
|
||||
return@withContext Result.success(Unit)
|
||||
// Try WebSocket first; on failure, fall back to HTTP
|
||||
val wsResult = WebSocketSyncClient.sync(appContext, syncRequest)
|
||||
val syncResponse = if (wsResult.isSuccess) {
|
||||
wsResult.getOrThrow()
|
||||
} else {
|
||||
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."))
|
||||
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()}"))
|
||||
}
|
||||
Logger.e("Sync", "[ERROR] Sync failed: HTTP ${response.code()}")
|
||||
return@withContext Result.failure(Exception("Sync failed: ${response.code()}"))
|
||||
response.body()!!
|
||||
}
|
||||
// Only log receive when there are actual server changes
|
||||
val inTotal = syncResponse.lists.size + syncResponse.fields.size + syncResponse.items.size + syncResponse.itemValues.size
|
||||
if (inTotal > 0) {
|
||||
Logger.i(
|
||||
"Sync",
|
||||
"[IN] Received changes (lists=${syncResponse.lists.size}, fields=${syncResponse.fields.size}, items=${syncResponse.items.size}, values=${syncResponse.itemValues.size})"
|
||||
)
|
||||
}
|
||||
|
||||
// Apply server changes to local database atomically in correct order
|
||||
database.withTransaction {
|
||||
// 1) Upsert lists first
|
||||
database.listDao().insertLists(syncResponse.lists)
|
||||
|
||||
// Build set of existing list ids to guard child inserts
|
||||
val existingListIds = database.listDao().getAllListIds().toSet()
|
||||
|
||||
// 2) Filter and upsert fields whose parent list exists
|
||||
val filteredFields = syncResponse.fields.filter { f -> f.listId in existingListIds }
|
||||
if (filteredFields.size != syncResponse.fields.size) {
|
||||
val dropped = syncResponse.fields.size - filteredFields.size
|
||||
Logger.w("Sync", "Dropping $dropped field(s) with missing parent list to avoid FK errors")
|
||||
}
|
||||
if (filteredFields.isNotEmpty()) {
|
||||
database.fieldDao().insertFields(filteredFields)
|
||||
}
|
||||
|
||||
// 3) Filter and upsert items whose parent list exists
|
||||
val filteredItems = syncResponse.items.filter { i -> i.listId in existingListIds }
|
||||
if (filteredItems.size != syncResponse.items.size) {
|
||||
val dropped = syncResponse.items.size - filteredItems.size
|
||||
Logger.w("Sync", "Dropping $dropped item(s) with missing parent list to avoid FK errors")
|
||||
}
|
||||
if (filteredItems.isNotEmpty()) {
|
||||
database.itemDao().insertItems(filteredItems)
|
||||
}
|
||||
|
||||
// 4) Filter item values to only those whose parents (item and field) exist locally
|
||||
val existingItemIds = database.itemDao().getAllItemIds().toSet()
|
||||
val existingFieldIds = database.fieldDao().getAllFieldIds().toSet()
|
||||
val filteredValues = syncResponse.itemValues.filter { v ->
|
||||
v.itemId in existingItemIds && v.fieldId in existingFieldIds
|
||||
}
|
||||
if (filteredValues.size != syncResponse.itemValues.size) {
|
||||
val dropped = syncResponse.itemValues.size - filteredValues.size
|
||||
Logger.w("Sync", "Dropping $dropped item value(s) with missing parents to avoid FK errors")
|
||||
}
|
||||
if (filteredValues.isNotEmpty()) {
|
||||
database.itemValueDao().insertValues(filteredValues)
|
||||
}
|
||||
}
|
||||
|
||||
// Update last sync timestamp
|
||||
setLastSyncTimestamp(syncResponse.serverTimestamp)
|
||||
|
||||
if (isInitialSync) {
|
||||
Logger.i("Sync", "[SYNC] Initial sync completed")
|
||||
}
|
||||
|
||||
return@withContext Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
// If WS indicated unauthorized, align behavior with HTTP path
|
||||
if (e.message?.contains("Unauthorized") == true) {
|
||||
setLastSyncTimestamp(0)
|
||||
}
|
||||
Logger.e("Sync", "[ERROR] Sync error: ${e.message}")
|
||||
return@withContext Result.failure(e)
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ fun ListsScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Tap + to create your first list",
|
||||
text = "Tap + to create your first table",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -268,7 +268,7 @@ private fun RenameListDialog(
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Rename List") },
|
||||
title = { Text("Rename Table") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = listName,
|
||||
|
||||
+5
-5
@@ -54,13 +54,13 @@ class ListsViewModel(
|
||||
private suspend fun performSync() {
|
||||
val result = syncRepository.performSync()
|
||||
result.onFailure { error ->
|
||||
Logger.e("Lists", "❌ Sync failed: ${error.message}")
|
||||
Logger.e("Tables", "❌ Sync failed: ${error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun createList(name: String) {
|
||||
viewModelScope.launch {
|
||||
Logger.i("Lists", "➕ Creating list: \"$name\"")
|
||||
Logger.i("Tables", "➕ Creating table: \"$name\"")
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val newList = CollabList(
|
||||
id = UUID.randomUUID().toString(),
|
||||
@@ -78,7 +78,7 @@ class ListsViewModel(
|
||||
viewModelScope.launch {
|
||||
val list = database.listDao().getListById(listId)
|
||||
if (list != null && newName.isNotBlank()) {
|
||||
Logger.i("Lists", "✏️ Renaming: \"${list.name}\" → \"$newName\"")
|
||||
Logger.i("Tables", "✏️ Renaming table: \"${list.name}\" → \"$newName\"")
|
||||
database.listDao().updateList(
|
||||
list.copy(
|
||||
name = newName.trim(),
|
||||
@@ -95,7 +95,7 @@ class ListsViewModel(
|
||||
viewModelScope.launch {
|
||||
val list = database.listDao().getListById(listId)
|
||||
if (list != null) {
|
||||
Logger.i("Lists", "🗑️ Deleting list: \"${list.name}\"")
|
||||
Logger.i("Tables", "🗑️ Deleting table: \"${list.name}\"")
|
||||
database.listDao().softDeleteList(listId, System.currentTimeMillis())
|
||||
// Sync immediately after deleting
|
||||
performSync()
|
||||
@@ -105,7 +105,7 @@ class ListsViewModel(
|
||||
|
||||
fun manualSync() {
|
||||
viewModelScope.launch {
|
||||
Logger.i("Lists", "🔄 Manual sync requested")
|
||||
Logger.i("Tables", "🔄 Manual sync requested")
|
||||
_isLoading.value = true
|
||||
performSync()
|
||||
_isLoading.value = false
|
||||
|
||||
+14
-93
@@ -27,7 +27,7 @@ fun ServerSetupScreen(
|
||||
val context = LocalContext.current
|
||||
val preferencesManager = remember { PreferencesManager.getInstance(context) }
|
||||
val viewModel = remember { ServerSetupViewModel(preferencesManager) }
|
||||
|
||||
|
||||
var serverUrl by remember { mutableStateOf("") }
|
||||
var serverPassword by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
@@ -37,7 +37,6 @@ fun ServerSetupScreen(
|
||||
|
||||
LaunchedEffect(validationResult) {
|
||||
if (validationResult == true) {
|
||||
// Validation successful, navigate to main screen
|
||||
onSetupComplete()
|
||||
}
|
||||
}
|
||||
@@ -57,34 +56,16 @@ fun ServerSetupScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(24.dp),
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Welcome text
|
||||
Text(
|
||||
text = "Welcome to CollabTable!",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Please configure your server connection to get started.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Server URL input
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
label = { Text("Server URL") },
|
||||
label = { Text("Server Hostname") },
|
||||
placeholder = { Text("example.com or 10.0.2.2:3000") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isValidating,
|
||||
@@ -95,7 +76,7 @@ fun ServerSetupScreen(
|
||||
text = validationError ?: "",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
else -> Text("Enter hostname with optional port (default: 80/443)")
|
||||
else -> Text("Hostname only. Port optional (80/443 default). No http://, no /api/")
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
@@ -117,8 +98,7 @@ fun ServerSetupScreen(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Server Password input
|
||||
|
||||
OutlinedTextField(
|
||||
value = serverPassword,
|
||||
onValueChange = { serverPassword = it },
|
||||
@@ -136,16 +116,11 @@ fun ServerSetupScreen(
|
||||
)
|
||||
}
|
||||
},
|
||||
supportingText = {
|
||||
Text("Password required for server authentication")
|
||||
}
|
||||
supportingText = { Text("Server password for authentication") }
|
||||
)
|
||||
|
||||
// Validate and continue button
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.validateAndSaveServerUrl(serverUrl.trim(), serverPassword.trim())
|
||||
},
|
||||
onClick = { viewModel.validateAndSaveServerUrl(serverUrl.trim(), serverPassword.trim()) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = serverUrl.isNotBlank() && serverPassword.isNotBlank() && !isValidating
|
||||
) {
|
||||
@@ -158,66 +133,12 @@ fun ServerSetupScreen(
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Validating...")
|
||||
} else {
|
||||
Text("Validate and Continue")
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Tips card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Connection Tips:",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• For Android Emulator: 10.0.2.2:3000",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• For Physical Device: YOUR_IP:3000",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• For domains: example.com (uses port 80/443)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• Port is optional, defaults to 80 (HTTP) or 443 (HTTPS)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• Just enter hostname:port, no need for http:// or /api/",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• Make sure the server is running and accessible",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Note about changing later
|
||||
|
||||
Text(
|
||||
text = "You can change this URL later in Settings",
|
||||
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
|
||||
|
||||
+110
-14
@@ -15,6 +15,12 @@ import com.collabtable.app.data.api.ApiClient
|
||||
import com.collabtable.app.data.preferences.PreferencesManager
|
||||
import com.collabtable.app.data.repository.SyncRepository
|
||||
import com.collabtable.app.utils.Logger
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material.icons.filled.DarkMode
|
||||
import androidx.compose.material.icons.filled.LightMode
|
||||
import androidx.compose.material.icons.filled.SettingsBrightness
|
||||
import androidx.compose.ui.Alignment
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -31,6 +37,10 @@ fun SettingsScreen(
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val serverUrl = preferencesManager.getServerUrl()
|
||||
val themeMode by preferencesManager.themeMode.collectAsState()
|
||||
val dynamicColor by preferencesManager.dynamicColor.collectAsState()
|
||||
val amoledDark by preferencesManager.amoledDark.collectAsState()
|
||||
val displayUrl = remember(serverUrl) { formatServerUrlForDisplay(serverUrl) }
|
||||
var showLeaveDialog by remember { mutableStateOf(false) }
|
||||
var isLeaving by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -69,23 +79,87 @@ fun SettingsScreen(
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Connected to:",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
text = "Connected to",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = serverUrl,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
text = displayUrl,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Appearance",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
// Theme mode selector
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = themeMode == PreferencesManager.THEME_MODE_SYSTEM,
|
||||
onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_SYSTEM) },
|
||||
label = { Text("System") },
|
||||
leadingIcon = { Icon(Icons.Default.SettingsBrightness, contentDescription = null) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = themeMode == PreferencesManager.THEME_MODE_LIGHT,
|
||||
onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_LIGHT) },
|
||||
label = { Text("Light") },
|
||||
leadingIcon = { Icon(Icons.Default.LightMode, contentDescription = null) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = themeMode == PreferencesManager.THEME_MODE_DARK,
|
||||
onClick = { preferencesManager.setThemeMode(PreferencesManager.THEME_MODE_DARK) },
|
||||
label = { Text("Dark") },
|
||||
leadingIcon = { Icon(Icons.Default.DarkMode, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
|
||||
// Dynamic color toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Use Material 3 colors")
|
||||
Text(
|
||||
text = "Dynamic colors on supported devices",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(checked = dynamicColor, onCheckedChange = { preferencesManager.setDynamicColorEnabled(it) })
|
||||
}
|
||||
|
||||
// AMOLED dark toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("AMOLED dark")
|
||||
Text(
|
||||
text = "Pure black background in dark mode",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(checked = amoledDark, onCheckedChange = { preferencesManager.setAmoledDarkEnabled(it) })
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { showLeaveDialog = true },
|
||||
@@ -116,31 +190,31 @@ fun SettingsScreen(
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "About Leave Server:",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
text = "Leaving removes local data",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• Disconnects from the current server",
|
||||
text = "Disconnects and clears local database",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• Clears stored server URL and password",
|
||||
text = "Clears stored server URL and password",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• Deletes all local data (lists, fields, items)",
|
||||
text = "Deletes all local data (tables, fields, items)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• Returns to server setup screen",
|
||||
text = "Returns to server setup screen",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
@@ -211,3 +285,25 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatServerUrlForDisplay(raw: String): String {
|
||||
var s = raw.trim()
|
||||
if (s.isEmpty()) return ""
|
||||
// Remove scheme
|
||||
s = s.replace(Regex("^https?://", RegexOption.IGNORE_CASE), "")
|
||||
// Remove trailing /api or /api/
|
||||
s = s.replace(Regex("/api/?$", RegexOption.IGNORE_CASE), "")
|
||||
// Trim trailing /
|
||||
s = s.trimEnd('/')
|
||||
// Split authority and path (in case any path remains)
|
||||
val firstSlash = s.indexOf('/')
|
||||
val authority = if (firstSlash >= 0) s.substring(0, firstSlash) else s
|
||||
val rest = if (firstSlash >= 0) s.substring(firstSlash) else ""
|
||||
// Remove default ports from authority
|
||||
val authorityStripped = when {
|
||||
authority.endsWith(":80") -> authority.removeSuffix(":80")
|
||||
authority.endsWith(":443") -> authority.removeSuffix(":443")
|
||||
else -> authority
|
||||
}
|
||||
return authorityStripped + rest
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
@@ -31,9 +32,10 @@ private val LightColorScheme = lightColorScheme(
|
||||
fun CollabTableTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
amoledDark: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
var colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
@@ -41,6 +43,14 @@ fun CollabTableTheme(
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
if (darkTheme && amoledDark) {
|
||||
colorScheme = colorScheme.copy(
|
||||
background = Color.Black,
|
||||
surface = Color.Black,
|
||||
surfaceVariant = Color.Black
|
||||
)
|
||||
}
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<resources>
|
||||
<string name="app_name">CollabTable</string>
|
||||
<string name="lists">Lists</string>
|
||||
<string name="create_list">Create List</string>
|
||||
<string name="list_name">List Name</string>
|
||||
<string name="lists">Tables</string>
|
||||
<string name="create_list">Create Table</string>
|
||||
<string name="list_name">Table Name</string>
|
||||
<string name="add_field">Add Field</string>
|
||||
<string name="field_name">Field Name</string>
|
||||
<string name="add_item">Add Item</string>
|
||||
@@ -11,7 +11,7 @@
|
||||
<string name="save">Save</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="edit">Edit</string>
|
||||
<string name="no_lists">No lists yet. Create one to get started!</string>
|
||||
<string name="no_lists">No tables yet. Create one to get started!</string>
|
||||
<string name="no_items">No items yet</string>
|
||||
<string name="search">Search</string>
|
||||
<string name="settings">Settings</string>
|
||||
@@ -19,7 +19,7 @@
|
||||
<string name="sync">Sync</string>
|
||||
<string name="syncing">Syncing…</string>
|
||||
<string name="sync_error">Sync error</string>
|
||||
<string name="delete_list">Delete List</string>
|
||||
<string name="delete_list">Delete Table</string>
|
||||
<string name="delete_field">Delete Field</string>
|
||||
<string name="delete_item">Delete Item</string>
|
||||
<string name="confirm_delete">Are you sure you want to delete this?</string>
|
||||
|
||||
Reference in New Issue
Block a user