From 9aa7ed88787d10ec696974950141b5aaf6dde241 Mon Sep 17 00:00:00 2001 From: gabriel20xx Date: Fri, 24 Oct 2025 20:27:18 +0200 Subject: [PATCH] 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 --- .gitignore | 26 + CollabTableAndroid/README.md | 118 + CollabTableAndroid/app/build.gradle | 89 + CollabTableAndroid/app/proguard-rules.pro | 1 + .../app/src/main/AndroidManifest.xml | 27 + .../collabtable/app/CollabTableApplication.kt | 12 + .../java/com/collabtable/app/MainActivity.kt | 27 + .../com/collabtable/app/data/api/ApiClient.kt | 73 + .../app/data/api/CollabTableApi.kt | 44 + .../com/collabtable/app/data/dao/FieldDao.kt | 32 + .../com/collabtable/app/data/dao/ItemDao.kt | 37 + .../collabtable/app/data/dao/ItemValueDao.kt | 32 + .../com/collabtable/app/data/dao/ListDao.kt | 37 + .../app/data/database/CollabTableDatabase.kt | 61 + .../collabtable/app/data/model/CollabList.kt | 13 + .../com/collabtable/app/data/model/Field.kt | 64 + .../com/collabtable/app/data/model/Item.kt | 26 + .../collabtable/app/data/model/ItemValue.kt | 32 + .../app/data/model/ItemWithValues.kt | 13 + .../app/data/model/ListWithFields.kt | 13 + .../data/preferences/PreferencesManager.kt | 59 + .../app/data/repository/SyncRepository.kt | 83 + .../app/ui/navigation/AppNavigation.kt | 89 + .../app/ui/screens/ListDetailScreen.kt | 1977 ++++++++++++++ .../app/ui/screens/ListDetailViewModel.kt | 194 ++ .../collabtable/app/ui/screens/ListsScreen.kt | 216 ++ .../app/ui/screens/ListsViewModel.kt | 89 + .../collabtable/app/ui/screens/LogsScreen.kt | 106 + .../app/ui/screens/ServerSetupScreen.kt | 227 ++ .../app/ui/screens/ServerSetupViewModel.kt | 206 ++ .../app/ui/screens/SettingsScreen.kt | 209 ++ .../app/ui/screens/SettingsViewModel.kt | 33 + .../com/collabtable/app/ui/theme/Color.kt | 11 + .../com/collabtable/app/ui/theme/Theme.kt | 58 + .../java/com/collabtable/app/ui/theme/Type.kt | 17 + .../java/com/collabtable/app/utils/Logger.kt | 72 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../mipmap-hdpi/ic_launcher_foreground.xml | 9 + .../app/src/main/res/values/colors.xml | 4 + .../app/src/main/res/values/strings.xml | 26 + .../app/src/main/res/values/themes.xml | 4 + .../main/res/xml/network_security_config.xml | 9 + CollabTableAndroid/build-errors.txt | 1 + CollabTableAndroid/build-output.txt | 16 + CollabTableAndroid/build.gradle | 7 + CollabTableAndroid/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45633 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + CollabTableAndroid/gradlew.bat | 88 + CollabTableAndroid/settings.gradle | 17 + CollabTableServer/.dockerignore | 9 + CollabTableServer/.env.example | 4 + CollabTableServer/.gitignore | 5 + CollabTableServer/Dockerfile | 24 + CollabTableServer/README.md | 231 ++ CollabTableServer/docker-compose.yml | 24 + CollabTableServer/entrypoint.sh | 2 + CollabTableServer/package-lock.json | 2351 +++++++++++++++++ CollabTableServer/package.json | 29 + CollabTableServer/src/database.ts | 83 + CollabTableServer/src/index.ts | 58 + CollabTableServer/src/middleware/auth.ts | 48 + CollabTableServer/src/routes/fieldRoutes.ts | 75 + CollabTableServer/src/routes/itemRoutes.ts | 103 + CollabTableServer/src/routes/listRoutes.ts | 88 + CollabTableServer/src/routes/syncRoutes.ts | 229 ++ CollabTableServer/src/routes/webRoutes.ts | 329 +++ CollabTableServer/tsconfig.json | 18 + README.md | 143 + SERVER_SETUP_IMPLEMENTATION.md | 137 + UPDATES.md | 301 +++ 72 files changed, 8916 insertions(+) create mode 100644 .gitignore create mode 100644 CollabTableAndroid/README.md create mode 100644 CollabTableAndroid/app/build.gradle create mode 100644 CollabTableAndroid/app/proguard-rules.pro create mode 100644 CollabTableAndroid/app/src/main/AndroidManifest.xml create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/CollabTableApplication.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/MainActivity.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/CollabTableApi.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/FieldDao.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ItemDao.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ItemValueDao.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ListDao.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/CollabList.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Field.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Item.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ItemValue.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ItemWithValues.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ListWithFields.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/navigation/AppNavigation.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/LogsScreen.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsViewModel.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Color.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Theme.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Type.kt create mode 100644 CollabTableAndroid/app/src/main/java/com/collabtable/app/utils/Logger.kt create mode 100644 CollabTableAndroid/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 CollabTableAndroid/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 CollabTableAndroid/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.xml create mode 100644 CollabTableAndroid/app/src/main/res/values/colors.xml create mode 100644 CollabTableAndroid/app/src/main/res/values/strings.xml create mode 100644 CollabTableAndroid/app/src/main/res/values/themes.xml create mode 100644 CollabTableAndroid/app/src/main/res/xml/network_security_config.xml create mode 100644 CollabTableAndroid/build-errors.txt create mode 100644 CollabTableAndroid/build-output.txt create mode 100644 CollabTableAndroid/build.gradle create mode 100644 CollabTableAndroid/gradle.properties create mode 100644 CollabTableAndroid/gradle/wrapper/gradle-wrapper.jar create mode 100644 CollabTableAndroid/gradle/wrapper/gradle-wrapper.properties create mode 100644 CollabTableAndroid/gradlew.bat create mode 100644 CollabTableAndroid/settings.gradle create mode 100644 CollabTableServer/.dockerignore create mode 100644 CollabTableServer/.env.example create mode 100644 CollabTableServer/.gitignore create mode 100644 CollabTableServer/Dockerfile create mode 100644 CollabTableServer/README.md create mode 100644 CollabTableServer/docker-compose.yml create mode 100644 CollabTableServer/entrypoint.sh create mode 100644 CollabTableServer/package-lock.json create mode 100644 CollabTableServer/package.json create mode 100644 CollabTableServer/src/database.ts create mode 100644 CollabTableServer/src/index.ts create mode 100644 CollabTableServer/src/middleware/auth.ts create mode 100644 CollabTableServer/src/routes/fieldRoutes.ts create mode 100644 CollabTableServer/src/routes/itemRoutes.ts create mode 100644 CollabTableServer/src/routes/listRoutes.ts create mode 100644 CollabTableServer/src/routes/syncRoutes.ts create mode 100644 CollabTableServer/src/routes/webRoutes.ts create mode 100644 CollabTableServer/tsconfig.json create mode 100644 README.md create mode 100644 SERVER_SETUP_IMPLEMENTATION.md create mode 100644 UPDATES.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fec6f27 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CollabTableAndroid/README.md b/CollabTableAndroid/README.md new file mode 100644 index 0000000..8fceacc --- /dev/null +++ b/CollabTableAndroid/README.md @@ -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 diff --git a/CollabTableAndroid/app/build.gradle b/CollabTableAndroid/app/build.gradle new file mode 100644 index 0000000..15f484f --- /dev/null +++ b/CollabTableAndroid/app/build.gradle @@ -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' +} diff --git a/CollabTableAndroid/app/proguard-rules.pro b/CollabTableAndroid/app/proguard-rules.pro new file mode 100644 index 0000000..a0f8336 --- /dev/null +++ b/CollabTableAndroid/app/proguard-rules.pro @@ -0,0 +1 @@ +# Add project specific ProGuard rules here. diff --git a/CollabTableAndroid/app/src/main/AndroidManifest.xml b/CollabTableAndroid/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3966920 --- /dev/null +++ b/CollabTableAndroid/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/CollabTableApplication.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/CollabTableApplication.kt new file mode 100644 index 0000000..59dea6a --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/CollabTableApplication.kt @@ -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) + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/MainActivity.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/MainActivity.kt new file mode 100644 index 0000000..71534bd --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/MainActivity.kt @@ -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() + } + } + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt new file mode 100644 index 0000000..ae7787c --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/ApiClient.kt @@ -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) + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/CollabTableApi.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/CollabTableApi.kt new file mode 100644 index 0000000..2e148b8 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/api/CollabTableApi.kt @@ -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, + val fields: List, + val items: List, + val itemValues: List +) + +data class SyncResponse( + val lists: List, + val fields: List, + val items: List, + val itemValues: List, + val serverTimestamp: Long +) + +interface CollabTableApi { + @POST("sync") + suspend fun sync(@Body request: SyncRequest): Response + + @GET("lists") + suspend fun getLists(): Response> + + @GET("lists/{listId}") + suspend fun getList(@Path("listId") listId: String): Response + + @GET("lists/{listId}/fields") + suspend fun getFields(@Path("listId") listId: String): Response> + + @GET("lists/{listId}/items") + suspend fun getItems(@Path("listId") listId: String): Response> + + @GET("items/{itemId}/values") + suspend fun getItemValues(@Path("itemId") itemId: String): Response> +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/FieldDao.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/FieldDao.kt new file mode 100644 index 0000000..3aaba0d --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/FieldDao.kt @@ -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> + + @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) + + @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 +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ItemDao.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ItemDao.kt new file mode 100644 index 0000000..b045547 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ItemDao.kt @@ -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> + + @Transaction + @Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY updatedAt DESC") + fun getItemsWithValuesForList(listId: String): Flow> + + @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) + + @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 +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ItemValueDao.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ItemValueDao.kt new file mode 100644 index 0000000..e70aacb --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ItemValueDao.kt @@ -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> + + @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) + + @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 +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ListDao.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ListDao.kt new file mode 100644 index 0000000..714a0f2 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/dao/ListDao.kt @@ -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> + + @Transaction + @Query("SELECT * FROM lists WHERE id = :listId AND isDeleted = 0") + fun getListWithFields(listId: String): Flow + + @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) + + @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 +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt new file mode 100644 index 0000000..11b331a --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/database/CollabTableDatabase.kt @@ -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 + } + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/CollabList.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/CollabList.kt new file mode 100644 index 0000000..c394786 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/CollabList.kt @@ -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 +) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Field.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Field.kt new file mode 100644 index 0000000..b775dbb --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Field.kt @@ -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 { + return if (fieldType == "DROPDOWN" && fieldOptions.isNotBlank()) { + fieldOptions.split("|") + } else { + emptyList() + } + } + + fun getCurrency(): String { + return if (fieldType == "PRICE" && fieldOptions.isNotBlank()) { + fieldOptions + } else { + "$" + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Item.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Item.kt new file mode 100644 index 0000000..3277cc8 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/Item.kt @@ -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 +) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ItemValue.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ItemValue.kt new file mode 100644 index 0000000..a43dcd3 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ItemValue.kt @@ -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 +) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ItemWithValues.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ItemWithValues.kt new file mode 100644 index 0000000..4399122 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ItemWithValues.kt @@ -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 +) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ListWithFields.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ListWithFields.kt new file mode 100644 index 0000000..26a2181 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/model/ListWithFields.kt @@ -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 +) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt new file mode 100644 index 0000000..076d362 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/preferences/PreferencesManager.kt @@ -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 = _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 } + } + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt new file mode 100644 index 0000000..e899ef4 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/data/repository/SyncRepository.kt @@ -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 = 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) + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/navigation/AppNavigation.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/navigation/AppNavigation.kt new file mode 100644 index 0000000..f898f85 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/navigation/AppNavigation.kt @@ -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() } + ) + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt new file mode 100644 index 0000000..e7e885f --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailScreen.kt @@ -0,0 +1,1977 @@ +package com.collabtable.app.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.collabtable.app.R +import com.collabtable.app.data.database.CollabTableDatabase +import com.collabtable.app.data.model.Field +import com.collabtable.app.data.model.ItemValue +import com.collabtable.app.data.model.ItemWithValues +import java.text.SimpleDateFormat +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun ListDetailScreen( + listId: String, + onNavigateBack: () -> Unit +) { + val context = LocalContext.current + val database = remember { CollabTableDatabase.getDatabase(context) } + val viewModel = remember { ListDetailViewModel(database, listId, context) } + + val list by viewModel.list.collectAsState() + val fields by viewModel.fields.collectAsState() + val items by viewModel.items.collectAsState() + + var showManageColumnsDialog by remember { mutableStateOf(false) } + var showAddItemDialog by remember { mutableStateOf(false) } + var itemToEdit by remember { mutableStateOf(null) } + var showFilterSortDialog by remember { mutableStateOf(false) } + + // Filter/Sort state + var sortField by remember { mutableStateOf(null) } + var sortAscending by remember { mutableStateOf(true) } + var groupByField by remember { mutableStateOf(null) } + var filterField by remember { mutableStateOf(null) } + var filterValue by remember { mutableStateOf("") } + + // Shared scroll state for synchronized horizontal scrolling + val horizontalScrollState = rememberScrollState() + + // Field widths state (resizable columns) + val fieldWidths = remember { mutableStateMapOf() } + + // Initialize field widths + LaunchedEffect(fields) { + fields.forEach { field -> + if (!fieldWidths.containsKey(field.id)) { + fieldWidths[field.id] = 150.dp + } + } + } + + // Apply filtering, sorting, and grouping + val processedItems = remember(items, filterField, filterValue, sortField, sortAscending, groupByField) { + var result = items + + // Apply filter + if (filterField != null && filterValue.isNotBlank()) { + result = result.filter { itemWithValues -> + val value = itemWithValues.values.find { it.fieldId == filterField!!.id }?.value ?: "" + value.contains(filterValue, ignoreCase = true) + } + } + + // Apply sort + if (sortField != null) { + result = result.sortedWith(compareBy { itemWithValues -> + val value = itemWithValues.values.find { it.fieldId == sortField!!.id }?.value ?: "" + if (sortAscending) value else value + }) + if (!sortAscending) { + result = result.reversed() + } + } + + result + } + + // Group items if groupByField is set + val groupedItems = remember(processedItems, groupByField) { + if (groupByField != null) { + processedItems.groupBy { itemWithValues -> + itemWithValues.values.find { it.fieldId == groupByField!!.id }?.value ?: "(Empty)" + } + } else { + mapOf("" to processedItems) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(list?.name ?: "") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { showManageColumnsDialog = true }) { + Icon(Icons.Default.Settings, contentDescription = "Manage Columns") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { showAddItemDialog = true } + ) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_item)) + } + } + ) { padding -> + if (fields.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "No fields yet. Add fields to get started!", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { showManageColumnsDialog = true }) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.add_field)) + } + } + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + // Filter/Sort Toolbar + if (sortField != null || filterField != null || groupByField != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (sortField != null) { + FilterChip( + selected = true, + onClick = { + sortField = null + sortAscending = true + }, + label = { + Text("Sort: ${sortField!!.name} ${if (sortAscending) "↑" else "↓"}") + }, + trailingIcon = { + Icon(Icons.Default.Close, contentDescription = "Remove", + modifier = Modifier.size(16.dp)) + } + ) + } + + if (groupByField != null) { + FilterChip( + selected = true, + onClick = { groupByField = null }, + label = { Text("Group: ${groupByField!!.name}") }, + trailingIcon = { + Icon(Icons.Default.Close, contentDescription = "Remove", + modifier = Modifier.size(16.dp)) + } + ) + } + + if (filterField != null) { + FilterChip( + selected = true, + onClick = { + filterField = null + filterValue = "" + }, + label = { Text("Filter: ${filterField!!.name} = \"$filterValue\"") }, + trailingIcon = { + Icon(Icons.Default.Close, contentDescription = "Remove", + modifier = Modifier.size(16.dp)) + } + ) + } + + IconButton(onClick = { showFilterSortDialog = true }) { + Icon(Icons.Default.Settings, contentDescription = "Configure") + } + } + } + + // Field headers with long-press to delete and resize handles + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(horizontalScrollState), + verticalAlignment = Alignment.CenterVertically + ) { + fields.forEach { field -> + FieldHeader( + field = field, + width = fieldWidths[field.id] ?: 150.dp, + onWidthChange = { delta -> + val currentWidth = fieldWidths[field.id] ?: 150.dp + val newWidth = (currentWidth.value + delta).coerceIn(100f, 400f) + fieldWidths[field.id] = newWidth.dp + } + ) + } + + // Filter/Sort button + if (items.isNotEmpty() && sortField == null && filterField == null && groupByField == null) { + IconButton( + onClick = { showFilterSortDialog = true }, + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Icon( + Icons.Default.Settings, + contentDescription = "Filter/Sort", + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + + // Items list with synchronized scrolling + if (items.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.no_items), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else if (processedItems.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "No items match the current filter", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = { + filterField = null + filterValue = "" + }) { + Text("Clear Filter") + } + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + groupedItems.forEach { (groupName, groupItems) -> + // Show group header if grouping is enabled + if (groupByField != null) { + item(key = "group_$groupName") { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.secondaryContainer + ) { + Text( + text = "$groupName (${groupItems.size})", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(12.dp), + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + } + + // Show items in the group + items(groupItems, key = { it.item.id }) { itemWithValues -> + ItemRow( + fields = fields, + fieldWidths = fieldWidths, + itemWithValues = itemWithValues, + scrollState = horizontalScrollState, + onClick = { itemToEdit = itemWithValues } + ) + } + } + } + } + } + } + } + + if (showManageColumnsDialog) { + ManageColumnsDialog( + fields = fields, + onDismiss = { showManageColumnsDialog = false }, + onAddField = { name, fieldType, fieldOptions -> + viewModel.addField(name, fieldType, fieldOptions) + }, + onUpdateField = { fieldId, fieldType, fieldOptions -> + viewModel.updateField(fieldId, fieldType, fieldOptions) + }, + onDeleteField = { fieldId -> + viewModel.deleteField(fieldId) + }, + onReorderFields = { reorderedFields -> + viewModel.reorderFields(reorderedFields) + } + ) + } + + if (showAddItemDialog) { + AddItemDialog( + fields = fields, + onDismiss = { showAddItemDialog = false }, + onAdd = { fieldValues -> + viewModel.addItemWithValues(fieldValues) + showAddItemDialog = false + } + ) + } + + if (showFilterSortDialog) { + FilterSortDialog( + fields = fields, + currentSortField = sortField, + currentSortAscending = sortAscending, + currentGroupByField = groupByField, + currentFilterField = filterField, + currentFilterValue = filterValue, + onDismiss = { showFilterSortDialog = false }, + onApply = { newSortField, newSortAscending, newGroupByField, newFilterField, newFilterValue -> + sortField = newSortField + sortAscending = newSortAscending + groupByField = newGroupByField + filterField = newFilterField + filterValue = newFilterValue + showFilterSortDialog = false + }, + onClearAll = { + sortField = null + sortAscending = true + groupByField = null + filterField = null + filterValue = "" + showFilterSortDialog = false + } + ) + } + + itemToEdit?.let { itemWithValues -> + EditItemDialog( + fields = fields, + itemWithValues = itemWithValues, + onDismiss = { itemToEdit = null }, + onUpdate = { fieldValues -> + fieldValues.forEach { (valueId, newValue) -> + viewModel.updateItemValue(valueId, newValue) + } + itemToEdit = null + }, + onDelete = { + viewModel.deleteItem(itemWithValues.item.id) + itemToEdit = null + } + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun FieldHeader( + field: Field, + width: Dp, + onWidthChange: (Float) -> Unit +) { + val density = LocalDensity.current + + Box( + modifier = Modifier + .width(width) + .border(1.dp, MaterialTheme.colorScheme.outline) + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = field.name, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.weight(1f) + ) + + // Resize handle + Box( + modifier = Modifier + .size(24.dp) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + with(density) { + onWidthChange(dragAmount.x.toDp().value) + } + } + } + ) { + Icon( + Icons.Default.Menu, + contentDescription = "Resize", + modifier = Modifier + .size(16.dp) + .align(Alignment.Center), + tint = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ItemRow( + fields: List, + fieldWidths: Map, + itemWithValues: ItemWithValues, + scrollState: androidx.compose.foundation.ScrollState, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .horizontalScroll(scrollState), + verticalAlignment = Alignment.CenterVertically + ) { + fields.forEachIndexed { _, field -> + val value = itemWithValues.values.find { it.fieldId == field.id } + val fieldWidth = fieldWidths[field.id] ?: 150.dp + + Box( + modifier = Modifier + .width(fieldWidth) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline + ) + .padding(8.dp) + ) { + when (field.getType()) { + com.collabtable.app.data.model.FieldType.STRING -> { + Text( + text = value?.value ?: "", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + } + + com.collabtable.app.data.model.FieldType.PRICE -> { + Text( + text = if (value?.value.isNullOrBlank()) "" + else "${field.getCurrency()}${value?.value}", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + } + + com.collabtable.app.data.model.FieldType.DROPDOWN -> { + Text( + text = value?.value ?: "", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + } + + com.collabtable.app.data.model.FieldType.URL -> { + val uriHandler = LocalUriHandler.current + val urlValue = value?.value ?: "" + if (urlValue.isNotBlank()) { + ClickableText( + text = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline + ) + ) { + append(urlValue) + } + }, + onClick = { + try { + uriHandler.openUri(urlValue) + } catch (e: Exception) { + // Handle invalid URL + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + style = MaterialTheme.typography.bodyMedium + ) + } else { + Text( + text = "", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + } + } + + com.collabtable.app.data.model.FieldType.DATE -> { + Text( + text = value?.value ?: "", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + } + + com.collabtable.app.data.model.FieldType.TIME -> { + Text( + text = value?.value ?: "", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + } + + com.collabtable.app.data.model.FieldType.DATETIME -> { + Text( + text = value?.value ?: "", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddFieldDialog( + onDismiss: () -> Unit, + onAdd: (String, String, String) -> Unit +) { + var fieldName by remember { mutableStateOf("") } + var selectedFieldType by remember { mutableStateOf("STRING") } + var dropdownOptions by remember { mutableStateOf("") } + var currency by remember { mutableStateOf("$") } + var expanded by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.add_field)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = fieldName, + onValueChange = { fieldName = it }, + label = { Text(stringResource(R.string.field_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + // Field Type Selector + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + value = when(selectedFieldType) { + "STRING" -> "Text" + "PRICE" -> "Price" + "DROPDOWN" -> "Dropdown" + "URL" -> "URL" + "DATE" -> "Date" + "TIME" -> "Time" + "DATETIME" -> "Date & Time" + else -> "Text" + }, + onValueChange = {}, + readOnly = true, + label = { Text("Field Type") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem( + text = { Text("Text") }, + onClick = { + selectedFieldType = "STRING" + expanded = false + } + ) + DropdownMenuItem( + text = { Text("Price") }, + onClick = { + selectedFieldType = "PRICE" + expanded = false + } + ) + DropdownMenuItem( + text = { Text("Dropdown") }, + onClick = { + selectedFieldType = "DROPDOWN" + expanded = false + } + ) + DropdownMenuItem( + text = { Text("URL") }, + onClick = { + selectedFieldType = "URL" + expanded = false + } + ) + DropdownMenuItem( + text = { Text("Date") }, + onClick = { + selectedFieldType = "DATE" + expanded = false + } + ) + DropdownMenuItem( + text = { Text("Time") }, + onClick = { + selectedFieldType = "TIME" + expanded = false + } + ) + DropdownMenuItem( + text = { Text("Date & Time") }, + onClick = { + selectedFieldType = "DATETIME" + expanded = false + } + ) + } + } + + // Price-specific options + if (selectedFieldType == "PRICE") { + OutlinedTextField( + value = currency, + onValueChange = { currency = it }, + label = { Text("Currency Symbol") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("$, €, £, etc.") } + ) + } + + // Dropdown-specific options + if (selectedFieldType == "DROPDOWN") { + OutlinedTextField( + value = dropdownOptions, + onValueChange = { dropdownOptions = it }, + label = { Text("Options (comma-separated)") }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Option 1, Option 2, Option 3") }, + supportingText = { Text("Enter dropdown choices separated by commas") } + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + if (fieldName.isNotBlank()) { + val options = when(selectedFieldType) { + "PRICE" -> currency.trim() + "DROPDOWN" -> dropdownOptions.split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + .joinToString("|") + else -> "" + } + onAdd(fieldName.trim(), selectedFieldType, options) + } + }, + enabled = fieldName.isNotBlank() && + (selectedFieldType != "DROPDOWN" || dropdownOptions.isNotBlank()) + ) { + Text(stringResource(R.string.add)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditFieldDialog( + field: Field, + onDismiss: () -> Unit, + onUpdate: (String, String) -> Unit +) { + var selectedFieldType by remember { mutableStateOf(field.fieldType ?: "STRING") } + var dropdownOptions by remember { mutableStateOf(field.getDropdownOptions().joinToString(", ")) } + var currency by remember { mutableStateOf(field.getCurrency()) } + var expanded by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Edit Field: ${field.name}") }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Field Type Selector + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + value = when(selectedFieldType) { + "STRING" -> "Text" + "PRICE" -> "Price" + "DROPDOWN" -> "Dropdown" + "URL" -> "URL" + "DATE" -> "Date" + "TIME" -> "Time" + "DATETIME" -> "Date & Time" + else -> "Text" + }, + onValueChange = {}, + readOnly = true, + label = { Text("Field Type") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem( + text = { Text("Text") }, + onClick = { + selectedFieldType = "STRING" + expanded = false + } + ) + DropdownMenuItem( + text = { Text("Price") }, + onClick = { + selectedFieldType = "PRICE" + expanded = false + } + ) + DropdownMenuItem( + text = { Text("Dropdown") }, + onClick = { + selectedFieldType = "DROPDOWN" + expanded = false + } + ) + DropdownMenuItem( + text = { Text("URL") }, + onClick = { + selectedFieldType = "URL" + expanded = false + } + ) + DropdownMenuItem( + text = { Text("Date") }, + onClick = { + selectedFieldType = "DATE" + expanded = false + } + ) + DropdownMenuItem( + text = { Text("Time") }, + onClick = { + selectedFieldType = "TIME" + expanded = false + } + ) + DropdownMenuItem( + text = { Text("Date & Time") }, + onClick = { + selectedFieldType = "DATETIME" + expanded = false + } + ) + } + } + + // Price-specific options + if (selectedFieldType == "PRICE") { + OutlinedTextField( + value = currency, + onValueChange = { currency = it }, + label = { Text("Currency Symbol") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("$, €, £, etc.") } + ) + } + + // Dropdown-specific options + if (selectedFieldType == "DROPDOWN") { + OutlinedTextField( + value = dropdownOptions, + onValueChange = { dropdownOptions = it }, + label = { Text("Options (comma-separated)") }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Option 1, Option 2, Option 3") }, + supportingText = { Text("Enter dropdown choices separated by commas") } + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + val options = when(selectedFieldType) { + "PRICE" -> currency.trim() + "DROPDOWN" -> dropdownOptions.split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + .joinToString("|") + else -> "" + } + onUpdate(selectedFieldType, options) + }, + enabled = selectedFieldType != "DROPDOWN" || dropdownOptions.isNotBlank() + ) { + Text("Update") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddItemDialog( + fields: List, + onDismiss: () -> Unit, + onAdd: (Map) -> Unit +) { + val fieldValues = remember { mutableStateMapOf() } + + // Initialize all field values to empty strings + LaunchedEffect(fields) { + fields.forEach { field -> + if (!fieldValues.containsKey(field.id)) { + fieldValues[field.id] = "" + } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.add_item)) }, + text = { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + items(fields, key = { it.id }) { field -> + FieldInput( + field = field, + value = fieldValues[field.id].orEmpty(), + onValueChange = { newValue -> + fieldValues[field.id] = newValue + } + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { onAdd(fieldValues.toMap()) } + ) { + Text(stringResource(R.string.add)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FieldInput( + field: Field, + value: String, + onValueChange: (String) -> Unit +) { + when (field.getType()) { + com.collabtable.app.data.model.FieldType.STRING -> { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + } + + com.collabtable.app.data.model.FieldType.PRICE -> { + OutlinedTextField( + value = value, + onValueChange = { newValue -> + val filtered = newValue.filter { it.isDigit() || it == '.' } + onValueChange(filtered) + }, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + leadingIcon = { Text(field.getCurrency()) }, + placeholder = { Text("0.00") } + ) + } + + com.collabtable.app.data.model.FieldType.DROPDOWN -> { + val options = field.getDropdownOptions() + var dropdownExpanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = dropdownExpanded, + onExpandedChange = { dropdownExpanded = !dropdownExpanded } + ) { + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(field.name) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded) + }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + + ExposedDropdownMenu( + expanded = dropdownExpanded, + onDismissRequest = { dropdownExpanded = false } + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + onValueChange(option) + dropdownExpanded = false + } + ) + } + } + } + } + + com.collabtable.app.data.model.FieldType.URL -> { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(field.name) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("https://example.com") } + ) + } + + com.collabtable.app.data.model.FieldType.DATE -> { + val dateFormat = remember { SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) } + val calendar = remember { Calendar.getInstance() } + var showDatePicker by remember { mutableStateOf(false) } + + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(field.name) }, + modifier = Modifier + .fillMaxWidth() + .clickable { showDatePicker = true }, + singleLine = true, + placeholder = { Text("Select date") }, + trailingIcon = { + IconButton(onClick = { showDatePicker = true }) { + Icon(Icons.Default.DateRange, contentDescription = "Pick date") + } + } + ) + + if (showDatePicker) { + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = if (value.isBlank()) { + System.currentTimeMillis() + } else { + try { + dateFormat.parse(value)?.time + } catch (e: Exception) { + System.currentTimeMillis() + } + } + ) + + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + datePickerState.selectedDateMillis?.let { millis -> + calendar.timeInMillis = millis + val formattedDate = dateFormat.format(calendar.time) + onValueChange(formattedDate) + } + showDatePicker = false + }) { + Text("OK") + } + }, + dismissButton = { + TextButton(onClick = { showDatePicker = false }) { + Text("Cancel") + } + } + ) { + DatePicker(state = datePickerState) + } + } + } + + com.collabtable.app.data.model.FieldType.TIME -> { + val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } + val calendar = remember { Calendar.getInstance() } + var showTimePicker by remember { mutableStateOf(false) } + + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(field.name) }, + modifier = Modifier + .fillMaxWidth() + .clickable { showTimePicker = true }, + singleLine = true, + placeholder = { Text("Select time") }, + trailingIcon = { + IconButton(onClick = { showTimePicker = true }) { + Icon(Icons.Default.AccountBox, contentDescription = "Pick time") + } + } + ) + + if (showTimePicker) { + val timePickerState = rememberTimePickerState( + initialHour = if (value.isBlank()) { + Calendar.getInstance().get(Calendar.HOUR_OF_DAY) + } else { + try { + timeFormat.parse(value)?.let { + calendar.time = it + calendar.get(Calendar.HOUR_OF_DAY) + } ?: 12 + } catch (e: Exception) { + 12 + } + }, + initialMinute = if (value.isBlank()) { + Calendar.getInstance().get(Calendar.MINUTE) + } else { + try { + timeFormat.parse(value)?.let { + calendar.time = it + calendar.get(Calendar.MINUTE) + } ?: 0 + } catch (e: Exception) { + 0 + } + } + ) + + AlertDialog( + onDismissRequest = { showTimePicker = false }, + confirmButton = { + TextButton(onClick = { + calendar.set(Calendar.HOUR_OF_DAY, timePickerState.hour) + calendar.set(Calendar.MINUTE, timePickerState.minute) + val formattedTime = timeFormat.format(calendar.time) + onValueChange(formattedTime) + showTimePicker = false + }) { + Text("OK") + } + }, + dismissButton = { + TextButton(onClick = { showTimePicker = false }) { + Text("Cancel") + } + }, + text = { + TimePicker(state = timePickerState) + } + ) + } + } + + com.collabtable.app.data.model.FieldType.DATETIME -> { + val dateFormat = remember { SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) } + val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } + val calendar = remember { Calendar.getInstance() } + var showDatePicker by remember { mutableStateOf(false) } + var showTimePicker by remember { mutableStateOf(false) } + var tempDate by remember { mutableStateOf("") } + + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(field.name) }, + modifier = Modifier + .fillMaxWidth() + .clickable { showDatePicker = true }, + singleLine = true, + placeholder = { Text("Select date & time") }, + trailingIcon = { + IconButton(onClick = { showDatePicker = true }) { + Icon(Icons.Default.DateRange, contentDescription = "Pick date & time") + } + } + ) + + if (showDatePicker) { + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = System.currentTimeMillis() + ) + + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + datePickerState.selectedDateMillis?.let { millis -> + calendar.timeInMillis = millis + tempDate = dateFormat.format(calendar.time) + } + showDatePicker = false + showTimePicker = true + }) { + Text("Next") + } + }, + dismissButton = { + TextButton(onClick = { showDatePicker = false }) { + Text("Cancel") + } + } + ) { + DatePicker(state = datePickerState) + } + } + + if (showTimePicker) { + val timePickerState = rememberTimePickerState( + initialHour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY), + initialMinute = Calendar.getInstance().get(Calendar.MINUTE) + ) + + AlertDialog( + onDismissRequest = { showTimePicker = false }, + confirmButton = { + TextButton(onClick = { + calendar.set(Calendar.HOUR_OF_DAY, timePickerState.hour) + calendar.set(Calendar.MINUTE, timePickerState.minute) + val time = timeFormat.format(calendar.time) + val dateTime = "$tempDate $time" + onValueChange(dateTime) + showTimePicker = false + }) { + Text("OK") + } + }, + dismissButton = { + TextButton(onClick = { showTimePicker = false }) { + Text("Cancel") + } + }, + text = { + TimePicker(state = timePickerState) + } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManageColumnsDialog( + fields: List, + onDismiss: () -> Unit, + onAddField: (String, String, String) -> Unit, + onUpdateField: (String, String, String) -> Unit, + onDeleteField: (String) -> Unit, + onReorderFields: (List) -> Unit +) { + var showAddField by remember { mutableStateOf(false) } + var fieldToEdit by remember { mutableStateOf(null) } + var fieldToDelete by remember { mutableStateOf(null) } + val reorderedFields = remember { mutableStateListOf().apply { addAll(fields) } } + + // Update reordered list when fields change + LaunchedEffect(fields) { + reorderedFields.clear() + reorderedFields.addAll(fields) + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.95f) + .fillMaxHeight(0.8f), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Manage Columns", + style = MaterialTheme.typography.headlineSmall + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + + Divider() + + // Column list + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed( + items = reorderedFields, + key = { _, field -> field.id } + ) { index, field -> + ColumnItem( + field = field, + onEdit = { fieldToEdit = field }, + onDelete = { fieldToDelete = field }, + onDragStart = { _ -> + // Store the dragged item index if needed + }, + onDragEnd = { fromIndex, toIndex -> + if (fromIndex != toIndex) { + val item = reorderedFields.removeAt(fromIndex) + reorderedFields.add(toIndex, item) + } + }, + currentIndex = index, + totalItems = reorderedFields.size + ) + } + } + + Divider() + + // Add button + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Button( + onClick = { showAddField = true }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Add Column") + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + onReorderFields(reorderedFields.toList()) + onDismiss() + } + ) { + Text("Done") + } + } + } + } + } + + if (showAddField) { + AddFieldDialog( + onDismiss = { showAddField = false }, + onAdd = { name, fieldType, fieldOptions -> + onAddField(name, fieldType, fieldOptions) + showAddField = false + } + ) + } + + fieldToEdit?.let { field -> + EditFieldDialog( + field = field, + onDismiss = { fieldToEdit = null }, + onUpdate = { fieldType, fieldOptions -> + onUpdateField(field.id, fieldType, fieldOptions) + fieldToEdit = null + } + ) + } + + fieldToDelete?.let { field -> + AlertDialog( + onDismissRequest = { fieldToDelete = null }, + title = { Text("Delete Column") }, + text = { Text("Are you sure you want to delete '${field.name}'? All data in this column will be lost.") }, + confirmButton = { + TextButton( + onClick = { + onDeleteField(field.id) + fieldToDelete = null + } + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { fieldToDelete = null }) { + Text("Cancel") + } + } + ) + } +} + +@Composable +fun ColumnItem( + field: Field, + onEdit: () -> Unit, + onDelete: () -> Unit, + onDragStart: (Int) -> Unit, + onDragEnd: (Int, Int) -> Unit, + currentIndex: Int, + totalItems: Int +) { + var isDragging by remember { mutableStateOf(false) } + var dragOffset by remember { mutableStateOf(0f) } + + Card( + modifier = Modifier + .fillMaxWidth() + .offset { IntOffset(0, dragOffset.toInt()) } + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { + isDragging = true + onDragStart(currentIndex) + }, + onDragEnd = { + val itemHeight = 80.dp.toPx() // Approximate item height with spacing + val movedItems = (dragOffset / itemHeight).toInt() + val targetIndex = (currentIndex + movedItems).coerceIn(0, totalItems - 1) + + onDragEnd(currentIndex, targetIndex) + isDragging = false + dragOffset = 0f + }, + onDragCancel = { + isDragging = false + dragOffset = 0f + }, + onDrag = { change, dragAmount -> + change.consume() + dragOffset += dragAmount.y + } + ) + }, + colors = CardDefaults.cardColors( + containerColor = if (isDragging) + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f) + else + MaterialTheme.colorScheme.surfaceVariant + ), + elevation = CardDefaults.cardElevation( + defaultElevation = if (isDragging) 8.dp else 1.dp + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Drag handle on the left + Icon( + Icons.Default.Menu, + contentDescription = "Drag to reorder", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = field.name, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = when (field.getType()) { + com.collabtable.app.data.model.FieldType.STRING -> "Text" + com.collabtable.app.data.model.FieldType.PRICE -> "Price (${field.getCurrency()})" + com.collabtable.app.data.model.FieldType.DROPDOWN -> "Dropdown (${field.getDropdownOptions().size} options)" + com.collabtable.app.data.model.FieldType.URL -> "URL" + com.collabtable.app.data.model.FieldType.DATE -> "Date" + com.collabtable.app.data.model.FieldType.TIME -> "Time" + com.collabtable.app.data.model.FieldType.DATETIME -> "Date & Time" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Edit button + IconButton(onClick = onEdit) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit", + tint = MaterialTheme.colorScheme.primary + ) + } + + // Delete button + IconButton(onClick = onDelete) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.error + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditItemDialog( + fields: List, + itemWithValues: ItemWithValues, + onDismiss: () -> Unit, + onUpdate: (Map) -> Unit, + onDelete: () -> Unit +) { + val fieldValues = remember { mutableStateMapOf() } + var showDeleteConfirmation by remember { mutableStateOf(false) } + + // Initialize field values from existing item + LaunchedEffect(itemWithValues) { + itemWithValues.values.forEach { itemValue -> + fieldValues[itemValue.id] = itemValue.value + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.95f) + .fillMaxHeight(0.85f), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Edit Item", + style = MaterialTheme.typography.headlineSmall + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + + Divider() + + // Fields list + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(fields, key = { it.id }) { field -> + val itemValue = itemWithValues.values.find { it.fieldId == field.id } + if (itemValue != null) { + FieldInput( + field = field, + value = fieldValues[itemValue.id].orEmpty(), + onValueChange = { newValue -> + fieldValues[itemValue.id] = newValue + } + ) + } + } + } + + Divider() + + // Action buttons + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Button( + onClick = { showDeleteConfirmation = true }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Icon(Icons.Default.Delete, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Delete") + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + + Button( + onClick = { + onUpdate(fieldValues.toMap()) + } + ) { + Text("Save") + } + } + } + } + } + } + + if (showDeleteConfirmation) { + AlertDialog( + onDismissRequest = { showDeleteConfirmation = false }, + title = { Text("Delete Item") }, + text = { Text("Are you sure you want to delete this item? This action cannot be undone.") }, + confirmButton = { + TextButton( + onClick = { + onDelete() + showDeleteConfirmation = false + } + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirmation = false }) { + Text("Cancel") + } + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FilterSortDialog( + fields: List, + currentSortField: Field?, + currentSortAscending: Boolean, + currentGroupByField: Field?, + currentFilterField: Field?, + currentFilterValue: String, + onDismiss: () -> Unit, + onApply: (sortField: Field?, sortAscending: Boolean, groupByField: Field?, filterField: Field?, filterValue: String) -> Unit, + onClearAll: () -> Unit +) { + var sortField by remember { mutableStateOf(currentSortField) } + var sortAscending by remember { mutableStateOf(currentSortAscending) } + var groupByField by remember { mutableStateOf(currentGroupByField) } + var filterField by remember { mutableStateOf(currentFilterField) } + var filterValue by remember { mutableStateOf(currentFilterValue) } + + var sortFieldExpanded by remember { mutableStateOf(false) } + var groupByFieldExpanded by remember { mutableStateOf(false) } + var filterFieldExpanded by remember { mutableStateOf(false) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.95f) + .fillMaxHeight(0.7f), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Filter, Sort & Group", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Content + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // Sort Section + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Sort By", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + ExposedDropdownMenuBox( + expanded = sortFieldExpanded, + onExpandedChange = { sortFieldExpanded = it } + ) { + OutlinedTextField( + value = sortField?.name ?: "None", + onValueChange = {}, + readOnly = true, + label = { Text("Sort Field") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = sortFieldExpanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + ) + + ExposedDropdownMenu( + expanded = sortFieldExpanded, + onDismissRequest = { sortFieldExpanded = false } + ) { + DropdownMenuItem( + text = { Text("None") }, + onClick = { + sortField = null + sortFieldExpanded = false + } + ) + fields.forEach { field -> + DropdownMenuItem( + text = { Text(field.name) }, + onClick = { + sortField = field + sortFieldExpanded = false + } + ) + } + } + } + + if (sortField != null) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text("Order:", modifier = Modifier.padding(end = 8.dp)) + FilterChip( + selected = sortAscending, + onClick = { sortAscending = true }, + label = { Text("Ascending") }, + leadingIcon = if (sortAscending) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else null + ) + Spacer(modifier = Modifier.width(8.dp)) + FilterChip( + selected = !sortAscending, + onClick = { sortAscending = false }, + label = { Text("Descending") }, + leadingIcon = if (!sortAscending) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else null + ) + } + } + } + + Divider() + + // Group By Section + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Group By", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + ExposedDropdownMenuBox( + expanded = groupByFieldExpanded, + onExpandedChange = { groupByFieldExpanded = it } + ) { + OutlinedTextField( + value = groupByField?.name ?: "None", + onValueChange = {}, + readOnly = true, + label = { Text("Group By Field") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = groupByFieldExpanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + ) + + ExposedDropdownMenu( + expanded = groupByFieldExpanded, + onDismissRequest = { groupByFieldExpanded = false } + ) { + DropdownMenuItem( + text = { Text("None") }, + onClick = { + groupByField = null + groupByFieldExpanded = false + } + ) + fields.forEach { field -> + DropdownMenuItem( + text = { Text(field.name) }, + onClick = { + groupByField = field + groupByFieldExpanded = false + } + ) + } + } + } + } + + Divider() + + // Filter Section + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Filter", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + ExposedDropdownMenuBox( + expanded = filterFieldExpanded, + onExpandedChange = { filterFieldExpanded = it } + ) { + OutlinedTextField( + value = filterField?.name ?: "None", + onValueChange = {}, + readOnly = true, + label = { Text("Filter Field") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = filterFieldExpanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + ) + + ExposedDropdownMenu( + expanded = filterFieldExpanded, + onDismissRequest = { filterFieldExpanded = false } + ) { + DropdownMenuItem( + text = { Text("None") }, + onClick = { + filterField = null + filterValue = "" + filterFieldExpanded = false + } + ) + fields.forEach { field -> + DropdownMenuItem( + text = { Text(field.name) }, + onClick = { + filterField = field + filterFieldExpanded = false + } + ) + } + } + } + + if (filterField != null) { + OutlinedTextField( + value = filterValue, + onValueChange = { filterValue = it }, + label = { Text("Filter Value") }, + placeholder = { Text("Enter text to filter...") }, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + Divider() + + // Action buttons + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + OutlinedButton(onClick = onClearAll) { + Text("Clear All") + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + + Button( + onClick = { + onApply(sortField, sortAscending, groupByField, filterField, filterValue) + } + ) { + Text("Apply") + } + } + } + } + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt new file mode 100644 index 0000000..d0db62f --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListDetailViewModel.kt @@ -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(null) + val list: StateFlow = _list.asStateFlow() + + private val _fields = MutableStateFlow>(emptyList()) + val fields: StateFlow> = _fields.asStateFlow() + + private val _items = MutableStateFlow>(emptyList()) + val items: StateFlow> = _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) { + 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) { + viewModelScope.launch { + val timestamp = System.currentTimeMillis() + reorderedFields.forEachIndexed { index, field -> + database.fieldDao().updateField( + field.copy( + order = index, + updatedAt = timestamp + ) + ) + } + performSync() + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt new file mode 100644 index 0000000..2e68764 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsScreen.kt @@ -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(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)) +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt new file mode 100644 index 0000000..6edcf3a --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ListsViewModel.kt @@ -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>(emptyList()) + val lists: StateFlow> = _lists.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _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 + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/LogsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/LogsScreen.kt new file mode 100644 index 0000000..9686598 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/LogsScreen.kt @@ -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) + ) +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt new file mode 100644 index 0000000..ab14968 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupScreen.kt @@ -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 + ) + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt new file mode 100644 index 0000000..ae4e8e3 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/ServerSetupViewModel.kt @@ -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 = _isValidating.asStateFlow() + + private val _validationResult = MutableStateFlow(null) + val validationResult: StateFlow = _validationResult.asStateFlow() + + private val _validationError = MutableStateFlow(null) + val validationError: StateFlow = _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 + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..449ef6e --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsScreen.kt @@ -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") + } + } + ) + } + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsViewModel.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsViewModel.kt new file mode 100644 index 0000000..2a5b7f0 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/screens/SettingsViewModel.kt @@ -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 = _serverUrl.asStateFlow() + + private val _showSuccessMessage = MutableStateFlow(false) + val showSuccessMessage: StateFlow = _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 + } +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Color.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Color.kt new file mode 100644 index 0000000..5e603da --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Color.kt @@ -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) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Theme.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Theme.kt new file mode 100644 index 0000000..18ac0b8 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Type.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Type.kt new file mode 100644 index 0000000..ff93a87 --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/ui/theme/Type.kt @@ -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 + ) +) diff --git a/CollabTableAndroid/app/src/main/java/com/collabtable/app/utils/Logger.kt b/CollabTableAndroid/app/src/main/java/com/collabtable/app/utils/Logger.kt new file mode 100644 index 0000000..776fe1d --- /dev/null +++ b/CollabTableAndroid/app/src/main/java/com/collabtable/app/utils/Logger.kt @@ -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>(emptyList()) + val logs: StateFlow> = _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() + } +} diff --git a/CollabTableAndroid/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/CollabTableAndroid/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..84df209 --- /dev/null +++ b/CollabTableAndroid/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/CollabTableAndroid/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/CollabTableAndroid/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..84df209 --- /dev/null +++ b/CollabTableAndroid/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/CollabTableAndroid/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.xml b/CollabTableAndroid/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.xml new file mode 100644 index 0000000..53c7790 --- /dev/null +++ b/CollabTableAndroid/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.xml @@ -0,0 +1,9 @@ + + + diff --git a/CollabTableAndroid/app/src/main/res/values/colors.xml b/CollabTableAndroid/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..8ad3f72 --- /dev/null +++ b/CollabTableAndroid/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #6650A4 + diff --git a/CollabTableAndroid/app/src/main/res/values/strings.xml b/CollabTableAndroid/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..990cc65 --- /dev/null +++ b/CollabTableAndroid/app/src/main/res/values/strings.xml @@ -0,0 +1,26 @@ + + CollabTable + Lists + Create List + List Name + Add Field + Field Name + Add Item + Add + Delete + Save + Cancel + Edit + No lists yet. Create one to get started! + No items yet + Search + Settings + Server URL + Sync + Syncing… + Sync error + Delete List + Delete Field + Delete Item + Are you sure you want to delete this? + diff --git a/CollabTableAndroid/app/src/main/res/values/themes.xml b/CollabTableAndroid/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..198cf01 --- /dev/null +++ b/CollabTableAndroid/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + + + + +
+
+

CollabTable Server - Database Viewer

+ +
+ +
+

Database Statistics

+
+
+
Total Lists
+
-
+
+
+
Total Fields
+
-
+
+
+
Total Items
+
-
+
+
+
Total Values
+
-
+
+
+
+ +
+
Loading...
+
+
+ + + + + `); +}); + +// 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; diff --git a/CollabTableServer/tsconfig.json b/CollabTableServer/tsconfig.json new file mode 100644 index 0000000..f8ca20c --- /dev/null +++ b/CollabTableServer/tsconfig.json @@ -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"] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce927cf --- /dev/null +++ b/README.md @@ -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. diff --git a/SERVER_SETUP_IMPLEMENTATION.md b/SERVER_SETUP_IMPLEMENTATION.md new file mode 100644 index 0000000..3848f57 --- /dev/null +++ b/SERVER_SETUP_IMPLEMENTATION.md @@ -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 diff --git a/UPDATES.md b/UPDATES.md new file mode 100644 index 0000000..f5a66e2 --- /dev/null +++ b/UPDATES.md @@ -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 ` + - `/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)