feat: Implement list management routes with CRUD operations

feat: Add sync routes for data synchronization between client and server

feat: Create web routes for serving UI and data visualization

chore: Configure TypeScript for the server project

docs: Update README with project overview, features, and setup instructions

docs: Add server setup implementation details for first-run configuration

docs: Summarize updates and changes in the project, including new features and enhancements
This commit is contained in:
2025-10-24 20:27:18 +02:00
commit 9aa7ed8878
72 changed files with 8916 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Android
CollabTableAndroid/.gradle/
CollabTableAndroid/.idea/
CollabTableAndroid/local.properties
CollabTableAndroid/build/
CollabTableAndroid/app/build/
CollabTableAndroid/*.iml
CollabTableAndroid/.DS_Store
CollabTableAndroid/captures/
# Server
CollabTableServer/node_modules/
CollabTableServer/dist/
CollabTableServer/.env
CollabTableServer/*.log
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
+118
View File
@@ -0,0 +1,118 @@
# CollabTable Android App
A collaborative list 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.)
- Add items with values for each field
- Beautiful Material 3 design with dynamic colors
- Local Room database for offline support
- Automatic synchronization with server
- Soft delete support (items can be recovered on server)
## Architecture
- **UI Layer**: Jetpack Compose with Material 3
- **Business Logic**: ViewModels with Kotlin Coroutines
- **Data Layer**: Room database + Retrofit for API calls
- **Navigation**: Jetpack Navigation Compose
## Prerequisites
- Android Studio Hedgehog or later
- Android SDK 26 (Android 8.0) or higher
- JDK 17
## Building the Project
1. Clone the repository
2. Open the `CollabTableAndroid` folder in Android Studio
3. Wait for Gradle sync to complete
4. Run the app on an emulator or physical device
## Server Configuration
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
2. Enter your server URL
3. Tap "Save"
**Default URLs:**
- Android Emulator: `http://10.0.2.2:3000/api/`
- Physical Device: `http://YOUR_COMPUTER_IP:3000/api/` (replace YOUR_COMPUTER_IP with your actual IP address)
**Note:** Make sure to include `/api/` at the end of the URL.
## Project Structure
```
app/src/main/java/com/collabtable/app/
├── data/
│ ├── api/ # Retrofit API interfaces and client
│ ├── dao/ # Room DAOs
│ ├── database/ # Room database
│ ├── model/ # Data models
│ └── repository/ # Repository pattern for data access
├── ui/
│ ├── navigation/ # Navigation setup
│ ├── screens/ # UI screens and ViewModels
│ └── theme/ # Material 3 theme
├── CollabTableApplication.kt
└── MainActivity.kt
```
## Key Technologies
- **Jetpack Compose**: Modern declarative UI toolkit
- **Material 3**: Latest Material Design guidelines
- **Room**: Local SQLite database
- **Retrofit**: REST API client
- **Kotlin Coroutines**: Asynchronous programming
- **Flow**: Reactive data streams
## Usage
### Creating a List
1. Tap the + button on the main screen
2. Enter a list name
3. Tap "Save"
### Adding Fields
1. Open a list
2. Tap the + icon in the top bar
3. Enter a field name (e.g., "Name", "Price", "Category")
4. Tap "Add"
### Adding Items
1. After adding fields, tap the floating + button
2. Fill in values for each field
3. Values are saved automatically as you type
### Deleting
- Tap the delete icon next to any list, field, or item
- Confirm the deletion
## Synchronization
The app includes sync functionality that:
- Sends local changes to the server
- Receives changes from other clients
- Resolves conflicts using timestamps (latest wins)
To trigger sync, you can call the `SyncRepository.performSync()` method from your code.
## Building for Release
1. Update the version in `app/build.gradle`
2. Create a keystore for signing
3. Build the release APK:
```bash
./gradlew assembleRelease
```
## License
MIT
+89
View File
@@ -0,0 +1,89 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'com.google.devtools.ksp'
}
android {
namespace 'com.collabtable.app'
compileSdk 34
defaultConfig {
applicationId "com.collabtable.app"
minSdk 26
targetSdk 34
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = '21'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.5.4'
}
packaging {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
}
dependencies {
// Core Android
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
implementation 'androidx.activity:activity-compose:1.8.1'
// Compose
implementation platform('androidx.compose:compose-bom:2023.10.01')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-graphics'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3:1.1.2'
implementation 'androidx.compose.material:material-icons-extended'
implementation 'androidx.navigation:navigation-compose:2.7.5'
// ViewModel
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2'
// Room
implementation 'androidx.room:room-runtime:2.6.1'
implementation 'androidx.room:room-ktx:2.6.1'
ksp 'androidx.room:room-compiler:2.6.1'
// Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
// Coroutines
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
// Testing
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation platform('androidx.compose:compose-bom:2023.10.01')
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
}
+1
View File
@@ -0,0 +1 @@
# Add project specific ProGuard rules here.
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".CollabTableApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.CollabTable"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.CollabTable">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,12 @@
package com.collabtable.app
import android.app.Application
import com.collabtable.app.data.api.ApiClient
class CollabTableApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize API client with saved server URL
ApiClient.initialize(this)
}
}
@@ -0,0 +1,27 @@
package com.collabtable.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.collabtable.app.ui.navigation.AppNavigation
import com.collabtable.app.ui.theme.CollabTableTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CollabTableTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AppNavigation()
}
}
}
}
}
@@ -0,0 +1,73 @@
package com.collabtable.app.data.api
import android.content.Context
import com.collabtable.app.data.preferences.PreferencesManager
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object ApiClient {
private var baseUrl: String = "http://10.0.2.2:3000/api/" // Default for Android emulator
private var retrofit: Retrofit? = null
private var context: Context? = null
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
private val authInterceptor = Interceptor { chain ->
val request = chain.request()
val password = context?.let {
PreferencesManager.getInstance(it).getServerPassword()
}
val newRequest = if (!password.isNullOrBlank()) {
request.newBuilder()
.header("Authorization", "Bearer $password")
.build()
} else {
request
}
chain.proceed(newRequest)
}
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
private fun buildRetrofit(): Retrofit {
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
fun initialize(context: Context) {
this.context = context.applicationContext
val prefs = PreferencesManager.getInstance(context)
baseUrl = prefs.getServerUrl()
retrofit = buildRetrofit()
}
fun setBaseUrl(url: String) {
baseUrl = if (url.endsWith("/")) url else "$url/"
retrofit = buildRetrofit()
}
val api: CollabTableApi
get() {
if (retrofit == null) {
retrofit = buildRetrofit()
}
return retrofit!!.create(CollabTableApi::class.java)
}
}
@@ -0,0 +1,44 @@
package com.collabtable.app.data.api
import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.model.Field
import com.collabtable.app.data.model.Item
import com.collabtable.app.data.model.ItemValue
import retrofit2.Response
import retrofit2.http.*
data class SyncRequest(
val lastSyncTimestamp: Long,
val lists: List<CollabList>,
val fields: List<Field>,
val items: List<Item>,
val itemValues: List<ItemValue>
)
data class SyncResponse(
val lists: List<CollabList>,
val fields: List<Field>,
val items: List<Item>,
val itemValues: List<ItemValue>,
val serverTimestamp: Long
)
interface CollabTableApi {
@POST("sync")
suspend fun sync(@Body request: SyncRequest): Response<SyncResponse>
@GET("lists")
suspend fun getLists(): Response<List<CollabList>>
@GET("lists/{listId}")
suspend fun getList(@Path("listId") listId: String): Response<CollabList>
@GET("lists/{listId}/fields")
suspend fun getFields(@Path("listId") listId: String): Response<List<Field>>
@GET("lists/{listId}/items")
suspend fun getItems(@Path("listId") listId: String): Response<List<Item>>
@GET("items/{itemId}/values")
suspend fun getItemValues(@Path("itemId") itemId: String): Response<List<ItemValue>>
}
@@ -0,0 +1,32 @@
package com.collabtable.app.data.dao
import androidx.room.*
import com.collabtable.app.data.model.Field
import kotlinx.coroutines.flow.Flow
@Dao
interface FieldDao {
@Query("SELECT * FROM fields WHERE listId = :listId AND isDeleted = 0 ORDER BY `order`")
fun getFieldsForList(listId: String): Flow<List<Field>>
@Query("SELECT * FROM fields WHERE id = :fieldId")
suspend fun getFieldById(fieldId: String): Field?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertField(field: Field)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertFields(fields: List<Field>)
@Update
suspend fun updateField(field: Field)
@Query("UPDATE fields SET isDeleted = 1, updatedAt = :timestamp WHERE id = :fieldId")
suspend fun softDeleteField(fieldId: String, timestamp: Long)
@Query("DELETE FROM fields WHERE id = :fieldId")
suspend fun deleteField(fieldId: String)
@Query("SELECT * FROM fields WHERE updatedAt >= :since")
suspend fun getFieldsUpdatedSince(since: Long): List<Field>
}
@@ -0,0 +1,37 @@
package com.collabtable.app.data.dao
import androidx.room.*
import com.collabtable.app.data.model.Item
import com.collabtable.app.data.model.ItemWithValues
import kotlinx.coroutines.flow.Flow
@Dao
interface ItemDao {
@Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY updatedAt DESC")
fun getItemsForList(listId: String): Flow<List<Item>>
@Transaction
@Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY updatedAt DESC")
fun getItemsWithValuesForList(listId: String): Flow<List<ItemWithValues>>
@Query("SELECT * FROM items WHERE id = :itemId")
suspend fun getItemById(itemId: String): Item?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItem(item: Item)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItems(items: List<Item>)
@Update
suspend fun updateItem(item: Item)
@Query("UPDATE items SET isDeleted = 1, updatedAt = :timestamp WHERE id = :itemId")
suspend fun softDeleteItem(itemId: String, timestamp: Long)
@Query("DELETE FROM items WHERE id = :itemId")
suspend fun deleteItem(itemId: String)
@Query("SELECT * FROM items WHERE updatedAt >= :since")
suspend fun getItemsUpdatedSince(since: Long): List<Item>
}
@@ -0,0 +1,32 @@
package com.collabtable.app.data.dao
import androidx.room.*
import com.collabtable.app.data.model.ItemValue
import kotlinx.coroutines.flow.Flow
@Dao
interface ItemValueDao {
@Query("SELECT * FROM item_values WHERE itemId = :itemId")
fun getValuesForItem(itemId: String): Flow<List<ItemValue>>
@Query("SELECT * FROM item_values WHERE id = :valueId")
suspend fun getValueById(valueId: String): ItemValue?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertValue(value: ItemValue)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertValues(values: List<ItemValue>)
@Update
suspend fun updateValue(value: ItemValue)
@Query("DELETE FROM item_values WHERE id = :valueId")
suspend fun deleteValue(valueId: String)
@Query("DELETE FROM item_values WHERE itemId = :itemId")
suspend fun deleteValuesForItem(itemId: String)
@Query("SELECT * FROM item_values WHERE updatedAt >= :since")
suspend fun getValuesUpdatedSince(since: Long): List<ItemValue>
}
@@ -0,0 +1,37 @@
package com.collabtable.app.data.dao
import androidx.room.*
import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.model.ListWithFields
import kotlinx.coroutines.flow.Flow
@Dao
interface ListDao {
@Query("SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC")
fun getAllLists(): Flow<List<CollabList>>
@Transaction
@Query("SELECT * FROM lists WHERE id = :listId AND isDeleted = 0")
fun getListWithFields(listId: String): Flow<ListWithFields?>
@Query("SELECT * FROM lists WHERE id = :listId")
suspend fun getListById(listId: String): CollabList?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertList(list: CollabList)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertLists(lists: List<CollabList>)
@Update
suspend fun updateList(list: CollabList)
@Query("UPDATE lists SET isDeleted = 1, updatedAt = :timestamp WHERE id = :listId")
suspend fun softDeleteList(listId: String, timestamp: Long)
@Query("DELETE FROM lists WHERE id = :listId")
suspend fun deleteList(listId: String)
@Query("SELECT * FROM lists WHERE updatedAt >= :since")
suspend fun getListsUpdatedSince(since: Long): List<CollabList>
}
@@ -0,0 +1,61 @@
package com.collabtable.app.data.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.collabtable.app.data.dao.*
import com.collabtable.app.data.model.*
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE fields ADD COLUMN fieldType TEXT NOT NULL DEFAULT 'STRING'")
database.execSQL("ALTER TABLE fields ADD COLUMN fieldOptions TEXT NOT NULL DEFAULT ''")
}
}
@Database(
entities = [
CollabList::class,
Field::class,
Item::class,
ItemValue::class
],
version = 2,
exportSchema = false
)
abstract class CollabTableDatabase : RoomDatabase() {
abstract fun listDao(): ListDao
abstract fun fieldDao(): FieldDao
abstract fun itemDao(): ItemDao
abstract fun itemValueDao(): ItemValueDao
companion object {
@Volatile
private var INSTANCE: CollabTableDatabase? = null
fun getDatabase(context: Context): CollabTableDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
CollabTableDatabase::class.java,
"collab_table_database"
)
.addMigrations(MIGRATION_1_2)
.build()
INSTANCE = instance
instance
}
}
fun clearDatabase(context: Context) {
synchronized(this) {
INSTANCE?.close()
context.deleteDatabase("collab_table_database")
INSTANCE = null
}
}
}
}
@@ -0,0 +1,13 @@
package com.collabtable.app.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "lists")
data class CollabList(
@PrimaryKey val id: String,
val name: String,
val createdAt: Long,
val updatedAt: Long,
val isDeleted: Boolean = false
)
@@ -0,0 +1,64 @@
package com.collabtable.app.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
enum class FieldType {
STRING,
PRICE,
DROPDOWN,
URL,
DATE,
TIME,
DATETIME
}
@Entity(
tableName = "fields",
foreignKeys = [
ForeignKey(
entity = CollabList::class,
parentColumns = ["id"],
childColumns = ["listId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("listId")]
)
data class Field(
@PrimaryKey val id: String,
val listId: String,
val name: String,
val fieldType: String = "STRING", // STRING, PRICE, DROPDOWN
val fieldOptions: String = "", // JSON string for dropdown options or currency for price
val order: Int,
val createdAt: Long,
val updatedAt: Long,
val isDeleted: Boolean = false
) {
fun getType(): FieldType {
return try {
FieldType.valueOf(fieldType)
} catch (e: Exception) {
FieldType.STRING
}
}
fun getDropdownOptions(): List<String> {
return if (fieldType == "DROPDOWN" && fieldOptions.isNotBlank()) {
fieldOptions.split("|")
} else {
emptyList()
}
}
fun getCurrency(): String {
return if (fieldType == "PRICE" && fieldOptions.isNotBlank()) {
fieldOptions
} else {
"$"
}
}
}
@@ -0,0 +1,26 @@
package com.collabtable.app.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "items",
foreignKeys = [
ForeignKey(
entity = CollabList::class,
parentColumns = ["id"],
childColumns = ["listId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("listId")]
)
data class Item(
@PrimaryKey val id: String,
val listId: String,
val createdAt: Long,
val updatedAt: Long,
val isDeleted: Boolean = false
)
@@ -0,0 +1,32 @@
package com.collabtable.app.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "item_values",
foreignKeys = [
ForeignKey(
entity = Item::class,
parentColumns = ["id"],
childColumns = ["itemId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = Field::class,
parentColumns = ["id"],
childColumns = ["fieldId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("itemId"), Index("fieldId")]
)
data class ItemValue(
@PrimaryKey val id: String,
val itemId: String,
val fieldId: String,
val value: String,
val updatedAt: Long
)
@@ -0,0 +1,13 @@
package com.collabtable.app.data.model
import androidx.room.Embedded
import androidx.room.Relation
data class ItemWithValues(
@Embedded val item: Item,
@Relation(
parentColumn = "id",
entityColumn = "itemId"
)
val values: List<ItemValue>
)
@@ -0,0 +1,13 @@
package com.collabtable.app.data.model
import androidx.room.Embedded
import androidx.room.Relation
data class ListWithFields(
@Embedded val list: CollabList,
@Relation(
parentColumn = "id",
entityColumn = "listId"
)
val fields: List<Field>
)
@@ -0,0 +1,59 @@
package com.collabtable.app.data.preferences
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class PreferencesManager(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE)
private val _serverUrl = MutableStateFlow(getServerUrl())
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
fun getServerUrl(): String {
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL
}
fun setServerUrl(url: String) {
val cleanUrl = url.trim().let {
if (it.endsWith("/")) it else "$it/"
}
prefs.edit().putString(KEY_SERVER_URL, cleanUrl).apply()
_serverUrl.value = cleanUrl
}
fun isFirstRun(): Boolean {
return prefs.getBoolean(KEY_FIRST_RUN, true)
}
fun setIsFirstRun(isFirstRun: Boolean) {
prefs.edit().putBoolean(KEY_FIRST_RUN, isFirstRun).apply()
}
fun getServerPassword(): String? {
return prefs.getString(KEY_SERVER_PASSWORD, null)
}
fun setServerPassword(password: String) {
prefs.edit().putString(KEY_SERVER_PASSWORD, password).apply()
}
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 DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/"
@Volatile
private var instance: PreferencesManager? = null
fun getInstance(context: Context): PreferencesManager {
return instance ?: synchronized(this) {
instance ?: PreferencesManager(context.applicationContext).also { instance = it }
}
}
}
}
@@ -0,0 +1,83 @@
package com.collabtable.app.data.repository
import android.content.Context
import com.collabtable.app.data.api.ApiClient
import com.collabtable.app.data.api.SyncRequest
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.utils.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class SyncRepository(context: Context) {
private val database = CollabTableDatabase.getDatabase(context)
private val api = ApiClient.api
private val prefs = context.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE)
private fun getLastSyncTimestamp(): Long {
return prefs.getLong("last_sync_timestamp", 0)
}
private fun setLastSyncTimestamp(timestamp: Long) {
prefs.edit().putLong("last_sync_timestamp", timestamp).apply()
}
suspend fun performSync(): Result<Unit> = withContext(Dispatchers.IO) {
try {
val lastSync = getLastSyncTimestamp()
Logger.d("Sync", "Starting sync with lastSyncTimestamp: $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)
Logger.i("Sync", "Sending to server: ${localLists.size} lists, ${localFields.size} fields, ${localItems.size} items")
// Log details of lists being sent
localLists.forEach { list ->
Logger.d("Sync", " List: ${list.id} - ${list.name} (updated: ${list.updatedAt})")
}
// Send to server and get updates
val syncRequest = SyncRequest(
lastSyncTimestamp = lastSync,
lists = localLists,
fields = localFields,
items = localItems,
itemValues = localValues
)
val response = api.sync(syncRequest)
if (response.isSuccessful) {
val syncResponse = response.body()!!
Logger.i("Sync", "Received from server: ${syncResponse.lists.size} lists, ${syncResponse.fields.size} fields, ${syncResponse.items.size} items")
// Log details of lists being received
syncResponse.lists.forEach { list ->
Logger.d("Sync", " Inserting list: ${list.id} - ${list.name} (updated: ${list.updatedAt})")
}
// Apply server changes to local database
database.listDao().insertLists(syncResponse.lists)
database.fieldDao().insertFields(syncResponse.fields)
database.itemDao().insertItems(syncResponse.items)
database.itemValueDao().insertValues(syncResponse.itemValues)
// Update last sync timestamp
setLastSyncTimestamp(syncResponse.serverTimestamp)
Logger.i("Sync", "Sync completed successfully. Timestamp updated to: ${syncResponse.serverTimestamp}")
return@withContext Result.success(Unit)
} else {
Logger.e("Sync", "Sync failed with code: ${response.code()}, message: ${response.message()}")
return@withContext Result.failure(Exception("Sync failed: ${response.code()}"))
}
} catch (e: Exception) {
Logger.e("Sync", "Sync error", e)
return@withContext Result.failure(e)
}
}
}
@@ -0,0 +1,89 @@
package com.collabtable.app.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.ui.screens.ListDetailScreen
import com.collabtable.app.ui.screens.ListsScreen
import com.collabtable.app.ui.screens.LogsScreen
import com.collabtable.app.ui.screens.ServerSetupScreen
import com.collabtable.app.ui.screens.SettingsScreen
@Composable
fun AppNavigation() {
val navController = rememberNavController()
val context = LocalContext.current
val preferencesManager = remember { PreferencesManager.getInstance(context) }
// Determine start destination based on first run
val startDestination = if (preferencesManager.isFirstRun()) {
"server_setup"
} else {
"lists"
}
NavHost(
navController = navController,
startDestination = startDestination
) {
composable("server_setup") {
ServerSetupScreen(
onSetupComplete = {
// Navigate to lists and clear back stack
navController.navigate("lists") {
popUpTo("server_setup") { inclusive = true }
}
}
)
}
composable("lists") {
ListsScreen(
onNavigateToList = { listId ->
navController.navigate("list/$listId")
},
onNavigateToSettings = {
navController.navigate("settings")
},
onNavigateToLogs = {
navController.navigate("logs")
}
)
}
composable(
route = "list/{listId}",
arguments = listOf(navArgument("listId") { type = NavType.StringType })
) { backStackEntry ->
val listId = backStackEntry.arguments?.getString("listId") ?: return@composable
ListDetailScreen(
listId = listId,
onNavigateBack = { navController.popBackStack() }
)
}
composable("settings") {
SettingsScreen(
onNavigateBack = { navController.popBackStack() },
onLeaveServer = {
// Navigate back to server setup and clear entire back stack
navController.navigate("server_setup") {
popUpTo(0) { inclusive = true }
}
}
)
}
composable("logs") {
LogsScreen(
onNavigateBack = { navController.popBackStack() }
)
}
}
}
@@ -0,0 +1,194 @@
package com.collabtable.app.ui.screens
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.*
import com.collabtable.app.data.repository.SyncRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.UUID
class ListDetailViewModel(
private val database: CollabTableDatabase,
private val listId: String,
private val context: Context
) : ViewModel() {
private val _list = MutableStateFlow<CollabList?>(null)
val list: StateFlow<CollabList?> = _list.asStateFlow()
private val _fields = MutableStateFlow<List<Field>>(emptyList())
val fields: StateFlow<List<Field>> = _fields.asStateFlow()
private val _items = MutableStateFlow<List<ItemWithValues>>(emptyList())
val items: StateFlow<List<ItemWithValues>> = _items.asStateFlow()
private val syncRepository = SyncRepository(context)
init {
loadListData()
startPeriodicSync()
}
private fun loadListData() {
viewModelScope.launch {
database.listDao().getListWithFields(listId).collect { listWithFields ->
_list.value = listWithFields?.list
_fields.value = listWithFields?.fields ?: emptyList()
}
}
viewModelScope.launch {
database.itemDao().getItemsWithValuesForList(listId).collect { itemsData ->
_items.value = itemsData
}
}
}
private fun startPeriodicSync() {
viewModelScope.launch {
while (true) {
performSync()
kotlinx.coroutines.delay(5000) // Sync every 5 seconds
}
}
}
private suspend fun performSync() {
syncRepository.performSync()
}
fun addField(name: String, fieldType: String = "STRING", fieldOptions: String = "") {
viewModelScope.launch {
val timestamp = System.currentTimeMillis()
val maxOrder = _fields.value.maxOfOrNull { it.order } ?: -1
val newField = Field(
id = UUID.randomUUID().toString(),
listId = listId,
name = name,
fieldType = fieldType,
fieldOptions = fieldOptions,
order = maxOrder + 1,
createdAt = timestamp,
updatedAt = timestamp
)
database.fieldDao().insertField(newField)
performSync()
}
}
fun deleteField(fieldId: String) {
viewModelScope.launch {
database.fieldDao().softDeleteField(fieldId, System.currentTimeMillis())
performSync()
}
}
fun updateField(fieldId: String, fieldType: String, fieldOptions: String) {
viewModelScope.launch {
val field = database.fieldDao().getFieldById(fieldId)
if (field != null) {
database.fieldDao().updateField(
field.copy(
fieldType = fieldType,
fieldOptions = fieldOptions,
updatedAt = System.currentTimeMillis()
)
)
performSync()
}
}
}
fun addItem() {
viewModelScope.launch {
val timestamp = System.currentTimeMillis()
val newItem = Item(
id = UUID.randomUUID().toString(),
listId = listId,
createdAt = timestamp,
updatedAt = timestamp
)
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
)
}
database.itemValueDao().insertValues(values)
performSync()
}
}
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
)
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
)
}
database.itemValueDao().insertValues(values)
performSync()
}
}
fun updateItemValue(itemValueId: String, newValue: String) {
viewModelScope.launch {
val itemValue = database.itemValueDao().getValueById(itemValueId)
if (itemValue != null) {
database.itemValueDao().updateValue(
itemValue.copy(
value = newValue,
updatedAt = System.currentTimeMillis()
)
)
performSync()
}
}
}
fun deleteItem(itemId: String) {
viewModelScope.launch {
database.itemDao().softDeleteItem(itemId, System.currentTimeMillis())
performSync()
}
}
fun reorderFields(reorderedFields: List<Field>) {
viewModelScope.launch {
val timestamp = System.currentTimeMillis()
reorderedFields.forEachIndexed { index, field ->
database.fieldDao().updateField(
field.copy(
order = index,
updatedAt = timestamp
)
)
}
performSync()
}
}
}
@@ -0,0 +1,216 @@
package com.collabtable.app.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.collabtable.app.R
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList
import java.text.SimpleDateFormat
import java.util.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListsScreen(
onNavigateToList: (String) -> Unit,
onNavigateToSettings: () -> Unit,
onNavigateToLogs: () -> Unit = {}
) {
val context = LocalContext.current
val database = remember { CollabTableDatabase.getDatabase(context) }
val viewModel = remember { ListsViewModel(database, context) }
val lists by viewModel.lists.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
var showCreateDialog by remember { mutableStateOf(false) }
var listToDelete by remember { mutableStateOf<CollabList?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.lists)) },
actions = {
IconButton(onClick = { viewModel.manualSync() }) {
Icon(Icons.Default.Refresh, contentDescription = "Sync")
}
IconButton(onClick = onNavigateToLogs) {
Icon(Icons.Default.List, contentDescription = "Logs")
}
IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { showCreateDialog = true }
) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.create_list))
}
}
) { padding ->
if (lists.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.no_lists),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(lists, key = { it.id }) { list ->
ListItem(
list = list,
onListClick = { onNavigateToList(list.id) },
onDeleteClick = { listToDelete = list }
)
}
}
}
}
if (showCreateDialog) {
CreateListDialog(
onDismiss = { showCreateDialog = false },
onCreate = { name ->
viewModel.createList(name)
showCreateDialog = false
}
)
}
listToDelete?.let { list ->
AlertDialog(
onDismissRequest = { listToDelete = null },
title = { Text(stringResource(R.string.delete_list)) },
text = { Text(stringResource(R.string.confirm_delete)) },
confirmButton = {
TextButton(
onClick = {
viewModel.deleteList(list.id)
listToDelete = null
}
) {
Text(stringResource(R.string.delete))
}
},
dismissButton = {
TextButton(onClick = { listToDelete = null }) {
Text(stringResource(R.string.cancel))
}
}
)
}
}
@Composable
fun ListItem(
list: CollabList,
onListClick: () -> Unit,
onDeleteClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onListClick)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = list.name,
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatDate(list.updatedAt),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onDeleteClick) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.delete),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
@Composable
fun CreateListDialog(
onDismiss: () -> Unit,
onCreate: (String) -> Unit
) {
var listName by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.create_list)) },
text = {
OutlinedTextField(
value = listName,
onValueChange = { listName = it },
label = { Text(stringResource(R.string.list_name)) },
singleLine = true
)
},
confirmButton = {
TextButton(
onClick = { if (listName.isNotBlank()) onCreate(listName.trim()) },
enabled = listName.isNotBlank()
) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
private fun formatDate(timestamp: Long): String {
val sdf = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault())
return sdf.format(Date(timestamp))
}
@@ -0,0 +1,89 @@
package com.collabtable.app.ui.screens
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.CollabList
import com.collabtable.app.data.repository.SyncRepository
import com.collabtable.app.utils.Logger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.UUID
class ListsViewModel(
private val database: CollabTableDatabase,
private val context: Context
) : ViewModel() {
private val _lists = MutableStateFlow<List<CollabList>>(emptyList())
val lists: StateFlow<List<CollabList>> = _lists.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val syncRepository = SyncRepository(context)
init {
loadLists()
// Do initial sync immediately, then start periodic sync
viewModelScope.launch {
performSync() // Initial sync on startup
startPeriodicSync()
}
}
private fun loadLists() {
viewModelScope.launch {
database.listDao().getAllLists().collect { listData ->
_lists.value = listData
}
}
}
private suspend fun startPeriodicSync() {
while (true) {
kotlinx.coroutines.delay(5000) // Wait 5 seconds before next sync
performSync()
}
}
private suspend fun performSync() {
syncRepository.performSync()
}
fun createList(name: String) {
viewModelScope.launch {
Logger.i("ListsViewModel", "Creating list: $name")
val timestamp = System.currentTimeMillis()
val newList = CollabList(
id = UUID.randomUUID().toString(),
name = name,
createdAt = timestamp,
updatedAt = timestamp
)
database.listDao().insertList(newList)
// Sync immediately after creating
performSync()
}
}
fun deleteList(listId: String) {
viewModelScope.launch {
Logger.i("ListsViewModel", "Deleting list: $listId")
database.listDao().softDeleteList(listId, System.currentTimeMillis())
// Sync immediately after deleting
performSync()
}
}
fun manualSync() {
viewModelScope.launch {
Logger.i("ListsViewModel", "Manual sync triggered")
_isLoading.value = true
performSync()
_isLoading.value = false
}
}
}
@@ -0,0 +1,106 @@
package com.collabtable.app.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.collabtable.app.utils.LogEntry
import com.collabtable.app.utils.LogLevel
import com.collabtable.app.utils.Logger
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LogsScreen(
onNavigateBack: () -> Unit
) {
val logs by Logger.logs.collectAsState()
val listState = rememberLazyListState()
// Auto-scroll to bottom when new logs arrive
LaunchedEffect(logs.size) {
if (logs.isNotEmpty()) {
listState.animateScrollToItem(logs.size - 1)
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Logs") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { Logger.clear() }) {
Icon(Icons.Default.Delete, contentDescription = "Clear logs")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}
) { padding ->
if (logs.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Text(
text = "No logs yet",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(Color(0xFF1E1E1E)),
contentPadding = PaddingValues(8.dp)
) {
items(logs) { log ->
LogItem(log)
}
}
}
}
}
@Composable
fun LogItem(log: LogEntry) {
val color = when (log.level) {
LogLevel.DEBUG -> Color(0xFF808080)
LogLevel.INFO -> Color(0xFF4FC3F7)
LogLevel.WARN -> Color(0xFFFFA726)
LogLevel.ERROR -> Color(0xFFEF5350)
}
Text(
text = log.toFormattedString(),
color = color,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
)
}
@@ -0,0 +1,227 @@
package com.collabtable.app.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.collabtable.app.data.preferences.PreferencesManager
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ServerSetupScreen(
onSetupComplete: () -> Unit
) {
val context = LocalContext.current
val preferencesManager = remember { PreferencesManager.getInstance(context) }
val viewModel = remember { ServerSetupViewModel(preferencesManager) }
var serverUrl by remember { mutableStateOf("") }
var serverPassword by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
val isValidating by viewModel.isValidating.collectAsState()
val validationResult by viewModel.validationResult.collectAsState()
val validationError by viewModel.validationError.collectAsState()
LaunchedEffect(validationResult) {
if (validationResult == true) {
// Validation successful, navigate to main screen
onSetupComplete()
}
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Server Setup") },
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.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
OutlinedTextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("Server URL") },
placeholder = { Text("example.com or 10.0.2.2:3000") },
modifier = Modifier.fillMaxWidth(),
enabled = !isValidating,
isError = validationError != null,
supportingText = {
when {
validationError != null -> Text(
text = validationError ?: "",
color = MaterialTheme.colorScheme.error
)
else -> Text("Enter hostname with optional port (default: 80/443)")
}
},
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
)
}
}
)
// Server Password input
OutlinedTextField(
value = serverPassword,
onValueChange = { serverPassword = it },
label = { Text("Server Password") },
placeholder = { Text("Enter server password") },
modifier = Modifier.fillMaxWidth(),
enabled = !isValidating,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
contentDescription = if (passwordVisible) "Hide password" else "Show password"
)
}
},
supportingText = {
Text("Password required for server authentication")
}
)
// Validate and continue button
Button(
onClick = {
viewModel.validateAndSaveServerUrl(serverUrl.trim(), serverPassword.trim())
},
modifier = Modifier.fillMaxWidth(),
enabled = serverUrl.isNotBlank() && serverPassword.isNotBlank() && !isValidating
) {
if (isValidating) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("Validating...")
} else {
Text("Validate and Continue")
}
}
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",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
@@ -0,0 +1,206 @@
package com.collabtable.app.ui.screens
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.data.api.ApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import android.os.NetworkOnMainThreadException
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.util.concurrent.TimeUnit
class ServerSetupViewModel(
private val preferencesManager: PreferencesManager
) : ViewModel() {
private val _isValidating = MutableStateFlow(false)
val isValidating: StateFlow<Boolean> = _isValidating.asStateFlow()
private val _validationResult = MutableStateFlow<Boolean?>(null)
val validationResult: StateFlow<Boolean?> = _validationResult.asStateFlow()
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()
fun validateAndSaveServerUrl(url: String, password: String) {
viewModelScope.launch {
_isValidating.value = true
_validationError.value = null
_validationResult.value = null
try {
// Normalize URL - add protocol if missing
var normalizedUrl = url.trim()
// Check if URL has a protocol
val hasProtocol = normalizedUrl.startsWith("http://") || normalizedUrl.startsWith("https://")
val hasPort = normalizedUrl.contains(":")
// Add http:// if no protocol specified
if (!hasProtocol) {
normalizedUrl = "http://$normalizedUrl"
}
// Add default port if no port specified
if (!hasPort || normalizedUrl.matches(Regex("^https?://.+$")) && !normalizedUrl.substringAfter("://").contains(":")) {
// Extract protocol and host
val protocol = if (normalizedUrl.startsWith("https://")) "https" else "http"
val hostAndPath = normalizedUrl.substringAfter("://")
val host = if (hostAndPath.contains("/")) hostAndPath.substringBefore("/") else hostAndPath
val path = if (hostAndPath.contains("/")) "/" + hostAndPath.substringAfter("/") else ""
// Add default port based on protocol
val defaultPort = if (protocol == "https") "443" else "80"
normalizedUrl = "$protocol://$host:$defaultPort$path"
}
// Remove trailing slash if present for consistent handling
normalizedUrl = normalizedUrl.trimEnd('/')
// Add /api/ if not present
if (!normalizedUrl.contains("/api")) {
normalizedUrl = "$normalizedUrl/api"
}
// Ensure it ends with /
normalizedUrl = if (normalizedUrl.endsWith("/")) normalizedUrl else "$normalizedUrl/"
// Validate password is not empty
if (password.isBlank()) {
_validationError.value = "Password cannot be empty"
_isValidating.value = false
return@launch
}
// Try to reach the health endpoint (no auth required)
val healthUrl = normalizedUrl.replace("/api/", "/health")
val healthRequest = Request.Builder()
.url(healthUrl)
.get()
.build()
try {
// Execute network call on IO dispatcher
val healthResponse = withContext(Dispatchers.IO) {
okHttpClient.newCall(healthRequest).execute()
}
if (!healthResponse.isSuccessful) {
val errorMessage = when (healthResponse.code) {
404 -> "Health endpoint not found. Check server URL."
500 -> "Server internal error. Check server logs."
503 -> "Service unavailable. Server may be starting up."
else -> "Server returned HTTP ${healthResponse.code}"
}
healthResponse.close()
_validationError.value = errorMessage
_isValidating.value = false
return@launch
}
healthResponse.close()
// Try to upgrade to HTTPS if currently using HTTP
var finalUrl = normalizedUrl
if (normalizedUrl.startsWith("http://")) {
// Replace http:// with https:// and change port 80 to 443
var httpsUrl = normalizedUrl.replace("http://", "https://")
if (httpsUrl.contains(":80/")) {
httpsUrl = httpsUrl.replace(":80/", ":443/")
}
val httpsHealthUrl = httpsUrl.replace("/api/", "/health")
val httpsHealthRequest = Request.Builder()
.url(httpsHealthUrl)
.get()
.build()
try {
// Execute HTTPS check on IO dispatcher
val httpsResponse = withContext(Dispatchers.IO) {
okHttpClient.newCall(httpsHealthRequest).execute()
}
if (httpsResponse.isSuccessful) {
// HTTPS works! Upgrade to it
finalUrl = httpsUrl
}
httpsResponse.close()
} catch (e: Exception) {
// HTTPS doesn't work, stick with HTTP
}
}
// Now validate the password by making an authenticated request
val testUrl = "${finalUrl}lists"
val authRequest = Request.Builder()
.url(testUrl)
.header("Authorization", "Bearer $password")
.get()
.build()
// Execute auth check on IO dispatcher
val authResponse = withContext(Dispatchers.IO) {
okHttpClient.newCall(authRequest).execute()
}
if (authResponse.isSuccessful) {
// Password is valid, save both URL and password
preferencesManager.setServerUrl(finalUrl)
preferencesManager.setServerPassword(password)
preferencesManager.setIsFirstRun(false)
ApiClient.setBaseUrl(finalUrl)
_validationResult.value = true
} else if (authResponse.code == 401) {
_validationError.value = "Invalid password. Please check and try again."
} else {
val errorMessage = when (authResponse.code) {
403 -> "Access forbidden. Check server configuration."
404 -> "API endpoint not found. Check URL path."
500 -> "Server error. Check server logs."
503 -> "Service unavailable. Try again later."
else -> "Server returned HTTP ${authResponse.code}"
}
_validationError.value = errorMessage
}
authResponse.close()
} catch (e: NetworkOnMainThreadException) {
_validationError.value = "Network operation attempted on main thread. This is a developer error - please report this bug."
} catch (e: UnknownHostException) {
_validationError.value = "Cannot resolve hostname. Check URL spelling and network connection."
} catch (e: ConnectException) {
_validationError.value = "Cannot connect to server. Check if server is running and URL is correct."
} catch (e: SocketTimeoutException) {
_validationError.value = "Connection timeout after 10 seconds. Server may be offline or unreachable."
} catch (e: IOException) {
_validationError.value = "Network error: ${e.message ?: "Unable to communicate with server"}"
} catch (e: Exception) {
_validationError.value = "Unexpected error: ${e.javaClass.simpleName} - ${e.message ?: "Unknown cause"}"
}
} catch (e: IllegalArgumentException) {
_validationError.value = "Invalid URL format: ${e.message ?: "Malformed URL"}"
} catch (e: Exception) {
_validationError.value = "Configuration error: ${e.javaClass.simpleName} - ${e.message ?: "Unable to process URL"}"
} finally {
_isValidating.value = false
}
}
}
fun clearValidationState() {
_validationResult.value = null
_validationError.value = null
}
}
@@ -0,0 +1,209 @@
package com.collabtable.app.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.collabtable.app.R
import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.data.repository.SyncRepository
import com.collabtable.app.utils.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigateBack: () -> Unit,
onLeaveServer: () -> Unit = {}
) {
val context = LocalContext.current
val preferencesManager = remember { PreferencesManager.getInstance(context) }
val syncRepository = remember { SyncRepository(context) }
val coroutineScope = rememberCoroutineScope()
val serverUrl = preferencesManager.getServerUrl()
var showLeaveDialog by remember { mutableStateOf(false) }
var isLeaving by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.settings)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Server Connection",
style = MaterialTheme.typography.titleMedium
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Connected to:",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = serverUrl,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { showLeaveDialog = true },
modifier = Modifier.fillMaxWidth(),
enabled = !isLeaving,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) {
if (isLeaving) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
color = MaterialTheme.colorScheme.onError,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("Leaving...")
} else {
Text("Leave Server")
}
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "About Leave Server:",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
text = "• Disconnects from the current server",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
text = "• Clears stored server URL and password",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
text = "• Deletes all local data (lists, fields, items)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
text = "• Returns to server setup screen",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
if (showLeaveDialog) {
AlertDialog(
onDismissRequest = { showLeaveDialog = false },
title = { Text("Leave Server?") },
text = {
Text("All your data will be synced to the server before disconnecting. After leaving, all local data will be deleted and you'll need to set up a new connection. This action cannot be undone.")
},
confirmButton = {
TextButton(
onClick = {
isLeaving = true
coroutineScope.launch {
try {
Logger.i("Settings", "Performing final sync before leaving server")
// Perform final sync to ensure all data is uploaded
withContext(Dispatchers.IO) {
syncRepository.performSync()
}
Logger.i("Settings", "Final sync completed, clearing local data")
// Clear database in background
withContext(Dispatchers.IO) {
CollabTableDatabase.clearDatabase(context)
}
// Clear preferences
preferencesManager.setServerUrl("")
preferencesManager.setServerPassword("")
preferencesManager.setIsFirstRun(true)
Logger.i("Settings", "Left server successfully")
showLeaveDialog = false
onLeaveServer()
} catch (e: Exception) {
// Handle error if needed
Logger.e("Settings", "Error leaving server", e)
isLeaving = false
}
}
},
enabled = !isLeaving,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Leave Server")
}
},
dismissButton = {
TextButton(onClick = { showLeaveDialog = false }) {
Text("Cancel")
}
}
)
}
}
}
@@ -0,0 +1,33 @@
package com.collabtable.app.ui.screens
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.collabtable.app.data.preferences.PreferencesManager
import com.collabtable.app.data.api.ApiClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class SettingsViewModel(
private val preferencesManager: PreferencesManager
) : ViewModel() {
private val _serverUrl = MutableStateFlow(preferencesManager.getServerUrl())
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
private val _showSuccessMessage = MutableStateFlow(false)
val showSuccessMessage: StateFlow<Boolean> = _showSuccessMessage.asStateFlow()
fun updateServerUrl(url: String) {
viewModelScope.launch {
preferencesManager.setServerUrl(url)
ApiClient.setBaseUrl(url)
_serverUrl.value = preferencesManager.getServerUrl()
_showSuccessMessage.value = true
}
}
fun clearSuccessMessage() {
_showSuccessMessage.value = false
}
}
@@ -0,0 +1,11 @@
package com.collabtable.app.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,58 @@
package com.collabtable.app.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
)
@Composable
fun CollabTableTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val 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
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,17 @@
package com.collabtable.app.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
)
@@ -0,0 +1,72 @@
package com.collabtable.app.utils
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.text.SimpleDateFormat
import java.util.*
data class LogEntry(
val timestamp: Long,
val level: LogLevel,
val tag: String,
val message: String
) {
fun toFormattedString(): String {
val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
val time = dateFormat.format(Date(timestamp))
return "[$time] [${level.name}] [$tag] $message"
}
}
enum class LogLevel {
DEBUG, INFO, WARN, ERROR
}
object Logger {
private val _logs = MutableStateFlow<List<LogEntry>>(emptyList())
val logs: StateFlow<List<LogEntry>> = _logs.asStateFlow()
private const val MAX_LOGS = 500
fun d(tag: String, message: String) {
log(LogLevel.DEBUG, tag, message)
Log.d(tag, message)
}
fun i(tag: String, message: String) {
log(LogLevel.INFO, tag, message)
Log.i(tag, message)
}
fun w(tag: String, message: String) {
log(LogLevel.WARN, tag, message)
Log.w(tag, message)
}
fun e(tag: String, message: String, throwable: Throwable? = null) {
val msg = if (throwable != null) "$message: ${throwable.message}" else message
log(LogLevel.ERROR, tag, msg)
if (throwable != null) {
Log.e(tag, message, throwable)
} else {
Log.e(tag, message)
}
}
private fun log(level: LogLevel, tag: String, message: String) {
val entry = LogEntry(
timestamp = System.currentTimeMillis(),
level = level,
tag = tag,
message = message
)
_logs.value = (_logs.value + entry).takeLast(MAX_LOGS)
}
fun clear() {
_logs.value = emptyList()
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M32,32L76,32L76,40L32,40ZM32,48L76,48L76,56L32,56ZM32,64L76,64L76,72L32,72Z"/>
</vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#6650A4</color>
</resources>
@@ -0,0 +1,26 @@
<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="add_field">Add Field</string>
<string name="field_name">Field Name</string>
<string name="add_item">Add Item</string>
<string name="add">Add</string>
<string name="delete">Delete</string>
<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_items">No items yet</string>
<string name="search">Search</string>
<string name="settings">Settings</string>
<string name="server_url">Server URL</string>
<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_field">Delete Field</string>
<string name="delete_item">Delete Item</string>
<string name="confirm_delete">Are you sure you want to delete this?</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.CollabTable" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Allow cleartext traffic for all domains -->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
+1
View File
@@ -0,0 +1 @@
^C
+16
View File
@@ -0,0 +1,16 @@
Initialized native services in: C:\Users\gabri\.gradle\native
Initialized jansi services in: C:\Users\gabri\.gradle\native
Received JVM installation metadata from 'C:\Programs\Java 22 JDK': {JAVA_HOME=C:\Programs\Java 22 JDK, JAVA_VERSION=22.0.1, JAVA_VENDOR=Oracle Corporation, RUNTIME_NAME=Java(TM) SE Runtime Environment, RUNTIME_VERSION=22.0.1+8-16, VM_NAME=Java HotSpot(TM) 64-Bit Server VM, VM_VERSION=22.0.1+8-16, VM_VENDOR=Oracle Corporation, OS_ARCH=amd64}
Removing 0 daemon stop events from registry
Starting a Gradle Daemon (subsequent builds will be faster)
Starting process 'Gradle build daemon'. Working directory: C:\Users\gabri\.gradle\daemon\8.5 Command: C:\Programs\Java 22 JDK\bin\java.exe --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.prefs/java.util.prefs=ALL-UNNAMED --add-opens=java.base/java.nio.charset=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.util.concurrent.atomic=ALL-UNNAMED -Xmx2048m -Dfile.encoding=UTF-8 -Duser.country=GB -Duser.language=en -Duser.variant -cp C:\Users\gabri\.gradle\wrapper\dists\gradle-8.5-bin\5t9huq95ubn472n8rpzujfbqh\gradle-8.5\lib\gradle-launcher-8.5.jar -javaagent:C:\Users\gabri\.gradle\wrapper\dists\gradle-8.5-bin\5t9huq95ubn472n8rpzujfbqh\gradle-8.5\lib\agents\gradle-instrumentation-agent-8.5.jar org.gradle.launcher.daemon.bootstrap.GradleDaemon 8.5
Successfully started process 'Gradle build daemon'
An attempt to start the daemon took 8.895 secs.
The client will now receive all logging from the daemon (pid: 35040). The daemon log file: C:\Users\gabri\.gradle\daemon\8.5\daemon-35040.out.log
Starting build in new daemon [memory: 2 GiB]
Using 16 worker leases.
Received JVM installation metadata from 'C:\Programs\Java 22 JDK': {JAVA_HOME=C:\Programs\Java 22 JDK, JAVA_VERSION=22.0.1, JAVA_VENDOR=Oracle Corporation, RUNTIME_NAME=Java(TM) SE Runtime Environment, RUNTIME_VERSION=22.0.1+8-16, VM_NAME=Java HotSpot(TM) 64-Bit Server VM, VM_VERSION=22.0.1+8-16, VM_VENDOR=Oracle Corporation, OS_ARCH=amd64}
Watching the file system is configured to be enabled if available
Now considering [C:\Users\gabri\VSCode Projects\CollabTable\CollabTableAndroid] as hierarchies to watch
File system watching is active
Terminate batch job (Y/N)?
+7
View File
@@ -0,0 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '8.3.0' apply false
id 'com.android.library' version '8.3.0' apply false
id 'org.jetbrains.kotlin.android' version '1.9.20' apply false
id 'com.google.devtools.ksp' version '1.9.20-1.0.14' apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+88
View File
@@ -0,0 +1,88 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if %ERRORLEVEL% neq 0 exit /b %ERRORLEVEL%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+17
View File
@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "CollabTable"
include ':app'
+9
View File
@@ -0,0 +1,9 @@
node_modules
dist
*.log
.env
.git
.gitignore
README.md
docker-compose.yml
Dockerfile
+4
View File
@@ -0,0 +1,4 @@
PORT=3000
DB_PATH=/data/collabtable.db
NODE_ENV=production
SERVER_PASSWORD=your_secure_password_here
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.env
*.log
.DS_Store
+24
View File
@@ -0,0 +1,24 @@
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy source code
COPY . .
# Build TypeScript
RUN npm run build
# Create data directory for SQLite
RUN mkdir -p /data
# Expose port
EXPOSE 3000
# Start the application
CMD ["npm", "start"]
+231
View File
@@ -0,0 +1,231 @@
# CollabTable Server
A collaborative list management server built with Node.js, Express, TypeScript, and SQLite.
## Features
- RESTful API for managing lists, fields, items, and values
- Real-time synchronization support
- SQLite database with Sequelize ORM
- Dockerized deployment with persistent storage
- TypeScript for type safety
## Prerequisites
- Docker and Docker Compose
- Or Node.js 20+ (for local development)
## Quick Start with Docker
1. Clone the repository
2. Navigate to the server directory:
```bash
cd CollabTableServer
```
3. Copy the example environment file:
```bash
cp .env.example .env
```
4. Start the services:
```bash
docker-compose up -d
```
The server will be available at `http://localhost:3000`
**Important:** The SQLite database is stored in a Docker volume named `sqlite_data`, which persists across container restarts and rebuilds.
## Local Development
1. Install dependencies:
```bash
npm install
```
2. Create a `.env` file:
```bash
cp .env.example .env
```
3. Update the database path in `.env` if needed:
```
DB_PATH=./data/collabtable.db
```
4. Start the development server:
```bash
npm run dev
```
## API Endpoints
### Lists
- `GET /api/lists` - Get all lists
- `GET /api/lists/:id` - Get a specific list
- `POST /api/lists` - Create a new list
- `PUT /api/lists/:id` - Update a list
- `DELETE /api/lists/:id` - Delete a list (soft delete)
### Fields
- `GET /api/fields/list/:listId` - Get all fields for a list
- `POST /api/fields` - Create a new field
- `PUT /api/fields/:id` - Update a field
- `DELETE /api/fields/:id` - Delete a field (soft delete)
### Items
- `GET /api/items/list/:listId` - Get all items for a list
- `GET /api/items/:itemId/values` - Get all values for an item
- `POST /api/items` - Create a new item
- `POST /api/items/values` - Create/update an item value
- `PUT /api/items/:id` - Update an item
- `DELETE /api/items/:id` - Delete an item (soft delete)
### Sync
- `POST /api/sync` - Synchronize data between client and server
### Health Check
- `GET /health` - Check server health
## Data Models
### List
```typescript
{
id: string;
name: string;
createdAt: number;
updatedAt: number;
isDeleted: boolean;
}
```
### Field
```typescript
{
id: string;
listId: string;
name: string;
order: number;
createdAt: number;
updatedAt: number;
isDeleted: boolean;
}
```
### Item
```typescript
{
id: string;
listId: string;
createdAt: number;
updatedAt: number;
isDeleted: boolean;
}
```
### ItemValue
```typescript
{
id: string;
itemId: string;
fieldId: string;
value: string;
updatedAt: number;
}
```
## Sync Protocol
The sync endpoint accepts a POST request with the following structure:
```json
{
"lastSyncTimestamp": 1234567890,
"lists": [...],
"fields": [...],
"items": [...],
"itemValues": [...]
}
```
The server responds with:
```json
{
"lists": [...],
"fields": [...],
"items": [...],
"itemValues": [...],
"serverTimestamp": 1234567890
}
```
The sync process:
1. Client sends all local changes since the last sync
2. Server saves the client's changes
3. Server returns all changes made on the server since the client's last sync
4. Client applies the server's changes locally
## Building for Production
Build the TypeScript code:
```bash
npm run build
```
Start the production server:
```bash
npm start
```
## Docker Commands
Start services:
```bash
docker-compose up -d
```
Stop services:
```bash
docker-compose down
```
View logs:
```bash
docker-compose logs -f
```
Rebuild and restart:
```bash
docker-compose up -d --build
```
## Environment Variables
- `PORT` - Server port (default: 3000)
- `DB_PATH` - Path to SQLite database file (default: /data/collabtable.db)
- `NODE_ENV` - Environment (development/production)
## Data Persistence
The SQLite database file is stored in a Docker volume, ensuring data persists across:
- Container restarts
- Container recreation
- Docker Compose down/up cycles
To backup your data:
```bash
docker cp collabtable-server:/data/collabtable.db ./backup.db
```
To restore data:
```bash
docker cp ./backup.db collabtable-server:/data/collabtable.db
docker-compose restart
```
## License
MIT
+24
View File
@@ -0,0 +1,24 @@
version: '3.8'
services:
server:
build: .
container_name: collabtable-server
restart: unless-stopped
environment:
- PORT=3000
- DB_PATH=/data/collabtable.db
- NODE_ENV=production
ports:
- "3000:3000"
volumes:
- sqlite_data:/data
networks:
- collabtable-network
volumes:
sqlite_data:
networks:
collabtable-network:
driver: bridge
+2
View File
@@ -0,0 +1,2 @@
# Ensure data directory exists
mkdir -p /data
+2351
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "collabtable-server",
"version": "1.0.0",
"description": "Collaborative list management server",
"main": "dist/index.js",
"scripts": {
"start": "node dist/index.js",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": ["collaboration", "api", "express"],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"better-sqlite3": "^11.0.0",
"dotenv": "^16.3.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/node": "^20.10.6",
"@types/better-sqlite3": "^7.6.11",
"typescript": "^5.3.3",
"ts-node-dev": "^2.0.0"
}
}
+83
View File
@@ -0,0 +1,83 @@
import path from 'path';
import fs from 'fs';
import BetterSqlite3 from 'better-sqlite3';
const DB_PATH = process.env.DB_PATH || './data/collabtable.db';
// Ensure the data directory exists
const dbDir = path.dirname(DB_PATH);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
console.log(`Created database directory: ${dbDir}`);
}
console.log(`Using database at: ${DB_PATH}`);
// Create and export the database connection
export const db = new BetterSqlite3(DB_PATH);
// Enable foreign keys
db.pragma('foreign_keys = ON');
// Create tables
export function initializeDatabase() {
// Create lists table
db.exec(`
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
isDeleted INTEGER DEFAULT 0
)
`);
// Create fields table
db.exec(`
CREATE TABLE IF NOT EXISTS fields (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
fieldType TEXT NOT NULL,
fieldOptions TEXT,
listId TEXT NOT NULL,
\`order\` INTEGER NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
isDeleted INTEGER DEFAULT 0,
FOREIGN KEY (listId) REFERENCES lists(id)
)
`);
// Create items table
db.exec(`
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
listId TEXT NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
isDeleted INTEGER DEFAULT 0,
FOREIGN KEY (listId) REFERENCES lists(id)
)
`);
// Create item_values table
db.exec(`
CREATE TABLE IF NOT EXISTS item_values (
id TEXT PRIMARY KEY,
itemId TEXT NOT NULL,
fieldId TEXT NOT NULL,
value TEXT,
updatedAt INTEGER NOT NULL,
FOREIGN KEY (itemId) REFERENCES items(id),
FOREIGN KEY (fieldId) REFERENCES fields(id)
)
`);
// Create indexes for better query performance
db.exec(`CREATE INDEX IF NOT EXISTS idx_fields_listId ON fields(listId)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_items_listId ON items(listId)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_item_values_itemId ON item_values(itemId)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_item_values_fieldId ON item_values(fieldId)`);
console.log('Database tables initialized');
}
+58
View File
@@ -0,0 +1,58 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import { initializeDatabase } from './database';
import { authenticatePassword } from './middleware/auth';
import listRoutes from './routes/listRoutes';
import fieldRoutes from './routes/fieldRoutes';
import itemRoutes from './routes/itemRoutes';
import syncRoutes from './routes/syncRoutes';
import webRoutes from './routes/webRoutes';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Health check (no auth required)
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: Date.now() });
});
// Apply authentication middleware to all API routes
app.use('/api', authenticatePassword);
// Routes (protected by auth)
app.use('/api/lists', listRoutes);
app.use('/api/fields', fieldRoutes);
app.use('/api/items', itemRoutes);
app.use('/api', syncRoutes);
// Web UI (no auth required, must be last to not interfere with API routes)
app.use('/', webRoutes);
// Initialize database and start server
try {
initializeDatabase();
console.log('Database synchronized successfully');
console.log('Database models loaded:');
console.log('- Lists');
console.log('- Fields');
console.log('- Items');
console.log('- ItemValues');
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(`Database: ${process.env.DB_PATH || './data/collabtable.db'}`);
});
} catch (error) {
console.error('Database initialization error:', error);
process.exit(1);
}
export default app;
+48
View File
@@ -0,0 +1,48 @@
import { Request, Response, NextFunction } from 'express';
let warningLogged = false;
export const authenticatePassword = (req: Request, res: Response, next: NextFunction) => {
const serverPassword = process.env.SERVER_PASSWORD;
// If no password is set in environment, skip authentication
if (!serverPassword) {
if (!warningLogged) {
console.warn('Warning: SERVER_PASSWORD not set in environment variables. Authentication is disabled.');
warningLogged = true;
}
return next();
}
// Get password from Authorization header (format: "Bearer <password>")
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({
error: 'Unauthorized',
message: 'No authorization header provided'
});
}
// Parse the authorization header
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
return res.status(401).json({
error: 'Unauthorized',
message: 'Invalid authorization header format. Expected: Bearer <password>'
});
}
const providedPassword = parts[1];
// Validate password
if (providedPassword !== serverPassword) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Invalid password'
});
}
// Password is valid, continue
next();
};
@@ -0,0 +1,75 @@
import { Router, Request, Response } from 'express';
import { db } from '../database';
const router = Router();
// Get fields for a list
router.get('/list/:listId', async (req: Request, res: Response) => {
try {
const fields = db.prepare('SELECT * FROM fields WHERE listId = ? AND isDeleted = 0 ORDER BY `order` ASC').all(req.params.listId);
const formattedFields = (fields as any[]).map(field => ({ ...field, isDeleted: !!field.isDeleted }));
res.json(formattedFields);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch fields' });
}
});
// Create field
router.post('/', async (req: Request, res: Response) => {
try {
const { id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted } = req.body;
db.prepare(`
INSERT INTO fields (id, name, fieldType, fieldOptions, listId, \`order\`, createdAt, updatedAt, isDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, name, fieldType, fieldOptions, listId, order, createdAt, updatedAt, isDeleted ? 1 : 0);
const field = db.prepare('SELECT * FROM fields WHERE id = ?').get(id);
res.status(201).json({ ...(field as any), isDeleted: !!(field as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to create field' });
}
});
// Update field
router.put('/:id', async (req: Request, res: Response) => {
try {
const updatedAt = Date.now();
const { name, fieldType, fieldOptions, order } = req.body;
const result = db.prepare(`
UPDATE fields
SET name = ?, fieldType = ?, fieldOptions = ?, \`order\` = ?, updatedAt = ?
WHERE id = ?
`).run(name, fieldType, fieldOptions, order, updatedAt, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Field not found' });
}
const field = db.prepare('SELECT * FROM fields WHERE id = ?').get(req.params.id);
res.json({ ...(field as any), isDeleted: !!(field as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to update field' });
}
});
// Delete field (soft delete)
router.delete('/:id', async (req: Request, res: Response) => {
try {
const updatedAt = Date.now();
const result = db.prepare(`
UPDATE fields
SET isDeleted = 1, updatedAt = ?
WHERE id = ?
`).run(updatedAt, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Field not found' });
}
res.json({ message: 'Field deleted successfully' });
} catch (error) {
res.status(500).json({ error: 'Failed to delete field' });
}
});
export default router;
+103
View File
@@ -0,0 +1,103 @@
import { Router, Request, Response } from 'express';
import { db } from '../database';
const router = Router();
// Get items for a list
router.get('/list/:listId', async (req: Request, res: Response) => {
try {
const items = db.prepare('SELECT * FROM items WHERE listId = ? AND isDeleted = 0 ORDER BY updatedAt DESC').all(req.params.listId);
const formattedItems = (items as any[]).map(item => ({ ...item, isDeleted: !!item.isDeleted }));
res.json(formattedItems);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch items' });
}
});
// Get item values
router.get('/:itemId/values', async (req: Request, res: Response) => {
try {
const values = db.prepare('SELECT * FROM item_values WHERE itemId = ?').all(req.params.itemId);
res.json(values);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch item values' });
}
});
// Create item
router.post('/', async (req: Request, res: Response) => {
try {
const { id, listId, createdAt, updatedAt, isDeleted } = req.body;
db.prepare(`
INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted)
VALUES (?, ?, ?, ?, ?)
`).run(id, listId, createdAt, updatedAt, isDeleted ? 1 : 0);
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(id);
res.status(201).json({ ...(item as any), isDeleted: !!(item as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to create item' });
}
});
// Create/update item value
router.post('/values', async (req: Request, res: Response) => {
try {
const { id, itemId, fieldId, value, updatedAt } = req.body;
db.prepare(`
INSERT INTO item_values (id, itemId, fieldId, value, updatedAt)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
value = ?,
updatedAt = ?
`).run(id, itemId, fieldId, value, updatedAt, value, updatedAt);
const itemValue = db.prepare('SELECT * FROM item_values WHERE id = ?').get(id);
res.json(itemValue);
} catch (error) {
res.status(500).json({ error: 'Failed to save item value' });
}
});
// Update item
router.put('/:id', async (req: Request, res: Response) => {
try {
const updatedAt = Date.now();
const result = db.prepare(`
UPDATE items
SET updatedAt = ?
WHERE id = ?
`).run(updatedAt, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Item not found' });
}
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id);
res.json({ ...(item as any), isDeleted: !!(item as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to update item' });
}
});
// Delete item (soft delete)
router.delete('/:id', async (req: Request, res: Response) => {
try {
const updatedAt = Date.now();
const result = db.prepare(`
UPDATE items
SET isDeleted = 1, updatedAt = ?
WHERE id = ?
`).run(updatedAt, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Item not found' });
}
res.json({ message: 'Item deleted successfully' });
} catch (error) {
res.status(500).json({ error: 'Failed to delete item' });
}
});
export default router;
@@ -0,0 +1,88 @@
import { Router, Request, Response } from 'express';
import { db } from '../database';
const router = Router();
// Get all lists
router.get('/', async (req: Request, res: Response) => {
try {
const lists = db.prepare('SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC').all();
// Convert isDeleted from INTEGER to BOOLEAN
const formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted }));
res.json(formattedLists);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch lists' });
}
});
// Get single list
router.get('/:id', async (req: Request, res: Response) => {
try {
const list = db.prepare('SELECT * FROM lists WHERE id = ? AND isDeleted = 0').get(req.params.id);
if (!list) {
return res.status(404).json({ error: 'List not found' });
}
res.json({ ...(list as any), isDeleted: !!(list as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to fetch list' });
}
});
// Create list
router.post('/', async (req: Request, res: Response) => {
try {
const { id, name, createdAt, updatedAt, isDeleted } = req.body;
db.prepare(`
INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted)
VALUES (?, ?, ?, ?, ?)
`).run(id, name, createdAt, updatedAt, isDeleted ? 1 : 0);
const list = db.prepare('SELECT * FROM lists WHERE id = ?').get(id);
res.status(201).json({ ...(list as any), isDeleted: !!(list as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to create list' });
}
});
// Update list
router.put('/:id', async (req: Request, res: Response) => {
try {
const updatedAt = Date.now();
const result = db.prepare(`
UPDATE lists
SET name = ?, updatedAt = ?
WHERE id = ?
`).run(req.body.name, updatedAt, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'List not found' });
}
const list = db.prepare('SELECT * FROM lists WHERE id = ?').get(req.params.id);
res.json({ ...(list as any), isDeleted: !!(list as any).isDeleted });
} catch (error) {
res.status(500).json({ error: 'Failed to update list' });
}
});
// Delete list (soft delete)
router.delete('/:id', async (req: Request, res: Response) => {
try {
const updatedAt = Date.now();
const result = db.prepare(`
UPDATE lists
SET isDeleted = 1, updatedAt = ?
WHERE id = ?
`).run(updatedAt, req.params.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'List not found' });
}
res.json({ message: 'List deleted successfully' });
} catch (error) {
res.status(500).json({ error: 'Failed to delete list' });
}
});
export default router;
+229
View File
@@ -0,0 +1,229 @@
import { Router, Request, Response } from 'express';
import { db } from '../database';
const router = Router();
interface SyncRequest {
lastSyncTimestamp: number;
lists: any[];
fields: any[];
items: any[];
itemValues: any[];
}
// Stats tracking
let syncStats = {
totalSyncs: 0,
listsReceived: 0,
fieldsReceived: 0,
itemsReceived: 0,
valuesReceived: 0,
listsSent: 0,
fieldsSent: 0,
itemsSent: 0,
valuesSent: 0,
lastReset: Date.now()
};
// Print stats every 30 seconds
setInterval(() => {
if (syncStats.totalSyncs > 0) {
const elapsed = Math.round((Date.now() - syncStats.lastReset) / 1000);
console.log(`\n[STATS] Last ${elapsed}s: ${syncStats.totalSyncs} syncs`);
console.log(` Received: ${syncStats.listsReceived} lists, ${syncStats.fieldsReceived} fields, ${syncStats.itemsReceived} items, ${syncStats.valuesReceived} values`);
console.log(` Sent: ${syncStats.listsSent} lists, ${syncStats.fieldsSent} fields, ${syncStats.itemsSent} items, ${syncStats.valuesSent} values\n`);
// Reset stats
syncStats = {
totalSyncs: 0,
listsReceived: 0,
fieldsReceived: 0,
itemsReceived: 0,
valuesReceived: 0,
listsSent: 0,
fieldsSent: 0,
itemsSent: 0,
valuesSent: 0,
lastReset: Date.now()
};
}
}, 30000);
// Helper function to get prepared statements (lazy initialization)
function getUpsertStatements() {
return {
upsertList: db.prepare(`
INSERT INTO lists (id, name, createdAt, updatedAt, isDeleted)
VALUES (@id, @name, @createdAt, @updatedAt, @isDeleted)
ON CONFLICT(id) DO UPDATE SET
name = @name,
updatedAt = @updatedAt,
isDeleted = @isDeleted
`),
upsertField: db.prepare(`
INSERT INTO fields (id, name, fieldType, fieldOptions, listId, \`order\`, createdAt, updatedAt, isDeleted)
VALUES (@id, @name, @fieldType, @fieldOptions, @listId, @order, @createdAt, @updatedAt, @isDeleted)
ON CONFLICT(id) DO UPDATE SET
name = @name,
fieldType = @fieldType,
fieldOptions = @fieldOptions,
\`order\` = @order,
updatedAt = @updatedAt,
isDeleted = @isDeleted
`),
upsertItem: db.prepare(`
INSERT INTO items (id, listId, createdAt, updatedAt, isDeleted)
VALUES (@id, @listId, @createdAt, @updatedAt, @isDeleted)
ON CONFLICT(id) DO UPDATE SET
updatedAt = @updatedAt,
isDeleted = @isDeleted
`),
upsertItemValue: db.prepare(`
INSERT INTO item_values (id, itemId, fieldId, value, updatedAt)
VALUES (@id, @itemId, @fieldId, @value, @updatedAt)
ON CONFLICT(id) DO UPDATE SET
value = @value,
updatedAt = @updatedAt
`)
};
}
router.post('/sync', async (req: Request, res: Response) => {
try {
const { lastSyncTimestamp, lists, fields, items, itemValues }: SyncRequest = req.body;
// Track stats
syncStats.totalSyncs++;
const incomingLists = lists?.length || 0;
const incomingFields = fields?.length || 0;
const incomingItems = items?.length || 0;
const incomingValues = itemValues?.length || 0;
syncStats.listsReceived += incomingLists;
syncStats.fieldsReceived += incomingFields;
syncStats.itemsReceived += incomingItems;
syncStats.valuesReceived += incomingValues;
// Log only when there's actual data being synced
const hasIncomingData = incomingLists > 0 || incomingFields > 0 || incomingItems > 0 || incomingValues > 0;
if (hasIncomingData) {
console.log(`[SYNC] Received: ${incomingLists} lists, ${incomingFields} fields, ${incomingItems} items, ${incomingValues} values`);
// Log list details
if (lists && lists.length > 0) {
lists.forEach(list => {
console.log(` List: ${list.id} - "${list.name}" (updated: ${list.updatedAt}, deleted: ${list.isDeleted})`);
});
}
}
// Save incoming data from client using a transaction
const transaction = db.transaction((data: SyncRequest) => {
// Get prepared statements lazily
const { upsertList, upsertField, upsertItem, upsertItemValue } = getUpsertStatements();
if (data.lists && data.lists.length > 0) {
for (const list of data.lists) {
upsertList.run({
id: list.id,
name: list.name,
createdAt: list.createdAt,
updatedAt: list.updatedAt,
isDeleted: list.isDeleted ? 1 : 0
});
console.log(` Saved list ${list.id}`);
}
}
if (data.fields && data.fields.length > 0) {
for (const field of data.fields) {
upsertField.run({
id: field.id,
name: field.name,
fieldType: field.fieldType,
fieldOptions: field.fieldOptions,
listId: field.listId,
order: field.order,
createdAt: field.createdAt,
updatedAt: field.updatedAt,
isDeleted: field.isDeleted ? 1 : 0
});
}
}
if (data.items && data.items.length > 0) {
for (const item of data.items) {
upsertItem.run({
id: item.id,
listId: item.listId,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
isDeleted: item.isDeleted ? 1 : 0
});
}
}
if (data.itemValues && data.itemValues.length > 0) {
for (const value of data.itemValues) {
upsertItemValue.run({
id: value.id,
itemId: value.itemId,
fieldId: value.fieldId,
value: value.value,
updatedAt: value.updatedAt
});
}
}
});
transaction({ lastSyncTimestamp, lists, fields, items, itemValues });
// Get updates from server since last sync
// If lastSyncTimestamp is 0, get all data (initial sync)
// Exclude deleted items from being sent
let serverLists, serverFields, serverItems, serverItemValues;
if (lastSyncTimestamp === 0) {
serverLists = db.prepare('SELECT * FROM lists WHERE isDeleted = 0').all();
serverFields = db.prepare('SELECT * FROM fields WHERE isDeleted = 0').all();
serverItems = db.prepare('SELECT * FROM items WHERE isDeleted = 0').all();
serverItemValues = db.prepare('SELECT * FROM item_values').all();
} else {
serverLists = db.prepare('SELECT * FROM lists WHERE updatedAt > ? AND isDeleted = 0').all(lastSyncTimestamp);
serverFields = db.prepare('SELECT * FROM fields WHERE updatedAt > ? AND isDeleted = 0').all(lastSyncTimestamp);
serverItems = db.prepare('SELECT * FROM items WHERE updatedAt > ? AND isDeleted = 0').all(lastSyncTimestamp);
serverItemValues = db.prepare('SELECT * FROM item_values WHERE updatedAt > ?').all(lastSyncTimestamp);
}
// Convert isDeleted from INTEGER (0/1) to BOOLEAN
serverLists = (serverLists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted }));
serverFields = (serverFields as any[]).map(field => ({ ...field, isDeleted: !!field.isDeleted }));
serverItems = (serverItems as any[]).map(item => ({ ...item, isDeleted: !!item.isDeleted }));
const serverTimestamp = Date.now();
// Track outgoing stats
syncStats.listsSent += (serverLists as any[]).length;
syncStats.fieldsSent += (serverFields as any[]).length;
syncStats.itemsSent += (serverItems as any[]).length;
syncStats.valuesSent += (serverItemValues as any[]).length;
// Log only when sending data back
const hasOutgoingData = (serverLists as any[]).length > 0 || (serverFields as any[]).length > 0 || (serverItems as any[]).length > 0 || (serverItemValues as any[]).length > 0;
if (hasOutgoingData) {
console.log(`[SYNC] Sending: ${(serverLists as any[]).length} lists, ${(serverFields as any[]).length} fields, ${(serverItems as any[]).length} items, ${(serverItemValues as any[]).length} values`);
}
res.json({
lists: serverLists,
fields: serverFields,
items: serverItems,
itemValues: serverItemValues,
serverTimestamp
});
} catch (error) {
console.error('[SYNC] Error:', error);
res.status(500).json({ error: 'Sync failed' });
}
});
export default router;
+329
View File
@@ -0,0 +1,329 @@
import { Router, Request, Response } from 'express';
import { db } from '../database';
const router = Router();
// Serve the web UI
router.get('/', (req: Request, res: Response) => {
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CollabTable Server - Database Viewer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 10px;
}
.stats {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.stat-card {
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
border-left: 4px solid #007bff;
}
.stat-label {
color: #666;
font-size: 14px;
margin-bottom: 5px;
}
.stat-value {
color: #333;
font-size: 24px;
font-weight: bold;
}
.lists-container {
display: grid;
gap: 20px;
}
.list-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 2px solid #e9ecef;
}
.list-name {
font-size: 20px;
font-weight: bold;
color: #333;
}
.list-id {
font-size: 12px;
color: #999;
font-family: monospace;
}
.fields-container {
margin-bottom: 15px;
}
.field-tag {
display: inline-block;
background: #e7f3ff;
color: #0066cc;
padding: 6px 12px;
border-radius: 4px;
margin-right: 8px;
margin-bottom: 8px;
font-size: 14px;
}
.items-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.items-table th {
background: #f8f9fa;
padding: 12px;
text-align: left;
font-weight: 600;
color: #495057;
border-bottom: 2px solid #dee2e6;
}
.items-table td {
padding: 12px;
border-bottom: 1px solid #dee2e6;
}
.no-data {
text-align: center;
color: #999;
padding: 40px;
font-style: italic;
}
.refresh-btn {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
margin-left: 10px;
}
.refresh-btn:hover {
background: #0056b3;
}
.deleted {
opacity: 0.5;
background: #fff3cd !important;
}
.badge {
display: inline-block;
background: #dc3545;
color: white;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
margin-left: 8px;
}
</style>
</head>
<body>
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>CollabTable Server - Database Viewer</h1>
<button class="refresh-btn" onclick="loadData()">Refresh</button>
</div>
<div class="stats">
<h2 style="margin-bottom: 10px;">Database Statistics</h2>
<div class="stat-grid">
<div class="stat-card">
<div class="stat-label">Total Lists</div>
<div class="stat-value" id="total-lists">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Fields</div>
<div class="stat-value" id="total-fields">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Items</div>
<div class="stat-value" id="total-items">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Values</div>
<div class="stat-value" id="total-values">-</div>
</div>
</div>
</div>
<div id="lists-container" class="lists-container">
<div class="no-data">Loading...</div>
</div>
</div>
<script>
async function loadData() {
try {
const response = await fetch('/web/data');
if (!response.ok) {
throw new Error('HTTP ' + response.status + ': ' + response.statusText);
}
const data = await response.json();
// Validate response structure
if (!data || typeof data !== 'object') {
throw new Error('Invalid response format');
}
// Provide defaults for missing properties
const lists = data.lists || [];
const fields = data.fields || [];
const items = data.items || [];
const values = data.values || [];
const stats = data.stats || { lists: 0, fields: 0, items: 0, values: 0 };
// Update stats
document.getElementById('total-lists').textContent = stats.lists;
document.getElementById('total-fields').textContent = stats.fields;
document.getElementById('total-items').textContent = stats.items;
document.getElementById('total-values').textContent = stats.values;
// Render lists
const container = document.getElementById('lists-container');
if (lists.length === 0) {
container.innerHTML = '<div class="no-data">No lists found in database</div>';
return;
}
container.innerHTML = lists.map(list => {
const listFields = fields.filter(f => f.listId === list.id);
const listItems = items.filter(i => i.listId === list.id);
return \`
<div class="list-card \${list.isDeleted ? 'deleted' : ''}">
<div class="list-header">
<div>
<div class="list-name">
\${list.name}
\${list.isDeleted ? '<span class="badge">DELETED</span>' : ''}
</div>
<div class="list-id">ID: \${list.id}</div>
<div class="list-id">Created: \${new Date(list.createdAt).toLocaleString()}</div>
<div class="list-id">Updated: \${new Date(list.updatedAt).toLocaleString()}</div>
</div>
</div>
<div class="fields-container">
<strong>Fields (\${listFields.length}):</strong><br>
\${listFields.length > 0
? listFields.map(f => \`<span class="field-tag \${f.isDeleted ? 'deleted' : ''}">\${f.name} (\${f.fieldType})</span>\`).join('')
: '<span style="color: #999;">No fields</span>'
}
</div>
\${listItems.length > 0 ? \`
<table class="items-table">
<thead>
<tr>
<th>Item ID</th>
\${listFields.map(f => \`<th>\${f.name}</th>\`).join('')}
<th>Updated</th>
</tr>
</thead>
<tbody>
\${listItems.map(item => {
const itemValues = values.filter(v => v.itemId === item.id);
return \`
<tr class="\${item.isDeleted ? 'deleted' : ''}">
<td>\${item.id.substring(0, 8)}...</td>
\${listFields.map(field => {
const value = itemValues.find(v => v.fieldId === field.id);
return \`<td>\${value ? value.value : '-'}</td>\`;
}).join('')}
<td>\${new Date(item.updatedAt).toLocaleString()}</td>
</tr>
\`;
}).join('')}
</tbody>
</table>
\` : '<div style="color: #999; margin-top: 10px;">No items</div>'}
</div>
\`;
}).join('');
} catch (error) {
console.error('Error loading data:', error);
document.getElementById('lists-container').innerHTML =
'<div class="no-data" style="color: #dc3545;">Error loading data: ' + (error.message || 'Unknown error') + '</div>';
}
}
// Load data on page load
loadData();
// Auto-refresh every 5 seconds
setInterval(loadData, 5000);
</script>
</body>
</html>
`);
});
// API endpoint to get all data (no auth required for web UI)
router.get('/web/data', async (req: Request, res: Response) => {
try {
const lists = db.prepare('SELECT * FROM lists ORDER BY updatedAt DESC').all();
const fields = db.prepare('SELECT * FROM fields ORDER BY listId ASC, `order` ASC').all();
const items = db.prepare('SELECT * FROM items ORDER BY listId ASC, updatedAt DESC').all();
const values = db.prepare('SELECT * FROM item_values').all();
// Convert isDeleted from INTEGER to BOOLEAN
const formattedLists = (lists as any[]).map(list => ({ ...list, isDeleted: !!list.isDeleted }));
const formattedFields = (fields as any[]).map(field => ({ ...field, isDeleted: !!field.isDeleted }));
const formattedItems = (items as any[]).map(item => ({ ...item, isDeleted: !!item.isDeleted }));
res.json({
lists: formattedLists,
fields: formattedFields,
items: formattedItems,
values,
stats: {
lists: formattedLists.length,
fields: formattedFields.length,
items: formattedItems.length,
values: (values as any[]).length
}
});
} catch (error) {
console.error('Error fetching data:', error);
res.status(500).json({ error: 'Failed to fetch data' });
}
});
export default router;
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"types": ["node"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
+143
View File
@@ -0,0 +1,143 @@
# CollabTable
A collaborative list management system with Android app and server for creating and managing shared lists with custom fields and items.
## Overview
CollabTable allows multiple users to collaboratively create and manage lists with custom fields. Perfect for:
- Shopping lists with price tracking
- Product catalogs
- Task management with custom attributes
- Any structured data collection
## Features
### Android
- ✨ Beautiful Material 3 design with dynamic colors
- 📱 Native Android app with Jetpack Compose
- 💾 Offline-first with Room database
- 🔄 Automatic synchronization with server
- 🎨 Custom fields (name, link, price, category, etc.)
- ✏️ Real-time editing of items
- 🗑️ Soft delete support
- ⚙️ Configurable server URL in settings
### Server
- 🚀 Express.js REST API with TypeScript
- 🐳 Fully containerized with Docker
- 💾 SQLite database with persistent storage
- 🔄 Sync protocol for conflict resolution
- 📊 Complete CRUD operations
## Quick Start
### Server (Docker)
```bash
cd CollabTableServer
docker-compose up -d
```
Server runs on `http://localhost:3000`
### Android App
1. Open `CollabTableAndroid` in Android Studio
2. Wait for Gradle sync
3. Run on emulator or device
4. Go to Settings (gear icon) to configure server URL
- For emulator: `http://10.0.2.2:3000/api/`
- For physical device: `http://YOUR_COMPUTER_IP:3000/api/`
## Project Structure
```
CollabTable/
├── CollabTableAndroid/ # Android app
│ ├── app/
│ │ ├── src/main/java/com/collabtable/app/
│ │ │ ├── data/ # Database, API, repositories
│ │ │ └── ui/ # Compose UI, screens, theme
│ │ └── build.gradle
│ └── README.md
└── CollabTableServer/ # Node.js server
├── src/
│ ├── models/ # MongoDB models
│ └── routes/ # API endpoints
├── Dockerfile
├── docker-compose.yml
└── README.md
```
## Technology Stack
### Android
- Kotlin
- Jetpack Compose
- Material 3
- Room Database
- Retrofit
- Coroutines & Flow
### Server
- Node.js
- Express
- TypeScript
- SQLite (Sequelize ORM)
- Docker
## API Endpoints
- `GET/POST /api/lists` - Manage lists
- `GET/POST /api/fields` - Manage fields
- `GET/POST /api/items` - Manage items
- `POST /api/sync` - Synchronize data
- `GET /health` - Health check
See [Server README](CollabTableServer/README.md) for detailed API documentation.
## Development
### Android Development
```bash
cd CollabTableAndroid
# Open in Android Studio
```
### Server Development
```bash
cd CollabTableServer
npm install
npm run dev
```
## Data Model
### List
Contains multiple fields and items
### Field
Defines a column/attribute (name, price, category, etc.)
### Item
A row in the list with values for each field
### ItemValue
The actual value for a specific field of an item
## Synchronization
The sync protocol uses timestamps to determine which changes to apply:
1. Client sends all local changes since last sync
2. Server merges changes and returns server-side updates
3. Client applies server updates locally
4. Latest timestamp wins in conflicts
## License
MIT
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
+137
View File
@@ -0,0 +1,137 @@
# Server Setup Feature - Implementation Summary
## Overview
Added a mandatory server setup screen on first app startup with endpoint validation to ensure users configure a valid server URL before using the app.
## Features Added
### 1. First-Run Server Setup Screen
- **File**: `ServerSetupScreen.kt`
- Mandatory setup screen shown on first app launch
- User-friendly interface with clear instructions
- Real-time validation feedback with visual indicators
- Cannot be skipped - must validate successfully to proceed
- Tips card with connection examples for emulator and physical devices
### 2. Server Endpoint Validation
- **File**: `ServerSetupViewModel.kt`
- Validates URL format (must start with http:// or https://)
- Tests server connectivity by calling the `/health` endpoint
- Shows specific error messages:
- Connection failures
- Timeout errors
- Invalid URL format
- Server error codes
- Loading states with progress indicators
- Success confirmation before proceeding
### 3. First-Run Detection
- **Updated**: `PreferencesManager.kt`
- Added `isFirstRun()` method to check if app has been configured
- Added `setIsFirstRun(Boolean)` method to mark setup as complete
- Persists across app restarts using SharedPreferences
### 4. Updated Navigation Flow
- **Updated**: `AppNavigation.kt`
- Dynamically determines start destination based on first-run status
- Shows `server_setup` screen on first run
- Shows `lists` screen on subsequent runs
- Prevents navigation back to setup after completion
## User Experience Flow
### First App Launch:
1. User opens app for the first time
2. Server Setup screen is displayed automatically
3. User enters server URL
4. App validates the endpoint:
- Shows loading indicator during validation
- Displays success checkmark if valid
- Shows error message if invalid
5. On successful validation:
- Server URL is saved
- First-run flag is set to false
- User is navigated to Lists screen
6. User can now use the app normally
### Subsequent Launches:
1. User opens app
2. App checks first-run status
3. Directly shows Lists screen (normal flow)
4. User can still change server URL via Settings
## Validation Process
The validation performs these checks:
1. **URL Format**: Ensures URL starts with http:// or https://
2. **Normalization**: Adds trailing slash if missing
3. **Health Check**: Calls `/health` endpoint (converted from `/api/` path)
4. **Timeout**: 10-second timeout for connection attempts
5. **Error Handling**: User-friendly error messages for common issues
## Error Messages
- "URL must start with http:// or https://"
- "Cannot connect to server. Check URL and network."
- "Connection timeout. Server might be offline."
- "Server returned error: [code]"
- "Invalid URL format"
- "Error: [specific error message]"
## Settings Integration
Users can still:
- Change server URL later via Settings screen
- Test new URLs at any time
- View connection tips in Settings
## Technical Details
### Dependencies Used:
- OkHttp for HTTP requests
- Kotlin Coroutines for async operations
- StateFlow for reactive state management
- SharedPreferences for persistence
### Network Configuration:
- Connection timeout: 10 seconds
- Read timeout: 10 seconds
- Validates against `/health` endpoint
- Supports both HTTP and HTTPS
## Testing Recommendations
1. **First Run Test**:
- Clear app data
- Launch app
- Verify server setup screen appears
2. **Validation Test**:
- Test with valid URL (should succeed)
- Test with invalid URL (should show error)
- Test with unreachable server (should show connection error)
- Test with malformed URL (should show format error)
3. **Subsequent Launch Test**:
- After successful setup, close and reopen app
- Verify Lists screen appears directly
4. **Settings Test**:
- Navigate to Settings
- Change server URL
- Verify changes persist
## Files Modified/Created
### New Files:
- `app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt`
- `app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt`
### Modified Files:
- `app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt`
- `app/src/main/java/com/collabtable/app/ui/navigation/AppNavigation.kt`
## Build Status
✅ Build successful with all tests passing
✅ No compilation errors
✅ All dependencies resolved correctly
+301
View File
@@ -0,0 +1,301 @@
# CollabTable - Updates Summary
## Latest Changes (October 2025)
### Field Editing Feature
**Added ability to edit field types and options after creation**
#### Android Changes:
- **New `EditFieldDialog` composable** in `ListDetailScreen.kt`
- Allows changing field type (STRING, PRICE, DROPDOWN, URL, DATE, TIME, DATETIME)
- Modify dropdown options or currency symbols
- Similar UI to AddFieldDialog for consistency
- **FieldHeader enhancements**
- Added Edit icon button next to field name
- Click to open EditFieldDialog
- Long-press still deletes field
- **ViewModel update**
- New `updateField()` method in `ListDetailViewModel`
- Updates field type and options in database
- Properly maintains field state
**Use case:** Change a STRING field to a DROPDOWN, or update dropdown options without recreating the field.
---
### Password Authentication Feature
**Server-side password protection with Android client support**
#### Server Changes:
1. **Environment Configuration** (`.env.example`)
- Added `SERVER_PASSWORD` environment variable
- Set your secure password in `.env` file
2. **Authentication Middleware** (`src/middleware/auth.ts`)
- Validates password on all `/api/*` routes
- Uses Bearer token format: `Authorization: Bearer <password>`
- `/health` endpoint remains unauthenticated
- Returns 401 for missing or invalid passwords
3. **Server Integration** (`src/index.ts`)
- Auth middleware applied before route handlers
- All API calls now require valid password
- Health check accessible without auth
#### Android Changes:
1. **PreferencesManager updates**
- `getServerPassword()` and `setServerPassword()` methods
- Secure storage in SharedPreferences
- Password persists across app sessions
2. **ServerSetupScreen enhancements**
- New password input field with show/hide toggle
- Password required for setup completion
- Validates password during initial setup
- Tests authentication before saving
3. **ServerSetupViewModel**
- Updated `validateAndSaveServerUrl()` to accept password
- Two-step validation: health check + authenticated request
- Stores password only after successful authentication
- Clear error messages for auth failures
4. **ApiClient authentication**
- New auth interceptor adds `Authorization` header
- Automatically includes password in all API requests
- Retrieves password from PreferencesManager
- No code changes needed in individual API calls
#### Setup Instructions:
**Server:**
```bash
# Set password in .env file
echo "SERVER_PASSWORD=your_secure_password" >> .env
# Restart server
docker-compose restart
```
**Android:**
1. Open app (first time or after reset)
2. Enter server URL
3. Enter server password
4. Tap "Validate and Continue"
5. Password is validated and stored
**Security Notes:**
- Password stored in Android SharedPreferences (encrypted on modern devices)
- Password sent via HTTPS in production (use SSL certificates)
- Consider using more robust auth (OAuth, JWT) for production use
- Current implementation is simple password-based authentication
---
## Previous Changes
### Android App Enhancements
#### 1. Settings Screen Added
- **New Files:**
- `PreferencesManager.kt` - Manages persistent storage of settings
- `SettingsScreen.kt` - UI for configuring server URL
- `SettingsViewModel.kt` - Business logic for settings
- **Features:**
- Settings icon in the main Lists screen toolbar
- Configurable server URL with validation
- Helpful tips for emulator vs physical device URLs
- Persistent storage across app restarts
- Success notification when URL is updated
#### 2. API Client Updates
- `ApiClient.kt` now initializes with saved preferences
- Server URL can be changed at runtime
- Automatically loads saved URL on app start
#### 3. Navigation Updates
- Added settings route to navigation graph
- Settings accessible from Lists screen
### Server Migration to SQLite
#### 1. Database Change
- **Replaced:** MongoDB → SQLite with Sequelize ORM
- **Reason:** Simpler deployment, built-in persistence, no separate database container needed
#### 2. Updated Dependencies
- **Removed:** `mongoose`
- **Added:** `sequelize`, `sqlite3`
#### 3. New Database Configuration
- `database.ts` - Sequelize connection setup
- Database path configurable via `DB_PATH` environment variable
- Default location: `/data/collabtable.db`
#### 4. Model Updates
All models converted from Mongoose schemas to Sequelize models:
- `List.ts`
- `Field.ts`
- `Item.ts`
- `ItemValue.ts`
#### 5. Routes Updates
All route handlers updated to use Sequelize methods:
- `findAll()` instead of `find()`
- `upsert()` instead of `findOneAndUpdate()`
- Sequelize operators (`Op.gt`) instead of MongoDB operators (`$gt`)
### Docker Configuration
#### 1. Simplified Setup
- **Removed:** MongoDB container
- **Changed:** Single server container with SQLite
- **Result:** Faster startup, simpler architecture
#### 2. Persistent Storage
- Docker volume: `sqlite_data`
- Mounted at: `/data` in container
- **Persists across:**
- Container restarts
- Container recreation
- `docker-compose down/up` cycles
#### 3. Environment Variables
- `PORT` - Server port (default: 3000)
- `DB_PATH` - SQLite database path (default: /data/collabtable.db)
- `NODE_ENV` - Environment mode
## How to Use
### Start the Server
```bash
cd CollabTableServer
docker-compose up -d
```
### Configure Android App
1. Open app in Android Studio
2. Build and run on device/emulator
3. Tap Settings icon (⚙️) in top bar
4. Enter server URL:
- **Emulator:** `http://10.0.2.2:3000/api/`
- **Physical Device:** `http://YOUR_IP:3000/api/`
5. Tap "Save"
### Backup Database
```bash
# Backup
docker cp collabtable-server:/data/collabtable.db ./backup.db
# Restore
docker cp ./backup.db collabtable-server:/data/collabtable.db
docker-compose restart
```
## Benefits
### Android App
✅ No hardcoded server URLs
✅ Easy to switch between environments
✅ User-friendly configuration
✅ Clear instructions for different device types
### Server
✅ Simpler deployment (one container vs two)
✅ No database configuration needed
✅ Built-in data persistence
✅ Easy backups (single file)
✅ Lower resource usage
✅ Faster startup time
## Testing
### Test Server Persistence
```bash
# Start server
docker-compose up -d
# Use the app to create data
# ...
# Restart server
docker-compose restart
# Data should still be there!
# Even after complete teardown
docker-compose down
docker-compose up -d
# Data persists because of the volume!
```
### Test Settings
1. Open app
2. Go to Settings
3. Change server URL
4. Close app
5. Reopen app
6. Go to Settings - URL should be saved!
## Architecture Diagram
```
┌─────────────────────┐
│ Android App │
│ (Material 3 UI) │
│ │
│ ┌───────────────┐ │
│ │ Settings │ │
│ │ Screen │ │
│ └───────────────┘ │
│ ↓ │
│ ┌───────────────┐ │
│ │ Preferences │ │
│ │ Manager │ │
│ └───────────────┘ │
│ ↓ │
│ ┌───────────────┐ │
│ │ API Client │ │
│ └───────────────┘ │
└──────────┬──────────┘
│ HTTP/REST
┌─────────────────────┐
│ Docker Container │
│ │
│ ┌───────────────┐ │
│ │ Express.js │ │
│ │ Server │ │
│ └───────┬───────┘ │
│ ↓ │
│ ┌───────────────┐ │
│ │ Sequelize │ │
│ │ ORM │ │
│ └───────┬───────┘ │
│ ↓ │
│ ┌───────────────┐ │
│ │ SQLite │ │
│ │ Database │ │
│ └───────────────┘ │
│ ↓ │
│ /data/collabtable.db
│ ↓ │
└──────────┬──────────┘
Docker Volume
(sqlite_data)
```
## Notes
- TypeScript compilation errors shown are expected until dependencies are installed
- Run `npm install` in the server directory before local development
- The Android app works offline-first and syncs when connected
- Server validates and sanitizes all incoming data
- Timestamps are used for conflict resolution (latest wins)