From 839b7f86f322c8f121a1845feac9347c2c90d0ac Mon Sep 17 00:00:00 2001 From: Tyler Little Date: Thu, 22 Jun 2023 09:41:10 -0600 Subject: [PATCH] Initial drafts support TODO: - update dialog when deleting drafts - fix drafts to load earlier - test db upgrades (fresh install worked though) --- app/src/main/java/com/jerboa/MainActivity.kt | 9 ++ app/src/main/java/com/jerboa/db/AppDB.kt | 70 +++++++++- .../ui/components/comment/edit/CommentEdit.kt | 3 + .../comment/edit/CommentEditActivity.kt | 3 + .../components/comment/reply/CommentReply.kt | 3 + .../comment/reply/CommentReplyActivity.kt | 3 + .../ui/components/common/InputFields.kt | 122 ++++++++++++++++++ app/src/main/res/values/strings.xml | 3 + 8 files changed, 214 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/jerboa/MainActivity.kt b/app/src/main/java/com/jerboa/MainActivity.kt index 21c72d07a..c7ce15e44 100644 --- a/app/src/main/java/com/jerboa/MainActivity.kt +++ b/app/src/main/java/com/jerboa/MainActivity.kt @@ -37,6 +37,9 @@ import com.jerboa.db.AppDB import com.jerboa.db.AppSettingsRepository import com.jerboa.db.AppSettingsViewModel import com.jerboa.db.AppSettingsViewModelFactory +import com.jerboa.db.DraftsRepository +import com.jerboa.db.DraftsViewModel +import com.jerboa.db.DraftsViewModelFactory import com.jerboa.ui.components.comment.edit.CommentEditActivity import com.jerboa.ui.components.comment.edit.CommentEditViewModel import com.jerboa.ui.components.comment.reply.CommentReplyActivity @@ -81,6 +84,7 @@ class JerboaApplication : Application() { private val database by lazy { AppDB.getDatabase(this) } val accountRepository by lazy { AccountRepository(database.accountDao()) } val appSettingsRepository by lazy { AppSettingsRepository(database.appSettingsDao()) } + val draftsRepository by lazy { DraftsRepository(database.draftsDao()) } } class MainActivity : ComponentActivity() { @@ -108,6 +112,9 @@ class MainActivity : ComponentActivity() { private val appSettingsViewModel: AppSettingsViewModel by viewModels { AppSettingsViewModelFactory((application as JerboaApplication).appSettingsRepository) } + private val draftsViewModel: DraftsViewModel by viewModels { + DraftsViewModelFactory((application as JerboaApplication).draftsRepository) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -521,6 +528,7 @@ class MainActivity : ComponentActivity() { postViewModel = postViewModel, accountViewModel = accountViewModel, personProfileViewModel = personProfileViewModel, + draftsViewModel = draftsViewModel, navController = navController, siteViewModel = siteViewModel, isModerator = isModerator, @@ -551,6 +559,7 @@ class MainActivity : ComponentActivity() { navController = navController, personProfileViewModel = personProfileViewModel, postViewModel = postViewModel, + draftsViewModel = draftsViewModel, ) } composable( diff --git a/app/src/main/java/com/jerboa/db/AppDB.kt b/app/src/main/java/com/jerboa/db/AppDB.kt index 8f568e5e1..fb41656f0 100644 --- a/app/src/main/java/com/jerboa/db/AppDB.kt +++ b/app/src/main/java/com/jerboa/db/AppDB.kt @@ -120,6 +120,12 @@ data class AppSettings( val secureWindow: Boolean, ) +@Entity +data class Draft( + @PrimaryKey(autoGenerate = true) val id: Int, + val message: String, +) + @Dao interface AccountDao { @Query("SELECT * FROM account") @@ -159,6 +165,21 @@ interface AppSettingsDao { suspend fun updatePostViewMode(postViewMode: Int) } +@Dao +interface DraftsDao { + @Query("SELECT * FROM Draft") + fun getAll(): LiveData> + + @Query("SELECT * FROM Draft") + fun getAllSync(): List + + @Insert + fun insert(draft: Draft) + + @Delete + fun delete(draft: Draft) +} + // Declares the DAO as a private property in the constructor. Pass in the DAO // instead of the whole database, because you only need access to the DAO class AccountRepository(private val accountDao: AccountDao) { @@ -248,6 +269,16 @@ class AppSettingsRepository( } } +class DraftsRepository(private val draftsDao: DraftsDao) { + val allDrafts = draftsDao.getAll() + + fun getAllSync(): List = draftsDao.getAllSync() + + fun delete(draft: Draft) = draftsDao.delete(draft) + + fun insert(draft: Draft) = draftsDao.insert(draft) +} + val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( @@ -436,14 +467,29 @@ val MIGRATION_15_16 = object : Migration(15, 16) { } } +val MIGRATION_16_17 = object : Migration(16, 17) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL(UPDATE_APP_CHANGELOG_UNVIEWED) + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS Draft( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + message TEXT NOT NULL + ) + """ + ) + } +} + @Database( - version = 16, - entities = [Account::class, AppSettings::class], + version = 17, + entities = [Account::class, AppSettings::class, Draft::class], exportSchema = true, ) abstract class AppDB : RoomDatabase() { abstract fun accountDao(): AccountDao abstract fun appSettingsDao(): AppSettingsDao + abstract fun draftsDao(): DraftsDao companion object { @Volatile @@ -477,6 +523,7 @@ abstract class AppDB : RoomDatabase() { MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, + MIGRATION_16_17, ) // Necessary because it can't insert data on creation .addCallback(object : Callback() { @@ -567,3 +614,22 @@ class AppSettingsViewModelFactory(private val repository: AppSettingsRepository) throw IllegalArgumentException("Unknown ViewModel class") } } + +class DraftsViewModel(private val repository: DraftsRepository): ViewModel() { + val drafts = repository.allDrafts + + fun delete(draft: Draft) = viewModelScope.launch { repository.delete(draft) } + + fun insert(message: String) = viewModelScope.launch { repository.insert(Draft(0, message)) } +} + +class DraftsViewModelFactory(private val repository: DraftsRepository) : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(DraftsViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return DraftsViewModel(repository) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/app/src/main/java/com/jerboa/ui/components/comment/edit/CommentEdit.kt b/app/src/main/java/com/jerboa/ui/components/comment/edit/CommentEdit.kt index 3c771cc6d..b8a3fbe92 100644 --- a/app/src/main/java/com/jerboa/ui/components/comment/edit/CommentEdit.kt +++ b/app/src/main/java/com/jerboa/ui/components/comment/edit/CommentEdit.kt @@ -19,6 +19,7 @@ import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController import com.jerboa.R import com.jerboa.db.Account +import com.jerboa.db.DraftsViewModel import com.jerboa.ui.components.common.MarkdownTextField @OptIn(ExperimentalMaterial3Api::class) @@ -72,6 +73,7 @@ fun CommentEdit( onContentChange: (TextFieldValue) -> Unit, account: Account?, padding: PaddingValues, + draftsViewModel: DraftsViewModel, ) { val scrollState = rememberScrollState() @@ -87,6 +89,7 @@ fun CommentEdit( account = account, modifier = Modifier.fillMaxWidth(), placeholder = stringResource(R.string.comment_edit_type_your_comment), + draftsViewModel = draftsViewModel, ) } } diff --git a/app/src/main/java/com/jerboa/ui/components/comment/edit/CommentEditActivity.kt b/app/src/main/java/com/jerboa/ui/components/comment/edit/CommentEditActivity.kt index b5ac77cea..fad32031b 100644 --- a/app/src/main/java/com/jerboa/ui/components/comment/edit/CommentEditActivity.kt +++ b/app/src/main/java/com/jerboa/ui/components/comment/edit/CommentEditActivity.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.navigation.NavController import com.jerboa.api.ApiState import com.jerboa.db.AccountViewModel +import com.jerboa.db.DraftsViewModel import com.jerboa.ui.components.common.getCurrentAccount import com.jerboa.ui.components.person.PersonProfileViewModel import com.jerboa.ui.components.post.PostViewModel @@ -24,6 +25,7 @@ fun CommentEditActivity( commentEditViewModel: CommentEditViewModel, personProfileViewModel: PersonProfileViewModel, postViewModel: PostViewModel, + draftsViewModel: DraftsViewModel, ) { Log.d("jerboa", "got to comment edit activity") @@ -64,6 +66,7 @@ fun CommentEditActivity( account = account, onContentChange = { content = it }, padding = padding, + draftsViewModel = draftsViewModel, ) }, ) diff --git a/app/src/main/java/com/jerboa/ui/components/comment/reply/CommentReply.kt b/app/src/main/java/com/jerboa/ui/components/comment/reply/CommentReply.kt index c2f2cffff..5ce8e3491 100644 --- a/app/src/main/java/com/jerboa/ui/components/comment/reply/CommentReply.kt +++ b/app/src/main/java/com/jerboa/ui/components/comment/reply/CommentReply.kt @@ -24,6 +24,7 @@ import com.jerboa.datatypes.types.CommentView import com.jerboa.datatypes.types.PersonMentionView import com.jerboa.datatypes.types.PostView import com.jerboa.db.Account +import com.jerboa.db.DraftsViewModel import com.jerboa.ui.components.comment.CommentNodeHeader import com.jerboa.ui.components.comment.mentionnode.CommentMentionNodeHeader import com.jerboa.ui.components.comment.replynode.CommentReplyNodeHeader @@ -279,6 +280,7 @@ fun PostReply( onPersonClick: (personId: Int) -> Unit, isModerator: Boolean, account: Account?, + draftsViewModel: DraftsViewModel, modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() @@ -298,6 +300,7 @@ fun PostReply( account = account, modifier = Modifier.fillMaxWidth(), placeholder = stringResource(R.string.comment_reply_type_your_comment), + draftsViewModel = draftsViewModel, ) } } diff --git a/app/src/main/java/com/jerboa/ui/components/comment/reply/CommentReplyActivity.kt b/app/src/main/java/com/jerboa/ui/components/comment/reply/CommentReplyActivity.kt index e2dd5a65c..d3bab8929 100644 --- a/app/src/main/java/com/jerboa/ui/components/comment/reply/CommentReplyActivity.kt +++ b/app/src/main/java/com/jerboa/ui/components/comment/reply/CommentReplyActivity.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.navigation.NavController import com.jerboa.api.ApiState import com.jerboa.db.AccountViewModel +import com.jerboa.db.DraftsViewModel import com.jerboa.isModerator import com.jerboa.ui.components.common.LoadingBar import com.jerboa.ui.components.common.getCurrentAccount @@ -29,6 +30,7 @@ fun CommentReplyActivity( personProfileViewModel: PersonProfileViewModel, postViewModel: PostViewModel, siteViewModel: SiteViewModel, + draftsViewModel: DraftsViewModel, navController: NavController, isModerator: Boolean, ) { @@ -98,6 +100,7 @@ fun CommentReplyActivity( navController.navigate(route = "profile/$personId") }, isModerator = isModerator, + draftsViewModel = draftsViewModel, modifier = Modifier .padding(padding) .imePadding(), diff --git a/app/src/main/java/com/jerboa/ui/components/common/InputFields.kt b/app/src/main/java/com/jerboa/ui/components/common/InputFields.kt index c915d78d8..6f47397de 100644 --- a/app/src/main/java/com/jerboa/ui/components/common/InputFields.kt +++ b/app/src/main/java/com/jerboa/ui/components/common/InputFields.kt @@ -8,11 +8,14 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -25,10 +28,12 @@ import androidx.compose.material.icons.outlined.FormatStrikethrough import androidx.compose.material.icons.outlined.Image import androidx.compose.material.icons.outlined.Link import androidx.compose.material.icons.outlined.Preview +import androidx.compose.material.icons.outlined.Save import androidx.compose.material.icons.outlined.Subscript import androidx.compose.material.icons.outlined.Superscript import androidx.compose.material.icons.outlined.Title import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -43,17 +48,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType @@ -61,10 +69,13 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.getSelectedText import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.jerboa.R import com.jerboa.api.uploadPictrsImage import com.jerboa.appendMarkdownImage import com.jerboa.db.Account +import com.jerboa.db.Draft +import com.jerboa.db.DraftsViewModel import com.jerboa.imageInputStreamFromUri import com.jerboa.ui.theme.MEDIUM_PADDING import com.jerboa.ui.theme.muted @@ -80,6 +91,7 @@ fun MarkdownTextField( placeholder: String = "", focusImmediate: Boolean = true, outlined: Boolean = false, + draftsViewModel: DraftsViewModel? = null, ) { val focusRequester = remember { FocusRequester() } val imageUploading = rememberSaveable { mutableStateOf(false) } @@ -87,6 +99,7 @@ fun MarkdownTextField( var showCreateLink by remember { mutableStateOf(false) } var showPreview by remember { mutableStateOf(false) } + var showDrafts by remember { mutableStateOf(false) } if (showCreateLink) { CreateLinkDialog( @@ -106,6 +119,23 @@ fun MarkdownTextField( ) } + if (showDrafts && draftsViewModel != null) { + val drafts = draftsViewModel.drafts.observeAsState() + + SavedDraftsDialog( + drafts = drafts.value ?: listOf(), + onDismiss = { showDrafts = false }, + onSave = { + showDrafts = false + if (text.text.isNotBlank()) { + draftsViewModel.insert(text.text) + } + }, + onSelect = { onTextChange(TextFieldValue(text.text.plus(it))) }, + onDelete = { draftsViewModel.delete(it) }, + ) + } + Column(modifier = modifier) { if (outlined) { OutlinedTextField( @@ -218,6 +248,9 @@ fun MarkdownTextField( onPreviewClick = { showPreview = true }, + onDraftsClick = { + showDrafts = true + } ) } @@ -305,6 +338,74 @@ fun CreateLinkDialog( ) } +@Composable +private fun DraftItem(message: String, onDelete: () -> Unit, onSelect: (String) -> Unit) { + Box(modifier = Modifier.fillMaxWidth()) { + ClickableText( + text = AnnotatedString(message), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth(0.9F), // leave room for the button + ) { onSelect(message) } + Button( + modifier = Modifier + .align(Alignment.TopEnd) + .size((20).dp), + onClick = onDelete, + ) { + Text("x") + } + } +} + +// @OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SavedDraftsDialog( + drafts: List, + onDismiss: () -> Unit, + onSave: () -> Unit, + onDelete: (Draft) -> Unit, + onSelect: (String) -> Unit, +) { + val drafts by rememberSaveable { mutableStateOf(drafts) } + + AlertDialog( + onDismissRequest = onDismiss, + text = { + Column( + modifier = Modifier + .padding(MEDIUM_PADDING) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MEDIUM_PADDING), + ) { + Text( + text = stringResource(R.string.input_saved_drafts), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + drafts.forEach { draft -> + DraftItem( + draft.message, + onDelete = { onDelete(draft) }, + ) { onSelect(draft.message) } + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(R.string.input_fields_cancel), + color = MaterialTheme.colorScheme.onBackground.muted, + ) + } + }, + confirmButton = { + TextButton(onClick = onSave) { + Text(text = stringResource(R.string.input_fields_save)) + } + }, + ) +} + @Composable fun ShowPreviewDialog( content: String, @@ -341,6 +442,16 @@ fun CreateLinkDialogPreview() { CreateLinkDialog(onClickOk = {}, onDismissRequest = {}, value = TextFieldValue("")) } +@Preview +@Composable +fun SavedDraftsDialogPreview() { + val drafts = listOf( + Draft(0, "first message is really really long and will go onto multiple lines"), + Draft(1, "second message"), + ); + SavedDraftsDialog(drafts = drafts, onDismiss = {}, onDelete = {}, onSelect = {}, onSave = {}) +} + @Composable private fun imageUploadLauncher( account: Account?, @@ -426,6 +537,7 @@ fun simpleMarkdownSurround( @Composable fun MarkdownHelperBar( onPreviewClick: () -> Unit, + onDraftsClick: () -> Unit, onHeaderClick: () -> Unit, onImageClick: () -> Unit, onLinkClick: () -> Unit, @@ -451,6 +563,15 @@ fun MarkdownHelperBar( tint = MaterialTheme.colorScheme.onBackground.muted, ) } + IconButton( + onClick = onDraftsClick, + ) { + Icon( + imageVector = Icons.Outlined.Save, + contentDescription = stringResource(R.string.markdownHelper_drafts), + tint = MaterialTheme.colorScheme.onBackground.muted, + ) + } IconButton( onClick = onLinkClick, ) { @@ -566,6 +687,7 @@ fun TextMarkdownBarPreview() { MarkdownHelperBar( onHeaderClick = {}, onPreviewClick = {}, + onDraftsClick = {}, onImageClick = {}, onListClick = {}, onQuoteClick = {}, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 20c02e877..2a10456e3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -167,6 +167,8 @@ Insert link Link OK + Save + Saved Drafts Text Show less options Back @@ -222,6 +224,7 @@ Insert list Insert quote View preview + Drafts More options View avatar image View banner image