From dcf83b88a00a1af58ff2b150b0760817afb904c4 Mon Sep 17 00:00:00 2001 From: Robert Huselius Date: Fri, 3 Nov 2023 03:37:32 +0100 Subject: [PATCH] Optimizations & refactorings galore --- app/build.gradle.kts | 8 +- .../main/java/us/huseli/retain/Constants.kt | 1 + .../us/huseli/retain/{data => }/Converters.kt | 2 +- .../us/huseli/retain/{data => }/Database.kt | 16 +- app/src/main/java/us/huseli/retain/Enums.kt | 3 + .../main/java/us/huseli/retain/Functions.kt | 66 ++-- app/src/main/java/us/huseli/retain/Logger.kt | 18 +- .../us/huseli/retain/RetainDestinations.kt | 33 +- .../main/java/us/huseli/retain/compose/App.kt | 83 +---- .../us/huseli/retain/compose/DebugScreen.kt | 3 +- .../us/huseli/retain/compose/HomeScreen.kt | 66 ++-- .../us/huseli/retain/compose/ImageCarousel.kt | 11 +- .../retain/compose/ImageCarouselScreen.kt | 4 +- .../java/us/huseli/retain/compose/NoteCard.kt | 89 ++--- .../retain/compose/NoteCardChecklist.kt | 60 ++++ .../us/huseli/retain/compose/NoteImageGrid.kt | 25 +- .../huseli/retain/compose/RetainScaffold.kt | 53 +-- .../compose/notescreen/BaseNoteScreen.kt | 235 ------------- .../notescreen/ChecklistNoteChecklist.kt | 50 ++- .../notescreen/ChecklistNoteChecklistRow.kt | 44 +-- .../compose/notescreen/ChecklistNoteScreen.kt | 141 -------- .../notescreen/ImageSelectionTopAppBar.kt | 56 +++ .../retain/compose/notescreen/NoteScreen.kt | 296 ++++++++++++++++ .../compose/notescreen/NoteScreenTopAppBar.kt | 44 --- .../compose/notescreen/TextNoteScreen.kt | 68 ---- .../retain/compose/settings/GeneralSection.kt | 3 +- .../compose/settings/NextCloudSection.kt | 2 +- .../retain/compose/settings/SettingsScreen.kt | 2 - .../us/huseli/retain/dao/ChecklistItemDao.kt | 24 ++ .../huseli/retain/{data => dao}/ImageDao.kt | 19 +- .../us/huseli/retain/{data => dao}/NoteDao.kt | 80 ++--- .../us/huseli/retain/data/ChecklistItemDao.kt | 35 -- .../us/huseli/retain/data/NoteRepository.kt | 143 -------- .../data/entities/ChecklistItemWithNote.kt | 8 - .../huseli/retain/data/entities/NoteCombo.kt | 15 - .../huseli/retain/dataclasses/GoogleNote.kt | 23 ++ .../dataclasses/NoteCardChecklistData.kt | 11 + .../us/huseli/retain/dataclasses/NotePojo.kt | 34 ++ .../us/huseli/retain/dataclasses/QuickNote.kt | 15 + .../entities/ChecklistItem.kt | 22 +- .../entities/DeletedNote.kt | 2 +- .../{data => dataclasses}/entities/Image.kt | 30 +- .../{data => dataclasses}/entities/Note.kt | 4 +- .../us/huseli/retain/di/DatabaseModule.kt | 8 +- .../retain/repositories/NoteRepository.kt | 110 ++++++ .../SyncBackendRepository.kt | 16 +- .../syncbackend/tasks/DownloadImagesTask.kt | 2 +- .../tasks/DownloadNoteCombosJSONTask.kt | 10 +- .../syncbackend/tasks/RemoveImagesTask.kt | 2 +- .../retain/syncbackend/tasks/SyncTask.kt | 34 +- .../tasks/UploadMissingImagesTask.kt | 2 +- .../syncbackend/tasks/UploadNoteCombosTask.kt | 4 +- .../viewmodels/BaseEditNoteViewModel.kt | 205 ----------- .../retain/viewmodels/DropboxViewModel.kt | 2 +- .../viewmodels/EditChecklistNoteViewModel.kt | 196 ----------- .../viewmodels/EditTextNoteViewModel.kt | 15 - .../viewmodels/ImageCarouselViewModel.kt | 19 +- .../retain/viewmodels/ImageViewModel.kt | 11 + .../retain/viewmodels/NextCloudViewModel.kt | 2 +- .../retain/viewmodels/NoteListViewModel.kt | 128 +++++++ .../huseli/retain/viewmodels/NoteViewModel.kt | 318 +++++++++++------- .../huseli/retain/viewmodels/SFTPViewModel.kt | 2 +- .../retain/viewmodels/SettingsViewModel.kt | 113 +++---- app/src/main/res/values/strings.xml | 135 ++++---- 64 files changed, 1398 insertions(+), 1883 deletions(-) rename app/src/main/java/us/huseli/retain/{data => }/Converters.kt (94%) rename app/src/main/java/us/huseli/retain/{data => }/Database.kt (85%) create mode 100644 app/src/main/java/us/huseli/retain/compose/NoteCardChecklist.kt delete mode 100644 app/src/main/java/us/huseli/retain/compose/notescreen/BaseNoteScreen.kt delete mode 100644 app/src/main/java/us/huseli/retain/compose/notescreen/ChecklistNoteScreen.kt create mode 100644 app/src/main/java/us/huseli/retain/compose/notescreen/ImageSelectionTopAppBar.kt create mode 100644 app/src/main/java/us/huseli/retain/compose/notescreen/NoteScreen.kt delete mode 100644 app/src/main/java/us/huseli/retain/compose/notescreen/TextNoteScreen.kt create mode 100644 app/src/main/java/us/huseli/retain/dao/ChecklistItemDao.kt rename app/src/main/java/us/huseli/retain/{data => dao}/ImageDao.kt (57%) rename app/src/main/java/us/huseli/retain/{data => dao}/NoteDao.kt (53%) delete mode 100644 app/src/main/java/us/huseli/retain/data/ChecklistItemDao.kt delete mode 100644 app/src/main/java/us/huseli/retain/data/NoteRepository.kt delete mode 100644 app/src/main/java/us/huseli/retain/data/entities/ChecklistItemWithNote.kt delete mode 100644 app/src/main/java/us/huseli/retain/data/entities/NoteCombo.kt create mode 100644 app/src/main/java/us/huseli/retain/dataclasses/GoogleNote.kt create mode 100644 app/src/main/java/us/huseli/retain/dataclasses/NoteCardChecklistData.kt create mode 100644 app/src/main/java/us/huseli/retain/dataclasses/NotePojo.kt create mode 100644 app/src/main/java/us/huseli/retain/dataclasses/QuickNote.kt rename app/src/main/java/us/huseli/retain/{data => dataclasses}/entities/ChecklistItem.kt (76%) rename app/src/main/java/us/huseli/retain/{data => dataclasses}/entities/DeletedNote.kt (81%) rename app/src/main/java/us/huseli/retain/{data => dataclasses}/entities/Image.kt (62%) rename app/src/main/java/us/huseli/retain/{data => dataclasses}/entities/Note.kt (94%) create mode 100644 app/src/main/java/us/huseli/retain/repositories/NoteRepository.kt rename app/src/main/java/us/huseli/retain/{data => repositories}/SyncBackendRepository.kt (92%) delete mode 100644 app/src/main/java/us/huseli/retain/viewmodels/BaseEditNoteViewModel.kt delete mode 100644 app/src/main/java/us/huseli/retain/viewmodels/EditChecklistNoteViewModel.kt delete mode 100644 app/src/main/java/us/huseli/retain/viewmodels/EditTextNoteViewModel.kt create mode 100644 app/src/main/java/us/huseli/retain/viewmodels/ImageViewModel.kt create mode 100644 app/src/main/java/us/huseli/retain/viewmodels/NoteListViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 394ffd9..29abbe7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,3 @@ -@file:Suppress("UnstableApiUsage") - import java.io.FileInputStream import java.util.Properties @@ -41,8 +39,8 @@ android { applicationId = "us.huseli.retain" minSdk = 26 targetSdk = targetSdk - versionCode = 3 - versionName = "1.0.0-beta.3" + versionCode = 4 + versionName = "1.0.0-beta.4" vectorDrawables.useSupportLibrary = true testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" // buildConfigField("String", "dropboxAppKey", "\"${dropboxAppKey}\"") @@ -168,5 +166,5 @@ dependencies { implementation("com.dropbox.core:dropbox-core-sdk:5.4.5") // Theme: - implementation("com.github.Eboreg:RetainTheme:2.1.0") + implementation("com.github.Eboreg:RetainTheme:2.2.1") } diff --git a/app/src/main/java/us/huseli/retain/Constants.kt b/app/src/main/java/us/huseli/retain/Constants.kt index fa3bbcb..9cb2c51 100644 --- a/app/src/main/java/us/huseli/retain/Constants.kt +++ b/app/src/main/java/us/huseli/retain/Constants.kt @@ -7,6 +7,7 @@ object Constants { const val DEFAULT_SFTP_BASE_DIR = ".retain" const val IMAGE_SUBDIR = "images" const val NAV_ARG_IMAGE_CAROUSEL_CURRENT_ID = "imageCarouselCurrentId" + const val NAV_ARG_NEW_NOTE_TYPE = "noteType" const val NAV_ARG_NOTE_ID = "noteId" const val PREF_DROPBOX_CREDENTIAL = "dropboxCredential" const val PREF_MIN_COLUMN_WIDTH = "minColumnWidth" diff --git a/app/src/main/java/us/huseli/retain/data/Converters.kt b/app/src/main/java/us/huseli/retain/Converters.kt similarity index 94% rename from app/src/main/java/us/huseli/retain/data/Converters.kt rename to app/src/main/java/us/huseli/retain/Converters.kt index 9837fc8..1d5c450 100644 --- a/app/src/main/java/us/huseli/retain/data/Converters.kt +++ b/app/src/main/java/us/huseli/retain/Converters.kt @@ -1,4 +1,4 @@ -package us.huseli.retain.data +package us.huseli.retain import androidx.room.TypeConverter import java.time.Instant diff --git a/app/src/main/java/us/huseli/retain/data/Database.kt b/app/src/main/java/us/huseli/retain/Database.kt similarity index 85% rename from app/src/main/java/us/huseli/retain/data/Database.kt rename to app/src/main/java/us/huseli/retain/Database.kt index 018a77d..2ec676c 100644 --- a/app/src/main/java/us/huseli/retain/data/Database.kt +++ b/app/src/main/java/us/huseli/retain/Database.kt @@ -1,4 +1,4 @@ -package us.huseli.retain.data +package us.huseli.retain import android.content.Context import android.util.Log @@ -9,13 +9,13 @@ import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent -import us.huseli.retain.BuildConfig -import us.huseli.retain.LogMessage -import us.huseli.retain.Logger -import us.huseli.retain.data.entities.ChecklistItem -import us.huseli.retain.data.entities.DeletedNote -import us.huseli.retain.data.entities.Image -import us.huseli.retain.data.entities.Note +import us.huseli.retain.dao.ChecklistItemDao +import us.huseli.retain.dao.ImageDao +import us.huseli.retain.dao.NoteDao +import us.huseli.retain.dataclasses.entities.ChecklistItem +import us.huseli.retain.dataclasses.entities.DeletedNote +import us.huseli.retain.dataclasses.entities.Image +import us.huseli.retain.dataclasses.entities.Note import java.util.concurrent.Executors @androidx.room.Database( diff --git a/app/src/main/java/us/huseli/retain/Enums.kt b/app/src/main/java/us/huseli/retain/Enums.kt index 63c9dc8..8ea337a 100644 --- a/app/src/main/java/us/huseli/retain/Enums.kt +++ b/app/src/main/java/us/huseli/retain/Enums.kt @@ -2,8 +2,11 @@ package us.huseli.retain object Enums { enum class NoteType { TEXT, CHECKLIST } + enum class Side { LEFT, RIGHT } + enum class HomeScreenViewType { LIST, GRID } + enum class SyncBackend(val displayName: String) { NONE("None"), NEXTCLOUD("Nextcloud"), diff --git a/app/src/main/java/us/huseli/retain/Functions.kt b/app/src/main/java/us/huseli/retain/Functions.kt index fdfdd01..4cb2efe 100644 --- a/app/src/main/java/us/huseli/retain/Functions.kt +++ b/app/src/main/java/us/huseli/retain/Functions.kt @@ -3,6 +3,7 @@ package us.huseli.retain import android.content.ContentResolver import android.content.Context import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.graphics.ImageDecoder import android.net.Uri import android.os.Build @@ -11,13 +12,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap import androidx.core.graphics.scale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import us.huseli.retain.Constants.ZIP_BUFFER_SIZE -import us.huseli.retain.data.entities.Image +import us.huseli.retain.dataclasses.entities.Image import java.io.File import java.io.FileOutputStream import java.io.StringWriter @@ -56,51 +55,36 @@ fun outlinedTextFieldColors() = OutlinedTextFieldDefaults.colors( ) -fun cleanUri(value: String): String { - val regex = Regex("^https?://.+") - if (value.isBlank()) return "" - if (!regex.matches(value)) return "https://$value".trimEnd('/') - return value.trimEnd('/') +fun String.cleanUri(): String { + if (isBlank()) return "" + if (!Regex("^https?://.+").matches(this)) return "https://$this".trimEnd('/') + return trimEnd('/') } -@Suppress("SameReturnValue") -fun fileToImageBitmap(file: File, context: Context): ImageBitmap? { - if (file.isFile) { - Uri.fromFile(file)?.let { uri -> - val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, uri)) - } else { - @Suppress("DEPRECATION") - MediaStore.Images.Media.getBitmap(context.contentResolver, uri) - } - return bitmap?.asImageBitmap() - } - } - return null -} - +fun ZipFile.readTextFile(zipEntry: ZipEntry): String { + return getInputStream(zipEntry).use { inputStream -> + val buffer = ByteArray(ZIP_BUFFER_SIZE) + val writer = StringWriter() + var length: Int -fun readTextFileFromZip(zipFile: ZipFile, zipEntry: ZipEntry): String { - val inputStream = zipFile.getInputStream(zipEntry) - val buffer = ByteArray(ZIP_BUFFER_SIZE) - val writer = StringWriter() - var length: Int - while (inputStream.read(buffer).also { length = it } > 0) { - writer.write(buffer.decodeToString(0, length)) + while (inputStream.read(buffer).also { length = it } > 0) { + writer.write(buffer.decodeToString(0, length)) + } + writer.toString() } - return writer.toString() } -fun extractFileFromZip(zipFile: ZipFile, zipEntry: ZipEntry, outfile: File) { +fun ZipFile.extractFile(zipEntry: ZipEntry, outfile: File) { val buffer = ByteArray(ZIP_BUFFER_SIZE) - FileOutputStream(outfile).use { outputStream -> - val inputStream = zipFile.getInputStream(zipEntry) - var length: Int - while (inputStream.read(buffer).also { length = it } > 0) { - outputStream.write(buffer, 0, length) + outfile.outputStream().use { outputStream -> + getInputStream(zipEntry).use { inputStream -> + var length: Int + while (inputStream.read(buffer).also { length = it } > 0) { + outputStream.write(buffer, 0, length) + } } } } @@ -127,13 +111,14 @@ suspend fun uriToImage(context: Context, uri: Uri, noteId: UUID): Image? { val factor = Constants.DEFAULT_MAX_IMAGE_DIMEN.toFloat() / max(bitmap.width, bitmap.height) val width = (bitmap.width * factor).roundToInt() val height = (bitmap.height * factor).roundToInt() + basename = "${UUID.randomUUID()}.png" mimeType = "image/png" finalBitmap = bitmap.scale(width = width, height = height) imageFile = File(imageDir, basename) withContext(Dispatchers.IO) { - FileOutputStream(imageFile).use { outputStream -> + imageFile.outputStream().use { outputStream -> finalBitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) } } @@ -168,3 +153,6 @@ fun isImageFile(filename: String): Boolean { fun Uri.getMimeType(context: Context): String? = if (scheme == ContentResolver.SCHEME_CONTENT) context.contentResolver.getType(this) else Files.probeContentType(Paths.get(path)) + + +fun File.toBitmap(): Bitmap? = takeIf { it.isFile }?.inputStream().use { BitmapFactory.decodeStream(it) } diff --git a/app/src/main/java/us/huseli/retain/Logger.kt b/app/src/main/java/us/huseli/retain/Logger.kt index 49249b1..be348e9 100644 --- a/app/src/main/java/us/huseli/retain/Logger.kt +++ b/app/src/main/java/us/huseli/retain/Logger.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.parcelize.Parcelize +import us.huseli.retaintheme.snackbar.SnackbarEngine import java.time.Instant import javax.inject.Singleton @@ -39,19 +40,16 @@ data class LogMessage( class Logger { private val _logMessages = MutableSharedFlow( replay = 500, - onBufferOverflow = BufferOverflow.DROP_OLDEST + onBufferOverflow = BufferOverflow.DROP_OLDEST, ) - private val _snackbarMessage = MutableSharedFlow(extraBufferCapacity = 5, replay = 1000) - private var _lastSnackbarMessage: LogMessage? = null - val logMessages = _logMessages.asSharedFlow() - val snackbarMessage = _snackbarMessage.asSharedFlow() - val lastSnackbarMessage: LogMessage? - get() = _lastSnackbarMessage private fun addMessage(logMessage: LogMessage, showInSnackbar: Boolean = false) { if (showInSnackbar) { - _snackbarMessage.tryEmit(logMessage) + when (logMessage.level) { + Log.ERROR -> SnackbarEngine.addError(logMessage.message) + else -> SnackbarEngine.addInfo(logMessage.message) + } } if (BuildConfig.DEBUG) { Log.println( @@ -64,10 +62,6 @@ class Logger { } fun log(logMessage: LogMessage, showInSnackbar: Boolean = false) = addMessage(logMessage, showInSnackbar) - - fun setLastSnackbarMessage(logMessage: LogMessage) { - _lastSnackbarMessage = logMessage - } } interface LogInterface { diff --git a/app/src/main/java/us/huseli/retain/RetainDestinations.kt b/app/src/main/java/us/huseli/retain/RetainDestinations.kt index 5a2ead1..b78565d 100644 --- a/app/src/main/java/us/huseli/retain/RetainDestinations.kt +++ b/app/src/main/java/us/huseli/retain/RetainDestinations.kt @@ -3,20 +3,11 @@ package us.huseli.retain import androidx.navigation.NavType import androidx.navigation.navArgument import us.huseli.retain.Constants.NAV_ARG_IMAGE_CAROUSEL_CURRENT_ID +import us.huseli.retain.Constants.NAV_ARG_NEW_NOTE_TYPE import us.huseli.retain.Constants.NAV_ARG_NOTE_ID +import us.huseli.retain.Enums.NoteType import java.util.UUID -abstract class NoteDestination { - abstract val baseRoute: String - val arguments = listOf( - navArgument(NAV_ARG_NOTE_ID) { type = NavType.StringType }, - ) - val routeTemplate: String - get() = "$baseRoute/{$NAV_ARG_NOTE_ID}" - - fun route(noteId: UUID) = "$baseRoute/$noteId" -} - object HomeDestination { const val route = "home" } @@ -29,12 +20,22 @@ object DebugDestination { const val route = "debug" } -object TextNoteDestination : NoteDestination() { - override val baseRoute = "textNote" -} +object NoteDestination { + const val routeTemplate = "note?id={$NAV_ARG_NOTE_ID}&type={$NAV_ARG_NEW_NOTE_TYPE}" + val arguments = listOf( + navArgument(NAV_ARG_NOTE_ID) { + type = NavType.StringType + nullable = true + }, + navArgument(NAV_ARG_NEW_NOTE_TYPE) { + type = NavType.StringType + nullable = true + }, + ) + + fun route(noteId: UUID) = "note?id=$noteId" -object ChecklistNoteDestination : NoteDestination() { - override val baseRoute = "checklistNote" + fun route(newNoteType: NoteType) = "note?type=$newNoteType" } object ImageCarouselDestination { diff --git a/app/src/main/java/us/huseli/retain/compose/App.kt b/app/src/main/java/us/huseli/retain/compose/App.kt index 670b4d6..43d6547 100644 --- a/app/src/main/java/us/huseli/retain/compose/App.kt +++ b/app/src/main/java/us/huseli/retain/compose/App.kt @@ -8,26 +8,23 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import us.huseli.retaintheme.ui.theme.RetainTheme -import us.huseli.retain.ChecklistNoteDestination import us.huseli.retain.DebugDestination import us.huseli.retain.Enums.NoteType import us.huseli.retain.HomeDestination import us.huseli.retain.ImageCarouselDestination import us.huseli.retain.Logger +import us.huseli.retain.NoteDestination import us.huseli.retain.SettingsDestination -import us.huseli.retain.TextNoteDestination -import us.huseli.retain.compose.notescreen.ChecklistNoteScreen -import us.huseli.retain.compose.notescreen.TextNoteScreen +import us.huseli.retain.compose.notescreen.NoteScreen import us.huseli.retain.compose.settings.SettingsScreen -import us.huseli.retain.viewmodels.NoteViewModel +import us.huseli.retain.viewmodels.NoteListViewModel import us.huseli.retain.viewmodels.SettingsViewModel -import java.util.UUID +import us.huseli.retaintheme.ui.theme.RetainTheme @Composable fun App( logger: Logger, - viewModel: NoteViewModel = hiltViewModel(), + viewModel: NoteListViewModel = hiltViewModel(), settingsViewModel: SettingsViewModel = hiltViewModel(), navController: NavHostController = rememberNavController(), snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } @@ -43,32 +40,16 @@ fun App( HomeScreen( viewModel = viewModel, settingsViewModel = settingsViewModel, - onAddTextNoteClick = { - navController.navigate(TextNoteDestination.route(UUID.randomUUID())) - }, - onAddChecklistClick = { - navController.navigate(ChecklistNoteDestination.route(UUID.randomUUID())) - }, - onCardClick = { note -> - when (note.type) { - NoteType.TEXT -> navController.navigate(TextNoteDestination.route(note.id)) - NoteType.CHECKLIST -> navController.navigate(ChecklistNoteDestination.route(note.id)) - } - }, - onSettingsClick = { - navController.navigate(SettingsDestination.route) - }, - onDebugClick = { - navController.navigate(DebugDestination.route) - }, + onAddTextNoteClick = { navController.navigate(NoteDestination.route(NoteType.TEXT)) }, + onAddChecklistClick = { navController.navigate(NoteDestination.route(NoteType.CHECKLIST)) }, + onCardClick = { note -> navController.navigate(NoteDestination.route(note.id)) }, + onSettingsClick = { navController.navigate(SettingsDestination.route) }, + onDebugClick = { navController.navigate(DebugDestination.route) }, ) } composable(route = DebugDestination.route) { - DebugScreen( - logger = logger, - onClose = onClose, - ) + DebugScreen(logger = logger, onClose = onClose) } composable(route = SettingsDestination.route) { @@ -80,42 +61,10 @@ fun App( } composable( - route = TextNoteDestination.routeTemplate, - arguments = TextNoteDestination.arguments, - ) { - TextNoteScreen( - onSave = { dirtyNote, dirtyChecklistItems, dirtyImages, deletedChecklistItemIds, deletedImageIds -> - viewModel.save( - dirtyNote, - dirtyChecklistItems, - dirtyImages, - deletedChecklistItemIds, - deletedImageIds - ) - viewModel.uploadNotes() - }, - onBackClick = onClose, - onImageCarouselStart = { noteId, imageId -> - navController.navigate(ImageCarouselDestination.route(noteId, imageId)) - }, - ) - } - - composable( - route = ChecklistNoteDestination.routeTemplate, - arguments = ChecklistNoteDestination.arguments, + route = NoteDestination.routeTemplate, + arguments = NoteDestination.arguments, ) { - ChecklistNoteScreen( - onSave = { dirtyNote, dirtyChecklistItems, dirtyImages, deletedChecklistItemIds, deletedImageIds -> - viewModel.save( - dirtyNote, - dirtyChecklistItems, - dirtyImages, - deletedChecklistItemIds, - deletedImageIds - ) - viewModel.uploadNotes() - }, + NoteScreen( onBackClick = onClose, onImageCarouselStart = { noteId, imageId -> navController.navigate(ImageCarouselDestination.route(noteId, imageId)) @@ -127,9 +76,7 @@ fun App( route = ImageCarouselDestination.routeTemplate, arguments = ImageCarouselDestination.arguments, ) { - ImageCarouselScreen( - onClose = onClose, - ) + ImageCarouselScreen(onClose = onClose) } } } diff --git a/app/src/main/java/us/huseli/retain/compose/DebugScreen.kt b/app/src/main/java/us/huseli/retain/compose/DebugScreen.kt index a45b2b1..845d7a5 100644 --- a/app/src/main/java/us/huseli/retain/compose/DebugScreen.kt +++ b/app/src/main/java/us/huseli/retain/compose/DebugScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -97,7 +98,7 @@ fun DebugTopAppBar( @Composable fun DebugScreen(modifier: Modifier = Modifier, logger: Logger, onClose: () -> Unit) { val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS").withZone(ZoneId.systemDefault()) - var logLevel by rememberSaveable { mutableStateOf(Log.INFO) } + var logLevel by rememberSaveable { mutableIntStateOf(Log.INFO) } val listState = rememberLazyListState() val logMessages = remember { mutableStateListOf( diff --git a/app/src/main/java/us/huseli/retain/compose/HomeScreen.kt b/app/src/main/java/us/huseli/retain/compose/HomeScreen.kt index b403b00..8e90365 100644 --- a/app/src/main/java/us/huseli/retain/compose/HomeScreen.kt +++ b/app/src/main/java/us/huseli/retain/compose/HomeScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -28,6 +29,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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 androidx.compose.ui.zIndex @@ -39,15 +41,17 @@ import org.burnoutcrew.reorderable.rememberReorderableLazyListState import org.burnoutcrew.reorderable.reorderable import us.huseli.retain.Enums.HomeScreenViewType import us.huseli.retain.R -import us.huseli.retain.data.entities.Note -import us.huseli.retain.viewmodels.NoteViewModel +import us.huseli.retain.dataclasses.NotePojo +import us.huseli.retain.dataclasses.entities.Note +import us.huseli.retain.viewmodels.NoteListViewModel import us.huseli.retain.viewmodels.SettingsViewModel +import us.huseli.retaintheme.snackbar.SnackbarEngine @OptIn(ExperimentalMaterialApi::class) @Composable fun HomeScreen( modifier: Modifier = Modifier, - viewModel: NoteViewModel = hiltViewModel(), + viewModel: NoteListViewModel = hiltViewModel(), settingsViewModel: SettingsViewModel = hiltViewModel(), onAddTextNoteClick: () -> Unit, onAddChecklistClick: () -> Unit, @@ -55,16 +59,17 @@ fun HomeScreen( onSettingsClick: () -> Unit, onDebugClick: () -> Unit, ) { + val context = LocalContext.current val syncBackend by viewModel.syncBackend.collectAsStateWithLifecycle() val isSyncBackendSyncing by viewModel.isSyncBackendSyncing.collectAsStateWithLifecycle(false) val isSyncBackendEnabled by settingsViewModel.isSyncBackendEnabled.collectAsStateWithLifecycle(false) - val notes by viewModel.notes.collectAsStateWithLifecycle(emptyList()) - val images by viewModel.images.collectAsStateWithLifecycle(emptyList()) - val checklistData by viewModel.checklistData.collectAsStateWithLifecycle(emptyList()) + val pojos by viewModel.pojos.collectAsStateWithLifecycle() val isSelectEnabled by viewModel.isSelectEnabled.collectAsStateWithLifecycle(false) val selectedNoteIds by viewModel.selectedNoteIds.collectAsStateWithLifecycle() val minColumnWidth by settingsViewModel.minColumnWidth.collectAsStateWithLifecycle() val showArchive by viewModel.showArchive.collectAsStateWithLifecycle() + val trashedPojos by viewModel.trashedPojos.collectAsStateWithLifecycle() + val reorderableState = rememberReorderableLazyListState( onMove = { from, to -> viewModel.switchNotePositions(from, to) }, onDragEnd = { _, _ -> viewModel.saveNotePositions() } @@ -89,30 +94,51 @@ fun HomeScreen( viewModel.deselectAllNotes() } - val lazyContent: @Composable (Note, Boolean) -> Unit = { note, isDragging -> + LaunchedEffect(Unit) { + viewModel.uploadNotes { result -> + if (!result.success) SnackbarEngine.addError( + context.getString(R.string.failed_to_upload_notes_to, syncBackend.displayName, result.message) + ) + } + } + + LaunchedEffect(trashedPojos) { + if (trashedPojos.isNotEmpty()) { + SnackbarEngine.addInfo( + message = context.resources.getQuantityString( + R.plurals.x_notes_trashed, + trashedPojos.size, + trashedPojos.size, + ), + actionLabel = context.resources.getString(R.string.undo).uppercase(), + onActionPerformed = { viewModel.undoTrashNotes() }, + onDismissed = { viewModel.reallyTrashNotes() }, + ) + } + } + + val lazyContent: @Composable (NotePojo, Boolean) -> Unit = { pojo, isDragging -> NoteCard( modifier = Modifier.fillMaxWidth(), - note = note, - checklistData = checklistData.find { it.noteId == note.id }, - images = images.filter { it.noteId == note.id }, + pojo = pojo, isDragging = isDragging, onClick = { - if (isSelectEnabled) viewModel.toggleNoteSelected(note.id) - else onCardClick(note) + if (isSelectEnabled) viewModel.toggleNoteSelected(pojo.note.id) + else onCardClick(pojo.note) isFABExpanded = false }, onLongClick = { - viewModel.toggleNoteSelected(note.id) + viewModel.toggleNoteSelected(pojo.note.id) isFABExpanded = false }, - isSelected = selectedNoteIds.contains(note.id), + isSelected = selectedNoteIds.contains(pojo.note.id), showDragHandle = viewType == HomeScreenViewType.LIST, reorderableState = reorderableState, + secondaryImageGridRowHeight = if (viewType == HomeScreenViewType.LIST) 200.dp else 100.dp, ) } RetainScaffold( - viewModel = viewModel, topBar = { if (isSelectEnabled) SelectionTopAppBar( selectedCount = selectedNoteIds.size, @@ -152,7 +178,7 @@ fun HomeScreen( ) } - Column(modifier = lazyModifier.padding(innerPadding).fillMaxWidth()) { + Column(modifier = lazyModifier.padding(innerPadding).padding(horizontal = 8.dp).fillMaxWidth()) { if (isSyncBackendSyncing) { Row( modifier = Modifier.fillMaxWidth(), @@ -174,7 +200,7 @@ fun HomeScreen( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalItemSpacing = 8.dp, ) { - items(notes, key = { it.id }) { note -> lazyContent(note, false) } + items(pojos, key = { it.note.id }) { pojo -> lazyContent(pojo, false) } } } else { LazyColumn( @@ -185,9 +211,9 @@ fun HomeScreen( state = reorderableState.listState, verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(notes, key = { it.id }) { note -> - ReorderableItem(reorderableState, key = note.id) { isDragging -> - lazyContent(note, isDragging) + items(pojos, key = { it.note.id }) { pojo -> + ReorderableItem(reorderableState, key = pojo.note.id) { isDragging -> + lazyContent(pojo, isDragging) } } } diff --git a/app/src/main/java/us/huseli/retain/compose/ImageCarousel.kt b/app/src/main/java/us/huseli/retain/compose/ImageCarousel.kt index fc8abe3..ece0fd9 100644 --- a/app/src/main/java/us/huseli/retain/compose/ImageCarousel.kt +++ b/app/src/main/java/us/huseli/retain/compose/ImageCarousel.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -42,7 +43,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import us.huseli.retain.Enums.Side import us.huseli.retain.R -import us.huseli.retain.data.entities.Image +import us.huseli.retain.dataclasses.entities.Image import kotlin.math.abs import kotlin.math.roundToInt @@ -59,10 +60,10 @@ fun ImageCarousel( ) { var isTransformable by remember(image) { mutableStateOf(false) } var maxHeightDp by remember { mutableStateOf(0.dp) } - var overScroll by remember(image) { mutableStateOf(0f) } - var panX by remember(image) { mutableStateOf(0f) } - var panY by remember(image) { mutableStateOf(0f) } - var scale by remember(image) { mutableStateOf(1f) } + var overScroll by remember(image) { mutableFloatStateOf(0f) } + var panX by remember(image) { mutableFloatStateOf(0f) } + var panY by remember(image) { mutableFloatStateOf(0f) } + var scale by remember(image) { mutableFloatStateOf(1f) } var slideFrom by remember { mutableStateOf(null) } var slideTo by remember(image) { mutableStateOf(null) } diff --git a/app/src/main/java/us/huseli/retain/compose/ImageCarouselScreen.kt b/app/src/main/java/us/huseli/retain/compose/ImageCarouselScreen.kt index ac56d9f..b1be0a2 100644 --- a/app/src/main/java/us/huseli/retain/compose/ImageCarouselScreen.kt +++ b/app/src/main/java/us/huseli/retain/compose/ImageCarouselScreen.kt @@ -16,11 +16,11 @@ fun ImageCarouselScreen( ) { val images by viewModel.images.collectAsStateWithLifecycle(emptyList()) val currentImage by viewModel.currentImage.collectAsStateWithLifecycle(null) - val currentImageBitmap = currentImage?.imageBitmap?.collectAsStateWithLifecycle(null) + val currentImageBitmap by viewModel.currentImageBitmap.collectAsStateWithLifecycle(null) RetainScaffold { innerPadding -> currentImage?.let { image -> - currentImageBitmap?.value?.let { imageBitmap -> + currentImageBitmap?.let { imageBitmap -> ImageCarousel( modifier = modifier.padding(innerPadding), image = image, diff --git a/app/src/main/java/us/huseli/retain/compose/NoteCard.kt b/app/src/main/java/us/huseli/retain/compose/NoteCard.kt index 1c7efa6..fdf5053 100644 --- a/app/src/main/java/us/huseli/retain/compose/NoteCard.kt +++ b/app/src/main/java/us/huseli/retain/compose/NoteCard.kt @@ -6,16 +6,12 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.sharp.CheckBox -import androidx.compose.material.icons.sharp.CheckBoxOutlineBlank import androidx.compose.material.icons.sharp.DragIndicator import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilledTonalIconButton @@ -31,18 +27,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.burnoutcrew.reorderable.ReorderableLazyListState import org.burnoutcrew.reorderable.detectReorder import us.huseli.retain.Enums import us.huseli.retain.R -import us.huseli.retain.data.entities.Image -import us.huseli.retain.data.entities.Note +import us.huseli.retain.dataclasses.NotePojo import us.huseli.retain.ui.theme.getNoteColor -import us.huseli.retain.viewmodels.NoteCardChecklistData @OptIn(ExperimentalFoundationApi::class) @Composable @@ -50,17 +44,16 @@ fun NoteCard( modifier: Modifier = Modifier, onClick: () -> Unit, onLongClick: () -> Unit, - note: Note, - checklistData: NoteCardChecklistData?, - images: List, + pojo: NotePojo, isSelected: Boolean, reorderableState: ReorderableLazyListState? = null, showDragHandle: Boolean = false, isDragging: Boolean, + secondaryImageGridRowHeight: Dp = 100.dp, ) { val elevation by animateDpAsState(if (isDragging) 16.dp else 0.dp) val defaultColor = MaterialTheme.colorScheme.background - val noteColor = getNoteColor(note.color, defaultColor) + val noteColor = getNoteColor(pojo.note.color, defaultColor) val border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f)) @@ -81,18 +74,18 @@ fun NoteCard( Column(modifier = Modifier.fillMaxWidth()) { if (!isDragging) { NoteImageGrid( - images = images, + images = pojo.images, maxRows = 2, - secondaryRowHeight = 100.dp, + secondaryRowHeight = secondaryImageGridRowHeight, ) } Column(modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth()) { - if (note.title.isNotBlank()) { + if (pojo.note.title.isNotBlank()) { Spacer(modifier = Modifier.height(16.dp)) Text( modifier = if (showDragHandle) Modifier.padding(end = 24.dp) else Modifier, - text = note.title, + text = pojo.note.title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -100,28 +93,30 @@ fun NoteCard( } if (!isDragging) { - when (note.type) { + when (pojo.note.type) { Enums.NoteType.TEXT -> { - if (note.text.isNotBlank()) { - Spacer(modifier = Modifier.height(if (note.title.isNotBlank()) 8.dp else 16.dp)) + if (pojo.note.text.isNotBlank()) { + Spacer(modifier = Modifier.height(if (pojo.note.title.isNotBlank()) 8.dp else 16.dp)) Text( - text = note.text, + text = pojo.note.text, overflow = TextOverflow.Ellipsis, maxLines = 6, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f), ) } - if (note.text.isNotBlank() || note.title.isNotBlank()) + if (pojo.note.text.isNotBlank() || pojo.note.title.isNotBlank()) Spacer(modifier = Modifier.height(16.dp)) } - Enums.NoteType.CHECKLIST -> checklistData?.let { - if (it.shownChecklistItems.isNotEmpty()) { - Spacer(modifier = Modifier.height(if (note.title.isNotBlank()) 8.dp else 16.dp)) - NoteCardChecklist(data = it) + Enums.NoteType.CHECKLIST -> { + val checklistData = pojo.getCardChecklist() + + if (checklistData.shownChecklistItems.isNotEmpty()) { + Spacer(modifier = Modifier.height(if (pojo.note.title.isNotBlank()) 8.dp else 16.dp)) + NoteCardChecklist(data = checklistData) } - if (it.shownChecklistItems.isNotEmpty() || note.title.isNotBlank()) + if (checklistData.shownChecklistItems.isNotEmpty() || pojo.note.title.isNotBlank()) Spacer(modifier = Modifier.height(16.dp)) } } @@ -136,7 +131,7 @@ fun NoteCard( .detectReorder(reorderableState) .align(Alignment.TopEnd), colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = if (images.isEmpty()) noteColor else Color.Transparent + containerColor = if (pojo.images.isEmpty()) noteColor else Color.Transparent ), onClick = {}, ) { @@ -148,43 +143,3 @@ fun NoteCard( } } } - -@Composable -fun NoteCardChecklist(data: NoteCardChecklistData) { - Column { - data.shownChecklistItems.forEach { item -> - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 2.dp)) { - Icon( - imageVector = if (item.checked) Icons.Sharp.CheckBox else Icons.Sharp.CheckBoxOutlineBlank, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp).size(16.dp), - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f), - ) - Text( - text = item.text, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f), - ) - } - } - - // "+ X items" - if (data.hiddenChecklistItemCount > 0) { - val text = pluralStringResource( - if (data.hiddenChecklistItemAllChecked) R.plurals.plus_x_checked_items - else R.plurals.plus_x_items, - data.hiddenChecklistItemCount, - data.hiddenChecklistItemCount, - ) - - Text( - text = text, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), - modifier = Modifier.padding(top = 8.dp), - ) - } - } -} diff --git a/app/src/main/java/us/huseli/retain/compose/NoteCardChecklist.kt b/app/src/main/java/us/huseli/retain/compose/NoteCardChecklist.kt new file mode 100644 index 0000000..3b51db9 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/compose/NoteCardChecklist.kt @@ -0,0 +1,60 @@ +package us.huseli.retain.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.sharp.CheckBox +import androidx.compose.material.icons.sharp.CheckBoxOutlineBlank +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import us.huseli.retain.R +import us.huseli.retain.dataclasses.NoteCardChecklistData + +@Composable +fun NoteCardChecklist(data: NoteCardChecklistData) { + Column { + data.shownChecklistItems.forEach { item -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 2.dp)) { + Icon( + imageVector = if (item.checked) Icons.Sharp.CheckBox else Icons.Sharp.CheckBoxOutlineBlank, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp).size(16.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f), + ) + Text( + text = item.text, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f), + ) + } + } + + // "+ X items" + if (data.hiddenChecklistItemCount > 0) { + val text = pluralStringResource( + if (data.hiddenChecklistItemAllChecked) R.plurals.plus_x_checked_items + else R.plurals.plus_x_items, + data.hiddenChecklistItemCount, + data.hiddenChecklistItemCount, + ) + + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/us/huseli/retain/compose/NoteImageGrid.kt b/app/src/main/java/us/huseli/retain/compose/NoteImageGrid.kt index d7928a5..e34d30e 100644 --- a/app/src/main/java/us/huseli/retain/compose/NoteImageGrid.kt +++ b/app/src/main/java/us/huseli/retain/compose/NoteImageGrid.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.material3.MaterialTheme @@ -17,9 +16,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import okhttp3.internal.toImmutableList -import us.huseli.retain.data.entities.Image +import us.huseli.retain.dataclasses.entities.Image +import us.huseli.retain.viewmodels.ImageViewModel class ImageIterator( private val objects: List, @@ -50,7 +51,7 @@ class ImageIterator( @OptIn(ExperimentalFoundationApi::class) @Composable fun NoteImageGrid( - modifier: Modifier = Modifier, + viewModel: ImageViewModel = hiltViewModel(), images: List, maxRows: Int = Int.MAX_VALUE, secondaryRowHeight: Dp, @@ -58,24 +59,20 @@ fun NoteImageGrid( onImageLongClick: ((String) -> Unit)? = null, selectedImages: Set? = null, ) { - val imageLists = ImageIterator( - objects = images, - maxRows = maxRows - ) + val imageLists = ImageIterator(objects = images, maxRows = maxRows) imageLists.asSequence().forEachIndexed { index, sublist -> - var rowModifier = modifier.fillMaxWidth() + var rowModifier = Modifier.fillMaxWidth() if (index > 0) rowModifier = rowModifier.heightIn(max = secondaryRowHeight) Row(modifier = rowModifier) { sublist.forEach { image -> - val imageBitmap by image.imageBitmap.collectAsStateWithLifecycle(null) + val imageBitmap by viewModel.getImageBitmap(image.filename).collectAsStateWithLifecycle(null) + val boxModifier = + if (sublist.size == 1) Modifier.weight(1f) + else Modifier.weight(image.ratio) - Box( - modifier = Modifier - .weight(if (sublist.size == 1) 1f else image.ratio) - .aspectRatio(image.ratio) - ) { + Box(modifier = boxModifier) { var imageModifier = Modifier.fillMaxWidth() if (onImageClick != null || onImageLongClick != null) { diff --git a/app/src/main/java/us/huseli/retain/compose/RetainScaffold.kt b/app/src/main/java/us/huseli/retain/compose/RetainScaffold.kt index 7caaaac..dfa6ce8 100644 --- a/app/src/main/java/us/huseli/retain/compose/RetainScaffold.kt +++ b/app/src/main/java/us/huseli/retain/compose/RetainScaffold.kt @@ -3,37 +3,22 @@ package us.huseli.retain.compose import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.zIndex -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.systemuicontroller.rememberSystemUiController -import us.huseli.retain.R -import us.huseli.retain.viewmodels.NoteViewModel +import us.huseli.retaintheme.compose.SnackbarHosts @Composable fun RetainScaffold( modifier: Modifier = Modifier, - viewModel: NoteViewModel = hiltViewModel(), - snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, statusBarColor: Color = MaterialTheme.colorScheme.surface, navigationBarColor: Color = MaterialTheme.colorScheme.background, topBar: @Composable () -> Unit = {}, content: @Composable (PaddingValues) -> Unit ) { - val context = LocalContext.current - val snackbarMessage by viewModel.logger.snackbarMessage.collectAsStateWithLifecycle(null) - val trashedNoteCount by viewModel.trashedNoteCount.collectAsStateWithLifecycle(0) val systemUiController = rememberSystemUiController() LaunchedEffect(statusBarColor) { @@ -44,44 +29,10 @@ fun RetainScaffold( systemUiController.setNavigationBarColor(navigationBarColor) } - LaunchedEffect(snackbarMessage) { - if (snackbarMessage != viewModel.logger.lastSnackbarMessage) { - snackbarMessage?.let { - snackbarHostState.showSnackbar(it.message) - viewModel.logger.setLastSnackbarMessage(it) - } - } - } - - LaunchedEffect(trashedNoteCount) { - if (trashedNoteCount > 0) { - val message = context.resources.getQuantityString( - R.plurals.x_notes_trashed, - trashedNoteCount, - trashedNoteCount - ) - val result = snackbarHostState.showSnackbar( - message = message, - actionLabel = context.resources.getString(R.string.undo).uppercase(), - duration = SnackbarDuration.Long, - withDismissAction = true, - ) - when (result) { - SnackbarResult.ActionPerformed -> viewModel.undoTrashNotes() - SnackbarResult.Dismissed -> viewModel.reallyTrashNotes() - } - } - } - Scaffold( modifier = modifier, topBar = topBar, - snackbarHost = { - SnackbarHost( - hostState = snackbarHostState, - modifier = Modifier.zIndex(2f), - ) - }, + snackbarHost = { SnackbarHosts(modifier = Modifier.zIndex(2f)) }, content = content, ) } diff --git a/app/src/main/java/us/huseli/retain/compose/notescreen/BaseNoteScreen.kt b/app/src/main/java/us/huseli/retain/compose/notescreen/BaseNoteScreen.kt deleted file mode 100644 index ed49497..0000000 --- a/app/src/main/java/us/huseli/retain/compose/notescreen/BaseNoteScreen.kt +++ /dev/null @@ -1,235 +0,0 @@ -package us.huseli.retain.compose.notescreen - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -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.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -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.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.delay -import org.burnoutcrew.reorderable.ReorderableLazyListState -import org.burnoutcrew.reorderable.reorderable -import us.huseli.retain.R -import us.huseli.retain.compose.NoteImageGrid -import us.huseli.retain.compose.RetainScaffold -import us.huseli.retain.data.entities.ChecklistItem -import us.huseli.retain.data.entities.Image -import us.huseli.retain.data.entities.Note -import us.huseli.retain.outlinedTextFieldColors -import us.huseli.retain.ui.theme.getAppBarColor -import us.huseli.retain.ui.theme.getNoteColor -import us.huseli.retain.viewmodels.BaseEditNoteViewModel -import java.lang.Integer.max -import java.util.UUID - -@Composable -fun BaseNoteScreen( - modifier: Modifier = Modifier, - viewModel: BaseEditNoteViewModel, - noteId: UUID, - title: String, - color: String, - reorderableState: ReorderableLazyListState? = null, - lazyListState: LazyListState = reorderableState?.listState ?: rememberLazyListState(), - snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, - onTitleFieldNext: (() -> Unit)?, - onBackClick: () -> Unit, - onBackgroundClick: (() -> Unit)? = null, - onSave: (Note?, List, List, List, List) -> Unit, - onImageCarouselStart: (UUID, String) -> Unit, - contextMenu: @Composable (() -> Unit)? = null, - content: LazyListScope.() -> Unit, -) { - val context = LocalContext.current - val images by viewModel.images.collectAsStateWithLifecycle() - val trashedImageCount by viewModel.trashedImageCount.collectAsStateWithLifecycle(0) - val background = MaterialTheme.colorScheme.background - val surface = MaterialTheme.colorScheme.surface - val noteColor by remember(color) { mutableStateOf(getNoteColor(context, color, background)) } - val appBarColor by remember(color) { mutableStateOf(getAppBarColor(context, color, surface)) } - val selectedImages by viewModel.selectedImages.collectAsStateWithLifecycle() - val imageAdded by viewModel.imageAdded.collectAsStateWithLifecycle(null) - val isImageSelectEnabled = selectedImages.isNotEmpty() - - BackHandler(isImageSelectEnabled) { - viewModel.deselectAllImages() - } - - LaunchedEffect(imageAdded) { - if (imageAdded != null) lazyListState.animateScrollToItem(max(images.size - 1, 0)) - } - - LaunchedEffect(trashedImageCount) { - if (trashedImageCount > 0) { - val message = context.resources.getQuantityString( - R.plurals.x_images_trashed, - trashedImageCount, - trashedImageCount - ) - val result = snackbarHostState.showSnackbar( - message = message, - actionLabel = context.resources.getString(R.string.undo).uppercase(), - duration = SnackbarDuration.Long, - withDismissAction = true, - ) - when (result) { - SnackbarResult.ActionPerformed -> viewModel.undoTrashBitmapImages() - SnackbarResult.Dismissed -> viewModel.clearTrashedImages() - } - } - } - - DisposableEffect(Unit) { - onDispose { - onSave( - viewModel.dirtyNote, - viewModel.dirtyChecklistItems, - viewModel.dirtyImages, - viewModel.deletedChecklistItemIds, - viewModel.deletedImageIds - ) - } - } - - LaunchedEffect(Unit) { - while (true) { - onSave( - viewModel.dirtyNote, - viewModel.dirtyChecklistItems, - viewModel.dirtyImages, - viewModel.deletedChecklistItemIds, - viewModel.deletedImageIds, - ) - delay(5000L) - } - } - - RetainScaffold( - snackbarHostState = snackbarHostState, - statusBarColor = appBarColor, - navigationBarColor = noteColor, - topBar = { - if (isImageSelectEnabled) { - ImageSelectionTopAppBar( - backgroundColor = appBarColor, - selectedImageCount = selectedImages.size, - onCloseClick = { viewModel.deselectAllImages() }, - onSelectAllClick = { viewModel.selectAllImages() }, - onTrashClick = { viewModel.trashSelectedImages() }, - ) - } else { - NoteScreenTopAppBar( - backgroundColor = appBarColor, - onBackClick = onBackClick, - onImagePick = { uri -> viewModel.insertImage(uri) }, - onColorSelected = { index -> viewModel.setColor(index) } - ) - } - }, - ) { innerPadding -> - var columnModifier = modifier - .fillMaxSize() - .padding(innerPadding) - .background(noteColor) - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { - onBackgroundClick?.invoke() - } - if (reorderableState != null) columnModifier = columnModifier.reorderable(reorderableState) - - LazyColumn( - state = reorderableState?.listState ?: rememberLazyListState(), - modifier = columnModifier, - ) { - item { - NoteImageGrid( - images = images, - secondaryRowHeight = 200.dp, - onImageClick = { - if (isImageSelectEnabled) viewModel.toggleImageSelected(it) - else onImageCarouselStart(noteId, it) - }, - onImageLongClick = { viewModel.toggleImageSelected(it) }, - selectedImages = selectedImages, - ) - } - item { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - NoteTitleField( - modifier = Modifier.weight(1f), - value = title, - onValueChange = { viewModel.setTitle(it) }, - onNext = onTitleFieldNext, - ) - contextMenu?.invoke() - } - Spacer(Modifier.height(4.dp)) - } - content() - } - } -} - - -@Composable -fun NoteTitleField( - modifier: Modifier = Modifier, - value: String, - onValueChange: (String) -> Unit, - onNext: (() -> Unit)? = null, -) { - OutlinedTextField( - modifier = modifier, - value = value, - onValueChange = { onValueChange(it) }, - textStyle = MaterialTheme.typography.headlineSmall, - keyboardOptions = KeyboardOptions.Default.copy( - imeAction = ImeAction.Next, - capitalization = KeyboardCapitalization.Sentences, - ), - keyboardActions = KeyboardActions( - onNext = onNext?.let { { onNext() } } - ), - placeholder = { - Text( - text = stringResource(R.string.title), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) - ) - }, - singleLine = true, - colors = outlinedTextFieldColors(), - ) -} diff --git a/app/src/main/java/us/huseli/retain/compose/notescreen/ChecklistNoteChecklist.kt b/app/src/main/java/us/huseli/retain/compose/notescreen/ChecklistNoteChecklist.kt index 66c75b7..dd00e7d 100644 --- a/app/src/main/java/us/huseli/retain/compose/notescreen/ChecklistNoteChecklist.kt +++ b/app/src/main/java/us/huseli/retain/compose/notescreen/ChecklistNoteChecklist.kt @@ -25,53 +25,48 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.burnoutcrew.reorderable.ReorderableItem import org.burnoutcrew.reorderable.ReorderableLazyListState import us.huseli.retain.R -import us.huseli.retain.viewmodels.ChecklistItemFlow +import us.huseli.retain.dataclasses.entities.ChecklistItem import java.util.UUID -fun ChecklistNoteChecklist( +fun LazyListScope.ChecklistNoteChecklist( modifier: Modifier = Modifier, - scope: LazyListScope, state: ReorderableLazyListState, focusedItemId: UUID?, showChecked: Boolean, - uncheckedItems: List, - checkedItems: List, - onItemDeleteClick: (ChecklistItemFlow) -> Unit, - onItemCheckedChange: (ChecklistItemFlow, Boolean) -> Unit, - onItemTextFieldValueChange: (ChecklistItemFlow, TextFieldValue) -> Unit, - onNextItem: (ChecklistItemFlow) -> Unit, - onItemFocus: (ChecklistItemFlow) -> Unit, + uncheckedItems: List, + checkedItems: List, + onItemDeleteClick: (ChecklistItem) -> Unit, + onItemCheckedChange: (ChecklistItem, Boolean) -> Unit, + onItemTextChange: (ChecklistItem, String) -> Unit, + onNextItem: (ChecklistItem, TextFieldValue) -> Unit, + onItemFocus: (ChecklistItem) -> Unit, onShowCheckedClick: () -> Unit, backgroundColor: Color, ) { - scope.items(uncheckedItems, key = { it.id }) { item -> - val textFieldValue by item.textFieldValue.collectAsStateWithLifecycle() - val checked by item.checked.collectAsStateWithLifecycle() - + items(uncheckedItems, key = { it.id }) { item -> ReorderableItem(state, key = item.id) { isDragging -> ChecklistNoteChecklistRow( + item = item, modifier = modifier.background(backgroundColor), isFocused = focusedItemId == item.id, isDragging = isDragging, - textFieldValue = textFieldValue, - checked = checked, + checked = item.checked, onFocus = { onItemFocus(item) }, onDeleteClick = { onItemDeleteClick(item) }, onCheckedChange = { onItemCheckedChange(item, it) }, - onNext = { onNextItem(item) }, + onNext = { onNextItem(item, it) }, reorderableState = state, - onTextFieldValueChange = { onItemTextFieldValueChange(item, it) }, + onTextChange = { onItemTextChange(item, it) }, ) } } // Checked items: if (checkedItems.isNotEmpty()) { - scope.item { + item { val showCheckedIconRotation by animateFloatAsState(if (showChecked) 0f else 180f) Row( @@ -96,28 +91,25 @@ fun ChecklistNoteChecklist( } if (showChecked) { - scope.items(checkedItems, key = { it.id }) { item -> - val textFieldValue by item.textFieldValue.collectAsStateWithLifecycle() - val checked by item.checked.collectAsStateWithLifecycle() - + items(checkedItems, key = { it.id }) { item -> ReorderableItem(state, key = item.id) { isDragging -> ChecklistNoteChecklistRow( + item = item, modifier = modifier.background(backgroundColor), isFocused = focusedItemId == item.id, isDragging = isDragging, - textFieldValue = textFieldValue, - checked = checked, + checked = item.checked, onFocus = { onItemFocus(item) }, onDeleteClick = { onItemDeleteClick(item) }, onCheckedChange = { onItemCheckedChange(item, it) }, - onNext = { onNextItem(item) }, + onNext = { onNextItem(item, it) }, reorderableState = state, - onTextFieldValueChange = { onItemTextFieldValueChange(item, it) }, + onTextChange = { onItemTextChange(item, it) }, ) } } } else { - scope.item { + item { Spacer(Modifier.height(4.dp)) } } diff --git a/app/src/main/java/us/huseli/retain/compose/notescreen/ChecklistNoteChecklistRow.kt b/app/src/main/java/us/huseli/retain/compose/notescreen/ChecklistNoteChecklistRow.kt index 5384cda..213ed7b 100644 --- a/app/src/main/java/us/huseli/retain/compose/notescreen/ChecklistNoteChecklistRow.kt +++ b/app/src/main/java/us/huseli/retain/compose/notescreen/ChecklistNoteChecklistRow.kt @@ -17,7 +17,11 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ShapeDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -33,31 +37,34 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.burnoutcrew.reorderable.ReorderableLazyListState import org.burnoutcrew.reorderable.detectReorder +import us.huseli.retain.dataclasses.entities.ChecklistItem @Composable fun ChecklistNoteChecklistRow( modifier: Modifier = Modifier, + item: ChecklistItem, isFocused: Boolean, isDragging: Boolean, - textFieldValue: TextFieldValue, checked: Boolean, onFocus: () -> Unit, onDeleteClick: () -> Unit, onCheckedChange: (Boolean) -> Unit, - onNext: () -> Unit, + onTextChange: (String) -> Unit, + onNext: (TextFieldValue) -> Unit, reorderableState: ReorderableLazyListState, - onTextFieldValueChange: (TextFieldValue) -> Unit, ) { val alpha = if (checked) 0.5f else 1f val focusRequester = remember { FocusRequester() } + var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } + + textFieldValue = textFieldValue.copy(text = item.text) val realModifier = - if (isDragging) modifier - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), - shape = ShapeDefaults.ExtraSmall - ) + if (isDragging) modifier.border( + width = 1.dp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + shape = ShapeDefaults.ExtraSmall, + ) else modifier Row( @@ -88,14 +95,15 @@ fun ChecklistNoteChecklistRow( color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha), fontSize = 16.sp, ), - onValueChange = onTextFieldValueChange, + onValueChange = { + textFieldValue = it + onTextChange(it.text) + }, keyboardOptions = KeyboardOptions.Default.copy( imeAction = ImeAction.Next, capitalization = KeyboardCapitalization.Sentences, ), - keyboardActions = KeyboardActions( - onNext = { onNext() } - ), + keyboardActions = KeyboardActions(onNext = { onNext(textFieldValue) }), modifier = Modifier .onFocusChanged { if (it.isFocused) onFocus() } .weight(1f) @@ -103,11 +111,9 @@ fun ChecklistNoteChecklistRow( .focusRequester(focusRequester) .onGloballyPositioned { if (isFocused) focusRequester.requestFocus() }, ) - IconButton(onClick = onDeleteClick) { - Icon( - imageVector = Icons.Sharp.Close, - contentDescription = null - ) - } + IconButton( + onClick = onDeleteClick, + content = { Icon(Icons.Sharp.Close, null) }, + ) } } diff --git a/app/src/main/java/us/huseli/retain/compose/notescreen/ChecklistNoteScreen.kt b/app/src/main/java/us/huseli/retain/compose/notescreen/ChecklistNoteScreen.kt deleted file mode 100644 index 3c670f3..0000000 --- a/app/src/main/java/us/huseli/retain/compose/notescreen/ChecklistNoteScreen.kt +++ /dev/null @@ -1,141 +0,0 @@ -package us.huseli.retain.compose.notescreen - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.sharp.Add -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -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 androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.burnoutcrew.reorderable.rememberReorderableLazyListState -import us.huseli.retain.R -import us.huseli.retain.data.entities.ChecklistItem -import us.huseli.retain.data.entities.Image -import us.huseli.retain.data.entities.Note -import us.huseli.retain.ui.theme.getNoteColor -import us.huseli.retain.viewmodels.EditChecklistNoteViewModel -import java.util.UUID - -@Composable -fun ChecklistNoteScreen( - modifier: Modifier = Modifier, - viewModel: EditChecklistNoteViewModel = hiltViewModel(), - onSave: (Note?, List, List, List, List) -> Unit, - onBackClick: () -> Unit, - onImageCarouselStart: (UUID, String) -> Unit, -) { - val context = LocalContext.current - val snackbarHostState = remember { SnackbarHostState() } - val note by viewModel.note.collectAsStateWithLifecycle() - val trashedChecklistItems by viewModel.trashedItems.collectAsStateWithLifecycle() - val checkedItems by viewModel.checkedItems.collectAsStateWithLifecycle(emptyList()) - val uncheckedItems by viewModel.uncheckedItems.collectAsStateWithLifecycle(emptyList()) - val focusedItemId by viewModel.focusedItemId.collectAsStateWithLifecycle() - val reorderableState = rememberReorderableLazyListState( - onMove = { from, to -> viewModel.switchItemPositions(from, to) }, - ) - val defaultColor = MaterialTheme.colorScheme.background - val noteColor by remember(note.color) { mutableStateOf(getNoteColor(context, note.color, defaultColor)) } - - LaunchedEffect(trashedChecklistItems) { - if (trashedChecklistItems.isNotEmpty()) { - val message = context.resources.getQuantityString( - R.plurals.x_checklistitems_trashed, - trashedChecklistItems.size, - trashedChecklistItems.size - ) - val result = snackbarHostState.showSnackbar( - message = message, - actionLabel = context.resources.getString(R.string.undo).uppercase(), - duration = SnackbarDuration.Long, - withDismissAction = true, - ) - when (result) { - SnackbarResult.ActionPerformed -> viewModel.undoDeleteItems() - SnackbarResult.Dismissed -> viewModel.clearTrashedItems() - } - } - } - - BaseNoteScreen( - modifier = modifier, - viewModel = viewModel, - noteId = note.id, - color = note.color, - title = note.title, - reorderableState = reorderableState, - onTitleFieldNext = { - if (checkedItems.isEmpty() && uncheckedItems.isEmpty()) { - viewModel.insertItem(text = "", checked = false, index = 0) - } - }, - onBackClick = onBackClick, - onSave = onSave, - onImageCarouselStart = onImageCarouselStart, - snackbarHostState = snackbarHostState, - contextMenu = { - ChecklistNoteContextMenu( - onDeleteCheckedClick = { viewModel.deleteCheckedItems() }, - onUncheckAllClick = { viewModel.uncheckAllItems() }, - ) - } - ) { - ChecklistNoteChecklist( - scope = this, - state = reorderableState, - showChecked = note.showChecked, - uncheckedItems = uncheckedItems, - checkedItems = checkedItems, - onItemDeleteClick = { viewModel.deleteItem(it) }, - onItemCheckedChange = { item, value -> viewModel.updateItemChecked(item, value) }, - onItemTextFieldValueChange = { item, textFieldValue -> - viewModel.onTextFieldValueChange(item, textFieldValue) - }, - onNextItem = { item -> viewModel.onNextItem(item) }, - onShowCheckedClick = { viewModel.toggleShowChecked() }, - backgroundColor = noteColor, - focusedItemId = focusedItemId, - onItemFocus = { viewModel.onItemFocus(it) } - ) - - item { - // "Add item" link: - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { viewModel.insertItem(text = "", checked = false, index = uncheckedItems.size) } - .padding(vertical = 8.dp) - .fillMaxWidth() - ) { - Icon( - imageVector = Icons.Sharp.Add, - contentDescription = null, - modifier = Modifier.padding(horizontal = 12.dp), - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), - ) - Text( - text = stringResource(R.string.add_item), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), - modifier = Modifier.padding(horizontal = 6.dp) - ) - } - } - } -} diff --git a/app/src/main/java/us/huseli/retain/compose/notescreen/ImageSelectionTopAppBar.kt b/app/src/main/java/us/huseli/retain/compose/notescreen/ImageSelectionTopAppBar.kt new file mode 100644 index 0000000..c1a3d55 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/compose/notescreen/ImageSelectionTopAppBar.kt @@ -0,0 +1,56 @@ +package us.huseli.retain.compose.notescreen + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.sharp.Close +import androidx.compose.material.icons.sharp.Delete +import androidx.compose.material.icons.sharp.SelectAll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import us.huseli.retain.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ImageSelectionTopAppBar( + modifier: Modifier = Modifier, + backgroundColor: Color, + selectedImageCount: Int, + onCloseClick: () -> Unit, + onSelectAllClick: () -> Unit, + onTrashClick: () -> Unit, +) { + TopAppBar( + modifier = modifier, + title = { Text(selectedImageCount.toString()) }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = backgroundColor), + navigationIcon = { + IconButton(onClick = onCloseClick) { + Icon( + imageVector = Icons.Sharp.Close, + contentDescription = stringResource(R.string.exit_selection_mode), + ) + } + }, + actions = { + IconButton(onClick = onSelectAllClick) { + Icon( + imageVector = Icons.Sharp.SelectAll, + contentDescription = stringResource(R.string.select_all_images), + ) + } + IconButton(onClick = onTrashClick) { + Icon( + imageVector = Icons.Sharp.Delete, + contentDescription = stringResource(R.string.delete_selected_images), + ) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/us/huseli/retain/compose/notescreen/NoteScreen.kt b/app/src/main/java/us/huseli/retain/compose/notescreen/NoteScreen.kt new file mode 100644 index 0000000..25bd1fe --- /dev/null +++ b/app/src/main/java/us/huseli/retain/compose/notescreen/NoteScreen.kt @@ -0,0 +1,296 @@ +package us.huseli.retain.compose.notescreen + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.sharp.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.delay +import org.burnoutcrew.reorderable.rememberReorderableLazyListState +import org.burnoutcrew.reorderable.reorderable +import us.huseli.retain.Enums.NoteType +import us.huseli.retain.R +import us.huseli.retain.compose.NoteImageGrid +import us.huseli.retain.compose.RetainScaffold +import us.huseli.retain.outlinedTextFieldColors +import us.huseli.retain.ui.theme.getAppBarColor +import us.huseli.retain.ui.theme.getNoteColor +import us.huseli.retain.viewmodels.NoteViewModel +import us.huseli.retaintheme.snackbar.SnackbarEngine +import java.util.UUID + +@Composable +fun NoteScreen( + onBackClick: () -> Unit, + onImageCarouselStart: (UUID, String) -> Unit, + modifier: Modifier = Modifier, + viewModel: NoteViewModel = hiltViewModel(), +) { + val focusRequester = remember { FocusRequester() } + val context = LocalContext.current + val background = MaterialTheme.colorScheme.background + val surface = MaterialTheme.colorScheme.surface + val note by viewModel.note.collectAsStateWithLifecycle() + val images by viewModel.images.collectAsStateWithLifecycle() + val selectedImages by viewModel.selectedImages.collectAsStateWithLifecycle() + val checkedItems by viewModel.checkedItems.collectAsStateWithLifecycle(emptyList()) + val uncheckedItems by viewModel.uncheckedItems.collectAsStateWithLifecycle(emptyList()) + val focusedChecklistItemId by viewModel.focusedChecklistItemId.collectAsStateWithLifecycle() + var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } + val isUnsaved by viewModel.isUnsaved.collectAsStateWithLifecycle() + + val appBarColor by remember(note?.color) { + mutableStateOf(note?.color?.let { getAppBarColor(context, it, surface) } ?: surface) + } + val noteColor by remember(note?.color) { + mutableStateOf(note?.color?.let { getNoteColor(context, it, background) } ?: background) + } + val reorderableState = rememberReorderableLazyListState( + onMove = { from, to -> viewModel.switchItemPositions(from, to) }, + ) + + note?.text?.also { textFieldValue = textFieldValue.copy(text = it) } + + BackHandler(selectedImages.isNotEmpty()) { + viewModel.deselectAllImages() + } + + LaunchedEffect(Unit) { + while (true) { + if (isUnsaved) viewModel.save() + delay(10_000) + } + } + + val onImagesDeleted = { count: Int -> + if (count > 0) { + SnackbarEngine.addInfo( + message = context.resources.getQuantityString( + R.plurals.x_images_trashed, + count, + count, + ), + actionLabel = context.getString(R.string.undo).uppercase(), + onActionPerformed = { viewModel.undeleteImages() }, + ) + } + } + + val onChecklistItemsDeleted = { count: Int -> + if (count > 0) { + SnackbarEngine.addInfo( + message = context.resources.getQuantityString( + R.plurals.x_checklistitems_trashed, + count, + count, + ), + actionLabel = context.getString(R.string.undo).uppercase(), + onActionPerformed = { viewModel.undeleteChecklistItems() }, + ) + } + } + + RetainScaffold( + navigationBarColor = noteColor, + statusBarColor = appBarColor, + topBar = { + if (selectedImages.isNotEmpty()) { + ImageSelectionTopAppBar( + backgroundColor = appBarColor, + selectedImageCount = selectedImages.size, + onCloseClick = { viewModel.deselectAllImages() }, + onSelectAllClick = { viewModel.selectAllImages() }, + onTrashClick = { viewModel.deleteSelectedImages(onImagesDeleted) }, + ) + } else { + NoteScreenTopAppBar( + backgroundColor = appBarColor, + onBackClick = { + viewModel.save() + onBackClick() + }, + onImagePick = { uri -> viewModel.insertImage(uri) }, + onColorSelected = { index -> viewModel.setColor(index) } + ) + } + }, + ) { innerPadding -> + LazyColumn( + state = reorderableState.listState, + modifier = modifier + .fillMaxSize() + .padding(innerPadding) + .background(noteColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { + if (note?.type == NoteType.TEXT) { + focusRequester.requestFocus() + textFieldValue = textFieldValue.copy(selection = TextRange(note?.text?.length ?: 0)) + } + }, + ) + .reorderable(reorderableState) + ) { + note?.also { note -> + item { + NoteImageGrid( + images = images, + secondaryRowHeight = 200.dp, + onImageClick = { + if (selectedImages.isNotEmpty()) viewModel.toggleImageSelected(it) + else onImageCarouselStart(note.id, it) + }, + onImageLongClick = { viewModel.toggleImageSelected(it) }, + selectedImages = selectedImages, + ) + } + } + + item { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + modifier = Modifier.weight(1f), + value = note?.title ?: "", + onValueChange = { viewModel.setTitle(it) }, + textStyle = MaterialTheme.typography.headlineSmall, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Next, + capitalization = KeyboardCapitalization.Sentences, + ), + keyboardActions = KeyboardActions( + onNext = { + if (note?.type == NoteType.CHECKLIST && checkedItems.isEmpty() && uncheckedItems.isEmpty()) { + viewModel.insertChecklistItem(text = "", checked = false, index = 0) + } + } + ), + placeholder = { + Text( + text = stringResource(R.string.title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + }, + singleLine = true, + colors = outlinedTextFieldColors(), + ) + + if (note?.type == NoteType.CHECKLIST) { + ChecklistNoteContextMenu( + onDeleteCheckedClick = { viewModel.deleteCheckedItems(onChecklistItemsDeleted) }, + onUncheckAllClick = { viewModel.uncheckAllItems() }, + ) + } + } + } + + item { Spacer(Modifier.height(4.dp)) } + + note?.also { note -> + when (note.type) { + NoteType.TEXT -> { + item { + OutlinedTextField( + value = textFieldValue, + onValueChange = { + textFieldValue = it + viewModel.setText(it.text) + }, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + placeholder = { Text(text = stringResource(R.string.note)) }, + colors = outlinedTextFieldColors(), + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + } + } + NoteType.CHECKLIST -> { + ChecklistNoteChecklist( + state = reorderableState, + showChecked = note.showChecked, + uncheckedItems = uncheckedItems, + checkedItems = checkedItems, + onItemDeleteClick = { viewModel.deleteChecklistItem(it, onChecklistItemsDeleted) }, + onItemCheckedChange = { item, value -> viewModel.updateChecklistItemChecked(item, value) }, + onItemTextChange = { item, text -> viewModel.setChecklistItemText(item, text) }, + onNextItem = { item, textFieldValue -> viewModel.splitChecklistItem(item, textFieldValue) }, + onShowCheckedClick = { viewModel.toggleShowCheckedItems() }, + backgroundColor = noteColor, + focusedItemId = focusedChecklistItemId, + onItemFocus = { viewModel.setChecklistItemFocus(it) }, + ) + + item { + // "Add item" link: + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { + viewModel.insertChecklistItem( + text = "", + checked = false, + index = uncheckedItems.size, + ) + } + .padding(vertical = 8.dp) + .fillMaxWidth() + ) { + Icon( + imageVector = Icons.Sharp.Add, + contentDescription = null, + modifier = Modifier.padding(horizontal = 12.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + ) + Text( + text = stringResource(R.string.add_item), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.padding(horizontal = 6.dp) + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/us/huseli/retain/compose/notescreen/NoteScreenTopAppBar.kt b/app/src/main/java/us/huseli/retain/compose/notescreen/NoteScreenTopAppBar.kt index eca7ce1..bd75e5b 100644 --- a/app/src/main/java/us/huseli/retain/compose/notescreen/NoteScreenTopAppBar.kt +++ b/app/src/main/java/us/huseli/retain/compose/notescreen/NoteScreenTopAppBar.kt @@ -8,15 +8,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.sharp.AddAPhoto import androidx.compose.material.icons.sharp.AddPhotoAlternate import androidx.compose.material.icons.sharp.ArrowBack -import androidx.compose.material.icons.sharp.Close -import androidx.compose.material.icons.sharp.Delete import androidx.compose.material.icons.sharp.Palette -import androidx.compose.material.icons.sharp.SelectAll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -106,43 +102,3 @@ fun NoteScreenTopAppBar( } ) } - - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ImageSelectionTopAppBar( - modifier: Modifier = Modifier, - backgroundColor: Color, - selectedImageCount: Int, - onCloseClick: () -> Unit, - onSelectAllClick: () -> Unit, - onTrashClick: () -> Unit, -) { - TopAppBar( - modifier = modifier, - title = { Text(selectedImageCount.toString()) }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = backgroundColor), - navigationIcon = { - IconButton(onClick = onCloseClick) { - Icon( - imageVector = Icons.Sharp.Close, - contentDescription = stringResource(R.string.exit_selection_mode), - ) - } - }, - actions = { - IconButton(onClick = onSelectAllClick) { - Icon( - imageVector = Icons.Sharp.SelectAll, - contentDescription = stringResource(R.string.select_all_images), - ) - } - IconButton(onClick = onTrashClick) { - Icon( - imageVector = Icons.Sharp.Delete, - contentDescription = stringResource(R.string.delete_selected_images), - ) - } - } - ) -} diff --git a/app/src/main/java/us/huseli/retain/compose/notescreen/TextNoteScreen.kt b/app/src/main/java/us/huseli/retain/compose/notescreen/TextNoteScreen.kt deleted file mode 100644 index 8a345ed..0000000 --- a/app/src/main/java/us/huseli/retain/compose/notescreen/TextNoteScreen.kt +++ /dev/null @@ -1,68 +0,0 @@ -package us.huseli.retain.compose.notescreen - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import us.huseli.retain.R -import us.huseli.retain.data.entities.ChecklistItem -import us.huseli.retain.data.entities.Image -import us.huseli.retain.data.entities.Note -import us.huseli.retain.outlinedTextFieldColors -import us.huseli.retain.viewmodels.EditTextNoteViewModel -import java.util.UUID - -@Composable -fun TextNoteScreen( - modifier: Modifier = Modifier, - viewModel: EditTextNoteViewModel = hiltViewModel(), - onSave: (Note?, List, List, List, List) -> Unit, - onBackClick: () -> Unit, - onImageCarouselStart: (UUID, String) -> Unit, -) { - val note by viewModel.note.collectAsStateWithLifecycle() - val focusRequester = remember { FocusRequester() } - val textFieldValue by viewModel.textFieldValue.collectAsStateWithLifecycle() - val snackbarHostState = remember { SnackbarHostState() } - - BaseNoteScreen( - modifier = modifier, - viewModel = viewModel, - noteId = note.id, - color = note.color, - title = note.title, - onTitleFieldNext = null, - onBackClick = onBackClick, - onBackgroundClick = { - viewModel.moveCursorLast() - focusRequester.requestFocus() - }, - onSave = onSave, - onImageCarouselStart = onImageCarouselStart, - snackbarHostState = snackbarHostState, - ) { - item { - OutlinedTextField( - value = textFieldValue, - onValueChange = { viewModel.setTextFieldValue(it) }, - modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), - placeholder = { Text(text = stringResource(R.string.note)) }, - colors = outlinedTextFieldColors(), - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - ) - } - } -} diff --git a/app/src/main/java/us/huseli/retain/compose/settings/GeneralSection.kt b/app/src/main/java/us/huseli/retain/compose/settings/GeneralSection.kt index 88baa36..dbe2cbf 100644 --- a/app/src/main/java/us/huseli/retain/compose/settings/GeneralSection.kt +++ b/app/src/main/java/us/huseli/retain/compose/settings/GeneralSection.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -28,7 +29,7 @@ fun GeneralSection( viewModel: SettingsViewModel, ) { val minColumnWidth by viewModel.minColumnWidth.collectAsStateWithLifecycle() - var minColumnWidthSliderPos by remember { mutableStateOf(minColumnWidth) } + var minColumnWidthSliderPos by remember { mutableIntStateOf(minColumnWidth) } val maxScreenWidth = max(LocalConfiguration.current.screenHeightDp, LocalConfiguration.current.screenWidthDp) val maxColumnWidth = (maxScreenWidth - 24) / 10 * 10 val columnWidthSteps = (maxColumnWidth - 51) / 10 diff --git a/app/src/main/java/us/huseli/retain/compose/settings/NextCloudSection.kt b/app/src/main/java/us/huseli/retain/compose/settings/NextCloudSection.kt index feefbbe..b9e7f99 100644 --- a/app/src/main/java/us/huseli/retain/compose/settings/NextCloudSection.kt +++ b/app/src/main/java/us/huseli/retain/compose/settings/NextCloudSection.kt @@ -86,7 +86,7 @@ fun NextCloudSection( .onFocusChanged { if (!it.isFocused) { if (uriState.isNotEmpty()) { - uriState = cleanUri(uriState) + uriState = uriState.cleanUri() viewModel.updateField(PREF_NEXTCLOUD_URI, uriState) } } diff --git a/app/src/main/java/us/huseli/retain/compose/settings/SettingsScreen.kt b/app/src/main/java/us/huseli/retain/compose/settings/SettingsScreen.kt index 0eaaf8b..64f61c7 100644 --- a/app/src/main/java/us/huseli/retain/compose/settings/SettingsScreen.kt +++ b/app/src/main/java/us/huseli/retain/compose/settings/SettingsScreen.kt @@ -1,6 +1,5 @@ package us.huseli.retain.compose.settings -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -20,7 +19,6 @@ import us.huseli.retain.compose.RetainScaffold import us.huseli.retain.viewmodels.SettingsViewModel -@OptIn(ExperimentalFoundationApi::class) @Composable fun SettingsScreen( modifier: Modifier = Modifier, diff --git a/app/src/main/java/us/huseli/retain/dao/ChecklistItemDao.kt b/app/src/main/java/us/huseli/retain/dao/ChecklistItemDao.kt new file mode 100644 index 0000000..727e53a --- /dev/null +++ b/app/src/main/java/us/huseli/retain/dao/ChecklistItemDao.kt @@ -0,0 +1,24 @@ +package us.huseli.retain.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import us.huseli.retain.dataclasses.entities.ChecklistItem +import java.util.UUID + +@Dao +interface ChecklistItemDao { + @Query("DELETE FROM checklistitem WHERE checklistItemNoteId=:noteId AND checklistItemId NOT IN (:except)") + suspend fun _deleteByNoteId(noteId: UUID, except: Collection = emptyList()) + + @Transaction + suspend fun replace(noteId: UUID, items: Collection) { + _deleteByNoteId(noteId, except = items.map { it.id }) + upsert(items) + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(items: Collection) +} diff --git a/app/src/main/java/us/huseli/retain/data/ImageDao.kt b/app/src/main/java/us/huseli/retain/dao/ImageDao.kt similarity index 57% rename from app/src/main/java/us/huseli/retain/data/ImageDao.kt rename to app/src/main/java/us/huseli/retain/dao/ImageDao.kt index d6bca67..9b2759f 100644 --- a/app/src/main/java/us/huseli/retain/data/ImageDao.kt +++ b/app/src/main/java/us/huseli/retain/dao/ImageDao.kt @@ -1,38 +1,27 @@ -package us.huseli.retain.data +package us.huseli.retain.dao import androidx.room.Dao -import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction -import kotlinx.coroutines.flow.Flow -import us.huseli.retain.data.entities.Image +import us.huseli.retain.dataclasses.entities.Image import java.util.UUID @Dao interface ImageDao { - @Delete - suspend fun delete(images: Collection) - @Query("DELETE FROM image WHERE imageNoteId=:noteId AND imageFilename NOT IN (:except)") - suspend fun deleteByNoteId(noteId: UUID, except: Collection = emptyList()) - - @Query("SELECT * FROM image ORDER BY imageNoteId, imagePosition") - fun flowList(): Flow> + suspend fun _deleteByNoteId(noteId: UUID, except: Collection = emptyList()) @Query("SELECT COALESCE(MAX(imagePosition), -1) FROM image WHERE imageNoteId = :noteId") suspend fun getMaxPosition(noteId: UUID): Int - @Query("SELECT * FROM image WHERE imageFilename IN (:ids)") - suspend fun list(ids: List): List - @Query("SELECT * FROM image WHERE imageNoteId = :noteId ORDER BY imagePosition") suspend fun listByNoteId(noteId: UUID): List @Transaction suspend fun replace(noteId: UUID, images: Collection) { - deleteByNoteId(noteId, except = images.map { it.filename }) + _deleteByNoteId(noteId, except = images.map { it.filename }) upsert(images) } diff --git a/app/src/main/java/us/huseli/retain/data/NoteDao.kt b/app/src/main/java/us/huseli/retain/dao/NoteDao.kt similarity index 53% rename from app/src/main/java/us/huseli/retain/data/NoteDao.kt rename to app/src/main/java/us/huseli/retain/dao/NoteDao.kt index 3b52cff..9ae1be1 100644 --- a/app/src/main/java/us/huseli/retain/data/NoteDao.kt +++ b/app/src/main/java/us/huseli/retain/dao/NoteDao.kt @@ -1,82 +1,82 @@ -package us.huseli.retain.data +package us.huseli.retain.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert +import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import androidx.room.Update import kotlinx.coroutines.flow.Flow -import us.huseli.retain.data.entities.DeletedNote -import us.huseli.retain.data.entities.Note -import us.huseli.retain.data.entities.NoteCombo +import us.huseli.retain.dataclasses.NotePojo +import us.huseli.retain.dataclasses.entities.DeletedNote +import us.huseli.retain.dataclasses.entities.Note import java.util.UUID @Dao interface NoteDao { @Delete - suspend fun delete(notes: Collection) + suspend fun _delete(notes: Collection) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun _insert(note: Note) + + @Insert + suspend fun _insertDeletedNotes(objs: Collection) + + @Query("SELECT * FROM Note WHERE noteIsDeleted = 1") + suspend fun _listDeletedNotes(): List + + @Query( + """ + UPDATE note SET notePosition = notePosition + 1 WHERE noteId != :id AND notePosition >= :position AND + EXISTS(SELECT * FROM note WHERE noteId != :id AND notePosition = :position) + """ + ) + suspend fun _makePlaceFor(id: UUID, position: Int) + + @Query("UPDATE note SET notePosition = :position WHERE noteId = :id") + suspend fun _updatePosition(id: UUID, position: Int) @Transaction suspend fun deleteTrashed() { - val notes = listDeletedNotes() - insertDeletedNotes(notes.map { DeletedNote(it.id) }) - delete(notes) + val notes = _listDeletedNotes() + _insertDeletedNotes(notes.map { DeletedNote(it.id) }) + _delete(notes) } + @Transaction + @Query("SELECT * FROM Note WHERE noteId = :noteId") + fun flowNotePojo(noteId: UUID): Flow + + @Transaction @Query("SELECT * FROM note WHERE noteIsDeleted = 0 ORDER BY notePosition") - fun flowList(): Flow> + fun flowNotePojoList(): Flow> @Query("SELECT COALESCE(MAX(notePosition), -1) FROM note") suspend fun getMaxPosition(): Int - @Query("SELECT * FROM note WHERE noteId = :id") - suspend fun getNote(id: UUID): Note? - - @Insert - suspend fun insert(note: Note) - - @Insert + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(notes: Collection) - @Insert - suspend fun insertDeletedNotes(objs: Collection) - @Transaction @Query("SELECT * FROM note") - suspend fun listAllCombos(): List + suspend fun listNotePojos(): List @Query("SELECT deletedNoteId FROM deletednote") suspend fun listDeletedIds(): List - @Query("SELECT * FROM Note WHERE noteIsDeleted = 1") - suspend fun listDeletedNotes(): List - - @Query( - """ - UPDATE note SET notePosition = notePosition + 1 WHERE noteId != :id AND notePosition >= :position AND - EXISTS(SELECT * FROM note WHERE noteId != :id AND notePosition = :position) - """ - ) - suspend fun makePlaceFor(id: UUID, position: Int) - - @Update - suspend fun update(note: Note) - @Update suspend fun update(notes: Collection) - @Query("UPDATE note SET notePosition = :position WHERE noteId = :id") - suspend fun updatePosition(id: UUID, position: Int) - @Transaction suspend fun updatePositions(notes: Collection) { - notes.forEach { updatePosition(it.id, it.position) } + notes.forEach { _updatePosition(it.id, it.position) } } @Transaction suspend fun upsert(note: Note) { - makePlaceFor(note.id, note.position) - getNote(note.id)?.let { update(note) } ?: kotlin.run { insert(note) } + _makePlaceFor(note.id, note.position) + _insert(note) } } diff --git a/app/src/main/java/us/huseli/retain/data/ChecklistItemDao.kt b/app/src/main/java/us/huseli/retain/data/ChecklistItemDao.kt deleted file mode 100644 index 000033a..0000000 --- a/app/src/main/java/us/huseli/retain/data/ChecklistItemDao.kt +++ /dev/null @@ -1,35 +0,0 @@ -package us.huseli.retain.data - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Transaction -import kotlinx.coroutines.flow.Flow -import us.huseli.retain.data.entities.ChecklistItem -import us.huseli.retain.data.entities.ChecklistItemWithNote -import java.util.UUID - -@Dao -interface ChecklistItemDao { - @Query("DELETE FROM checklistitem WHERE checklistItemId IN (:ids)") - suspend fun delete(ids: Collection) - - @Query("DELETE FROM checklistitem WHERE checklistItemNoteId=:noteId AND checklistItemId NOT IN (:except)") - suspend fun deleteByNoteId(noteId: UUID, except: Collection = emptyList()) - - @Query("SELECT * FROM checklistitem INNER JOIN note ON checklistItemNoteId = noteId ORDER BY checklistItemNoteId, checklistItemChecked, checklistItemPosition") - fun flowListWithNote(): Flow> - - @Query("SELECT * FROM checklistitem WHERE checklistItemNoteId = :noteId ORDER BY checklistItemPosition") - suspend fun listByNoteId(noteId: UUID): List - - @Transaction - suspend fun replace(noteId: UUID, items: Collection) { - deleteByNoteId(noteId, except = items.map { it.id }) - upsert(items) - } - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsert(items: Collection) -} diff --git a/app/src/main/java/us/huseli/retain/data/NoteRepository.kt b/app/src/main/java/us/huseli/retain/data/NoteRepository.kt deleted file mode 100644 index 7aa16eb..0000000 --- a/app/src/main/java/us/huseli/retain/data/NoteRepository.kt +++ /dev/null @@ -1,143 +0,0 @@ -package us.huseli.retain.data - -import android.content.Context -import android.net.Uri -import android.os.FileObserver -import android.util.Log -import androidx.compose.ui.graphics.ImageBitmap -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.map -import us.huseli.retain.Constants.IMAGE_SUBDIR -import us.huseli.retain.LogInterface -import us.huseli.retain.Logger -import us.huseli.retain.data.entities.ChecklistItem -import us.huseli.retain.data.entities.ChecklistItemWithNote -import us.huseli.retain.data.entities.Image -import us.huseli.retain.data.entities.Note -import us.huseli.retain.data.entities.NoteCombo -import us.huseli.retain.fileToImageBitmap -import java.io.File -import java.util.UUID -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class NoteRepository @Inject constructor( - @ApplicationContext private val context: Context, - private val noteDao: NoteDao, - private val checklistItemDao: ChecklistItemDao, - private val imageDao: ImageDao, - override val logger: Logger, - private val database: Database, -) : LogInterface { - private val _eventTypeMask = FileObserver.CLOSE_WRITE or FileObserver.MOVED_TO - private val _imageDir = File(context.filesDir, IMAGE_SUBDIR).apply { mkdirs() } - private val _imageBitmaps = MutableStateFlow>(emptyMap()) - private val _imageDirObserver = object : FileObserver(_imageDir) { - override fun onEvent(event: Int, path: String?) { - if (event and _eventTypeMask != 0 && path != null) { - val eventType = when (event) { - CLOSE_WRITE -> "CLOSE_WRITE" - MOVED_TO -> "MOVED_TO" - else -> event.toString() - } - try { - addImageBitmap(path, false) - log("_imageDirObserver.onEvent($eventType, $path): finished") - } catch (e: Exception) { - log("_imageDirObserver.onEvent($eventType, $path): could not process: $e", level = Log.ERROR) - } - } - } - } - - val checklistItemsWithNote: Flow> = checklistItemDao.flowListWithNote() - val images: Flow> = imageDao.flowList().map { images -> - images.map { it.copy(imageBitmap = getImageBitmap(it.filename)) } - } - val notes: Flow> = noteDao.flowList() - - init { - _imageDirObserver.startWatching() - _imageDir.listFiles()?.forEach { file -> addImageBitmap(file) } - } - - private fun addImageBitmap(file: File, silent: Boolean = true) { - try { - fileToImageBitmap(file, context)?.let { - _imageBitmaps.value = _imageBitmaps.value.toMutableMap().apply { set(file.name, it) } - } - } catch (e: Exception) { - if (!silent) throw e - } - } - - internal fun addImageBitmap(filename: String, silent: Boolean = true) = - addImageBitmap(File(_imageDir, filename), silent) - - suspend fun archiveNotes(notes: Collection) = noteDao.update(notes.map { it.copy(isArchived = true) }) - - suspend fun deleteChecklistItems(ids: List) = checklistItemDao.delete(ids) - - suspend fun deleteImages(images: Collection) { - @Suppress("Destructure") - images.forEach { image -> - File(_imageDir, image.filename).apply { if (isFile) delete() } - } - imageDao.delete(images) - } - - suspend fun deleteTrashedNotes() = noteDao.deleteTrashed() - - suspend fun getCombo(noteId: UUID): NoteCombo? = - noteDao.getNote(noteId)?.let { note -> - NoteCombo( - note = note, - checklistItems = checklistItemDao.listByNoteId(noteId), - images = imageDao.listByNoteId(noteId).map { - it.copy(imageBitmap = getImageBitmap(it.filename)) - }, - databaseVersion = database.openHelper.readableDatabase.version, - ) - } - - private fun getImageBitmap(filename: String): Flow = _imageBitmaps.map { it[filename] } - - suspend fun getMaxNotePosition() = noteDao.getMaxPosition() - - suspend fun insertCombos(combos: List) { - noteDao.insert(combos.map { it.note }) - checklistItemDao.upsert(combos.flatMap { it.checklistItems }) - imageDao.upsert(combos.flatMap { it.images }) - } - - suspend fun listImages(ids: List) = imageDao.list(ids) - - suspend fun listImagesByNoteId(noteId: UUID) = imageDao.listByNoteId(noteId).map { - it.copy(imageBitmap = getImageBitmap(it.filename)) - } - - suspend fun trashNotes(notes: Collection) = noteDao.update(notes.map { it.copy(isDeleted = true) }) - - suspend fun unarchiveNotes(notes: Collection) = noteDao.update(notes.map { it.copy(isArchived = false) }) - - suspend fun updateNotePositions(notes: Collection) = noteDao.updatePositions(notes) - - suspend fun upsertChecklistItems(items: Collection) = checklistItemDao.upsert(items) - - suspend fun upsertImages(images: Collection) = imageDao.upsert(images) - - suspend fun upsertNote(note: Note) = noteDao.upsert(note) - - suspend fun updateNotes(notes: Collection) = noteDao.update(notes) - - suspend fun uriToImage(uri: Uri, noteId: UUID): Image? = - us.huseli.retain.uriToImage(context, uri, noteId)?.let { - it.copy( - position = imageDao.getMaxPosition(noteId) + 1, - imageBitmap = getImageBitmap(it.filename), - ) - } -} diff --git a/app/src/main/java/us/huseli/retain/data/entities/ChecklistItemWithNote.kt b/app/src/main/java/us/huseli/retain/data/entities/ChecklistItemWithNote.kt deleted file mode 100644 index 2dd83c5..0000000 --- a/app/src/main/java/us/huseli/retain/data/entities/ChecklistItemWithNote.kt +++ /dev/null @@ -1,8 +0,0 @@ -package us.huseli.retain.data.entities - -import androidx.room.Embedded - -data class ChecklistItemWithNote( - @Embedded val checklistItem: ChecklistItem, - @Embedded val note: Note, -) diff --git a/app/src/main/java/us/huseli/retain/data/entities/NoteCombo.kt b/app/src/main/java/us/huseli/retain/data/entities/NoteCombo.kt deleted file mode 100644 index e02ef37..0000000 --- a/app/src/main/java/us/huseli/retain/data/entities/NoteCombo.kt +++ /dev/null @@ -1,15 +0,0 @@ -package us.huseli.retain.data.entities - -import androidx.room.Embedded -import androidx.room.Ignore -import androidx.room.Relation - -data class NoteCombo( - @Embedded val note: Note, - @Relation(parentColumn = "noteId", entityColumn = "checklistItemNoteId") val checklistItems: List, - @Relation(parentColumn = "noteId", entityColumn = "imageNoteId") val images: List, - @Ignore val databaseVersion: Int? = null, -) { - constructor(note: Note, checklistItems: List, images: List) : - this(note, checklistItems, images, null) -} diff --git a/app/src/main/java/us/huseli/retain/dataclasses/GoogleNote.kt b/app/src/main/java/us/huseli/retain/dataclasses/GoogleNote.kt new file mode 100644 index 0000000..c869219 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/dataclasses/GoogleNote.kt @@ -0,0 +1,23 @@ +package us.huseli.retain.dataclasses + +data class GoogleNoteListContent( + val text: String, + val isChecked: Boolean, +) + +data class GoogleNoteAttachment( + val filePath: String, + val mimetype: String, +) + +data class GoogleNoteEntry( + val attachments: List? = null, + val color: String? = null, + val isTrashed: Boolean = false, + val isArchived: Boolean = false, + val listContent: List? = null, + val title: String? = null, + val userEditedTimestampUsec: Long? = null, + val createdTimestampUsec: Long? = null, + val textContent: String? = null, +) diff --git a/app/src/main/java/us/huseli/retain/dataclasses/NoteCardChecklistData.kt b/app/src/main/java/us/huseli/retain/dataclasses/NoteCardChecklistData.kt new file mode 100644 index 0000000..dfa7e75 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/dataclasses/NoteCardChecklistData.kt @@ -0,0 +1,11 @@ +package us.huseli.retain.dataclasses + +import us.huseli.retain.dataclasses.entities.ChecklistItem +import java.util.UUID + +data class NoteCardChecklistData( + val noteId: UUID, + val shownChecklistItems: List, + val hiddenChecklistItemCount: Int, + val hiddenChecklistItemAllChecked: Boolean, +) \ No newline at end of file diff --git a/app/src/main/java/us/huseli/retain/dataclasses/NotePojo.kt b/app/src/main/java/us/huseli/retain/dataclasses/NotePojo.kt new file mode 100644 index 0000000..93fa08e --- /dev/null +++ b/app/src/main/java/us/huseli/retain/dataclasses/NotePojo.kt @@ -0,0 +1,34 @@ +package us.huseli.retain.dataclasses + +import androidx.room.Embedded +import androidx.room.Ignore +import androidx.room.Relation +import us.huseli.retain.dataclasses.entities.ChecklistItem +import us.huseli.retain.dataclasses.entities.Image +import us.huseli.retain.dataclasses.entities.Note +import kotlin.math.min + +data class NotePojo( + @Embedded val note: Note, + @Relation(parentColumn = "noteId", entityColumn = "checklistItemNoteId") val checklistItems: List, + @Relation(parentColumn = "noteId", entityColumn = "imageNoteId") val images: List, + @Ignore val databaseVersion: Int? = null, +) { + constructor(note: Note, checklistItems: List, images: List) : + this(note, checklistItems, images, null) + + enum class Component { NOTE, CHECKLIST_ITEMS, IMAGES } + + fun getCardChecklist(): NoteCardChecklistData { + val filteredItems = if (note.showChecked) checklistItems else checklistItems.filter { !it.checked } + val shownItems = filteredItems.subList(0, min(filteredItems.size, 5)) + val hiddenItems = checklistItems.minus(shownItems.toSet()) + + return NoteCardChecklistData( + noteId = note.id, + shownChecklistItems = shownItems, + hiddenChecklistItemCount = hiddenItems.size, + hiddenChecklistItemAllChecked = hiddenItems.all { it.checked }, + ) + } +} diff --git a/app/src/main/java/us/huseli/retain/dataclasses/QuickNote.kt b/app/src/main/java/us/huseli/retain/dataclasses/QuickNote.kt new file mode 100644 index 0000000..2de3b53 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/dataclasses/QuickNote.kt @@ -0,0 +1,15 @@ +package us.huseli.retain.dataclasses + +data class QuickNoteTodoList( + val todo: Collection? = null, + val done: Collection? = null, +) + +@Suppress("PropertyName") +data class QuickNoteEntry( + val creation_date: Long? = null, + val title: String? = null, + val last_modification_date: Long? = null, + val color: String? = null, + val todolists: Collection? = null, +) diff --git a/app/src/main/java/us/huseli/retain/data/entities/ChecklistItem.kt b/app/src/main/java/us/huseli/retain/dataclasses/entities/ChecklistItem.kt similarity index 76% rename from app/src/main/java/us/huseli/retain/data/entities/ChecklistItem.kt rename to app/src/main/java/us/huseli/retain/dataclasses/entities/ChecklistItem.kt index b64cef2..380a771 100644 --- a/app/src/main/java/us/huseli/retain/data/entities/ChecklistItem.kt +++ b/app/src/main/java/us/huseli/retain/dataclasses/entities/ChecklistItem.kt @@ -1,4 +1,4 @@ -package us.huseli.retain.data.entities +package us.huseli.retain.dataclasses.entities import androidx.room.ColumnInfo import androidx.room.Entity @@ -17,16 +17,18 @@ import java.util.UUID )], indices = [Index("checklistItemNoteId"), Index("checklistItemPosition")], ) -open class ChecklistItem( +data class ChecklistItem( @ColumnInfo(name = "checklistItemId") @PrimaryKey val id: UUID = UUID.randomUUID(), @ColumnInfo(name = "checklistItemText", defaultValue = "") val text: String = "", @ColumnInfo(name = "checklistItemNoteId") val noteId: UUID, @ColumnInfo(name = "checklistItemChecked", defaultValue = "0") val checked: Boolean = false, @ColumnInfo(name = "checklistItemPosition", defaultValue = "0") val position: Int = 0, -) { +) : Comparable { override fun toString() = "" + override fun compareTo(other: ChecklistItem): Int = position - other.position + override fun equals(other: Any?) = other is ChecklistItem && other.id == id && other.text == text && @@ -35,18 +37,4 @@ open class ChecklistItem( other.position == position override fun hashCode() = id.hashCode() - - open fun copy( - text: String = this.text, - checked: Boolean = this.checked, - position: Int = this.position, - ): ChecklistItem { - return ChecklistItem( - id = id, - text = text, - checked = checked, - position = position, - noteId = noteId, - ) - } } diff --git a/app/src/main/java/us/huseli/retain/data/entities/DeletedNote.kt b/app/src/main/java/us/huseli/retain/dataclasses/entities/DeletedNote.kt similarity index 81% rename from app/src/main/java/us/huseli/retain/data/entities/DeletedNote.kt rename to app/src/main/java/us/huseli/retain/dataclasses/entities/DeletedNote.kt index a60f364..6c12230 100644 --- a/app/src/main/java/us/huseli/retain/data/entities/DeletedNote.kt +++ b/app/src/main/java/us/huseli/retain/dataclasses/entities/DeletedNote.kt @@ -1,4 +1,4 @@ -package us.huseli.retain.data.entities +package us.huseli.retain.dataclasses.entities import androidx.room.ColumnInfo import androidx.room.Entity diff --git a/app/src/main/java/us/huseli/retain/data/entities/Image.kt b/app/src/main/java/us/huseli/retain/dataclasses/entities/Image.kt similarity index 62% rename from app/src/main/java/us/huseli/retain/data/entities/Image.kt rename to app/src/main/java/us/huseli/retain/dataclasses/entities/Image.kt index f739184..bfce2ec 100644 --- a/app/src/main/java/us/huseli/retain/data/entities/Image.kt +++ b/app/src/main/java/us/huseli/retain/dataclasses/entities/Image.kt @@ -1,13 +1,10 @@ -package us.huseli.retain.data.entities +package us.huseli.retain.dataclasses.entities -import androidx.compose.ui.graphics.ImageBitmap import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Ignore import androidx.room.PrimaryKey -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import java.time.Instant import java.util.UUID @@ -29,43 +26,20 @@ data class Image( @ColumnInfo(name = "imageAdded") val added: Instant = Instant.now(), @ColumnInfo(name = "imageSize") val size: Int, @ColumnInfo(name = "imagePosition", defaultValue = "0") val position: Int = 0, - @Ignore @Transient val imageBitmap: Flow = MutableStateFlow(null), ) : Comparable { @Ignore val ratio: Float = if (width != null && height != null) width.toFloat() / height.toFloat() else 0f - constructor( - filename: String, - mimeType: String?, - width: Int?, - height: Int?, - noteId: UUID, - added: Instant, - size: Int, - position: Int - ) : this( - filename = filename, - mimeType = mimeType, - width = width, - height = height, - noteId = noteId, - added = added, - size = size, - position = position, - imageBitmap = MutableStateFlow(null) - ) - override fun equals(other: Any?) = other is Image && other.filename == filename && other.mimeType == mimeType && other.width == width && other.height == height && other.noteId == noteId && - other.added == added && other.size == size && other.position == position override fun hashCode() = filename.hashCode() - override fun compareTo(other: Image) = (added.epochSecond - other.added.epochSecond).toInt() + override fun compareTo(other: Image) = position - other.position } diff --git a/app/src/main/java/us/huseli/retain/data/entities/Note.kt b/app/src/main/java/us/huseli/retain/dataclasses/entities/Note.kt similarity index 94% rename from app/src/main/java/us/huseli/retain/data/entities/Note.kt rename to app/src/main/java/us/huseli/retain/dataclasses/entities/Note.kt index fa059cd..86aa744 100644 --- a/app/src/main/java/us/huseli/retain/data/entities/Note.kt +++ b/app/src/main/java/us/huseli/retain/dataclasses/entities/Note.kt @@ -1,4 +1,4 @@ -package us.huseli.retain.data.entities +package us.huseli.retain.dataclasses.entities import androidx.room.ColumnInfo import androidx.room.Entity @@ -31,8 +31,6 @@ data class Note( other.id == id && other.title == title && other.text == text && - other.created == created && - other.updated == updated && other.position == position && other.type == type && other.showChecked == showChecked && diff --git a/app/src/main/java/us/huseli/retain/di/DatabaseModule.kt b/app/src/main/java/us/huseli/retain/di/DatabaseModule.kt index a3978a0..4b5fb72 100644 --- a/app/src/main/java/us/huseli/retain/di/DatabaseModule.kt +++ b/app/src/main/java/us/huseli/retain/di/DatabaseModule.kt @@ -6,10 +6,10 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import us.huseli.retain.data.ChecklistItemDao -import us.huseli.retain.data.Database -import us.huseli.retain.data.ImageDao -import us.huseli.retain.data.NoteDao +import us.huseli.retain.dao.ChecklistItemDao +import us.huseli.retain.Database +import us.huseli.retain.dao.ImageDao +import us.huseli.retain.dao.NoteDao import javax.inject.Singleton @InstallIn(SingletonComponent::class) diff --git a/app/src/main/java/us/huseli/retain/repositories/NoteRepository.kt b/app/src/main/java/us/huseli/retain/repositories/NoteRepository.kt new file mode 100644 index 0000000..a9af0b1 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/repositories/NoteRepository.kt @@ -0,0 +1,110 @@ +package us.huseli.retain.repositories + +import android.content.Context +import android.net.Uri +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.room.withTransaction +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import us.huseli.retain.Constants.IMAGE_SUBDIR +import us.huseli.retain.Database +import us.huseli.retain.LogInterface +import us.huseli.retain.Logger +import us.huseli.retain.dao.ChecklistItemDao +import us.huseli.retain.dao.ImageDao +import us.huseli.retain.dao.NoteDao +import us.huseli.retain.dataclasses.NotePojo +import us.huseli.retain.dataclasses.entities.Image +import us.huseli.retain.dataclasses.entities.Note +import us.huseli.retain.toBitmap +import java.io.File +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NoteRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val noteDao: NoteDao, + private val checklistItemDao: ChecklistItemDao, + private val imageDao: ImageDao, + override val logger: Logger, + private val database: Database, + private val ioScope: CoroutineScope, +) : LogInterface { + private val _imageDir = File(context.filesDir, IMAGE_SUBDIR).apply { mkdirs() } + private val _imageBitmaps = MutableStateFlow>(emptyMap()) + + val pojos: Flow> = noteDao.flowNotePojoList() + + suspend fun archiveNotes(notes: Collection) = noteDao.update(notes.map { it.copy(isArchived = true) }) + + suspend fun deleteTrashedNotes() = noteDao.deleteTrashed() + + fun flowNotePojo(noteId: UUID) = noteDao.flowNotePojo(noteId).map { pojo -> + pojo?.let { it.copy(checklistItems = it.checklistItems.sorted(), images = it.images.sorted()) } + } + + fun getImageBitmap(filename: String): Flow = _imageBitmaps.map { imageBitmapMap -> + imageBitmapMap[filename].let { imageBitmap -> + imageBitmap ?: File(_imageDir, filename).toBitmap() + ?.asImageBitmap() + ?.also { _imageBitmaps.value += filename to it } + } + } + + suspend fun getMaxNotePosition(): Int = noteDao.getMaxPosition() + + suspend fun insertNotePojos(pojos: List) { + noteDao.insert(pojos.map { it.note }) + checklistItemDao.upsert(pojos.flatMap { it.checklistItems }) + imageDao.upsert(pojos.flatMap { it.images }) + } + + suspend fun listImagesByNoteId(noteId: UUID): List = imageDao.listByNoteId(noteId) + + fun saveNotePojo(pojo: NotePojo, components: List) { + /** + * Not a suspend function, since it should not be tied to the scope of + * any single viewmodel or composition. + * + * Reindexes ChecklistItems and Images before saving. + */ + ioScope.launch { + database.withTransaction { + if (components.contains(NotePojo.Component.NOTE)) noteDao.upsert(pojo.note) + if (components.contains(NotePojo.Component.CHECKLIST_ITEMS)) { + val checkedItems = pojo.checklistItems.filter { it.checked } + .mapIndexed { index, item -> item.copy(position = index) } + val uncheckedItems = pojo.checklistItems.filter { !it.checked } + .mapIndexed { index, item -> item.copy(position = index) } + checklistItemDao.replace(pojo.note.id, uncheckedItems + checkedItems) + } + if (components.contains(NotePojo.Component.IMAGES)) { + imageDao.replace( + pojo.note.id, + pojo.images.mapIndexed { index, image -> image.copy(position = index) }, + ) + } + } + } + } + + suspend fun trashNotes(notes: Collection) = noteDao.update(notes.map { it.copy(isDeleted = true) }) + + suspend fun unarchiveNotes(notes: Collection) = noteDao.update(notes.map { it.copy(isArchived = false) }) + + suspend fun updateNotePositions(notes: Collection) = noteDao.updatePositions(notes) + + suspend fun updateNotes(notes: Collection) = noteDao.update(notes) + + suspend fun uriToImage(uri: Uri, noteId: UUID): Image? = + us.huseli.retain.uriToImage(context, uri, noteId)?.copy( + position = imageDao.getMaxPosition(noteId) + 1, + ) +} diff --git a/app/src/main/java/us/huseli/retain/data/SyncBackendRepository.kt b/app/src/main/java/us/huseli/retain/repositories/SyncBackendRepository.kt similarity index 92% rename from app/src/main/java/us/huseli/retain/data/SyncBackendRepository.kt rename to app/src/main/java/us/huseli/retain/repositories/SyncBackendRepository.kt index 0364d5a..44fb41a 100644 --- a/app/src/main/java/us/huseli/retain/data/SyncBackendRepository.kt +++ b/app/src/main/java/us/huseli/retain/repositories/SyncBackendRepository.kt @@ -1,4 +1,4 @@ -package us.huseli.retain.data +package us.huseli.retain.repositories import android.content.Context import android.content.SharedPreferences @@ -14,10 +14,14 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import us.huseli.retain.Constants import us.huseli.retain.Constants.PREF_SYNC_BACKEND +import us.huseli.retain.Database import us.huseli.retain.Enums.SyncBackend import us.huseli.retain.LogInterface import us.huseli.retain.Logger -import us.huseli.retain.data.entities.Image +import us.huseli.retain.dao.ChecklistItemDao +import us.huseli.retain.dao.ImageDao +import us.huseli.retain.dao.NoteDao +import us.huseli.retain.dataclasses.entities.Image import us.huseli.retain.syncbackend.DropboxEngine import us.huseli.retain.syncbackend.Engine import us.huseli.retain.syncbackend.NextCloudEngine @@ -86,10 +90,10 @@ class SyncBackendRepository @Inject constructor( engine.value?.let { SyncTask( engine = it, - localCombos = noteDao.listAllCombos().map { combo -> + localPojos = noteDao.listNotePojos().map { combo -> combo.copy(databaseVersion = database.openHelper.readableDatabase.version) }, - onRemoteComboUpdated = { combo -> + onRemotePojoUpdated = { combo -> ioScope.launch { noteDao.upsert(combo.note) checklistItemDao.replace(combo.note.id, combo.checklistItems) @@ -115,10 +119,10 @@ class SyncBackendRepository @Inject constructor( suspend fun uploadNotes(onResult: ((OperationTaskResult) -> Unit)? = null) { engine.value?.let { - val combos = noteDao.listAllCombos() + val pojos = noteDao.listNotePojos() UploadNoteCombosTask( engine = it, - combos = combos.map { combo -> + combos = pojos.map { combo -> combo.copy(databaseVersion = database.openHelper.readableDatabase.version) }, ).run { result -> onResult?.invoke(result) } diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadImagesTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadImagesTask.kt index b046bf8..7927803 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadImagesTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadImagesTask.kt @@ -1,7 +1,7 @@ package us.huseli.retain.syncbackend.tasks import us.huseli.retain.Constants.SYNCBACKEND_IMAGE_SUBDIR -import us.huseli.retain.data.entities.Image +import us.huseli.retain.dataclasses.entities.Image import us.huseli.retain.syncbackend.Engine import java.io.File diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadNoteCombosJSONTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadNoteCombosJSONTask.kt index 83093ab..eed2c05 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadNoteCombosJSONTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadNoteCombosJSONTask.kt @@ -2,7 +2,7 @@ package us.huseli.retain.syncbackend.tasks import com.google.gson.reflect.TypeToken import us.huseli.retain.Constants.SYNCBACKEND_JSON_SUBDIR -import us.huseli.retain.data.entities.NoteCombo +import us.huseli.retain.dataclasses.NotePojo import us.huseli.retain.syncbackend.Engine import java.io.File import java.util.UUID @@ -10,15 +10,15 @@ import java.util.UUID class DownloadNoteCombosJSONTask( engine: ET, private val deletedNoteIds: Collection -) : DownloadListJSONTask( +) : DownloadListJSONTask( engine = engine, remotePath = engine.getAbsolutePath(SYNCBACKEND_JSON_SUBDIR, "noteCombos.json"), localFile = File(engine.tempDirDown, "noteCombos.json"), ) { - override fun deserialize(json: String): List? { - val listType = object : TypeToken>() {} + override fun deserialize(json: String): List? { + val listType = object : TypeToken>() {} @Suppress("RemoveExplicitTypeArguments") - return engine.gson.fromJson>(json, listType)?.filter { + return engine.gson.fromJson>(json, listType)?.filter { !deletedNoteIds.contains(it.note.id) } } diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveImagesTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveImagesTask.kt index b5393ac..722386a 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveImagesTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveImagesTask.kt @@ -1,7 +1,7 @@ package us.huseli.retain.syncbackend.tasks import us.huseli.retain.Constants.SYNCBACKEND_IMAGE_SUBDIR -import us.huseli.retain.data.entities.Image +import us.huseli.retain.dataclasses.entities.Image import us.huseli.retain.syncbackend.Engine /** Remove: 0..n image files */ diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/SyncTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/SyncTask.kt index d20fa3a..97f6640 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/SyncTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/SyncTask.kt @@ -1,6 +1,6 @@ package us.huseli.retain.syncbackend.tasks -import us.huseli.retain.data.entities.NoteCombo +import us.huseli.retain.dataclasses.NotePojo import us.huseli.retain.syncbackend.Engine import java.io.File import java.util.UUID @@ -12,13 +12,13 @@ data class TaskLog(val simpleName: String, val totalCount: Int = 1, var finished class SyncTask( engine: ET, - private val localCombos: Collection, + private val localPojos: Collection, private val deletedNoteIds: Collection, - private val onRemoteComboUpdated: (NoteCombo) -> Unit, + private val onRemotePojoUpdated: (NotePojo) -> Unit, private val localImageDir: File, ) : Task(engine = engine) { private var isCancelled = false - private var remoteUpdatedCombos: List = emptyList() + private var remoteUpdatedPojos: List = emptyList() private val finishedTasks = listOf( TaskLog(DownloadImagesTask::class.java.simpleName, 2), TaskLog(DownloadNoteCombosJSONTask::class.java.simpleName), @@ -53,7 +53,7 @@ class SyncTask( override fun start(onResult: (TaskResult) -> Unit) { this.onResult = onResult engine.isSyncing.value = true - val images = localCombos.flatMap { it.images }.toMutableList() + val images = localPojos.flatMap { it.images }.toMutableList() runChildTask(DownloadImagesTask(engine, images.filter { !File(localImageDir, it.filename).exists() })) @@ -61,28 +61,28 @@ class SyncTask( // All notes on remote that either don't exist locally, or // have a newer timestamp than their local counterparts: @Suppress("destructure") - remoteUpdatedCombos = downTaskResult.objects.filter { remote -> - localCombos + remoteUpdatedPojos = downTaskResult.objects.filter { remote -> + localPojos .find { it.note.id == remote.note.id } ?.let { local -> local.note < remote.note } - ?: true + ?: true } - remoteUpdatedCombos.forEach { combo -> - images.addAll(combo.images) - // This will save combo to DB: - onRemoteComboUpdated(combo) + remoteUpdatedPojos.forEach { pojo -> + images.addAll(pojo.images) + // This will save pojo to DB: + onRemotePojoUpdated(pojo) } - if (remoteUpdatedCombos.isNotEmpty()) { + if (remoteUpdatedPojos.isNotEmpty()) { log( - message = "${remoteUpdatedCombos.size} new or updated notes synced from ${engine.backend.displayName}.", + message = "${remoteUpdatedPojos.size} new or updated notes synced from ${engine.backend.displayName}.", showInSnackbar = true, ) } - runChildTask(DownloadImagesTask(engine, remoteUpdatedCombos.flatMap { it.images })) + runChildTask(DownloadImagesTask(engine, remoteUpdatedPojos.flatMap { it.images })) // Now upload all notes (i.e. the pre-existing local notes joined with the updated remote ones): - val combos = localCombos.toSet().union(remoteUpdatedCombos.toSet()) - runChildTask(UploadNoteCombosTask(engine, combos)) + val pojos = localPojos.toSet().union(remoteUpdatedPojos.toSet()) + runChildTask(UploadNoteCombosTask(engine, pojos)) // Upload any images that are missing/wrong on remote: val imageFilenames = images.map { it.filename } diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadMissingImagesTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadMissingImagesTask.kt index 8b14949..97eaa95 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadMissingImagesTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadMissingImagesTask.kt @@ -2,7 +2,7 @@ package us.huseli.retain.syncbackend.tasks import us.huseli.retain.Constants import us.huseli.retain.Constants.SYNCBACKEND_IMAGE_SUBDIR -import us.huseli.retain.data.entities.Image +import us.huseli.retain.dataclasses.entities.Image import us.huseli.retain.syncbackend.Engine import java.io.File diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadNoteCombosTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadNoteCombosTask.kt index decdae0..cb39987 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadNoteCombosTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadNoteCombosTask.kt @@ -4,14 +4,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import us.huseli.retain.Constants.SYNCBACKEND_JSON_SUBDIR -import us.huseli.retain.data.entities.NoteCombo +import us.huseli.retain.dataclasses.NotePojo import us.huseli.retain.syncbackend.Engine import java.io.File import java.io.FileWriter class UploadNoteCombosTask( engine: ET, - private val combos: Collection + private val combos: Collection ) : OperationTask(engine) { private val filename = "noteCombos.json" private val remotePath = engine.getAbsolutePath(SYNCBACKEND_JSON_SUBDIR, filename) diff --git a/app/src/main/java/us/huseli/retain/viewmodels/BaseEditNoteViewModel.kt b/app/src/main/java/us/huseli/retain/viewmodels/BaseEditNoteViewModel.kt deleted file mode 100644 index 5b0fc4c..0000000 --- a/app/src/main/java/us/huseli/retain/viewmodels/BaseEditNoteViewModel.kt +++ /dev/null @@ -1,205 +0,0 @@ -package us.huseli.retain.viewmodels - -import android.net.Uri -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.TextFieldValue -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import okhttp3.internal.toImmutableList -import us.huseli.retain.Constants.NAV_ARG_NOTE_ID -import us.huseli.retain.Enums.NoteType -import us.huseli.retain.LogInterface -import us.huseli.retain.data.NoteRepository -import us.huseli.retain.data.entities.ChecklistItem -import us.huseli.retain.data.entities.Image -import us.huseli.retain.data.entities.Note -import java.util.UUID - -data class ChecklistItemFlow( - val item: ChecklistItem, - val id: UUID = item.id, - val checked: MutableStateFlow = MutableStateFlow(item.checked), - val position: MutableStateFlow = MutableStateFlow(item.position), - val textFieldValue: MutableStateFlow = MutableStateFlow( - TextFieldValue(item.text, TextRange(0)) - ) -) { - override fun equals(other: Any?): Boolean { - return when (other) { - is ChecklistItemFlow -> - other.position.value == position.value && - other.checked.value == checked.value && - other.id == id && - other.textFieldValue.value.text == textFieldValue.value.text - - is ChecklistItem -> - other.id == id && - other.position == position.value && - other.checked == checked.value && - other.text == textFieldValue.value.text - - else -> false - } - } - - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + checked.hashCode() - result = 31 * result + position.hashCode() - result = 31 * result + textFieldValue.hashCode() - return result - } -} - -abstract class BaseEditNoteViewModel( - savedStateHandle: SavedStateHandle, - private val repository: NoteRepository, - type: NoteType -) : ViewModel(), LogInterface { - private val _dirtyImages = mutableListOf() - private val _images = MutableStateFlow>(emptyList()) - private val _originalImages = mutableListOf() - private val _trashedImages = MutableStateFlow>(emptyList()) - private var _isNew = true - private val _deletedImageIds = mutableListOf() - private val _imageAdded = MutableSharedFlow() - private val _selectedImages = MutableStateFlow>(emptySet()) - - protected val _deletedChecklistItemIds = mutableListOf() - protected val _checklistItems = MutableStateFlow>(emptyList()) - protected val _dirtyChecklistItems = mutableListOf() - protected val _noteId: UUID = UUID.fromString(savedStateHandle.get(NAV_ARG_NOTE_ID)!!) - protected val _originalChecklistItems = mutableListOf() - - private var _originalNote: Note = Note(type = type, id = _noteId) - protected val _note = MutableStateFlow(_originalNote) - private val _textFieldValue = MutableStateFlow(TextFieldValue(_note.value.text)) - - val deletedChecklistItemIds: List - get() = _deletedChecklistItemIds.toImmutableList() - val deletedImageIds: List - get() = _deletedImageIds.toImmutableList() - val dirtyChecklistItems: List - get() = _dirtyChecklistItems.map { - it.item.copy(text = it.textFieldValue.value.text, checked = it.checked.value, position = it.position.value) - } - val dirtyImages: List - get() = _dirtyImages.toImmutableList() - val dirtyNote: Note? - get() { - _note.value = _note.value.copy(text = _textFieldValue.value.text) - return if ( - _originalNote != _note.value || - (_isNew && (_dirtyChecklistItems.isNotEmpty() || _dirtyImages.isNotEmpty())) - ) _note.value else null - } - val images = _images.asStateFlow() - val note = _note.asStateFlow() - val trashedImageCount = _trashedImages.map { it.size } - val selectedImages = _selectedImages.asStateFlow() - val imageAdded = _imageAdded.asSharedFlow() - val textFieldValue = _textFieldValue.asStateFlow() - - init { - viewModelScope.launch { - @Suppress("Destructure") - repository.getCombo(_noteId)?.also { combo -> - _isNew = false - _originalNote = combo.note - _note.value = combo.note - _textFieldValue.value = TextFieldValue(combo.note.text) - _originalImages.addAll(combo.images) - _images.value = combo.images - if (type == NoteType.CHECKLIST) { - _originalChecklistItems.addAll(combo.checklistItems) - _checklistItems.value = combo.checklistItems.map { ChecklistItemFlow(it) } - } - } - } - } - - private fun addDirtyImage(image: Image) { - _dirtyImages.removeIf { it.filename == image.filename } - if (_originalImages.none { it == image }) _dirtyImages.add(image) - } - - fun clearTrashedImages() { - _trashedImages.value = emptyList() - } - - fun deselectAllImages() { - _selectedImages.value = emptySet() - } - - fun insertImage(uri: Uri) = viewModelScope.launch { - repository.uriToImage(uri, _noteId)?.let { image -> - _images.value = _images.value.toMutableList().apply { add(image) } - addDirtyImage(image) - updateImagePositions() - image.imageBitmap.collect { - if (it != null) _imageAdded.emit(it) - } - } - } - - fun moveCursorLast() { - _textFieldValue.value = _textFieldValue.value.copy(selection = TextRange(_textFieldValue.value.text.length)) - } - - fun selectAllImages() { - _selectedImages.value = _images.value.map { it.filename }.toSet() - } - - fun setColor(value: String) { - if (value != _note.value.color) _note.value = _note.value.copy(color = value) - } - - fun setTextFieldValue(value: TextFieldValue) { - if (value != _textFieldValue.value) _textFieldValue.value = value - } - - fun setTitle(value: String) { - if (value != _note.value.title) _note.value = _note.value.copy(title = value) - } - - fun toggleImageSelected(filename: String) { - _selectedImages.value = _selectedImages.value.toMutableSet().apply { - if (contains(filename)) remove(filename) - else add(filename) - } - } - - fun trashSelectedImages() { - clearTrashedImages() - _trashedImages.value = _images.value.filter { _selectedImages.value.contains(it.filename) } - _deletedImageIds.addAll(_selectedImages.value) - _images.value = _images.value.toMutableList().apply { - removeAll(_trashedImages.value.toSet()) - } - deselectAllImages() - } - - fun undoTrashBitmapImages() = viewModelScope.launch { - _images.value = _images.value.toMutableList().apply { - _trashedImages.value.forEach { add(it.position, it) } - } - _deletedImageIds.removeAll(_trashedImages.value.map { it.filename }.toSet()) - clearTrashedImages() - } - - private fun updateImagePositions() { - _images.value = _images.value.mapIndexed { index, image -> - if (image.position != index) { - image.copy(position = index).also { addDirtyImage(it) } - } else image - } - } -} diff --git a/app/src/main/java/us/huseli/retain/viewmodels/DropboxViewModel.kt b/app/src/main/java/us/huseli/retain/viewmodels/DropboxViewModel.kt index 8984bdf..336be4d 100644 --- a/app/src/main/java/us/huseli/retain/viewmodels/DropboxViewModel.kt +++ b/app/src/main/java/us/huseli/retain/viewmodels/DropboxViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import us.huseli.retain.data.SyncBackendRepository +import us.huseli.retain.repositories.SyncBackendRepository import us.huseli.retain.syncbackend.DropboxEngine import us.huseli.retain.syncbackend.tasks.TestTaskResult import javax.inject.Inject diff --git a/app/src/main/java/us/huseli/retain/viewmodels/EditChecklistNoteViewModel.kt b/app/src/main/java/us/huseli/retain/viewmodels/EditChecklistNoteViewModel.kt deleted file mode 100644 index e96c590..0000000 --- a/app/src/main/java/us/huseli/retain/viewmodels/EditChecklistNoteViewModel.kt +++ /dev/null @@ -1,196 +0,0 @@ -package us.huseli.retain.viewmodels - -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.TextFieldValue -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import org.burnoutcrew.reorderable.ItemPosition -import us.huseli.retain.Enums.NoteType -import us.huseli.retain.Logger -import us.huseli.retain.data.NoteRepository -import us.huseli.retain.data.entities.ChecklistItem -import java.util.UUID -import javax.inject.Inject - -@HiltViewModel -class EditChecklistNoteViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - repository: NoteRepository, - override val logger: Logger, -) : BaseEditNoteViewModel(savedStateHandle, repository, NoteType.CHECKLIST) { - private val _focusedItemId = MutableStateFlow(null) - private val _trashedItems = MutableStateFlow>(emptyList()) - private val _checkedItems = MutableStateFlow>(emptyList()) - private val _uncheckedItems = MutableStateFlow>(emptyList()) - - val checkedItems = _checkedItems.asStateFlow() - val focusedItemId = _focusedItemId.asStateFlow() - val trashedItems = _trashedItems.asStateFlow() - val uncheckedItems = _uncheckedItems.asStateFlow() - - init { - viewModelScope.launch { - _checklistItems.collect { items -> - _checkedItems.value = items.filter { it.checked.value } - _uncheckedItems.value = items.filter { !it.checked.value } - } - } - } - - private fun addDirtyItem(item: ChecklistItemFlow) { - _dirtyChecklistItems.removeIf { it.id == item.id } - if (_originalChecklistItems.none { item.equals(it) }) _dirtyChecklistItems.add(item) - } - - fun clearTrashedItems() { - _trashedItems.value = emptyList() - } - - fun deleteCheckedItems() { - clearTrashedItems() - _trashedItems.value = _checklistItems.value.filter { it.checked.value } - val trashedItemIds = _trashedItems.value.map { it.id } - _checklistItems.value = _checklistItems.value.toMutableList().apply { - removeAll { trashedItemIds.contains(it.id) } - } - _deletedChecklistItemIds.addAll(trashedItemIds) - _dirtyChecklistItems.removeAll { trashedItemIds.contains(it.id) } - } - - fun deleteItem(item: ChecklistItemFlow, permanent: Boolean = false) { - if (!permanent) _trashedItems.value = listOf(item) - _checklistItems.value = _checklistItems.value.toMutableList().apply { - removeIf { it.id == item.id } - } - _deletedChecklistItemIds.add(item.id) - _dirtyChecklistItems.removeIf { it.id == item.id } - } - - private fun insertItem(item: ChecklistItemFlow) { - log("insertItem: inserting $item with textFieldValue=${item.textFieldValue.value}") - _checklistItems.value = _checklistItems.value.toMutableList().apply { add(item.position.value, item) } - _focusedItemId.value = item.id - addDirtyItem(item) - updatePositions() - } - - fun insertItem(text: String, checked: Boolean, index: Int) = - insertItem(ChecklistItemFlow(ChecklistItem(text = text, checked = checked, noteId = _noteId, position = index))) - - fun onItemFocus(item: ChecklistItemFlow) { - _focusedItemId.value = item.id - } - - fun onNextItem(item: ChecklistItemFlow) { - val index = _checklistItems.value.indexOf(item) // todo: go after id - val textFieldValue = item.textFieldValue.value - val head = textFieldValue.text.substring(0, textFieldValue.selection.start) - val tail = textFieldValue.text.substring(textFieldValue.selection.start) - - log("onNextItem: item=$item, head=$head, tail=$tail, index=$index") - if (tail.isNotEmpty()) updateItemTextFieldValue( - item = item, - text = head, - selection = item.textFieldValue.value.selection, - composition = null, - ) - insertItem(tail, item.checked.value, index + 1) - } - - fun onTextFieldValueChange(item: ChecklistItemFlow, textFieldValue: TextFieldValue) { - if (item.id == _focusedItemId.value && textFieldValue != item.textFieldValue.value) { - updateItemTextFieldValue( - item = item, - text = textFieldValue.text, - selection = textFieldValue.selection, - composition = textFieldValue.composition, - ) - } - } - - fun switchItemPositions(from: ItemPosition, to: ItemPosition) { - /** - * We cannot use ItemPosition.index because the lazy column contains - * a whole bunch of other junk than checklist items. - */ - val fromIdx = _checklistItems.value.indexOfFirst { it.id == from.key } - val toIdx = _checklistItems.value.indexOfFirst { it.id == to.key } - - if (fromIdx > -1 && toIdx > -1) { - log("switchItemPositions($from, $to) before: ${_checklistItems.value}") - _checklistItems.value = _checklistItems.value.toMutableList().apply { add(toIdx, removeAt(fromIdx)) } - log("switchItemPositions($from, $to) after: ${_checklistItems.value}") - updatePositions() - } - } - - fun toggleShowChecked() { - _note.value = _note.value.copy(showChecked = !_note.value.showChecked) - } - - fun uncheckAllItems() { - _checklistItems.value.filter { it.checked.value }.forEach { - it.checked.value = false - addDirtyItem(it) - } - updatePositions() - updateItemListFlows() - } - - fun undoDeleteItems() { - _checklistItems.value = _checklistItems.value.toMutableList().apply { - _trashedItems.value.forEach { - add(it.position.value, it) - addDirtyItem(it) - } - } - _deletedChecklistItemIds.removeAll(_trashedItems.value.map { it.id }.toSet()) - clearTrashedItems() - } - - fun updateItemChecked(item: ChecklistItemFlow, checked: Boolean) { - val index = _checklistItems.value.indexOfFirst { it.id == item.id } - - if (index > -1) { - item.checked.value = checked - addDirtyItem(item) - updatePositions() - updateItemListFlows() - } - } - - private fun updateItemTextFieldValue( - item: ChecklistItemFlow, - text: String, - selection: TextRange, - composition: TextRange?, - ) { - item.textFieldValue.value = item.textFieldValue.value.copy( - text = text, - selection = selection, - composition = composition, - ) - _dirtyChecklistItems.removeIf { it.id == item.id } - if (_originalChecklistItems.none { it.id == item.id && it.text == item.textFieldValue.value.text }) { - _dirtyChecklistItems.add(item) - } - } - - private fun updatePositions() { - _checklistItems.value.forEachIndexed { index, item -> - if (item.position.value != index) { - item.position.value = index - addDirtyItem(item) - } - } - } - - private fun updateItemListFlows() { - _checkedItems.value = _checklistItems.value.filter { it.checked.value } - _uncheckedItems.value = _checklistItems.value.filter { !it.checked.value } - } -} diff --git a/app/src/main/java/us/huseli/retain/viewmodels/EditTextNoteViewModel.kt b/app/src/main/java/us/huseli/retain/viewmodels/EditTextNoteViewModel.kt deleted file mode 100644 index 2f9dcbb..0000000 --- a/app/src/main/java/us/huseli/retain/viewmodels/EditTextNoteViewModel.kt +++ /dev/null @@ -1,15 +0,0 @@ -package us.huseli.retain.viewmodels - -import androidx.lifecycle.SavedStateHandle -import dagger.hilt.android.lifecycle.HiltViewModel -import us.huseli.retain.Enums.NoteType -import us.huseli.retain.Logger -import us.huseli.retain.data.NoteRepository -import javax.inject.Inject - -@HiltViewModel -class EditTextNoteViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - repository: NoteRepository, - override val logger: Logger, -) : BaseEditNoteViewModel(savedStateHandle, repository, NoteType.TEXT) diff --git a/app/src/main/java/us/huseli/retain/viewmodels/ImageCarouselViewModel.kt b/app/src/main/java/us/huseli/retain/viewmodels/ImageCarouselViewModel.kt index 6f92189..bab8f09 100644 --- a/app/src/main/java/us/huseli/retain/viewmodels/ImageCarouselViewModel.kt +++ b/app/src/main/java/us/huseli/retain/viewmodels/ImageCarouselViewModel.kt @@ -1,16 +1,23 @@ package us.huseli.retain.viewmodels +import androidx.compose.ui.graphics.ImageBitmap import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import us.huseli.retain.Constants import us.huseli.retain.Constants.NAV_ARG_IMAGE_CAROUSEL_CURRENT_ID -import us.huseli.retain.data.NoteRepository -import us.huseli.retain.data.entities.Image +import us.huseli.retain.LogInterface +import us.huseli.retain.Logger +import us.huseli.retain.dataclasses.entities.Image +import us.huseli.retain.repositories.NoteRepository import java.util.UUID import javax.inject.Inject @@ -18,7 +25,8 @@ import javax.inject.Inject class ImageCarouselViewModel @Inject constructor( repository: NoteRepository, savedStateHandle: SavedStateHandle, -) : ViewModel() { + override val logger: Logger, +) : ViewModel(), LogInterface { private val _noteId = UUID.fromString(savedStateHandle.get(Constants.NAV_ARG_NOTE_ID)!!) private val _startImageId = savedStateHandle.get(NAV_ARG_IMAGE_CAROUSEL_CURRENT_ID)!! private val _images = MutableStateFlow>(emptyList()) @@ -27,6 +35,11 @@ class ImageCarouselViewModel @Inject constructor( val images = _images.asStateFlow() val currentImage = _currentImage.asStateFlow() + @OptIn(ExperimentalCoroutinesApi::class) + val currentImageBitmap: Flow = _currentImage.flatMapLatest { image -> + image?.let { repository.getImageBitmap(it.filename) } ?: emptyFlow() + } + init { viewModelScope.launch { _images.value = repository.listImagesByNoteId(_noteId) diff --git a/app/src/main/java/us/huseli/retain/viewmodels/ImageViewModel.kt b/app/src/main/java/us/huseli/retain/viewmodels/ImageViewModel.kt new file mode 100644 index 0000000..072f7fc --- /dev/null +++ b/app/src/main/java/us/huseli/retain/viewmodels/ImageViewModel.kt @@ -0,0 +1,11 @@ +package us.huseli.retain.viewmodels + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import us.huseli.retain.repositories.NoteRepository +import javax.inject.Inject + +@HiltViewModel +class ImageViewModel @Inject constructor(private val repository: NoteRepository) : ViewModel() { + fun getImageBitmap(filename: String) = repository.getImageBitmap(filename) +} diff --git a/app/src/main/java/us/huseli/retain/viewmodels/NextCloudViewModel.kt b/app/src/main/java/us/huseli/retain/viewmodels/NextCloudViewModel.kt index a094c3b..181b83c 100644 --- a/app/src/main/java/us/huseli/retain/viewmodels/NextCloudViewModel.kt +++ b/app/src/main/java/us/huseli/retain/viewmodels/NextCloudViewModel.kt @@ -16,7 +16,7 @@ import us.huseli.retain.Constants.PREF_NEXTCLOUD_BASE_DIR import us.huseli.retain.Constants.PREF_NEXTCLOUD_PASSWORD import us.huseli.retain.Constants.PREF_NEXTCLOUD_URI import us.huseli.retain.Constants.PREF_NEXTCLOUD_USERNAME -import us.huseli.retain.data.SyncBackendRepository +import us.huseli.retain.repositories.SyncBackendRepository import us.huseli.retain.syncbackend.NextCloudEngine import us.huseli.retain.syncbackend.tasks.TaskResult import us.huseli.retain.syncbackend.tasks.TestTaskResult diff --git a/app/src/main/java/us/huseli/retain/viewmodels/NoteListViewModel.kt b/app/src/main/java/us/huseli/retain/viewmodels/NoteListViewModel.kt new file mode 100644 index 0000000..b1f4fbf --- /dev/null +++ b/app/src/main/java/us/huseli/retain/viewmodels/NoteListViewModel.kt @@ -0,0 +1,128 @@ +package us.huseli.retain.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.burnoutcrew.reorderable.ItemPosition +import us.huseli.retain.LogInterface +import us.huseli.retain.Logger +import us.huseli.retain.dataclasses.NotePojo +import us.huseli.retain.repositories.NoteRepository +import us.huseli.retain.repositories.SyncBackendRepository +import us.huseli.retain.syncbackend.tasks.OperationTaskResult +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class NoteListViewModel @Inject constructor( + private val repository: NoteRepository, + private val syncBackendRepository: SyncBackendRepository, + override val logger: Logger +) : ViewModel(), LogInterface { + private val _selectedNoteIds = MutableStateFlow>(emptySet()) + private val _trashedPojos = MutableStateFlow>(emptySet()) + private val _pojos = MutableStateFlow>(emptyList()) + private val _showArchive = MutableStateFlow(false) + + val syncBackend = syncBackendRepository.syncBackend + val isSyncBackendSyncing = syncBackendRepository.isSyncing + val showArchive = _showArchive.asStateFlow() + val trashedPojos = _trashedPojos.asStateFlow() + val isSelectEnabled = _selectedNoteIds.map { it.isNotEmpty() } + val selectedNoteIds = _selectedNoteIds.asStateFlow() + val pojos = _pojos.asStateFlow() + + init { + viewModelScope.launch { + repository.deleteTrashedNotes() + } + + viewModelScope.launch { + combine(repository.pojos, _showArchive) { pojos, showArchive -> + pojos.filter { it.note.isArchived == showArchive } + }.distinctUntilChanged().collect { _pojos.value = it } + } + } + + fun archiveSelectedNotes() = viewModelScope.launch { + val selected = _pojos.value.filter { _selectedNoteIds.value.contains(it.note.id) }.map { it.note } + + deselectAllNotes() + if (selected.isNotEmpty()) { + repository.archiveNotes(selected) + log("Archived ${selected.size} notes.", showInSnackbar = true) + } + } + + fun deselectAllNotes() { + _selectedNoteIds.value = emptySet() + } + + fun reallyTrashNotes() = viewModelScope.launch { + _trashedPojos.value = emptySet() + repository.deleteTrashedNotes() + syncBackendRepository.uploadNotes() + } + + fun saveNotePositions() = viewModelScope.launch { + repository.updateNotePositions( + _pojos.value.mapIndexedNotNull { index, pojo -> + if (pojo.note.position != index) pojo.note.copy(position = index) else null + } + ) + } + + fun selectAllNotes() { + _selectedNoteIds.value = _pojos.value.map { it.note.id }.toSet() + } + + fun switchNotePositions(from: ItemPosition, to: ItemPosition) { + _pojos.value = _pojos.value.toMutableList().apply { add(to.index, removeAt(from.index)) } + } + + fun syncBackend() = viewModelScope.launch { syncBackendRepository.sync() } + + fun toggleNoteSelected(noteId: UUID) { + if (_selectedNoteIds.value.contains(noteId)) _selectedNoteIds.value -= noteId + else _selectedNoteIds.value += noteId + } + + fun toggleShowArchive() { + _showArchive.value = !_showArchive.value + } + + fun trashSelectedNotes() { + _trashedPojos.value = _pojos.value.filter { _selectedNoteIds.value.contains(it.note.id) }.toSet() + deselectAllNotes() + viewModelScope.launch { + repository.trashNotes(_trashedPojos.value.map { it.note }) + } + } + + fun unarchiveSelectedNotes() { + val selected = _pojos.value.filter { _selectedNoteIds.value.contains(it.note.id) }.map { it.note } + + deselectAllNotes() + if (selected.isNotEmpty()) { + viewModelScope.launch { + repository.unarchiveNotes(selected) + log("Unarchived ${selected.size} notes.", showInSnackbar = true) + } + } + } + + fun undoTrashNotes() = viewModelScope.launch { + repository.updateNotes(_trashedPojos.value.map { it.note }) + _trashedPojos.value = emptySet() + } + + suspend fun uploadNotes(onResult: ((OperationTaskResult) -> Unit)? = null) { + syncBackendRepository.uploadNotes(onResult) + } +} diff --git a/app/src/main/java/us/huseli/retain/viewmodels/NoteViewModel.kt b/app/src/main/java/us/huseli/retain/viewmodels/NoteViewModel.kt index 953527f..8f9106f 100644 --- a/app/src/main/java/us/huseli/retain/viewmodels/NoteViewModel.kt +++ b/app/src/main/java/us/huseli/retain/viewmodels/NoteViewModel.kt @@ -1,195 +1,253 @@ package us.huseli.retain.viewmodels +import android.net.Uri +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch import org.burnoutcrew.reorderable.ItemPosition +import us.huseli.retain.Constants.NAV_ARG_NEW_NOTE_TYPE +import us.huseli.retain.Constants.NAV_ARG_NOTE_ID +import us.huseli.retain.Enums.NoteType import us.huseli.retain.LogInterface import us.huseli.retain.Logger -import us.huseli.retain.data.NoteRepository -import us.huseli.retain.data.SyncBackendRepository -import us.huseli.retain.data.entities.ChecklistItem -import us.huseli.retain.data.entities.Image -import us.huseli.retain.data.entities.Note +import us.huseli.retain.dataclasses.NotePojo +import us.huseli.retain.dataclasses.entities.ChecklistItem +import us.huseli.retain.dataclasses.entities.Image +import us.huseli.retain.dataclasses.entities.Note +import us.huseli.retain.repositories.NoteRepository import java.util.UUID import javax.inject.Inject -import kotlin.math.min - -data class NoteCardChecklistData( - val noteId: UUID, - val shownChecklistItems: List, - val hiddenChecklistItemCount: Int, - val hiddenChecklistItemAllChecked: Boolean, -) @HiltViewModel class NoteViewModel @Inject constructor( private val repository: NoteRepository, - private val syncBackendRepository: SyncBackendRepository, - override val logger: Logger + savedStateHandle: SavedStateHandle, + override val logger: Logger, ) : ViewModel(), LogInterface { - private val _selectedNoteIds = MutableStateFlow>(emptySet()) - private val _trashedNotes = MutableStateFlow>(emptySet()) - private val _notes = MutableStateFlow>(emptyList()) - private val _showArchive = MutableStateFlow(false) - - val syncBackend = syncBackendRepository.syncBackend - val isSyncBackendSyncing = syncBackendRepository.isSyncing - val showArchive = _showArchive.asStateFlow() - val trashedNoteCount = _trashedNotes.map { it.size } - val isSelectEnabled = _selectedNoteIds.map { it.isNotEmpty() } - val selectedNoteIds = _selectedNoteIds.asStateFlow() - val notes = _notes.asStateFlow() - val images: Flow> = repository.images - val checklistData = repository.checklistItemsWithNote.map { items -> - items - .groupBy { it.note } - .map { (note, itemsWithNote) -> note to itemsWithNote.map { it.checklistItem } } - .map { (note, items) -> - val filteredItems = if (note.showChecked) items else items.filter { !it.checked } - val shownItems = filteredItems.subList(0, min(filteredItems.size, 5)) - val hiddenItems = items.minus(shownItems.toSet()) - - NoteCardChecklistData( - noteId = note.id, - shownChecklistItems = shownItems, - hiddenChecklistItemCount = hiddenItems.size, - hiddenChecklistItemAllChecked = hiddenItems.all { it.checked }, - ) - } - } + private val _noteId: UUID = + savedStateHandle.get(NAV_ARG_NOTE_ID)?.let { UUID.fromString(it) } ?: UUID.randomUUID() + private val _newNoteType: NoteType? = + savedStateHandle.get(NAV_ARG_NEW_NOTE_TYPE)?.let { NoteType.valueOf(it) } + private val _note = MutableStateFlow(null) + private val _images = MutableStateFlow>(emptyList()) + private val _checklistItems = MutableStateFlow>(emptyList()) + private val _selectedImages = MutableStateFlow>(emptySet()) + private val _focusedChecklistItemId = MutableStateFlow(null) + private val _isUnsaved = MutableStateFlow(true) + private val _checklistItemUndoState = MutableStateFlow?>(null) + private val _imageUndoState = MutableStateFlow?>(null) + + val images = _images.asStateFlow() + val note = _note.asStateFlow() + val checkedItems = _checklistItems.map { items -> items.filter { it.checked } } + val uncheckedItems = _checklistItems.map { items -> items.filter { !it.checked } } + val selectedImages = _selectedImages.asStateFlow() + val focusedChecklistItemId = _focusedChecklistItemId.asStateFlow() + val isUnsaved = _isUnsaved.asStateFlow() init { - // Just make sure note positions are set: viewModelScope.launch { - var fetchCount = 0 - - repository.notes.takeWhile { fetchCount++ == 0 }.collect { notes -> - notes.toMutableList().mapIndexedNotNull { index, note -> - if (note.position != index) note.copy(position = index) else null - }.also { repository.updateNotes(it) } + repository.flowNotePojo(_noteId).filterNotNull().distinctUntilChanged().collect { pojo -> + _note.value = pojo.note + _images.value = pojo.images + _checklistItems.value = pojo.checklistItems + _isUnsaved.value = false } } - viewModelScope.launch { - repository.deleteTrashedNotes() + _newNoteType?.also { noteType -> + _note.value = Note(id = _noteId, type = noteType) } + } - viewModelScope.launch { - combine(repository.notes, _showArchive) { notes, showArchive -> - notes.filter { it.isArchived == showArchive } - }.collect { notes -> - _notes.value = notes - } + fun deleteCheckedItems(onFinish: (Int) -> Unit) = + deleteChecklistItems(_checklistItems.value.filter { it.checked }.map { it.id }, onFinish) + + fun deleteChecklistItem(item: ChecklistItem, onFinish: (Int) -> Unit) = + deleteChecklistItems(listOf(item.id), onFinish) + + fun deleteSelectedImages(onFinish: (Int) -> Unit) { + val trashedImageIds = _selectedImages.value + + _imageUndoState.value = _images.value + _images.value = _images.value.toMutableList().apply { + removeAll { trashedImageIds.contains(it.filename) } } + deselectAllImages() + save(NotePojo.Component.IMAGES) + onFinish(trashedImageIds.size) } - fun archiveSelectedNotes() { - val selectedNotes = _notes.value.filter { _selectedNoteIds.value.contains(it.id) } + fun deselectAllImages() { + _selectedImages.value = emptySet() + } - deselectAllNotes() - if (selectedNotes.isNotEmpty()) { - viewModelScope.launch { - repository.archiveNotes(selectedNotes) - log("Archived ${selectedNotes.size} notes.", showInSnackbar = true) - } + fun insertChecklistItem(text: String, checked: Boolean, index: Int): ChecklistItem = + ChecklistItem(text = text, checked = checked, position = index, noteId = _noteId).also { item -> + _checklistItems.value = _checklistItems.value.toMutableList().apply { add(item.position, item) } + setChecklistItemFocus(item) + updateChecklistItemPositions() + save(NotePojo.Component.CHECKLIST_ITEMS) + } + + fun insertImage(uri: Uri) = viewModelScope.launch { + repository.uriToImage(uri, _noteId)?.let { image -> + _images.value = _images.value.toMutableList().apply { add(image) } + updateImagePositions() + save(NotePojo.Component.IMAGES) } } - fun deselectAllNotes() { - _selectedNoteIds.value = emptySet() + fun save() = save(listOf(NotePojo.Component.CHECKLIST_ITEMS, NotePojo.Component.NOTE, NotePojo.Component.IMAGES)) + + fun selectAllImages() { + _selectedImages.value = _images.value.map { it.filename }.toSet() } - fun reallyTrashNotes() { - _trashedNotes.value = emptySet() - viewModelScope.launch { - repository.deleteTrashedNotes() - syncBackendRepository.uploadNotes() + fun setChecklistItemFocus(item: ChecklistItem) { + _focusedChecklistItemId.value = item.id + } + + fun setChecklistItemText(item: ChecklistItem, value: String) { + _checklistItems.value = _checklistItems.value.map { + if (it.id == item.id) it.copy(text = value) else it } + _isUnsaved.value = true } - fun save( - note: Note?, - checklistItems: List, - images: List, - deletedChecklistItemIds: List, - deletedImageIds: List - ) = viewModelScope.launch { - log("save(): note=$note, checklistItems=$checklistItems, images=$images") - note?.let { repository.upsertNote(it) } - if (checklistItems.isNotEmpty()) repository.upsertChecklistItems(checklistItems) - if (images.isNotEmpty()) repository.upsertImages(images) - if (deletedChecklistItemIds.isNotEmpty()) repository.deleteChecklistItems(deletedChecklistItemIds) - if (deletedImageIds.isNotEmpty()) { - val deletedImages = repository.listImages(deletedImageIds) - syncBackendRepository.removeImages(deletedImages) - repository.deleteImages(deletedImages) + fun setColor(value: String) { + if (value != _note.value?.color) { + _note.value = _note.value?.copy(color = value) + save(NotePojo.Component.NOTE) } } - fun saveNotePositions() = viewModelScope.launch { - repository.updateNotePositions( - _notes.value.mapIndexedNotNull { index, note -> - if (note.position != index) note.copy(position = index) else null - } - ) + fun setText(value: String) { + if (value != _note.value?.text) _note.value = _note.value?.copy(text = value) + _isUnsaved.value = true } - fun selectAllNotes() { - _selectedNoteIds.value = _notes.value.map { it.id }.toSet() + fun setTitle(value: String) { + if (value != _note.value?.title) _note.value = _note.value?.copy(title = value) + _isUnsaved.value = true } - fun switchNotePositions(from: ItemPosition, to: ItemPosition) { - _notes.value = _notes.value.toMutableList().apply { add(to.index, removeAt(from.index)) } + fun splitChecklistItem(item: ChecklistItem, textFieldValue: TextFieldValue) { + /** + * Splits item's text at cursor position, moves the last part to a new + * item, moves focus to this item. + */ + val index = _checklistItems.value.indexOfFirst { it.id == item.id } + val head = textFieldValue.text.substring(0, textFieldValue.selection.start) + val tail = textFieldValue.text.substring(textFieldValue.selection.start) + + log("onNextItem: item=$item, head=$head, tail=$tail, index=$index") + setChecklistItemText(item, head) + insertChecklistItem(text = tail, checked = item.checked, index = index + 1) } - fun syncBackend() = viewModelScope.launch { syncBackendRepository.sync() } + fun switchItemPositions(from: ItemPosition, to: ItemPosition) { + /** + * We cannot use ItemPosition.index because the lazy column contains + * a whole bunch of other junk than checklist items. + */ + val fromIdx = _checklistItems.value.indexOfFirst { it.id == from.key } + val toIdx = _checklistItems.value.indexOfFirst { it.id == to.key } + + if (fromIdx > -1 && toIdx > -1) { + log("switchItemPositions($from, $to) before: ${_checklistItems.value}") + _checklistItems.value = _checklistItems.value.toMutableList().apply { add(toIdx, removeAt(fromIdx)) } + log("switchItemPositions($from, $to) after: ${_checklistItems.value}") + updateChecklistItemPositions() + _isUnsaved.value = true + } + } - fun toggleNoteSelected(noteId: UUID) { - if (_selectedNoteIds.value.contains(noteId)) _selectedNoteIds.value -= noteId - else _selectedNoteIds.value += noteId + fun toggleImageSelected(filename: String) { + _selectedImages.value = _selectedImages.value.toMutableSet().apply { + if (contains(filename)) remove(filename) + else add(filename) + } } - fun toggleShowArchive() { - _showArchive.value = !_showArchive.value + fun toggleShowCheckedItems() { + _note.value = _note.value?.let { it.copy(showChecked = !it.showChecked) } + save(NotePojo.Component.NOTE) } - fun trashSelectedNotes() { - _trashedNotes.value = _notes.value.filter { _selectedNoteIds.value.contains(it.id) }.toSet() - deselectAllNotes() - viewModelScope.launch { - repository.trashNotes(_trashedNotes.value) + fun uncheckAllItems() { + _checklistItems.value = _checklistItems.value.map { it.copy(checked = false) } + updateChecklistItemPositions() + save(NotePojo.Component.CHECKLIST_ITEMS) + } + + fun undeleteChecklistItems() { + _checklistItemUndoState.value?.also { + _checklistItems.value = it + save(NotePojo.Component.CHECKLIST_ITEMS) } + _checklistItemUndoState.value = null } - fun unarchiveSelectedNotes() { - val selectedNotes = _notes.value.filter { _selectedNoteIds.value.contains(it.id) } + fun undeleteImages() { + _imageUndoState.value?.also { + _images.value = it + save(NotePojo.Component.IMAGES) + } + _imageUndoState.value = null + } - deselectAllNotes() - if (selectedNotes.isNotEmpty()) { - viewModelScope.launch { - repository.unarchiveNotes(selectedNotes) - log("Unarchived ${selectedNotes.size} notes.", showInSnackbar = true) - } + fun updateChecklistItemChecked(item: ChecklistItem, checked: Boolean) { + _checklistItems.value = _checklistItems.value.toMutableList().apply { + val position = filter { it.checked == checked }.takeIf { it.isNotEmpty() }?.maxOf { it.position } ?: -1 + + removeIf { it.id == item.id } + add(item.copy(checked = checked, position = position + 1)) + } + updateChecklistItemPositions() + save(NotePojo.Component.CHECKLIST_ITEMS) + } + + /** PRIVATE METHODS ******************************************************/ + + private fun deleteChecklistItems(itemIds: List, onFinish: (Int) -> Unit) { + _checklistItemUndoState.value = _checklistItems.value + _checklistItems.value = _checklistItems.value.toMutableList().apply { + removeAll { itemIds.contains(it.id) } } + save(NotePojo.Component.CHECKLIST_ITEMS) + onFinish(itemIds.size) } - fun undoTrashNotes() = viewModelScope.launch { - repository.updateNotes(_trashedNotes.value) - _trashedNotes.value = emptySet() + private fun save(component: NotePojo.Component) = save(listOf(component)) + + private fun save(components: List) = _note.value?.also { note -> + repository.saveNotePojo( + NotePojo(note = note, checklistItems = _checklistItems.value, images = _images.value), + components, + ) + _isUnsaved.value = false + } + + private fun updateChecklistItemPositions() { + _checklistItems.value = _checklistItems.value.mapIndexed { index, item -> + if (item.position != index) item.copy(position = index) else item + } + _isUnsaved.value = true } - fun uploadNotes() = viewModelScope.launch { - syncBackendRepository.uploadNotes { result -> - if (!result.success) - showError("Failed to upload note(s) to ${syncBackend.value.displayName}: ${result.message}") + private fun updateImagePositions() { + _images.value = _images.value.mapIndexed { index, image -> + if (image.position != index) image.copy(position = index) else image } + _isUnsaved.value = true } } diff --git a/app/src/main/java/us/huseli/retain/viewmodels/SFTPViewModel.kt b/app/src/main/java/us/huseli/retain/viewmodels/SFTPViewModel.kt index 409f844..b3cb4fa 100644 --- a/app/src/main/java/us/huseli/retain/viewmodels/SFTPViewModel.kt +++ b/app/src/main/java/us/huseli/retain/viewmodels/SFTPViewModel.kt @@ -17,7 +17,7 @@ import us.huseli.retain.Constants.PREF_SFTP_HOSTNAME import us.huseli.retain.Constants.PREF_SFTP_PASSWORD import us.huseli.retain.Constants.PREF_SFTP_PORT import us.huseli.retain.Constants.PREF_SFTP_USERNAME -import us.huseli.retain.data.SyncBackendRepository +import us.huseli.retain.repositories.SyncBackendRepository import us.huseli.retain.syncbackend.SFTPEngine import us.huseli.retain.syncbackend.tasks.TestTaskResult import javax.inject.Inject diff --git a/app/src/main/java/us/huseli/retain/viewmodels/SettingsViewModel.kt b/app/src/main/java/us/huseli/retain/viewmodels/SettingsViewModel.kt index 63a0abf..b9aa97c 100644 --- a/app/src/main/java/us/huseli/retain/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/us/huseli/retain/viewmodels/SettingsViewModel.kt @@ -3,7 +3,6 @@ package us.huseli.retain.viewmodels import android.content.Context import android.content.SharedPreferences import android.net.Uri -import android.util.Log import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf @@ -32,56 +31,23 @@ import us.huseli.retain.Enums.SyncBackend import us.huseli.retain.LogInterface import us.huseli.retain.Logger import us.huseli.retain.copyFileToLocal -import us.huseli.retain.data.NoteRepository -import us.huseli.retain.data.SyncBackendRepository -import us.huseli.retain.data.entities.ChecklistItem -import us.huseli.retain.data.entities.Image -import us.huseli.retain.data.entities.Note -import us.huseli.retain.data.entities.NoteCombo -import us.huseli.retain.extractFileFromZip +import us.huseli.retain.dataclasses.GoogleNoteEntry +import us.huseli.retain.dataclasses.NotePojo +import us.huseli.retain.dataclasses.QuickNoteEntry +import us.huseli.retain.dataclasses.entities.ChecklistItem +import us.huseli.retain.dataclasses.entities.Image +import us.huseli.retain.dataclasses.entities.Note +import us.huseli.retain.extractFile import us.huseli.retain.isImageFile -import us.huseli.retain.readTextFileFromZip +import us.huseli.retain.readTextFile +import us.huseli.retain.repositories.NoteRepository +import us.huseli.retain.repositories.SyncBackendRepository import us.huseli.retain.uriToImage import java.io.File import java.time.Instant import java.util.zip.ZipFile import javax.inject.Inject -data class QuickNoteTodoList( - val todo: Collection? = null, - val done: Collection? = null, -) - -data class QuickNoteEntry( - val creation_date: Long? = null, - val title: String? = null, - val last_modification_date: Long? = null, - val color: String? = null, - val todolists: Collection? = null, -) - -data class GoogleNoteListContent( - val text: String, - val isChecked: Boolean, -) - -data class GoogleNoteAttachment( - val filePath: String, - val mimetype: String, -) - -data class GoogleNoteEntry( - val attachments: List? = null, - val color: String? = null, - val isTrashed: Boolean = false, - val isArchived: Boolean = false, - val listContent: List? = null, - val title: String? = null, - val userEditedTimestampUsec: Long? = null, - val createdTimestampUsec: Long? = null, - val textContent: String? = null, -) - @HiltViewModel class SettingsViewModel @Inject constructor( @ApplicationContext context: Context, @@ -143,7 +109,7 @@ class SettingsViewModel @Inject constructor( val gson: Gson = GsonBuilder().create() val zipFile = withContext(Dispatchers.IO) { ZipFile(keepFile) } val entries = mutableListOf() - val combos = mutableListOf() + val pojos = mutableListOf() val startPosition = repository.getMaxNotePosition() + 1 var noteCount = 0 var imageCount = 0 @@ -164,7 +130,7 @@ class SettingsViewModel @Inject constructor( if (zipEntry.name.startsWith("Takeout/Keep/")) { if (zipEntry.name.endsWith(".json")) { updateCurrentAction("Extracting ${zipEntry.name}") - val json = readTextFileFromZip(zipFile, zipEntry) + val json = zipFile.readTextFile(zipEntry) gson.fromJson(json, GoogleNoteEntry::class.java)?.let { if (!it.isTrashed) entries.add(it) } @@ -172,7 +138,7 @@ class SettingsViewModel @Inject constructor( updateCurrentAction("Extracting ${zipEntry.name}") val imageFile = File(tempDir, zipEntry.name.substringAfterLast('/')).apply { deleteOnExit() } - extractFileFromZip(zipFile, zipEntry, imageFile) + zipFile.extractFile(zipEntry, imageFile) } } } @@ -189,10 +155,12 @@ class SettingsViewModel @Inject constructor( text = noteEntry.textContent ?: "", type = if (noteEntry.listContent != null) NoteType.CHECKLIST else NoteType.TEXT, isArchived = noteEntry.isArchived, - created = noteEntry.createdTimestampUsec?.let { Instant.ofEpochMilli(it / 1000) } - ?: Instant.now(), - updated = noteEntry.userEditedTimestampUsec?.let { Instant.ofEpochMilli(it / 1000) } - ?: Instant.now(), + created = noteEntry.createdTimestampUsec + ?.let { Instant.ofEpochMilli(it / 1000) } + ?: Instant.now(), + updated = noteEntry.userEditedTimestampUsec + ?.let { Instant.ofEpochMilli(it / 1000) } + ?: Instant.now(), position = startPosition + noteIndex, ) val checklistItems = noteEntry.listContent?.mapIndexed { checklistItemIndex, checklistItemEntry -> @@ -212,8 +180,8 @@ class SettingsViewModel @Inject constructor( } else null } - combos.add( - NoteCombo( + pojos.add( + NotePojo( note = note, checklistItems = checklistItems ?: emptyList(), images = images ?: emptyList() @@ -221,13 +189,13 @@ class SettingsViewModel @Inject constructor( ) } - if (combos.isNotEmpty()) { + if (pojos.isNotEmpty()) { updateCurrentAction("Saving to database") - repository.insertCombos(combos) + repository.insertNotePojos(pojos) } - log("Imported ${combos.size} notes.", showInSnackbar = true) + log("Imported ${pojos.size} notes.", showInSnackbar = true) } catch (e: Exception) { - log("Error: $e", level = Log.ERROR, showInSnackbar = true) + showError("Error: $e", e) } finally { _keepImportIsActive.value = false } @@ -251,7 +219,7 @@ class SettingsViewModel @Inject constructor( copyFileToLocal(context, zipUri, quickNoteFile) val zipFile = withContext(Dispatchers.IO) { ZipFile(quickNoteFile) } - val combos = mutableListOf() + val pojos = mutableListOf() val sqdDirs = mutableListOf() val sqdDirRegex = Regex("^.*\\.sqd/$") var notePosition = repository.getMaxNotePosition() + 1 @@ -261,9 +229,9 @@ class SettingsViewModel @Inject constructor( if (zipEntry.name.endsWith(".sqd") && !zipEntry.isDirectory) { val sqdFile = File(tempDir, zipEntry.name.substringAfterLast('/')) updateCurrentAction("Extracting ${sqdFile.name}") - extractFileFromZip(zipFile, zipEntry, sqdFile) + zipFile.extractFile(zipEntry, sqdFile) extractQuickNoteSqd(sqdFile, context, notePosition)?.let { - combos.add(it) + pojos.add(it) notePosition++ } } else sqdDirRegex.find(zipEntry.name)?.let { result -> @@ -274,18 +242,18 @@ class SettingsViewModel @Inject constructor( sqdDirs.forEach { dirname -> extractQuickNoteSqd(quickNoteFile, context, notePosition, dirname)?.let { - combos.add(it) + pojos.add(it) notePosition++ } } - if (combos.isNotEmpty()) { + if (pojos.isNotEmpty()) { updateCurrentAction("Saving to database") - repository.insertCombos(combos) + repository.insertNotePojos(pojos) } - log("Imported ${combos.size} notes.", showInSnackbar = true) + log("Imported ${pojos.size} notes.", showInSnackbar = true) } catch (e: Exception) { - log("Error: $e", level = Log.ERROR, showInSnackbar = true) + showError("Error: $e", e) } finally { _quickNoteImportIsActive.value = false } @@ -327,8 +295,8 @@ class SettingsViewModel @Inject constructor( file: File, context: Context, notePosition: Int, - baseDir: String = "" - ): NoteCombo? { + baseDir: String = "", + ): NotePojo? { val zipFile = withContext(Dispatchers.IO) { ZipFile(file) } var quickNoteEntry: QuickNoteEntry? = null var text = "" @@ -336,7 +304,7 @@ class SettingsViewModel @Inject constructor( zipFile.getEntry("${baseDir}metadata.json")?.let { zipEntry -> updateCurrentAction("Parsing ${zipEntry.name}") - val json = readTextFileFromZip(zipFile, zipEntry) + val json = zipFile.readTextFile(zipEntry) gson.fromJson(json, QuickNoteEntry::class.java)?.let { quickNoteEntry = it log(quickNoteEntry.toString()) @@ -348,7 +316,7 @@ class SettingsViewModel @Inject constructor( quickNoteEntry?.let { entry -> zipFile.getEntry("${baseDir}index.html")?.let { zipEntry -> updateCurrentAction("Parsing ${zipEntry.name}") - val html = readTextFileFromZip(zipFile, zipEntry) + val html = zipFile.readTextFile(zipEntry) val doc = Jsoup.parseBodyFragment(html) text = doc.body().wholeText().trim().replace("\n\n", "\n") } @@ -357,8 +325,7 @@ class SettingsViewModel @Inject constructor( val images = mutableListOf() var imagePosition = 0 var checklistItemPosition = 0 - val title = - entry.title + val title = entry.title ?: (if (baseDir.isNotBlank()) Regex("(?:.*/)?([^/.]+?)(?:\\.sqd)?/?$").find(baseDir)?.groupValues?.last() else null) ?: file.nameWithoutExtension @@ -405,7 +372,7 @@ class SettingsViewModel @Inject constructor( ).apply { mkdirs() } val imageFile = File(tempDir, basename).apply { deleteOnExit() } updateCurrentAction("Extracting ${zipEntry.name}") - extractFileFromZip(zipFile, zipEntry, imageFile) + zipFile.extractFile(zipEntry, imageFile) if (imageFile.exists()) { updateCurrentAction("Copying ${imageFile.name}") uriToImage(context, imageFile.toUri(), note.id)?.let { image -> @@ -416,7 +383,7 @@ class SettingsViewModel @Inject constructor( } } - return NoteCombo( + return NotePojo( note = note, checklistItems = checklistItems.toImmutableList(), images = images.toImmutableList() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b4e74c4..becc236 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,79 +1,84 @@ - Retain - Note - Add text note Add checklist - Title + Add image Add item + Add text note + An error occurred. + Retain App settings - Exit selection mode - Delete selected notes - Nextcloud URI - Settings - Nextcloud username - Nextcloud password - General - Minimum column width on home screen + Archive + Archive selected notes + Backend sync + Cancel Close + Connect + Failed to connect to host. + Connected to Dropbox account: %1$s. Debug + Delete checked + Delete selected images + Delete selected notes + Do not sync + Dropbox error + Exit selection mode + %1$s import + General Go back - Add image - Take picture - Set colour - Select note colour - Test connection - Testing … - Nextcloud error - An error occurred. - The error was: - Successfully connected to Nextcloud. - The server reported an authorization error. Username and/or password is probably incorrect. - No host with this name was found. - Failed to connect to host. + Google Keep import + You can import Google Keep notes from a zip file produced by Google Takeout. + Grid view Hide password - Show password - Nextcloud base path + Importing from %1$s, don’t go anywhere. List view - Grid view + Minimum column width on home screen Move card - Undo - Uncheck all - Delete checked - Google Keep import - %1$s import - Select zip file - You can import Google Keep notes from a zip file produced by Google Takeout. - Cancel - Importing from %1$s, don\'t go anywhere. - Select all notes - Archive selected notes - Unarchive selected notes - Archive - Select all images - Delete selected images + Nextcloud base path + Nextcloud error + Nextcloud password + Nextcloud URI + Nextcloud username + No + Not connected to Dropbox. + Note Quicknote import Import from a zip file of Quicknote notes. Only tested with Carnet exports. - Syncing with %1$s - Backend sync - Sync with Nextcloud - Sync with SFTP + Relative to the user’s home directory. + Revoke + Select all images + Select all notes + Select note colour + Select zip file + The server reported an authorization error. Username and/or password is probably incorrect. + Set colour + Settings + SFTP base directory SFTP hostname + SFTP password SFTP port SFTP username - SFTP password - No - Yes - Do not sync - SFTP base directory - Relative to the user\'s home directory. + Show password + Successfully connected to Nextcloud. Sync with Dropbox - Not connected to Dropbox. - Connected to Dropbox account: %1$s. - Connect - Revoke - The Dropbox connection is working. + Sync with Nextcloud + Sync with SFTP + Syncing with %1$s + Take picture + Test connection + Testing … The Dropbox connection is not working somehow. - Dropbox error + The Dropbox connection is working. + The error was: + Title + Unarchive selected notes + Uncheck all + Undo + No host with this name was found. + Yes + Failed to upload note(s) to %1$s: %2$s + + + 1 checked item + + %1$d checked items + + 1 item + %1$d items @@ -82,20 +87,16 @@ 1 checked item %1$d checked items - - + 1 checked item - + %1$d checked items - Item sent to trash. %1$d items sent to trash. - - Note sent to trash. - %1$d notes sent to trash. - Image sent to trash. %1$d images sent to trash. + + Note sent to trash. + %1$d notes sent to trash. +