feat: Implement list management routes with CRUD operations
feat: Add sync routes for data synchronization between client and server feat: Create web routes for serving UI and data visualization chore: Configure TypeScript for the server project docs: Update README with project overview, features, and setup instructions docs: Add server setup implementation details for first-run configuration docs: Summarize updates and changes in the project, including new features and enhancements
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
# CollabTable Android App
|
||||
|
||||
A collaborative list management Android application built with Jetpack Compose and Material 3.
|
||||
|
||||
## Features
|
||||
|
||||
- Create and manage multiple lists
|
||||
- Add custom fields to lists (name, link, price, category, etc.)
|
||||
- Add items with values for each field
|
||||
- Beautiful Material 3 design with dynamic colors
|
||||
- Local Room database for offline support
|
||||
- Automatic synchronization with server
|
||||
- Soft delete support (items can be recovered on server)
|
||||
|
||||
## Architecture
|
||||
|
||||
- **UI Layer**: Jetpack Compose with Material 3
|
||||
- **Business Logic**: ViewModels with Kotlin Coroutines
|
||||
- **Data Layer**: Room database + Retrofit for API calls
|
||||
- **Navigation**: Jetpack Navigation Compose
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Android Studio Hedgehog or later
|
||||
- Android SDK 26 (Android 8.0) or higher
|
||||
- JDK 17
|
||||
|
||||
## Building the Project
|
||||
|
||||
1. Clone the repository
|
||||
2. Open the `CollabTableAndroid` folder in Android Studio
|
||||
3. Wait for Gradle sync to complete
|
||||
4. Run the app on an emulator or physical device
|
||||
|
||||
## Server Configuration
|
||||
|
||||
The app includes a Settings screen where you can configure the server URL:
|
||||
|
||||
1. Tap the Settings icon (⚙️) in the top bar of the Lists screen
|
||||
2. Enter your server URL
|
||||
3. Tap "Save"
|
||||
|
||||
**Default URLs:**
|
||||
- Android Emulator: `http://10.0.2.2:3000/api/`
|
||||
- Physical Device: `http://YOUR_COMPUTER_IP:3000/api/` (replace YOUR_COMPUTER_IP with your actual IP address)
|
||||
|
||||
**Note:** Make sure to include `/api/` at the end of the URL.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
app/src/main/java/com/collabtable/app/
|
||||
├── data/
|
||||
│ ├── api/ # Retrofit API interfaces and client
|
||||
│ ├── dao/ # Room DAOs
|
||||
│ ├── database/ # Room database
|
||||
│ ├── model/ # Data models
|
||||
│ └── repository/ # Repository pattern for data access
|
||||
├── ui/
|
||||
│ ├── navigation/ # Navigation setup
|
||||
│ ├── screens/ # UI screens and ViewModels
|
||||
│ └── theme/ # Material 3 theme
|
||||
├── CollabTableApplication.kt
|
||||
└── MainActivity.kt
|
||||
```
|
||||
|
||||
## Key Technologies
|
||||
|
||||
- **Jetpack Compose**: Modern declarative UI toolkit
|
||||
- **Material 3**: Latest Material Design guidelines
|
||||
- **Room**: Local SQLite database
|
||||
- **Retrofit**: REST API client
|
||||
- **Kotlin Coroutines**: Asynchronous programming
|
||||
- **Flow**: Reactive data streams
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating a List
|
||||
1. Tap the + button on the main screen
|
||||
2. Enter a list name
|
||||
3. Tap "Save"
|
||||
|
||||
### Adding Fields
|
||||
1. Open a list
|
||||
2. Tap the + icon in the top bar
|
||||
3. Enter a field name (e.g., "Name", "Price", "Category")
|
||||
4. Tap "Add"
|
||||
|
||||
### Adding Items
|
||||
1. After adding fields, tap the floating + button
|
||||
2. Fill in values for each field
|
||||
3. Values are saved automatically as you type
|
||||
|
||||
### Deleting
|
||||
- Tap the delete icon next to any list, field, or item
|
||||
- Confirm the deletion
|
||||
|
||||
## Synchronization
|
||||
|
||||
The app includes sync functionality that:
|
||||
- Sends local changes to the server
|
||||
- Receives changes from other clients
|
||||
- Resolves conflicts using timestamps (latest wins)
|
||||
|
||||
To trigger sync, you can call the `SyncRepository.performSync()` method from your code.
|
||||
|
||||
## Building for Release
|
||||
|
||||
1. Update the version in `app/build.gradle`
|
||||
2. Create a keystore for signing
|
||||
3. Build the release APK:
|
||||
```bash
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,89 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'com.google.devtools.ksp'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.collabtable.app'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.collabtable.app"
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '21'
|
||||
}
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion '1.5.4'
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core Android
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
|
||||
implementation 'androidx.activity:activity-compose:1.8.1'
|
||||
|
||||
// Compose
|
||||
implementation platform('androidx.compose:compose-bom:2023.10.01')
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.ui:ui-graphics'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
implementation 'androidx.compose.material3:material3:1.1.2'
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
implementation 'androidx.navigation:navigation-compose:2.7.5'
|
||||
|
||||
// ViewModel
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2'
|
||||
|
||||
// Room
|
||||
implementation 'androidx.room:room-runtime:2.6.1'
|
||||
implementation 'androidx.room:room-ktx:2.6.1'
|
||||
ksp 'androidx.room:room-compiler:2.6.1'
|
||||
|
||||
// Retrofit
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
|
||||
|
||||
// Coroutines
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
|
||||
// Testing
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation platform('androidx.compose:compose-bom:2023.10.01')
|
||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".CollabTableApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.CollabTable"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.CollabTable">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.collabtable.app
|
||||
|
||||
import android.app.Application
|
||||
import com.collabtable.app.data.api.ApiClient
|
||||
|
||||
class CollabTableApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Initialize API client with saved server URL
|
||||
ApiClient.initialize(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.collabtable.app
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.collabtable.app.ui.navigation.AppNavigation
|
||||
import com.collabtable.app.ui.theme.CollabTableTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
CollabTableTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
AppNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.collabtable.app.data.api
|
||||
|
||||
import android.content.Context
|
||||
import com.collabtable.app.data.preferences.PreferencesManager
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object ApiClient {
|
||||
private var baseUrl: String = "http://10.0.2.2:3000/api/" // Default for Android emulator
|
||||
private var retrofit: Retrofit? = null
|
||||
private var context: Context? = null
|
||||
|
||||
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
private val authInterceptor = Interceptor { chain ->
|
||||
val request = chain.request()
|
||||
val password = context?.let {
|
||||
PreferencesManager.getInstance(it).getServerPassword()
|
||||
}
|
||||
|
||||
val newRequest = if (!password.isNullOrBlank()) {
|
||||
request.newBuilder()
|
||||
.header("Authorization", "Bearer $password")
|
||||
.build()
|
||||
} else {
|
||||
request
|
||||
}
|
||||
|
||||
chain.proceed(newRequest)
|
||||
}
|
||||
|
||||
private val okHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private fun buildRetrofit(): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
}
|
||||
|
||||
fun initialize(context: Context) {
|
||||
this.context = context.applicationContext
|
||||
val prefs = PreferencesManager.getInstance(context)
|
||||
baseUrl = prefs.getServerUrl()
|
||||
retrofit = buildRetrofit()
|
||||
}
|
||||
|
||||
fun setBaseUrl(url: String) {
|
||||
baseUrl = if (url.endsWith("/")) url else "$url/"
|
||||
retrofit = buildRetrofit()
|
||||
}
|
||||
|
||||
val api: CollabTableApi
|
||||
get() {
|
||||
if (retrofit == null) {
|
||||
retrofit = buildRetrofit()
|
||||
}
|
||||
return retrofit!!.create(CollabTableApi::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.collabtable.app.data.api
|
||||
|
||||
import com.collabtable.app.data.model.CollabList
|
||||
import com.collabtable.app.data.model.Field
|
||||
import com.collabtable.app.data.model.Item
|
||||
import com.collabtable.app.data.model.ItemValue
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.*
|
||||
|
||||
data class SyncRequest(
|
||||
val lastSyncTimestamp: Long,
|
||||
val lists: List<CollabList>,
|
||||
val fields: List<Field>,
|
||||
val items: List<Item>,
|
||||
val itemValues: List<ItemValue>
|
||||
)
|
||||
|
||||
data class SyncResponse(
|
||||
val lists: List<CollabList>,
|
||||
val fields: List<Field>,
|
||||
val items: List<Item>,
|
||||
val itemValues: List<ItemValue>,
|
||||
val serverTimestamp: Long
|
||||
)
|
||||
|
||||
interface CollabTableApi {
|
||||
@POST("sync")
|
||||
suspend fun sync(@Body request: SyncRequest): Response<SyncResponse>
|
||||
|
||||
@GET("lists")
|
||||
suspend fun getLists(): Response<List<CollabList>>
|
||||
|
||||
@GET("lists/{listId}")
|
||||
suspend fun getList(@Path("listId") listId: String): Response<CollabList>
|
||||
|
||||
@GET("lists/{listId}/fields")
|
||||
suspend fun getFields(@Path("listId") listId: String): Response<List<Field>>
|
||||
|
||||
@GET("lists/{listId}/items")
|
||||
suspend fun getItems(@Path("listId") listId: String): Response<List<Item>>
|
||||
|
||||
@GET("items/{itemId}/values")
|
||||
suspend fun getItemValues(@Path("itemId") itemId: String): Response<List<ItemValue>>
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.collabtable.app.data.dao
|
||||
|
||||
import androidx.room.*
|
||||
import com.collabtable.app.data.model.Field
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface FieldDao {
|
||||
@Query("SELECT * FROM fields WHERE listId = :listId AND isDeleted = 0 ORDER BY `order`")
|
||||
fun getFieldsForList(listId: String): Flow<List<Field>>
|
||||
|
||||
@Query("SELECT * FROM fields WHERE id = :fieldId")
|
||||
suspend fun getFieldById(fieldId: String): Field?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertField(field: Field)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertFields(fields: List<Field>)
|
||||
|
||||
@Update
|
||||
suspend fun updateField(field: Field)
|
||||
|
||||
@Query("UPDATE fields SET isDeleted = 1, updatedAt = :timestamp WHERE id = :fieldId")
|
||||
suspend fun softDeleteField(fieldId: String, timestamp: Long)
|
||||
|
||||
@Query("DELETE FROM fields WHERE id = :fieldId")
|
||||
suspend fun deleteField(fieldId: String)
|
||||
|
||||
@Query("SELECT * FROM fields WHERE updatedAt >= :since")
|
||||
suspend fun getFieldsUpdatedSince(since: Long): List<Field>
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.collabtable.app.data.dao
|
||||
|
||||
import androidx.room.*
|
||||
import com.collabtable.app.data.model.Item
|
||||
import com.collabtable.app.data.model.ItemWithValues
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ItemDao {
|
||||
@Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY updatedAt DESC")
|
||||
fun getItemsForList(listId: String): Flow<List<Item>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY updatedAt DESC")
|
||||
fun getItemsWithValuesForList(listId: String): Flow<List<ItemWithValues>>
|
||||
|
||||
@Query("SELECT * FROM items WHERE id = :itemId")
|
||||
suspend fun getItemById(itemId: String): Item?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertItem(item: Item)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertItems(items: List<Item>)
|
||||
|
||||
@Update
|
||||
suspend fun updateItem(item: Item)
|
||||
|
||||
@Query("UPDATE items SET isDeleted = 1, updatedAt = :timestamp WHERE id = :itemId")
|
||||
suspend fun softDeleteItem(itemId: String, timestamp: Long)
|
||||
|
||||
@Query("DELETE FROM items WHERE id = :itemId")
|
||||
suspend fun deleteItem(itemId: String)
|
||||
|
||||
@Query("SELECT * FROM items WHERE updatedAt >= :since")
|
||||
suspend fun getItemsUpdatedSince(since: Long): List<Item>
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.collabtable.app.data.dao
|
||||
|
||||
import androidx.room.*
|
||||
import com.collabtable.app.data.model.ItemValue
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ItemValueDao {
|
||||
@Query("SELECT * FROM item_values WHERE itemId = :itemId")
|
||||
fun getValuesForItem(itemId: String): Flow<List<ItemValue>>
|
||||
|
||||
@Query("SELECT * FROM item_values WHERE id = :valueId")
|
||||
suspend fun getValueById(valueId: String): ItemValue?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertValue(value: ItemValue)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertValues(values: List<ItemValue>)
|
||||
|
||||
@Update
|
||||
suspend fun updateValue(value: ItemValue)
|
||||
|
||||
@Query("DELETE FROM item_values WHERE id = :valueId")
|
||||
suspend fun deleteValue(valueId: String)
|
||||
|
||||
@Query("DELETE FROM item_values WHERE itemId = :itemId")
|
||||
suspend fun deleteValuesForItem(itemId: String)
|
||||
|
||||
@Query("SELECT * FROM item_values WHERE updatedAt >= :since")
|
||||
suspend fun getValuesUpdatedSince(since: Long): List<ItemValue>
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.collabtable.app.data.dao
|
||||
|
||||
import androidx.room.*
|
||||
import com.collabtable.app.data.model.CollabList
|
||||
import com.collabtable.app.data.model.ListWithFields
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ListDao {
|
||||
@Query("SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC")
|
||||
fun getAllLists(): Flow<List<CollabList>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM lists WHERE id = :listId AND isDeleted = 0")
|
||||
fun getListWithFields(listId: String): Flow<ListWithFields?>
|
||||
|
||||
@Query("SELECT * FROM lists WHERE id = :listId")
|
||||
suspend fun getListById(listId: String): CollabList?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertList(list: CollabList)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertLists(lists: List<CollabList>)
|
||||
|
||||
@Update
|
||||
suspend fun updateList(list: CollabList)
|
||||
|
||||
@Query("UPDATE lists SET isDeleted = 1, updatedAt = :timestamp WHERE id = :listId")
|
||||
suspend fun softDeleteList(listId: String, timestamp: Long)
|
||||
|
||||
@Query("DELETE FROM lists WHERE id = :listId")
|
||||
suspend fun deleteList(listId: String)
|
||||
|
||||
@Query("SELECT * FROM lists WHERE updatedAt >= :since")
|
||||
suspend fun getListsUpdatedSince(since: Long): List<CollabList>
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.collabtable.app.data.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.collabtable.app.data.dao.*
|
||||
import com.collabtable.app.data.model.*
|
||||
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE fields ADD COLUMN fieldType TEXT NOT NULL DEFAULT 'STRING'")
|
||||
database.execSQL("ALTER TABLE fields ADD COLUMN fieldOptions TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
CollabList::class,
|
||||
Field::class,
|
||||
Item::class,
|
||||
ItemValue::class
|
||||
],
|
||||
version = 2,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class CollabTableDatabase : RoomDatabase() {
|
||||
abstract fun listDao(): ListDao
|
||||
abstract fun fieldDao(): FieldDao
|
||||
abstract fun itemDao(): ItemDao
|
||||
abstract fun itemValueDao(): ItemValueDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: CollabTableDatabase? = null
|
||||
|
||||
fun getDatabase(context: Context): CollabTableDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
CollabTableDatabase::class.java,
|
||||
"collab_table_database"
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2)
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
|
||||
fun clearDatabase(context: Context) {
|
||||
synchronized(this) {
|
||||
INSTANCE?.close()
|
||||
context.deleteDatabase("collab_table_database")
|
||||
INSTANCE = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.collabtable.app.data.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "lists")
|
||||
data class CollabList(
|
||||
@PrimaryKey val id: String,
|
||||
val name: String,
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long,
|
||||
val isDeleted: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.collabtable.app.data.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
enum class FieldType {
|
||||
STRING,
|
||||
PRICE,
|
||||
DROPDOWN,
|
||||
URL,
|
||||
DATE,
|
||||
TIME,
|
||||
DATETIME
|
||||
}
|
||||
|
||||
@Entity(
|
||||
tableName = "fields",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = CollabList::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["listId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [Index("listId")]
|
||||
)
|
||||
data class Field(
|
||||
@PrimaryKey val id: String,
|
||||
val listId: String,
|
||||
val name: String,
|
||||
val fieldType: String = "STRING", // STRING, PRICE, DROPDOWN
|
||||
val fieldOptions: String = "", // JSON string for dropdown options or currency for price
|
||||
val order: Int,
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long,
|
||||
val isDeleted: Boolean = false
|
||||
) {
|
||||
fun getType(): FieldType {
|
||||
return try {
|
||||
FieldType.valueOf(fieldType)
|
||||
} catch (e: Exception) {
|
||||
FieldType.STRING
|
||||
}
|
||||
}
|
||||
|
||||
fun getDropdownOptions(): List<String> {
|
||||
return if (fieldType == "DROPDOWN" && fieldOptions.isNotBlank()) {
|
||||
fieldOptions.split("|")
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrency(): String {
|
||||
return if (fieldType == "PRICE" && fieldOptions.isNotBlank()) {
|
||||
fieldOptions
|
||||
} else {
|
||||
"$"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.collabtable.app.data.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "items",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = CollabList::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["listId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [Index("listId")]
|
||||
)
|
||||
data class Item(
|
||||
@PrimaryKey val id: String,
|
||||
val listId: String,
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long,
|
||||
val isDeleted: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.collabtable.app.data.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "item_values",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Item::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["itemId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
),
|
||||
ForeignKey(
|
||||
entity = Field::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["fieldId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [Index("itemId"), Index("fieldId")]
|
||||
)
|
||||
data class ItemValue(
|
||||
@PrimaryKey val id: String,
|
||||
val itemId: String,
|
||||
val fieldId: String,
|
||||
val value: String,
|
||||
val updatedAt: Long
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.collabtable.app.data.model
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Relation
|
||||
|
||||
data class ItemWithValues(
|
||||
@Embedded val item: Item,
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "itemId"
|
||||
)
|
||||
val values: List<ItemValue>
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.collabtable.app.data.model
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Relation
|
||||
|
||||
data class ListWithFields(
|
||||
@Embedded val list: CollabList,
|
||||
@Relation(
|
||||
parentColumn = "id",
|
||||
entityColumn = "listId"
|
||||
)
|
||||
val fields: List<Field>
|
||||
)
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.collabtable.app.data.preferences
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class PreferencesManager(context: Context) {
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
private val _serverUrl = MutableStateFlow(getServerUrl())
|
||||
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
|
||||
|
||||
fun getServerUrl(): String {
|
||||
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL
|
||||
}
|
||||
|
||||
fun setServerUrl(url: String) {
|
||||
val cleanUrl = url.trim().let {
|
||||
if (it.endsWith("/")) it else "$it/"
|
||||
}
|
||||
prefs.edit().putString(KEY_SERVER_URL, cleanUrl).apply()
|
||||
_serverUrl.value = cleanUrl
|
||||
}
|
||||
|
||||
fun isFirstRun(): Boolean {
|
||||
return prefs.getBoolean(KEY_FIRST_RUN, true)
|
||||
}
|
||||
|
||||
fun setIsFirstRun(isFirstRun: Boolean) {
|
||||
prefs.edit().putBoolean(KEY_FIRST_RUN, isFirstRun).apply()
|
||||
}
|
||||
|
||||
fun getServerPassword(): String? {
|
||||
return prefs.getString(KEY_SERVER_PASSWORD, null)
|
||||
}
|
||||
|
||||
fun setServerPassword(password: String) {
|
||||
prefs.edit().putString(KEY_SERVER_PASSWORD, password).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_SERVER_URL = "server_url"
|
||||
private const val KEY_FIRST_RUN = "first_run"
|
||||
private const val KEY_SERVER_PASSWORD = "server_password"
|
||||
private const val DEFAULT_SERVER_URL = "http://10.0.2.2:3000/api/"
|
||||
|
||||
@Volatile
|
||||
private var instance: PreferencesManager? = null
|
||||
|
||||
fun getInstance(context: Context): PreferencesManager {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: PreferencesManager(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.collabtable.app.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.collabtable.app.data.api.ApiClient
|
||||
import com.collabtable.app.data.api.SyncRequest
|
||||
import com.collabtable.app.data.database.CollabTableDatabase
|
||||
import com.collabtable.app.utils.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class SyncRepository(context: Context) {
|
||||
private val database = CollabTableDatabase.getDatabase(context)
|
||||
private val api = ApiClient.api
|
||||
private val prefs = context.getSharedPreferences("collab_table_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
private fun getLastSyncTimestamp(): Long {
|
||||
return prefs.getLong("last_sync_timestamp", 0)
|
||||
}
|
||||
|
||||
private fun setLastSyncTimestamp(timestamp: Long) {
|
||||
prefs.edit().putLong("last_sync_timestamp", timestamp).apply()
|
||||
}
|
||||
|
||||
suspend fun performSync(): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val lastSync = getLastSyncTimestamp()
|
||||
Logger.d("Sync", "Starting sync with lastSyncTimestamp: $lastSync")
|
||||
|
||||
// Gather local changes since last sync
|
||||
val localLists = database.listDao().getListsUpdatedSince(lastSync)
|
||||
val localFields = database.fieldDao().getFieldsUpdatedSince(lastSync)
|
||||
val localItems = database.itemDao().getItemsUpdatedSince(lastSync)
|
||||
val localValues = database.itemValueDao().getValuesUpdatedSince(lastSync)
|
||||
|
||||
Logger.i("Sync", "Sending to server: ${localLists.size} lists, ${localFields.size} fields, ${localItems.size} items")
|
||||
|
||||
// Log details of lists being sent
|
||||
localLists.forEach { list ->
|
||||
Logger.d("Sync", " List: ${list.id} - ${list.name} (updated: ${list.updatedAt})")
|
||||
}
|
||||
|
||||
// Send to server and get updates
|
||||
val syncRequest = SyncRequest(
|
||||
lastSyncTimestamp = lastSync,
|
||||
lists = localLists,
|
||||
fields = localFields,
|
||||
items = localItems,
|
||||
itemValues = localValues
|
||||
)
|
||||
|
||||
val response = api.sync(syncRequest)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val syncResponse = response.body()!!
|
||||
|
||||
Logger.i("Sync", "Received from server: ${syncResponse.lists.size} lists, ${syncResponse.fields.size} fields, ${syncResponse.items.size} items")
|
||||
|
||||
// Log details of lists being received
|
||||
syncResponse.lists.forEach { list ->
|
||||
Logger.d("Sync", " Inserting list: ${list.id} - ${list.name} (updated: ${list.updatedAt})")
|
||||
}
|
||||
|
||||
// Apply server changes to local database
|
||||
database.listDao().insertLists(syncResponse.lists)
|
||||
database.fieldDao().insertFields(syncResponse.fields)
|
||||
database.itemDao().insertItems(syncResponse.items)
|
||||
database.itemValueDao().insertValues(syncResponse.itemValues)
|
||||
|
||||
// Update last sync timestamp
|
||||
setLastSyncTimestamp(syncResponse.serverTimestamp)
|
||||
|
||||
Logger.i("Sync", "Sync completed successfully. Timestamp updated to: ${syncResponse.serverTimestamp}")
|
||||
return@withContext Result.success(Unit)
|
||||
} else {
|
||||
Logger.e("Sync", "Sync failed with code: ${response.code()}, message: ${response.message()}")
|
||||
return@withContext Result.failure(Exception("Sync failed: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e("Sync", "Sync error", e)
|
||||
return@withContext Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package com.collabtable.app.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.collabtable.app.data.preferences.PreferencesManager
|
||||
import com.collabtable.app.ui.screens.ListDetailScreen
|
||||
import com.collabtable.app.ui.screens.ListsScreen
|
||||
import com.collabtable.app.ui.screens.LogsScreen
|
||||
import com.collabtable.app.ui.screens.ServerSetupScreen
|
||||
import com.collabtable.app.ui.screens.SettingsScreen
|
||||
|
||||
@Composable
|
||||
fun AppNavigation() {
|
||||
val navController = rememberNavController()
|
||||
val context = LocalContext.current
|
||||
val preferencesManager = remember { PreferencesManager.getInstance(context) }
|
||||
|
||||
// Determine start destination based on first run
|
||||
val startDestination = if (preferencesManager.isFirstRun()) {
|
||||
"server_setup"
|
||||
} else {
|
||||
"lists"
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination
|
||||
) {
|
||||
composable("server_setup") {
|
||||
ServerSetupScreen(
|
||||
onSetupComplete = {
|
||||
// Navigate to lists and clear back stack
|
||||
navController.navigate("lists") {
|
||||
popUpTo("server_setup") { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable("lists") {
|
||||
ListsScreen(
|
||||
onNavigateToList = { listId ->
|
||||
navController.navigate("list/$listId")
|
||||
},
|
||||
onNavigateToSettings = {
|
||||
navController.navigate("settings")
|
||||
},
|
||||
onNavigateToLogs = {
|
||||
navController.navigate("logs")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "list/{listId}",
|
||||
arguments = listOf(navArgument("listId") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val listId = backStackEntry.arguments?.getString("listId") ?: return@composable
|
||||
ListDetailScreen(
|
||||
listId = listId,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable("settings") {
|
||||
SettingsScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onLeaveServer = {
|
||||
// Navigate back to server setup and clear entire back stack
|
||||
navController.navigate("server_setup") {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable("logs") {
|
||||
LogsScreen(
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1977
File diff suppressed because it is too large
Load Diff
+194
@@ -0,0 +1,194 @@
|
||||
package com.collabtable.app.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.collabtable.app.data.database.CollabTableDatabase
|
||||
import com.collabtable.app.data.model.*
|
||||
import com.collabtable.app.data.repository.SyncRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
class ListDetailViewModel(
|
||||
private val database: CollabTableDatabase,
|
||||
private val listId: String,
|
||||
private val context: Context
|
||||
) : ViewModel() {
|
||||
private val _list = MutableStateFlow<CollabList?>(null)
|
||||
val list: StateFlow<CollabList?> = _list.asStateFlow()
|
||||
|
||||
private val _fields = MutableStateFlow<List<Field>>(emptyList())
|
||||
val fields: StateFlow<List<Field>> = _fields.asStateFlow()
|
||||
|
||||
private val _items = MutableStateFlow<List<ItemWithValues>>(emptyList())
|
||||
val items: StateFlow<List<ItemWithValues>> = _items.asStateFlow()
|
||||
|
||||
private val syncRepository = SyncRepository(context)
|
||||
|
||||
init {
|
||||
loadListData()
|
||||
startPeriodicSync()
|
||||
}
|
||||
|
||||
private fun loadListData() {
|
||||
viewModelScope.launch {
|
||||
database.listDao().getListWithFields(listId).collect { listWithFields ->
|
||||
_list.value = listWithFields?.list
|
||||
_fields.value = listWithFields?.fields ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
database.itemDao().getItemsWithValuesForList(listId).collect { itemsData ->
|
||||
_items.value = itemsData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPeriodicSync() {
|
||||
viewModelScope.launch {
|
||||
while (true) {
|
||||
performSync()
|
||||
kotlinx.coroutines.delay(5000) // Sync every 5 seconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performSync() {
|
||||
syncRepository.performSync()
|
||||
}
|
||||
|
||||
fun addField(name: String, fieldType: String = "STRING", fieldOptions: String = "") {
|
||||
viewModelScope.launch {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val maxOrder = _fields.value.maxOfOrNull { it.order } ?: -1
|
||||
val newField = Field(
|
||||
id = UUID.randomUUID().toString(),
|
||||
listId = listId,
|
||||
name = name,
|
||||
fieldType = fieldType,
|
||||
fieldOptions = fieldOptions,
|
||||
order = maxOrder + 1,
|
||||
createdAt = timestamp,
|
||||
updatedAt = timestamp
|
||||
)
|
||||
database.fieldDao().insertField(newField)
|
||||
performSync()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteField(fieldId: String) {
|
||||
viewModelScope.launch {
|
||||
database.fieldDao().softDeleteField(fieldId, System.currentTimeMillis())
|
||||
performSync()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateField(fieldId: String, fieldType: String, fieldOptions: String) {
|
||||
viewModelScope.launch {
|
||||
val field = database.fieldDao().getFieldById(fieldId)
|
||||
if (field != null) {
|
||||
database.fieldDao().updateField(
|
||||
field.copy(
|
||||
fieldType = fieldType,
|
||||
fieldOptions = fieldOptions,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
performSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addItem() {
|
||||
viewModelScope.launch {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val newItem = Item(
|
||||
id = UUID.randomUUID().toString(),
|
||||
listId = listId,
|
||||
createdAt = timestamp,
|
||||
updatedAt = timestamp
|
||||
)
|
||||
database.itemDao().insertItem(newItem)
|
||||
|
||||
// Create empty values for each field
|
||||
val values = _fields.value.map { field ->
|
||||
ItemValue(
|
||||
id = UUID.randomUUID().toString(),
|
||||
itemId = newItem.id,
|
||||
fieldId = field.id,
|
||||
value = "",
|
||||
updatedAt = timestamp
|
||||
)
|
||||
}
|
||||
database.itemValueDao().insertValues(values)
|
||||
performSync()
|
||||
}
|
||||
}
|
||||
|
||||
fun addItemWithValues(fieldValues: Map<String, String>) {
|
||||
viewModelScope.launch {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val newItem = Item(
|
||||
id = UUID.randomUUID().toString(),
|
||||
listId = listId,
|
||||
createdAt = timestamp,
|
||||
updatedAt = timestamp
|
||||
)
|
||||
database.itemDao().insertItem(newItem)
|
||||
|
||||
// Create values for each field with the provided values
|
||||
val values = _fields.value.map { field ->
|
||||
ItemValue(
|
||||
id = UUID.randomUUID().toString(),
|
||||
itemId = newItem.id,
|
||||
fieldId = field.id,
|
||||
value = fieldValues[field.id] ?: "",
|
||||
updatedAt = timestamp
|
||||
)
|
||||
}
|
||||
database.itemValueDao().insertValues(values)
|
||||
performSync()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateItemValue(itemValueId: String, newValue: String) {
|
||||
viewModelScope.launch {
|
||||
val itemValue = database.itemValueDao().getValueById(itemValueId)
|
||||
if (itemValue != null) {
|
||||
database.itemValueDao().updateValue(
|
||||
itemValue.copy(
|
||||
value = newValue,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
performSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteItem(itemId: String) {
|
||||
viewModelScope.launch {
|
||||
database.itemDao().softDeleteItem(itemId, System.currentTimeMillis())
|
||||
performSync()
|
||||
}
|
||||
}
|
||||
|
||||
fun reorderFields(reorderedFields: List<Field>) {
|
||||
viewModelScope.launch {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
reorderedFields.forEachIndexed { index, field ->
|
||||
database.fieldDao().updateField(
|
||||
field.copy(
|
||||
order = index,
|
||||
updatedAt = timestamp
|
||||
)
|
||||
)
|
||||
}
|
||||
performSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package com.collabtable.app.ui.screens
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.List
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.collabtable.app.R
|
||||
import com.collabtable.app.data.database.CollabTableDatabase
|
||||
import com.collabtable.app.data.model.CollabList
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ListsScreen(
|
||||
onNavigateToList: (String) -> Unit,
|
||||
onNavigateToSettings: () -> Unit,
|
||||
onNavigateToLogs: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val database = remember { CollabTableDatabase.getDatabase(context) }
|
||||
val viewModel = remember { ListsViewModel(database, context) }
|
||||
|
||||
val lists by viewModel.lists.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
var listToDelete by remember { mutableStateOf<CollabList?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.lists)) },
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.manualSync() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Sync")
|
||||
}
|
||||
IconButton(onClick = onNavigateToLogs) {
|
||||
Icon(Icons.Default.List, contentDescription = "Logs")
|
||||
}
|
||||
IconButton(onClick = onNavigateToSettings) {
|
||||
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = { showCreateDialog = true }
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.create_list))
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
if (lists.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_lists),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(lists, key = { it.id }) { list ->
|
||||
ListItem(
|
||||
list = list,
|
||||
onListClick = { onNavigateToList(list.id) },
|
||||
onDeleteClick = { listToDelete = list }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateDialog) {
|
||||
CreateListDialog(
|
||||
onDismiss = { showCreateDialog = false },
|
||||
onCreate = { name ->
|
||||
viewModel.createList(name)
|
||||
showCreateDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
listToDelete?.let { list ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { listToDelete = null },
|
||||
title = { Text(stringResource(R.string.delete_list)) },
|
||||
text = { Text(stringResource(R.string.confirm_delete)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.deleteList(list.id)
|
||||
listToDelete = null
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.delete))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { listToDelete = null }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ListItem(
|
||||
list: CollabList,
|
||||
onListClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onListClick)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = list.name,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = formatDate(list.updatedAt),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDeleteClick) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.delete),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateListDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (String) -> Unit
|
||||
) {
|
||||
var listName by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.create_list)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = listName,
|
||||
onValueChange = { listName = it },
|
||||
label = { Text(stringResource(R.string.list_name)) },
|
||||
singleLine = true
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { if (listName.isNotBlank()) onCreate(listName.trim()) },
|
||||
enabled = listName.isNotBlank()
|
||||
) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatDate(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault())
|
||||
return sdf.format(Date(timestamp))
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.collabtable.app.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.collabtable.app.data.database.CollabTableDatabase
|
||||
import com.collabtable.app.data.model.CollabList
|
||||
import com.collabtable.app.data.repository.SyncRepository
|
||||
import com.collabtable.app.utils.Logger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
class ListsViewModel(
|
||||
private val database: CollabTableDatabase,
|
||||
private val context: Context
|
||||
) : ViewModel() {
|
||||
private val _lists = MutableStateFlow<List<CollabList>>(emptyList())
|
||||
val lists: StateFlow<List<CollabList>> = _lists.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val syncRepository = SyncRepository(context)
|
||||
|
||||
init {
|
||||
loadLists()
|
||||
// Do initial sync immediately, then start periodic sync
|
||||
viewModelScope.launch {
|
||||
performSync() // Initial sync on startup
|
||||
startPeriodicSync()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadLists() {
|
||||
viewModelScope.launch {
|
||||
database.listDao().getAllLists().collect { listData ->
|
||||
_lists.value = listData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startPeriodicSync() {
|
||||
while (true) {
|
||||
kotlinx.coroutines.delay(5000) // Wait 5 seconds before next sync
|
||||
performSync()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performSync() {
|
||||
syncRepository.performSync()
|
||||
}
|
||||
|
||||
fun createList(name: String) {
|
||||
viewModelScope.launch {
|
||||
Logger.i("ListsViewModel", "Creating list: $name")
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val newList = CollabList(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = name,
|
||||
createdAt = timestamp,
|
||||
updatedAt = timestamp
|
||||
)
|
||||
database.listDao().insertList(newList)
|
||||
// Sync immediately after creating
|
||||
performSync()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteList(listId: String) {
|
||||
viewModelScope.launch {
|
||||
Logger.i("ListsViewModel", "Deleting list: $listId")
|
||||
database.listDao().softDeleteList(listId, System.currentTimeMillis())
|
||||
// Sync immediately after deleting
|
||||
performSync()
|
||||
}
|
||||
}
|
||||
|
||||
fun manualSync() {
|
||||
viewModelScope.launch {
|
||||
Logger.i("ListsViewModel", "Manual sync triggered")
|
||||
_isLoading.value = true
|
||||
performSync()
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.collabtable.app.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.collabtable.app.utils.LogEntry
|
||||
import com.collabtable.app.utils.LogLevel
|
||||
import com.collabtable.app.utils.Logger
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LogsScreen(
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val logs by Logger.logs.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
LaunchedEffect(logs.size) {
|
||||
if (logs.isNotEmpty()) {
|
||||
listState.animateScrollToItem(logs.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Logs") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { Logger.clear() }) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Clear logs")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
if (logs.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No logs yet",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.background(Color(0xFF1E1E1E)),
|
||||
contentPadding = PaddingValues(8.dp)
|
||||
) {
|
||||
items(logs) { log ->
|
||||
LogItem(log)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LogItem(log: LogEntry) {
|
||||
val color = when (log.level) {
|
||||
LogLevel.DEBUG -> Color(0xFF808080)
|
||||
LogLevel.INFO -> Color(0xFF4FC3F7)
|
||||
LogLevel.WARN -> Color(0xFFFFA726)
|
||||
LogLevel.ERROR -> Color(0xFFEF5350)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = log.toFormattedString(),
|
||||
color = color,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
package com.collabtable.app.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.collabtable.app.data.preferences.PreferencesManager
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ServerSetupScreen(
|
||||
onSetupComplete: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val preferencesManager = remember { PreferencesManager.getInstance(context) }
|
||||
val viewModel = remember { ServerSetupViewModel(preferencesManager) }
|
||||
|
||||
var serverUrl by remember { mutableStateOf("") }
|
||||
var serverPassword by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
val isValidating by viewModel.isValidating.collectAsState()
|
||||
val validationResult by viewModel.validationResult.collectAsState()
|
||||
val validationError by viewModel.validationError.collectAsState()
|
||||
|
||||
LaunchedEffect(validationResult) {
|
||||
if (validationResult == true) {
|
||||
// Validation successful, navigate to main screen
|
||||
onSetupComplete()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("Server Setup") },
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Welcome text
|
||||
Text(
|
||||
text = "Welcome to CollabTable!",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Please configure your server connection to get started.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Server URL input
|
||||
OutlinedTextField(
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("example.com or 10.0.2.2:3000") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isValidating,
|
||||
isError = validationError != null,
|
||||
supportingText = {
|
||||
when {
|
||||
validationError != null -> Text(
|
||||
text = validationError ?: "",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
else -> Text("Enter hostname with optional port (default: 80/443)")
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
when {
|
||||
isValidating -> CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
validationResult == true -> Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = "Valid",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
validationError != null -> Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = "Error",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Server Password input
|
||||
OutlinedTextField(
|
||||
value = serverPassword,
|
||||
onValueChange = { serverPassword = it },
|
||||
label = { Text("Server Password") },
|
||||
placeholder = { Text("Enter server password") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isValidating,
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
|
||||
contentDescription = if (passwordVisible) "Hide password" else "Show password"
|
||||
)
|
||||
}
|
||||
},
|
||||
supportingText = {
|
||||
Text("Password required for server authentication")
|
||||
}
|
||||
)
|
||||
|
||||
// Validate and continue button
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.validateAndSaveServerUrl(serverUrl.trim(), serverPassword.trim())
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = serverUrl.isNotBlank() && serverPassword.isNotBlank() && !isValidating
|
||||
) {
|
||||
if (isValidating) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Validating...")
|
||||
} else {
|
||||
Text("Validate and Continue")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Tips card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Connection Tips:",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• For Android Emulator: 10.0.2.2:3000",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• For Physical Device: YOUR_IP:3000",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• For domains: example.com (uses port 80/443)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• Port is optional, defaults to 80 (HTTP) or 443 (HTTPS)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• Just enter hostname:port, no need for http:// or /api/",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• Make sure the server is running and accessible",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Note about changing later
|
||||
Text(
|
||||
text = "You can change this URL later in Settings",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
package com.collabtable.app.ui.screens
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.collabtable.app.data.preferences.PreferencesManager
|
||||
import com.collabtable.app.data.api.ApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import android.os.NetworkOnMainThreadException
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ServerSetupViewModel(
|
||||
private val preferencesManager: PreferencesManager
|
||||
) : ViewModel() {
|
||||
private val _isValidating = MutableStateFlow(false)
|
||||
val isValidating: StateFlow<Boolean> = _isValidating.asStateFlow()
|
||||
|
||||
private val _validationResult = MutableStateFlow<Boolean?>(null)
|
||||
val validationResult: StateFlow<Boolean?> = _validationResult.asStateFlow()
|
||||
|
||||
private val _validationError = MutableStateFlow<String?>(null)
|
||||
val validationError: StateFlow<String?> = _validationError.asStateFlow()
|
||||
|
||||
private val okHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun validateAndSaveServerUrl(url: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_isValidating.value = true
|
||||
_validationError.value = null
|
||||
_validationResult.value = null
|
||||
|
||||
try {
|
||||
// Normalize URL - add protocol if missing
|
||||
var normalizedUrl = url.trim()
|
||||
|
||||
// Check if URL has a protocol
|
||||
val hasProtocol = normalizedUrl.startsWith("http://") || normalizedUrl.startsWith("https://")
|
||||
val hasPort = normalizedUrl.contains(":")
|
||||
|
||||
// Add http:// if no protocol specified
|
||||
if (!hasProtocol) {
|
||||
normalizedUrl = "http://$normalizedUrl"
|
||||
}
|
||||
|
||||
// Add default port if no port specified
|
||||
if (!hasPort || normalizedUrl.matches(Regex("^https?://.+$")) && !normalizedUrl.substringAfter("://").contains(":")) {
|
||||
// Extract protocol and host
|
||||
val protocol = if (normalizedUrl.startsWith("https://")) "https" else "http"
|
||||
val hostAndPath = normalizedUrl.substringAfter("://")
|
||||
val host = if (hostAndPath.contains("/")) hostAndPath.substringBefore("/") else hostAndPath
|
||||
val path = if (hostAndPath.contains("/")) "/" + hostAndPath.substringAfter("/") else ""
|
||||
|
||||
// Add default port based on protocol
|
||||
val defaultPort = if (protocol == "https") "443" else "80"
|
||||
normalizedUrl = "$protocol://$host:$defaultPort$path"
|
||||
}
|
||||
|
||||
// Remove trailing slash if present for consistent handling
|
||||
normalizedUrl = normalizedUrl.trimEnd('/')
|
||||
|
||||
// Add /api/ if not present
|
||||
if (!normalizedUrl.contains("/api")) {
|
||||
normalizedUrl = "$normalizedUrl/api"
|
||||
}
|
||||
|
||||
// Ensure it ends with /
|
||||
normalizedUrl = if (normalizedUrl.endsWith("/")) normalizedUrl else "$normalizedUrl/"
|
||||
|
||||
// Validate password is not empty
|
||||
if (password.isBlank()) {
|
||||
_validationError.value = "Password cannot be empty"
|
||||
_isValidating.value = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Try to reach the health endpoint (no auth required)
|
||||
val healthUrl = normalizedUrl.replace("/api/", "/health")
|
||||
val healthRequest = Request.Builder()
|
||||
.url(healthUrl)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
try {
|
||||
// Execute network call on IO dispatcher
|
||||
val healthResponse = withContext(Dispatchers.IO) {
|
||||
okHttpClient.newCall(healthRequest).execute()
|
||||
}
|
||||
|
||||
if (!healthResponse.isSuccessful) {
|
||||
val errorMessage = when (healthResponse.code) {
|
||||
404 -> "Health endpoint not found. Check server URL."
|
||||
500 -> "Server internal error. Check server logs."
|
||||
503 -> "Service unavailable. Server may be starting up."
|
||||
else -> "Server returned HTTP ${healthResponse.code}"
|
||||
}
|
||||
healthResponse.close()
|
||||
_validationError.value = errorMessage
|
||||
_isValidating.value = false
|
||||
return@launch
|
||||
}
|
||||
healthResponse.close()
|
||||
|
||||
// Try to upgrade to HTTPS if currently using HTTP
|
||||
var finalUrl = normalizedUrl
|
||||
if (normalizedUrl.startsWith("http://")) {
|
||||
// Replace http:// with https:// and change port 80 to 443
|
||||
var httpsUrl = normalizedUrl.replace("http://", "https://")
|
||||
if (httpsUrl.contains(":80/")) {
|
||||
httpsUrl = httpsUrl.replace(":80/", ":443/")
|
||||
}
|
||||
|
||||
val httpsHealthUrl = httpsUrl.replace("/api/", "/health")
|
||||
val httpsHealthRequest = Request.Builder()
|
||||
.url(httpsHealthUrl)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
try {
|
||||
// Execute HTTPS check on IO dispatcher
|
||||
val httpsResponse = withContext(Dispatchers.IO) {
|
||||
okHttpClient.newCall(httpsHealthRequest).execute()
|
||||
}
|
||||
if (httpsResponse.isSuccessful) {
|
||||
// HTTPS works! Upgrade to it
|
||||
finalUrl = httpsUrl
|
||||
}
|
||||
httpsResponse.close()
|
||||
} catch (e: Exception) {
|
||||
// HTTPS doesn't work, stick with HTTP
|
||||
}
|
||||
}
|
||||
|
||||
// Now validate the password by making an authenticated request
|
||||
val testUrl = "${finalUrl}lists"
|
||||
val authRequest = Request.Builder()
|
||||
.url(testUrl)
|
||||
.header("Authorization", "Bearer $password")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
// Execute auth check on IO dispatcher
|
||||
val authResponse = withContext(Dispatchers.IO) {
|
||||
okHttpClient.newCall(authRequest).execute()
|
||||
}
|
||||
|
||||
if (authResponse.isSuccessful) {
|
||||
// Password is valid, save both URL and password
|
||||
preferencesManager.setServerUrl(finalUrl)
|
||||
preferencesManager.setServerPassword(password)
|
||||
preferencesManager.setIsFirstRun(false)
|
||||
ApiClient.setBaseUrl(finalUrl)
|
||||
_validationResult.value = true
|
||||
} else if (authResponse.code == 401) {
|
||||
_validationError.value = "Invalid password. Please check and try again."
|
||||
} else {
|
||||
val errorMessage = when (authResponse.code) {
|
||||
403 -> "Access forbidden. Check server configuration."
|
||||
404 -> "API endpoint not found. Check URL path."
|
||||
500 -> "Server error. Check server logs."
|
||||
503 -> "Service unavailable. Try again later."
|
||||
else -> "Server returned HTTP ${authResponse.code}"
|
||||
}
|
||||
_validationError.value = errorMessage
|
||||
}
|
||||
authResponse.close()
|
||||
} catch (e: NetworkOnMainThreadException) {
|
||||
_validationError.value = "Network operation attempted on main thread. This is a developer error - please report this bug."
|
||||
} catch (e: UnknownHostException) {
|
||||
_validationError.value = "Cannot resolve hostname. Check URL spelling and network connection."
|
||||
} catch (e: ConnectException) {
|
||||
_validationError.value = "Cannot connect to server. Check if server is running and URL is correct."
|
||||
} catch (e: SocketTimeoutException) {
|
||||
_validationError.value = "Connection timeout after 10 seconds. Server may be offline or unreachable."
|
||||
} catch (e: IOException) {
|
||||
_validationError.value = "Network error: ${e.message ?: "Unable to communicate with server"}"
|
||||
} catch (e: Exception) {
|
||||
_validationError.value = "Unexpected error: ${e.javaClass.simpleName} - ${e.message ?: "Unknown cause"}"
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
_validationError.value = "Invalid URL format: ${e.message ?: "Malformed URL"}"
|
||||
} catch (e: Exception) {
|
||||
_validationError.value = "Configuration error: ${e.javaClass.simpleName} - ${e.message ?: "Unable to process URL"}"
|
||||
} finally {
|
||||
_isValidating.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearValidationState() {
|
||||
_validationResult.value = null
|
||||
_validationError.value = null
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
package com.collabtable.app.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.collabtable.app.R
|
||||
import com.collabtable.app.data.database.CollabTableDatabase
|
||||
import com.collabtable.app.data.preferences.PreferencesManager
|
||||
import com.collabtable.app.data.repository.SyncRepository
|
||||
import com.collabtable.app.utils.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onLeaveServer: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val preferencesManager = remember { PreferencesManager.getInstance(context) }
|
||||
val syncRepository = remember { SyncRepository(context) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val serverUrl = preferencesManager.getServerUrl()
|
||||
var showLeaveDialog by remember { mutableStateOf(false) }
|
||||
var isLeaving by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.settings)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Server Connection",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Connected to:",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = serverUrl,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = { showLeaveDialog = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isLeaving,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
) {
|
||||
if (isLeaving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
color = MaterialTheme.colorScheme.onError,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Leaving...")
|
||||
} else {
|
||||
Text("Leave Server")
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "About Leave Server:",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• Disconnects from the current server",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• Clears stored server URL and password",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• Deletes all local data (lists, fields, items)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "• Returns to server setup screen",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showLeaveDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showLeaveDialog = false },
|
||||
title = { Text("Leave Server?") },
|
||||
text = {
|
||||
Text("All your data will be synced to the server before disconnecting. After leaving, all local data will be deleted and you'll need to set up a new connection. This action cannot be undone.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
isLeaving = true
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
Logger.i("Settings", "Performing final sync before leaving server")
|
||||
|
||||
// Perform final sync to ensure all data is uploaded
|
||||
withContext(Dispatchers.IO) {
|
||||
syncRepository.performSync()
|
||||
}
|
||||
|
||||
Logger.i("Settings", "Final sync completed, clearing local data")
|
||||
|
||||
// Clear database in background
|
||||
withContext(Dispatchers.IO) {
|
||||
CollabTableDatabase.clearDatabase(context)
|
||||
}
|
||||
|
||||
// Clear preferences
|
||||
preferencesManager.setServerUrl("")
|
||||
preferencesManager.setServerPassword("")
|
||||
preferencesManager.setIsFirstRun(true)
|
||||
|
||||
Logger.i("Settings", "Left server successfully")
|
||||
|
||||
showLeaveDialog = false
|
||||
onLeaveServer()
|
||||
} catch (e: Exception) {
|
||||
// Handle error if needed
|
||||
Logger.e("Settings", "Error leaving server", e)
|
||||
isLeaving = false
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isLeaving,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Leave Server")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showLeaveDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.collabtable.app.ui.screens
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.collabtable.app.data.preferences.PreferencesManager
|
||||
import com.collabtable.app.data.api.ApiClient
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsViewModel(
|
||||
private val preferencesManager: PreferencesManager
|
||||
) : ViewModel() {
|
||||
private val _serverUrl = MutableStateFlow(preferencesManager.getServerUrl())
|
||||
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
|
||||
|
||||
private val _showSuccessMessage = MutableStateFlow(false)
|
||||
val showSuccessMessage: StateFlow<Boolean> = _showSuccessMessage.asStateFlow()
|
||||
|
||||
fun updateServerUrl(url: String) {
|
||||
viewModelScope.launch {
|
||||
preferencesManager.setServerUrl(url)
|
||||
ApiClient.setBaseUrl(url)
|
||||
_serverUrl.value = preferencesManager.getServerUrl()
|
||||
_showSuccessMessage.value = true
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSuccessMessage() {
|
||||
_showSuccessMessage.value = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.collabtable.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.collabtable.app.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CollabTableTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.primary.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.collabtable.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.collabtable.app.utils
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
data class LogEntry(
|
||||
val timestamp: Long,
|
||||
val level: LogLevel,
|
||||
val tag: String,
|
||||
val message: String
|
||||
) {
|
||||
fun toFormattedString(): String {
|
||||
val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||
val time = dateFormat.format(Date(timestamp))
|
||||
return "[$time] [${level.name}] [$tag] $message"
|
||||
}
|
||||
}
|
||||
|
||||
enum class LogLevel {
|
||||
DEBUG, INFO, WARN, ERROR
|
||||
}
|
||||
|
||||
object Logger {
|
||||
private val _logs = MutableStateFlow<List<LogEntry>>(emptyList())
|
||||
val logs: StateFlow<List<LogEntry>> = _logs.asStateFlow()
|
||||
|
||||
private const val MAX_LOGS = 500
|
||||
|
||||
fun d(tag: String, message: String) {
|
||||
log(LogLevel.DEBUG, tag, message)
|
||||
Log.d(tag, message)
|
||||
}
|
||||
|
||||
fun i(tag: String, message: String) {
|
||||
log(LogLevel.INFO, tag, message)
|
||||
Log.i(tag, message)
|
||||
}
|
||||
|
||||
fun w(tag: String, message: String) {
|
||||
log(LogLevel.WARN, tag, message)
|
||||
Log.w(tag, message)
|
||||
}
|
||||
|
||||
fun e(tag: String, message: String, throwable: Throwable? = null) {
|
||||
val msg = if (throwable != null) "$message: ${throwable.message}" else message
|
||||
log(LogLevel.ERROR, tag, msg)
|
||||
if (throwable != null) {
|
||||
Log.e(tag, message, throwable)
|
||||
} else {
|
||||
Log.e(tag, message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun log(level: LogLevel, tag: String, message: String) {
|
||||
val entry = LogEntry(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
level = level,
|
||||
tag = tag,
|
||||
message = message
|
||||
)
|
||||
|
||||
_logs.value = (_logs.value + entry).takeLast(MAX_LOGS)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_logs.value = emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M32,32L76,32L76,40L32,40ZM32,48L76,48L76,56L32,56ZM32,64L76,64L76,72L32,72Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#6650A4</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,26 @@
|
||||
<resources>
|
||||
<string name="app_name">CollabTable</string>
|
||||
<string name="lists">Lists</string>
|
||||
<string name="create_list">Create List</string>
|
||||
<string name="list_name">List Name</string>
|
||||
<string name="add_field">Add Field</string>
|
||||
<string name="field_name">Field Name</string>
|
||||
<string name="add_item">Add Item</string>
|
||||
<string name="add">Add</string>
|
||||
<string name="delete">Delete</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="edit">Edit</string>
|
||||
<string name="no_lists">No lists yet. Create one to get started!</string>
|
||||
<string name="no_items">No items yet</string>
|
||||
<string name="search">Search</string>
|
||||
<string name="settings">Settings</string>
|
||||
<string name="server_url">Server URL</string>
|
||||
<string name="sync">Sync</string>
|
||||
<string name="syncing">Syncing…</string>
|
||||
<string name="sync_error">Sync error</string>
|
||||
<string name="delete_list">Delete List</string>
|
||||
<string name="delete_field">Delete Field</string>
|
||||
<string name="delete_item">Delete Item</string>
|
||||
<string name="confirm_delete">Are you sure you want to delete this?</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.CollabTable" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!-- Allow cleartext traffic for all domains -->
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1 @@
|
||||
^C
|
||||
@@ -0,0 +1,16 @@
|
||||
Initialized native services in: C:\Users\gabri\.gradle\native
|
||||
Initialized jansi services in: C:\Users\gabri\.gradle\native
|
||||
Received JVM installation metadata from 'C:\Programs\Java 22 JDK': {JAVA_HOME=C:\Programs\Java 22 JDK, JAVA_VERSION=22.0.1, JAVA_VENDOR=Oracle Corporation, RUNTIME_NAME=Java(TM) SE Runtime Environment, RUNTIME_VERSION=22.0.1+8-16, VM_NAME=Java HotSpot(TM) 64-Bit Server VM, VM_VERSION=22.0.1+8-16, VM_VENDOR=Oracle Corporation, OS_ARCH=amd64}
|
||||
Removing 0 daemon stop events from registry
|
||||
Starting a Gradle Daemon (subsequent builds will be faster)
|
||||
Starting process 'Gradle build daemon'. Working directory: C:\Users\gabri\.gradle\daemon\8.5 Command: C:\Programs\Java 22 JDK\bin\java.exe --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.prefs/java.util.prefs=ALL-UNNAMED --add-opens=java.base/java.nio.charset=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.util.concurrent.atomic=ALL-UNNAMED -Xmx2048m -Dfile.encoding=UTF-8 -Duser.country=GB -Duser.language=en -Duser.variant -cp C:\Users\gabri\.gradle\wrapper\dists\gradle-8.5-bin\5t9huq95ubn472n8rpzujfbqh\gradle-8.5\lib\gradle-launcher-8.5.jar -javaagent:C:\Users\gabri\.gradle\wrapper\dists\gradle-8.5-bin\5t9huq95ubn472n8rpzujfbqh\gradle-8.5\lib\agents\gradle-instrumentation-agent-8.5.jar org.gradle.launcher.daemon.bootstrap.GradleDaemon 8.5
|
||||
Successfully started process 'Gradle build daemon'
|
||||
An attempt to start the daemon took 8.895 secs.
|
||||
The client will now receive all logging from the daemon (pid: 35040). The daemon log file: C:\Users\gabri\.gradle\daemon\8.5\daemon-35040.out.log
|
||||
Starting build in new daemon [memory: 2 GiB]
|
||||
Using 16 worker leases.
|
||||
Received JVM installation metadata from 'C:\Programs\Java 22 JDK': {JAVA_HOME=C:\Programs\Java 22 JDK, JAVA_VERSION=22.0.1, JAVA_VENDOR=Oracle Corporation, RUNTIME_NAME=Java(TM) SE Runtime Environment, RUNTIME_VERSION=22.0.1+8-16, VM_NAME=Java HotSpot(TM) 64-Bit Server VM, VM_VERSION=22.0.1+8-16, VM_VENDOR=Oracle Corporation, OS_ARCH=amd64}
|
||||
Watching the file system is configured to be enabled if available
|
||||
Now considering [C:\Users\gabri\VSCode Projects\CollabTable\CollabTableAndroid] as hierarchies to watch
|
||||
File system watching is active
|
||||
Terminate batch job (Y/N)?
|
||||
@@ -0,0 +1,7 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
id 'com.android.application' version '8.3.0' apply false
|
||||
id 'com.android.library' version '8.3.0' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '1.9.20' apply false
|
||||
id 'com.google.devtools.ksp' version '1.9.20-1.0.14' apply false
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
Vendored
+88
@@ -0,0 +1,88 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if %ERRORLEVEL% neq 0 exit /b %ERRORLEVEL%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,17 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "CollabTable"
|
||||
include ':app'
|
||||
Reference in New Issue
Block a user