From 31bd2b1ceb63666ec60c17123fa5f1f153b28bdd Mon Sep 17 00:00:00 2001 From: Fabian Wolter Date: Mon, 23 Feb 2026 17:05:47 +0100 Subject: [PATCH 1/2] feat: Initial Database stuff for `FolderPair`s This commit adds the initial Database design to save and load `FolderPair`s locally --- app/build.gradle.kts | 6 ++ .../fabisahne/cloudsync/data/AppDatabase.kt | 27 +++++++++ .../fabisahne/cloudsync/data/FolderPair.kt | 14 +++++ .../fabisahne/cloudsync/data/FolderPairDao.kt | 26 ++++++++ .../cloudsync/ui/tabs/FolderPairTab.kt | 59 +++++++++++++++++-- gradle/libs.versions.toml | 13 +++- 6 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/fabisahne/cloudsync/data/AppDatabase.kt create mode 100644 app/src/main/java/com/fabisahne/cloudsync/data/FolderPair.kt create mode 100644 app/src/main/java/com/fabisahne/cloudsync/data/FolderPairDao.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 22c0199..798b270 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) alias(libs.plugins.jetbrains.kotlin.serialization) + alias(libs.plugins.ksp) } android { @@ -63,4 +64,9 @@ dependencies { implementation(libs.kotlinx.serialization.core) implementation(libs.bundles.voyager) + + // Room + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) } \ No newline at end of file diff --git a/app/src/main/java/com/fabisahne/cloudsync/data/AppDatabase.kt b/app/src/main/java/com/fabisahne/cloudsync/data/AppDatabase.kt new file mode 100644 index 0000000..ebc4994 --- /dev/null +++ b/app/src/main/java/com/fabisahne/cloudsync/data/AppDatabase.kt @@ -0,0 +1,27 @@ +package com.fabisahne.cloudsync.data + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database(entities = [FolderPair::class], version = 1, exportSchema = false) +abstract class AppDatabase : RoomDatabase() { + abstract fun folderPairDao(): FolderPairDao + + companion object { + private var INSTANCE: AppDatabase? = null + + fun getDatabase(context: Context): AppDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "cloud_sync_database", + ).build() + INSTANCE = instance + instance + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fabisahne/cloudsync/data/FolderPair.kt b/app/src/main/java/com/fabisahne/cloudsync/data/FolderPair.kt new file mode 100644 index 0000000..b941b0c --- /dev/null +++ b/app/src/main/java/com/fabisahne/cloudsync/data/FolderPair.kt @@ -0,0 +1,14 @@ +package com.fabisahne.cloudsync.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "folder_pairs") +data class FolderPair( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val name: String, + val localDir: String, + val cloudPath: String, + val createdAt: Long = System.currentTimeMillis() +) diff --git a/app/src/main/java/com/fabisahne/cloudsync/data/FolderPairDao.kt b/app/src/main/java/com/fabisahne/cloudsync/data/FolderPairDao.kt new file mode 100644 index 0000000..c9a8d51 --- /dev/null +++ b/app/src/main/java/com/fabisahne/cloudsync/data/FolderPairDao.kt @@ -0,0 +1,26 @@ +package com.fabisahne.cloudsync.data + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow + +@Dao +interface FolderPairDao { + @Query("SELECT * FROM folder_pairs ORDER BY createdAt DESC") + fun getAll(): Flow> + + @Query("SELECT * FROM folder_pairs WHERE id = :id") + suspend fun getById(id: Long): FolderPair? + + @Insert + suspend fun insert(folderPair: FolderPair): Long + + @Update + suspend fun update(folderPair: FolderPair) + + @Delete + suspend fun delete(folderPair: FolderPair) +} \ No newline at end of file diff --git a/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/FolderPairTab.kt b/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/FolderPairTab.kt index 34bf629..1e7c97b 100644 --- a/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/FolderPairTab.kt +++ b/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/FolderPairTab.kt @@ -1,21 +1,33 @@ package com.fabisahne.cloudsync.ui.tabs -import androidx.compose.foundation.layout.absoluteOffset -import androidx.compose.foundation.layout.absolutePadding +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.FolderCopy import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalContext import cafe.adriel.voyager.navigator.tab.Tab import cafe.adriel.voyager.navigator.tab.TabOptions +import com.fabisahne.cloudsync.data.AppDatabase +import com.fabisahne.cloudsync.data.FolderPair +import kotlinx.coroutines.launch +import androidx.core.net.toUri object FolderPairTab : Tab { override val options: TabOptions @@ -43,17 +55,56 @@ object FolderPairTab : Tab { // ) { // Icon(Icons.Default.Add, "Add Pair") // } + val context = LocalContext.current + val scope = rememberCoroutineScope() + val dao = remember { AppDatabase.getDatabase(context).folderPairDao() } + val folderPairs by dao.getAll().collectAsState(initial = emptyList()) + + val directoryPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree() + ) { uri: Uri? -> + uri?.let { + val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(it, takeFlags) + + // Save to Room + scope.launch { + dao.insert( + FolderPair( + name = "New Folder Pair", + localDir = it.toString(), + cloudPath = "/backup" + ) + ) + } + } + } Scaffold( floatingActionButton = { FloatingActionButton( - onClick = {} + onClick = { + directoryPickerLauncher.launch(null) + } ) { Icon(Icons.Default.Add, "Add Pair") } } ) { paddingValues -> - Text("Pairs here", Modifier.padding(paddingValues)) + LazyColumn(modifier = Modifier.padding(paddingValues)) { + items(folderPairs.size) { pairIdx -> + val pair = folderPairs[pairIdx] + ListItem( + headlineContent = { Text(pair.name) }, + supportingContent = { + Text( + pair.localDir.toUri().lastPathSegment ?: pair.localDir + ) + } + ) + } + } } } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 511b5f0..0fd8fd3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,14 +1,16 @@ [versions] -agp = "9.0.0" +agp = "9.0.1" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.2" -kotlin = "2.0.21" +kotlin = "2.2.10" composeBom = "2024.09.00" voyager = "1.1.0-beta03" +room = "2.7.1" +ksp = "2.3.6" nav3Core = "1.0.0" lifeCycleViewmodelNav3 = "2.10.0" @@ -40,15 +42,22 @@ androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecy kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" } androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "material3AdaptiveNav3" } +# Voyager voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" } voyager-tab-navigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" } voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" } +# Room +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } [bundles] voyager = ["voyager-navigator", "voyager-screenmodel", "voyager-tab-navigator", "voyager-transitions"] \ No newline at end of file From c215ae3ec48622adcfcf0adc3dddbbec63f383f9 Mon Sep 17 00:00:00 2001 From: Fabian Wolter Date: Mon, 23 Feb 2026 18:29:23 +0100 Subject: [PATCH 2/2] feat: Added UI for the FolderPairTab This commit adds a FolderPairCard composable. Also fixed the warning: "Serializable object must implement 'readResolve'". --- .../cloudsync/ui/screens/HomeScreen.kt | 2 + .../fabisahne/cloudsync/ui/tabs/AccountTab.kt | 2 + .../cloudsync/ui/tabs/FolderPairTab.kt | 165 ++++++++++++++---- .../fabisahne/cloudsync/ui/tabs/HistoryTab.kt | 2 + .../cloudsync/ui/tabs/SettingsTab.kt | 2 + 5 files changed, 142 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/fabisahne/cloudsync/ui/screens/HomeScreen.kt b/app/src/main/java/com/fabisahne/cloudsync/ui/screens/HomeScreen.kt index ef06f3e..1dc2888 100644 --- a/app/src/main/java/com/fabisahne/cloudsync/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/fabisahne/cloudsync/ui/screens/HomeScreen.kt @@ -23,6 +23,8 @@ import com.fabisahne.cloudsync.ui.tabs.SettingsTab object HomeScreen : Screen { + private fun readResolve(): Any = HomeScreen + private val TABS = listOf( HistoryTab, FolderPairTab, diff --git a/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/AccountTab.kt b/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/AccountTab.kt index 17b0aa7..e1926b0 100644 --- a/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/AccountTab.kt +++ b/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/AccountTab.kt @@ -11,6 +11,8 @@ import cafe.adriel.voyager.navigator.tab.TabOptions object AccountTab : Tab { + private fun readResolve(): Any = AccountTab + override val options: TabOptions @Composable get() { diff --git a/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/FolderPairTab.kt b/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/FolderPairTab.kt index 1e7c97b..bf80acf 100644 --- a/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/FolderPairTab.kt +++ b/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/FolderPairTab.kt @@ -4,14 +4,23 @@ import android.content.Intent import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn 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.FolderCopy +import androidx.compose.material3.Button +import androidx.compose.material3.Card import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -19,17 +28,30 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.tab.Tab import cafe.adriel.voyager.navigator.tab.TabOptions import com.fabisahne.cloudsync.data.AppDatabase import com.fabisahne.cloudsync.data.FolderPair +import com.fabisahne.cloudsync.ui.screens.FolderPairScreen +import com.fabisahne.cloudsync.ui.theme.CloudSyncTheme import kotlinx.coroutines.launch -import androidx.core.net.toUri +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter object FolderPairTab : Tab { + private fun readResolve(): Any = FolderPairTab + override val options: TabOptions @Composable get() { @@ -47,38 +69,37 @@ object FolderPairTab : Tab { @Composable override fun Content() { -// Text("Folder Pairs") -// FloatingActionButton( -// -// modifier = Modifier.absolute(), -// onClick = {} -// ) { -// Icon(Icons.Default.Add, "Add Pair") -// } + val navigator = LocalNavigator.currentOrThrow.parent!! + val context = LocalContext.current val scope = rememberCoroutineScope() val dao = remember { AppDatabase.getDatabase(context).folderPairDao() } val folderPairs by dao.getAll().collectAsState(initial = emptyList()) - val directoryPickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenDocumentTree() - ) { uri: Uri? -> - uri?.let { - val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION - context.contentResolver.takePersistableUriPermission(it, takeFlags) + val directoryPickerLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> + uri?.let { + val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(it, takeFlags) - // Save to Room - scope.launch { - dao.insert( - FolderPair( - name = "New Folder Pair", - localDir = it.toString(), - cloudPath = "/backup" + // Save to Room + scope.launch { + dao.insert( + FolderPair( + name = "New Folder Pair", + localDir = it.toString(), + cloudPath = "/backup" + ) ) - ) + } } } + + val deletePair = { pair: FolderPair -> + scope.launch { + dao.delete(pair) + } } Scaffold( @@ -95,16 +116,98 @@ object FolderPairTab : Tab { LazyColumn(modifier = Modifier.padding(paddingValues)) { items(folderPairs.size) { pairIdx -> val pair = folderPairs[pairIdx] - ListItem( - headlineContent = { Text(pair.name) }, - supportingContent = { - Text( - pair.localDir.toUri().lastPathSegment ?: pair.localDir - ) + FolderPairCard( + pair, + onDelete = { + deletePair(pair) + }, + onClick = { + navigator.push(FolderPairScreen(pair.id)) } ) } } } } +} + + +@Composable +fun FolderPairCard(folderPair: FolderPair, onDelete: () -> Unit = {}, onClick: () -> Unit = {}) { + val instant = Instant.ofEpochMilli(folderPair.createdAt) + val dateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault()) + + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + + Card( + onClick = onClick, + modifier = Modifier + .padding(8.dp, 8.dp) + .height(100.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(10.dp, 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.fillMaxHeight(), + verticalArrangement = Arrangement.SpaceBetween + ) { + Text(dateTime.format(formatter), style = MaterialTheme.typography.labelMedium) + Text(folderPair.name, style = MaterialTheme.typography.titleMedium) + Text( + folderPair.localDir.toUri().lastPathSegment ?: folderPair.localDir, + style = MaterialTheme.typography.labelMedium + ) + } + Button( + onClick = onDelete + ) { + Icon( + Icons.Default.Delete, + null, + ) + } + } + } +} + +@Preview(backgroundColor = 0xFFFFFFFF, showBackground = true) +@Composable +fun FolderPairsCardPreview() { + val folderPair = FolderPair( + name = "Sample Folder Pair", + localDir = "sample/local/folder", + cloudPath = "sample/cloud/path" + ) + + CloudSyncTheme { + Column { + FolderPairCard(folderPair) + FolderPairCard(folderPair) + } + } +} + +@Preview(backgroundColor = 0xFF000000, showBackground = true) +@Composable +fun FolderPairsCardPreviewDark() { + val folderPair = FolderPair( + name = "Sample Folder Pair", + localDir = "sample/local/folder", + cloudPath = "sample/cloud/path" + ) + + CloudSyncTheme( + darkTheme = true + ) { + Column { + FolderPairCard(folderPair) + FolderPairCard(folderPair) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/HistoryTab.kt b/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/HistoryTab.kt index bee315d..90a94c7 100644 --- a/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/HistoryTab.kt +++ b/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/HistoryTab.kt @@ -32,6 +32,8 @@ import com.fabisahne.cloudsync.ui.theme.CloudSyncTheme import java.time.LocalDate object HistoryTab : Tab { + private fun readResolve(): Any = HistoryTab + override val options: TabOptions @Composable get() { diff --git a/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/SettingsTab.kt b/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/SettingsTab.kt index f4c7e06..c009356 100644 --- a/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/SettingsTab.kt +++ b/app/src/main/java/com/fabisahne/cloudsync/ui/tabs/SettingsTab.kt @@ -11,6 +11,8 @@ import cafe.adriel.voyager.navigator.tab.Tab import cafe.adriel.voyager.navigator.tab.TabOptions object SettingsTab : Tab { + private fun readResolve(): Any = SettingsTab + override val options: TabOptions @Composable get() {