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:
+26
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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>
|
||||||
|
}
|
||||||
+61
@@ -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>
|
||||||
|
)
|
||||||
+59
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -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() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1977
File diff suppressed because it is too large
Load Diff
+194
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
+227
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+206
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+209
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
^C
|
||||||
@@ -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)?
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Vendored
+88
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
PORT=3000
|
||||||
|
DB_PATH=/data/collabtable.db
|
||||||
|
NODE_ENV=production
|
||||||
|
SERVER_PASSWORD=your_secure_password_here
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Ensure data directory exists
|
||||||
|
mkdir -p /data
|
||||||
Generated
+2351
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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)
|
||||||
Reference in New Issue
Block a user