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:
2025-10-25 17:02:42 +02:00
parent 22ec511ca7
commit e5e72a39ac
24 changed files with 1183 additions and 395 deletions
+8 -8
View File
@@ -1,11 +1,11 @@
# CollabTable Android App
A collaborative list management Android application built with Jetpack Compose and Material 3.
A collaborative table management Android application built with Jetpack Compose and Material 3.
## Features
- Create and manage multiple lists
- Add custom fields to lists (name, link, price, category, etc.)
- Create and manage multiple tables
- Add custom fields to tables (name, link, price, category, etc.)
- Add items with values for each field
- Beautiful Material 3 design with dynamic colors
- Local Room database for offline support
@@ -36,7 +36,7 @@ A collaborative list management Android application built with Jetpack Compose a
The app includes a Settings screen where you can configure the server URL:
1. Tap the Settings icon (⚙️) in the top bar of the Lists screen
1. Tap the Settings icon (⚙️) in the top bar of the Tables screen
2. Enter your server URL
3. Tap "Save"
@@ -75,13 +75,13 @@ app/src/main/java/com/collabtable/app/
## Usage
### Creating a List
### Creating a Table
1. Tap the + button on the main screen
2. Enter a list name
2. Enter a table name
3. Tap "Save"
### Adding Fields
1. Open a list
1. Open a table
2. Tap the + icon in the top bar
3. Enter a field name (e.g., "Name", "Price", "Category")
4. Tap "Add"
@@ -92,7 +92,7 @@ app/src/main/java/com/collabtable/app/
3. Values are saved automatically as you type
### Deleting
- Tap the delete icon next to any list, field, or item
- Tap the delete icon next to any table, field, or item
- Confirm the deletion
## Synchronization
@@ -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
@@ -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)
}
}
}
@@ -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
@@ -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,
@@ -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
@@ -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
@@ -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>
Binary file not shown.