From 95ae988fd79de074f63bfad87e580fcdf94fcca3 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Tue, 3 Dec 2024 01:30:25 -0600 Subject: [PATCH] Added TaskEditScreen - WIP --- .../tasks/ui/editviewmodel/PriorityTests.kt | 10 +- .../tasks/ui/editviewmodel/ReminderTests.kt | 6 +- .../ui/editviewmodel/TaskEditViewModelTest.kt | 4 +- .../org/tasks/ui/editviewmodel/TitleTests.kt | 6 +- .../astrid/activity/TaskEditFragment.kt | 347 +++++--------- .../todoroo/astrid/files/FilesControlSet.kt | 22 +- .../astrid/repeats/RepeatControlSet.kt | 34 +- .../astrid/repeats/RepeatTaskHelper.kt | 3 +- .../com/todoroo/astrid/tags/TagsControlSet.kt | 33 +- .../todoroo/astrid/timers/TimerControlSet.kt | 4 +- .../todoroo/astrid/ui/ReminderControlSet.kt | 3 +- .../todoroo/astrid/ui/StartDateControlSet.kt | 6 +- .../org/tasks/compose/AddReminderDialog.kt | 8 +- .../java/org/tasks/compose/edit/AlarmRow.kt | 10 +- .../org/tasks/compose/edit/AttachmentRow.kt | 8 +- .../java/org/tasks/compose/edit/RepeatRow.kt | 27 +- .../java/org/tasks/compose/edit/TagsRow.kt | 12 +- .../org/tasks/compose/edit/TaskEditScreen.kt | 141 ++++++ .../java/org/tasks/data/PlaceExtensions.kt | 3 +- .../org/tasks/fragments/CommentBarFragment.kt | 3 +- .../TaskEditControlSetFragmentManager.kt | 67 --- .../java/org/tasks/ui/CalendarControlSet.kt | 12 +- .../java/org/tasks/ui/LocationControlSet.kt | 98 ++-- .../java/org/tasks/ui/SubtaskControlSet.kt | 38 +- .../java/org/tasks/ui/TaskEditViewModel.kt | 427 ++++++++++++------ app/src/main/res/values/keys.xml | 1 + .../kotlin/org/tasks/data/dao/TagDao.kt | 4 +- .../kotlin/org/tasks/data/entity/Task.kt | 3 +- 28 files changed, 722 insertions(+), 618 deletions(-) create mode 100644 app/src/main/java/org/tasks/compose/edit/TaskEditScreen.kt delete mode 100644 app/src/main/java/org/tasks/fragments/TaskEditControlSetFragmentManager.kt diff --git a/app/src/androidTest/java/org/tasks/ui/editviewmodel/PriorityTests.kt b/app/src/androidTest/java/org/tasks/ui/editviewmodel/PriorityTests.kt index 3f2894f42..820302326 100644 --- a/app/src/androidTest/java/org/tasks/ui/editviewmodel/PriorityTests.kt +++ b/app/src/androidTest/java/org/tasks/ui/editviewmodel/PriorityTests.kt @@ -1,11 +1,11 @@ package org.tasks.ui.editviewmodel import com.natpryce.makeiteasy.MakeItEasy -import org.tasks.data.entity.Task import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.UninstallModules import org.junit.Assert import org.junit.Test +import org.tasks.data.entity.Task import org.tasks.injection.ProductionModule import org.tasks.makers.TaskMaker @@ -16,7 +16,7 @@ class PriorityTests : BaseTaskEditViewModelTest() { fun changePriorityCausesChange() { setup(TaskMaker.newTask(MakeItEasy.with(TaskMaker.PRIORITY, Task.Priority.HIGH))) - viewModel.priority.value = Task.Priority.MEDIUM + viewModel.setPriority(Task.Priority.MEDIUM) Assert.assertTrue(viewModel.hasChanges()) } @@ -25,7 +25,7 @@ class PriorityTests : BaseTaskEditViewModelTest() { fun applyPriorityChange() { val task = TaskMaker.newTask(MakeItEasy.with(TaskMaker.PRIORITY, Task.Priority.HIGH)) setup(task) - viewModel.priority.value = Task.Priority.MEDIUM + viewModel.setPriority(Task.Priority.MEDIUM) save() @@ -36,8 +36,8 @@ class PriorityTests : BaseTaskEditViewModelTest() { fun noChangeWhenRevertingPriority() { setup(TaskMaker.newTask(MakeItEasy.with(TaskMaker.PRIORITY, Task.Priority.HIGH))) - viewModel.priority.value = Task.Priority.MEDIUM - viewModel.priority.value = Task.Priority.HIGH + viewModel.setPriority(Task.Priority.MEDIUM) + viewModel.setPriority(Task.Priority.HIGH) Assert.assertFalse(viewModel.hasChanges()) } diff --git a/app/src/androidTest/java/org/tasks/ui/editviewmodel/ReminderTests.kt b/app/src/androidTest/java/org/tasks/ui/editviewmodel/ReminderTests.kt index 6e693be14..5cd7030ca 100644 --- a/app/src/androidTest/java/org/tasks/ui/editviewmodel/ReminderTests.kt +++ b/app/src/androidTest/java/org/tasks/ui/editviewmodel/ReminderTests.kt @@ -39,7 +39,7 @@ class ReminderTests : BaseTaskEditViewModelTest() { assertEquals( listOf(Alarm(type = Alarm.TYPE_REL_START)), - viewModel.selectedAlarms.value + viewModel.viewState.value.alarms ) } @@ -56,7 +56,7 @@ class ReminderTests : BaseTaskEditViewModelTest() { assertEquals( listOf(Alarm(type = Alarm.TYPE_REL_END)), - viewModel.selectedAlarms.value + viewModel.viewState.value.alarms ) } @@ -73,7 +73,7 @@ class ReminderTests : BaseTaskEditViewModelTest() { assertEquals( listOf(whenOverdue(0)), - viewModel.selectedAlarms.value + viewModel.viewState.value.alarms ) } diff --git a/app/src/androidTest/java/org/tasks/ui/editviewmodel/TaskEditViewModelTest.kt b/app/src/androidTest/java/org/tasks/ui/editviewmodel/TaskEditViewModelTest.kt index c5006b49e..f34be1abd 100644 --- a/app/src/androidTest/java/org/tasks/ui/editviewmodel/TaskEditViewModelTest.kt +++ b/app/src/androidTest/java/org/tasks/ui/editviewmodel/TaskEditViewModelTest.kt @@ -1,12 +1,12 @@ package org.tasks.ui.editviewmodel -import org.tasks.data.entity.Task import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.UninstallModules import kotlinx.coroutines.runBlocking import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import org.tasks.data.entity.Task import org.tasks.injection.ProductionModule import org.tasks.makers.TaskMaker.newTask @@ -33,7 +33,7 @@ class TaskEditViewModelTest : BaseTaskEditViewModelTest() { fun dontSaveTaskTwice() = runBlocking { setup(newTask()) - viewModel.priority.value = Task.Priority.HIGH + viewModel.setPriority(Task.Priority.HIGH) assertTrue(save()) diff --git a/app/src/androidTest/java/org/tasks/ui/editviewmodel/TitleTests.kt b/app/src/androidTest/java/org/tasks/ui/editviewmodel/TitleTests.kt index 6a66da9bb..b7ca5c4e4 100644 --- a/app/src/androidTest/java/org/tasks/ui/editviewmodel/TitleTests.kt +++ b/app/src/androidTest/java/org/tasks/ui/editviewmodel/TitleTests.kt @@ -1,13 +1,13 @@ package org.tasks.ui.editviewmodel import com.natpryce.makeiteasy.MakeItEasy.with -import org.tasks.data.entity.Task.Priority.Companion.HIGH import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.UninstallModules import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import org.tasks.data.entity.Task.Priority.Companion.HIGH import org.tasks.injection.ProductionModule import org.tasks.makers.TaskMaker import org.tasks.makers.TaskMaker.newTask @@ -19,7 +19,7 @@ class TitleTests : BaseTaskEditViewModelTest() { fun changeTitleCausesChange() { setup(newTask()) - viewModel.title = "Test" + viewModel.setTitle("Test") assertTrue(viewModel.hasChanges()) } @@ -29,7 +29,7 @@ class TitleTests : BaseTaskEditViewModelTest() { val task = newTask() setup(task) - viewModel.priority.value = HIGH + viewModel.setPriority(HIGH) save() diff --git a/app/src/main/java/com/todoroo/astrid/activity/TaskEditFragment.kt b/app/src/main/java/com/todoroo/astrid/activity/TaskEditFragment.kt index 06264644c..aacc5bb64 100755 --- a/app/src/main/java/com/todoroo/astrid/activity/TaskEditFragment.kt +++ b/app/src/main/java/com/todoroo/astrid/activity/TaskEditFragment.kt @@ -8,31 +8,13 @@ import android.view.View import android.view.ViewGroup import androidx.activity.compose.BackHandler import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowBack -import androidx.compose.material.icons.outlined.Clear -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Save -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidViewBinding import androidx.core.os.BundleCompat import androidx.fragment.app.Fragment @@ -40,7 +22,6 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import com.todoroo.andlib.utility.AndroidUtilities.atLeastOreoMR1 -import com.todoroo.astrid.dao.TaskDao import com.todoroo.astrid.files.FilesControlSet import com.todoroo.astrid.repeats.RepeatControlSet import com.todoroo.astrid.tags.TagsControlSet @@ -50,29 +31,25 @@ import com.todoroo.astrid.ui.StartDateControlSet import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.tasks.R -import org.tasks.Strings.isNullOrEmpty -import org.tasks.analytics.Firebase import org.tasks.calendars.CalendarPicker -import org.tasks.compose.BeastModeBanner import org.tasks.compose.FilterSelectionActivity.Companion.launch import org.tasks.compose.FilterSelectionActivity.Companion.registerForListPickerResult -import org.tasks.compose.edit.CommentsRow import org.tasks.compose.edit.DescriptionRow import org.tasks.compose.edit.DueDateRow import org.tasks.compose.edit.InfoRow import org.tasks.compose.edit.ListRow import org.tasks.compose.edit.PriorityRow +import org.tasks.compose.edit.TaskEditScreen +import org.tasks.compose.edit.TitleRow import org.tasks.data.Location import org.tasks.data.dao.UserActivityDao import org.tasks.data.entity.Alarm import org.tasks.data.entity.TagData import org.tasks.data.entity.Task import org.tasks.databinding.TaskEditCalendarBinding -import org.tasks.databinding.TaskEditCommentBarBinding import org.tasks.databinding.TaskEditFilesBinding import org.tasks.databinding.TaskEditLocationBinding import org.tasks.databinding.TaskEditRemindersBinding @@ -87,14 +64,7 @@ import org.tasks.dialogs.DialogBuilder import org.tasks.dialogs.Linkify import org.tasks.extensions.Context.is24HourFormat import org.tasks.extensions.hideKeyboard -import org.tasks.files.FileHelper import org.tasks.filters.Filter -import org.tasks.fragments.TaskEditControlSetFragmentManager -import org.tasks.fragments.TaskEditControlSetFragmentManager.Companion.TAG_CREATION -import org.tasks.fragments.TaskEditControlSetFragmentManager.Companion.TAG_DESCRIPTION -import org.tasks.fragments.TaskEditControlSetFragmentManager.Companion.TAG_DUE_DATE -import org.tasks.fragments.TaskEditControlSetFragmentManager.Companion.TAG_LIST -import org.tasks.fragments.TaskEditControlSetFragmentManager.Companion.TAG_PRIORITY import org.tasks.kmp.org.tasks.time.DateStyle import org.tasks.kmp.org.tasks.time.getRelativeDateTime import org.tasks.markdown.MarkdownProvider @@ -110,20 +80,21 @@ import org.tasks.ui.SubtaskControlSet import org.tasks.ui.TaskEditEvent import org.tasks.ui.TaskEditEventBus import org.tasks.ui.TaskEditViewModel -import org.tasks.ui.TaskEditViewModel.Companion.stripCarriageReturns +import org.tasks.ui.TaskEditViewModel.Companion.TAG_CREATION +import org.tasks.ui.TaskEditViewModel.Companion.TAG_DESCRIPTION +import org.tasks.ui.TaskEditViewModel.Companion.TAG_DUE_DATE +import org.tasks.ui.TaskEditViewModel.Companion.TAG_LIST +import org.tasks.ui.TaskEditViewModel.Companion.TAG_PRIORITY +import org.tasks.ui.TaskEditViewModel.Companion.TAG_TITLE import java.util.Locale import javax.inject.Inject @AndroidEntryPoint class TaskEditFragment : Fragment() { - @Inject lateinit var taskDao: TaskDao @Inject lateinit var userActivityDao: UserActivityDao @Inject lateinit var notificationManager: NotificationManager @Inject lateinit var dialogBuilder: DialogBuilder - @Inject lateinit var context: Activity - @Inject lateinit var taskEditControlSetFragmentManager: TaskEditControlSetFragmentManager @Inject lateinit var preferences: Preferences - @Inject lateinit var firebase: Firebase @Inject lateinit var linkify: Linkify @Inject lateinit var markdownProvider: MarkdownProvider @Inject lateinit var taskEditEventBus: TaskEditEventBus @@ -133,145 +104,121 @@ class TaskEditFragment : Fragment() { @Inject lateinit var theme: Theme private val editViewModel: TaskEditViewModel by viewModels() - private var showKeyboard = false private val beastMode = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activity?.recreate() } private val listPickerLauncher = registerForListPickerResult { filter -> - editViewModel.selectedList.update { filter } + editViewModel.setList(filter) } val task: Task? get() = BundleCompat.getParcelable(requireArguments(), EXTRA_TASK, Task::class.java) - @OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { if (atLeastOreoMR1()) { activity?.setShowWhenLocked(preferences.showEditScreenWithoutUnlock) } - val model = editViewModel.task - if (!model.isNew) { - lifecycleScope.launch { - notificationManager.cancel(model.id) - } - } - if (savedInstanceState == null) { - showKeyboard = model.isNew && isNullOrEmpty(model.title) - } - val backButtonSavesTask = preferences.backButtonSavesTask() - val view = ComposeView(context).apply { + val view = ComposeView(requireActivity()).apply { setContent { - BackHandler { - if (backButtonSavesTask) { - lifecycleScope.launch { - save() + TasksTheme(theme = theme.themeBase.index,) { + val viewState = editViewModel.viewState.collectAsStateWithLifecycle().value + BackHandler { + if (viewState.backButtonSavesTask) { + lifecycleScope.launch { + save() + } + } else { + discardButtonClick() } - } else { - discardButtonClick() } - } - TasksTheme(theme = theme.themeBase.index,) { - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - if (editViewModel.isReadOnly) { - IconButton(onClick = { activity?.onBackPressed() }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.back) - ) - } - } else { - IconButton(onClick = { lifecycleScope.launch { save() } }) { - Icon( - imageVector = Icons.Outlined.Save, - contentDescription = stringResource(R.string.save) - ) - } - } - }, - title = {}, - actions = { - if (!editViewModel.isWritable) { - return@TopAppBar - } - if (!editViewModel.isNew) { - IconButton(onClick = { deleteButtonClick() }) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = stringResource(R.string.delete_task), - ) - } - } - IconButton(onClick = { discardButtonClick() }) { - Icon( - imageVector = Icons.Outlined.Clear, - contentDescription = stringResource(R.string.menu_discard_changes), - ) - } - }, - ) + LaunchedEffect(viewState.isNew) { + if (!viewState.isNew) { + notificationManager.cancel(viewState.task.id) + } + } + TaskEditScreen( + viewState = viewState, + comments = userActivityDao + .watchComments(viewState.task.uuid) + .collectAsStateWithLifecycle(emptyList()) + .value, + save = { lifecycleScope.launch { save() } }, + discard = { discardButtonClick() }, + onBackPressed = { activity?.onBackPressed() }, + delete = { deleteButtonClick() }, + openBeastModeSettings = { + editViewModel.hideBeastModeHint(click = true) + beastMode.launch(Intent(context, BeastModePreferences::class.java)) }, - bottomBar = { - if (preferences.getBoolean(R.string.p_show_task_edit_comments, false)) { - AndroidViewBinding(TaskEditCommentBarBinding::inflate) + dismissBeastMode = { editViewModel.hideBeastModeHint(click = false) }, + deleteComment = { + lifecycleScope.launch { + userActivityDao.delete(it) } }, - ) { paddingValues -> - Column( - modifier = Modifier - .gesturesDisabled(editViewModel.isReadOnly) - .padding(paddingValues) - .fillMaxSize() - .verticalScroll(rememberScrollState()), - ) { - TitleRow(requestFocus = showKeyboard) - HorizontalDivider() - taskEditControlSetFragmentManager.displayOrder.forEachIndexed { index, tag -> - if (index < taskEditControlSetFragmentManager.visibleSize) { - // TODO: remove ui-viewbinding library when these are all migrated - when (taskEditControlSetFragmentManager.controlSetFragments[tag]) { - TAG_DUE_DATE -> DueDateRow() - TAG_PRIORITY -> PriorityRow() - TAG_DESCRIPTION -> DescriptionRow() - TAG_LIST -> ListRow() - TAG_CREATION -> CreationRow() - CalendarControlSet.TAG -> AndroidViewBinding(TaskEditCalendarBinding::inflate) - StartDateControlSet.TAG -> AndroidViewBinding( - TaskEditStartDateBinding::inflate - ) - ReminderControlSet.TAG -> AndroidViewBinding( - TaskEditRemindersBinding::inflate + ) { tag -> + // TODO: remove ui-viewbinding library when these are all migrated + when (tag) { + TAG_TITLE -> + TitleRow( + viewState = viewState, + requestFocus = viewState.showKeyboard, + ) + + TAG_DUE_DATE -> DueDateRow() + TAG_PRIORITY -> + PriorityRow( + priority = viewState.task.priority, + onChangePriority = { editViewModel.setPriority(it) }, + ) + + TAG_DESCRIPTION -> + DescriptionRow( + text = viewState.task.notes, + onChanged = { text -> editViewModel.setDescription(text.toString().trim { it <= ' ' }) }, + linkify = if (viewState.linkify) linkify else null, + markdownProvider = markdownProvider, + ) + + TAG_LIST -> + ListRow( + list = viewState.list, + colorProvider = { chipProvider.getColor(it) }, + onClick = { + listPickerLauncher.launch( + context = context, + selectedFilter = viewState.list, + listsOnly = true ) - LocationControlSet.TAG -> AndroidViewBinding(TaskEditLocationBinding::inflate) - FilesControlSet.TAG -> AndroidViewBinding(TaskEditFilesBinding::inflate) - TimerControlSet.TAG -> AndroidViewBinding(TaskEditTimerBinding::inflate) - TagsControlSet.TAG -> AndroidViewBinding(TaskEditTagsBinding::inflate) - RepeatControlSet.TAG -> AndroidViewBinding(TaskEditRepeatBinding::inflate) - SubtaskControlSet.TAG -> AndroidViewBinding(TaskEditSubtasksBinding::inflate) - else -> throw IllegalArgumentException("Unknown row: $tag") } - HorizontalDivider() - } - } - if (preferences.getBoolean(R.string.p_show_task_edit_comments, false)) { - Comments() - } - val showBeastModeHint = editViewModel.showBeastModeHint.collectAsStateWithLifecycle().value - val context = LocalContext.current - BeastModeBanner( - showBeastModeHint, - showSettings = { - editViewModel.hideBeastModeHint(click = true) - beastMode.launch(Intent(context, BeastModePreferences::class.java)) - }, - dismiss = { - editViewModel.hideBeastModeHint(click = false) - } + ) + + TAG_CREATION -> + InfoRow( + creationDate = viewState.task.creationDate, + modificationDate = viewState.task.modificationDate, + completionDate = viewState.task.completionDate, + locale = locale, + ) + + CalendarControlSet.TAG -> AndroidViewBinding(TaskEditCalendarBinding::inflate) + StartDateControlSet.TAG -> AndroidViewBinding( + TaskEditStartDateBinding::inflate ) + + ReminderControlSet.TAG -> AndroidViewBinding( + TaskEditRemindersBinding::inflate + ) + + LocationControlSet.TAG -> AndroidViewBinding(TaskEditLocationBinding::inflate) + FilesControlSet.TAG -> AndroidViewBinding(TaskEditFilesBinding::inflate) + TimerControlSet.TAG -> AndroidViewBinding(TaskEditTimerBinding::inflate) + TagsControlSet.TAG -> AndroidViewBinding(TaskEditTagsBinding::inflate) + RepeatControlSet.TAG -> AndroidViewBinding(TaskEditRepeatBinding::inflate) + SubtaskControlSet.TAG -> AndroidViewBinding(TaskEditSubtasksBinding::inflate) + else -> throw IllegalArgumentException("Unknown row: $tag") } } } @@ -296,7 +243,7 @@ class TaskEditFragment : Fragment() { private suspend fun process(event: TaskEditEvent) { when (event) { is TaskEditEvent.Discard -> - if (event.id == editViewModel.task.id) { + if (event.id == editViewModel.viewState.value.task.id) { editViewModel.discard() } } @@ -346,8 +293,7 @@ class TaskEditFragment : Fragment() { } REQUEST_CODE_PICK_CALENDAR -> { if (resultCode == Activity.RESULT_OK) { - editViewModel.selectedCalendar.value = - data!!.getStringExtra(CalendarPicker.EXTRA_CALENDAR_ID) + editViewModel.setCalendar(data!!.getStringExtra(CalendarPicker.EXTRA_CALENDAR_ID)) } } else -> super.onActivityResult(requestCode, resultCode, data) @@ -356,26 +302,22 @@ class TaskEditFragment : Fragment() { @Composable private fun TitleRow( + viewState: TaskEditViewModel.ViewState, requestFocus: Boolean, ) { - val isComplete = editViewModel.completed.collectAsStateWithLifecycle().value - val recurrence = editViewModel.recurrence.collectAsStateWithLifecycle().value - val isRecurring = remember(recurrence) { - !recurrence.isNullOrBlank() - } - org.tasks.compose.edit.TitleRow( - text = editViewModel.title, - onChanged = { text -> editViewModel.title = text.toString().trim { it <= ' ' } }, - linkify = if (preferences.linkify) linkify else null, + TitleRow( + text = viewState.task.title, + onChanged = { text -> editViewModel.setTitle(text.toString().trim { it <= ' ' }) }, + linkify = if (viewState.linkify) linkify else null, markdownProvider = markdownProvider, - isCompleted = isComplete, - isRecurring = isRecurring, - priority = editViewModel.priority.collectAsStateWithLifecycle().value, + isCompleted = viewState.isCompleted, + isRecurring = viewState.task.isRecurring, + priority = viewState.task.priority, onComplete = { - if (isComplete) { - editViewModel.completed.value = false + if (viewState.isCompleted) { + editViewModel.setComplete(false) } else { - editViewModel.completed.value = true + editViewModel.setComplete(true) lifecycleScope.launch { save() } @@ -387,6 +329,7 @@ class TaskEditFragment : Fragment() { @Composable private fun DueDateRow() { + val viewState = editViewModel.viewState.collectAsStateWithLifecycle().value val dueDate = editViewModel.dueDate.collectAsStateWithLifecycle().value val context = LocalContext.current DueDateRow( @@ -413,73 +356,13 @@ class TaskEditFragment : Fragment() { R.string.p_auto_dismiss_datetime_edit_screen, false ), - hideNoDate = editViewModel.recurrence.value?.isNotBlank() == true, + hideNoDate = viewState.task.isRecurring, ) .show(parentFragmentManager, FRAG_TAG_DATE_PICKER) } ) } - @Composable - private fun PriorityRow() { - PriorityRow( - priority = editViewModel.priority.collectAsStateWithLifecycle().value, - onChangePriority = { editViewModel.priority.value = it }, - ) - } - - @Composable - private fun DescriptionRow() { - DescriptionRow( - text = editViewModel.description.stripCarriageReturns(), - onChanged = { text -> editViewModel.description = text.toString().trim { it <= ' ' } }, - linkify = if (preferences.linkify) linkify else null, - markdownProvider = markdownProvider, - ) - } - - @Composable - private fun ListRow() { - val list = editViewModel.selectedList.collectAsStateWithLifecycle().value - ListRow( - list = list, - colorProvider = { chipProvider.getColor(it) }, - onClick = { - listPickerLauncher.launch( - context = context, - selectedFilter = list, - listsOnly = true - ) - } - ) - } - - @Composable - fun CreationRow() { - InfoRow( - creationDate = editViewModel.creationDate, - modificationDate = editViewModel.modificationDate, - completionDate = editViewModel.completionDate, - locale = locale, - ) - } - - @Composable - fun Comments() { - CommentsRow( - comments = userActivityDao - .watchComments(editViewModel.task.uuid) - .collectAsStateWithLifecycle(emptyList()) - .value, - deleteComment = { - lifecycleScope.launch { - userActivityDao.delete(it) - } - }, - openImage = { FileHelper.startActionView(requireActivity(), it) } - ) - } - companion object { const val EXTRA_TASK = "extra_task" const val EXTRA_LIST = "extra_list" diff --git a/app/src/main/java/com/todoroo/astrid/files/FilesControlSet.kt b/app/src/main/java/com/todoroo/astrid/files/FilesControlSet.kt index d9f43333a..aafed77d9 100644 --- a/app/src/main/java/com/todoroo/astrid/files/FilesControlSet.kt +++ b/app/src/main/java/com/todoroo/astrid/files/FilesControlSet.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.platform.ComposeView import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.tasks.R import org.tasks.Strings @@ -34,7 +33,7 @@ class FilesControlSet : TaskEditControlFragment() { @Inject lateinit var preferences: Preferences override fun createView(savedInstanceState: Bundle?) { - val task = viewModel.task + val task = viewModel.viewState.value.task if (savedInstanceState == null) { if (task.hasTransitory(TaskAttachment.KEY)) { for (uri in (task.getTransitory>(TaskAttachment.KEY))!!) { @@ -47,15 +46,16 @@ class FilesControlSet : TaskEditControlFragment() { override fun bind(parent: ViewGroup?): View = (parent as ComposeView).apply { setContent { + val viewState = viewModel.viewState.collectAsStateWithLifecycle().value AttachmentRow( - attachments = viewModel.selectedAttachments.collectAsStateWithLifecycle().value, + attachments = viewState.attachments, openAttachment = { FileHelper.startActionView( requireActivity(), if (Strings.isNullOrEmpty(it.uri)) null else Uri.parse(it.uri) ) }, - deleteAttachment = this@FilesControlSet::deleteAttachment, + deleteAttachment = { viewModel.setAttachments(viewState.attachments - it) }, addAttachment = { AddAttachmentDialog.newAddAttachmentDialog(this@FilesControlSet) .show(parentFragmentManager, FRAG_TAG_ADD_ATTACHMENT_DIALOG) @@ -90,12 +90,6 @@ class FilesControlSet : TaskEditControlFragment() { } } - private fun deleteAttachment(attachment: TaskAttachment) { - viewModel.selectedAttachments.update { - it.minus(attachment) - } - } - private fun copyToAttachmentDirectory(input: Uri?) { newAttachment(FileHelper.copyToUri(requireContext(), preferences.attachmentsDirectory!!, input!!)) } @@ -107,11 +101,9 @@ class FilesControlSet : TaskEditControlFragment() { ) lifecycleScope.launch { taskAttachmentDao.insert(attachment) - viewModel.selectedAttachments.update { - it.plus( - taskAttachmentDao.getAttachment(attachment.remoteId) ?: return@launch - ) - } + viewModel.setAttachments( + viewModel.viewState.value.attachments + + (taskAttachmentDao.getAttachment(attachment.remoteId) ?: return@launch)) } } diff --git a/app/src/main/java/com/todoroo/astrid/repeats/RepeatControlSet.kt b/app/src/main/java/com/todoroo/astrid/repeats/RepeatControlSet.kt index 6db0a39ee..ea2f9f06d 100644 --- a/app/src/main/java/com/todoroo/astrid/repeats/RepeatControlSet.kt +++ b/app/src/main/java/com/todoroo/astrid/repeats/RepeatControlSet.kt @@ -19,14 +19,11 @@ import net.fortuna.ical4j.model.WeekDay import org.tasks.R import org.tasks.compose.edit.RepeatRow import org.tasks.data.dao.CaldavDao -import org.tasks.data.entity.CaldavAccount -import org.tasks.filters.CaldavFilter import org.tasks.repeats.BasicRecurrenceDialog import org.tasks.repeats.RecurrenceUtils.newRecur import org.tasks.repeats.RepeatRuleToString import org.tasks.time.DateTime import org.tasks.time.DateTimeUtils2.currentTimeMillis -import org.tasks.time.startOfDay import org.tasks.ui.TaskEditControlFragment import javax.inject.Inject @@ -39,10 +36,7 @@ class RepeatControlSet : TaskEditControlFragment() { if (requestCode == REQUEST_RECURRENCE) { if (resultCode == RESULT_OK) { val result = data?.getStringExtra(BasicRecurrenceDialog.EXTRA_RRULE) - viewModel.recurrence.value = result - if (result?.isNotBlank() == true && viewModel.dueDate.value == 0L) { - viewModel.setDueDate(currentTimeMillis().startOfDay()) - } + viewModel.setRecurrence(result) } } else { super.onActivityResult(requestCode, resultCode, data) @@ -50,7 +44,8 @@ class RepeatControlSet : TaskEditControlFragment() { } private fun onDueDateChanged() { - viewModel.recurrence.value?.takeIf { it.isNotBlank() }?.let { recurrence -> + // TODO: move to view model + viewModel.viewState.value.task.recurrence?.takeIf { it.isNotBlank() }?.let { recurrence -> val recur = newRecur(recurrence) if (recur.frequency == Recur.Frequency.MONTHLY && recur.dayList.isNotEmpty()) { val weekdayNum = recur.dayList[0] @@ -67,7 +62,7 @@ class RepeatControlSet : TaskEditControlFragment() { it.clear() it.add(WeekDay(dateTime.weekDay, num)) } - viewModel.recurrence.value = recur.toString() + viewModel.setRecurrence(recur.toString()) } } } @@ -83,31 +78,22 @@ class RepeatControlSet : TaskEditControlFragment() { override fun bind(parent: ViewGroup?): View = (parent as ComposeView).apply { setContent { + val viewState = viewModel.viewState.collectAsStateWithLifecycle().value RepeatRow( - recurrence = viewModel.recurrence.collectAsStateWithLifecycle().value?.let { - repeatRuleToString.toString(it) - }, - repeatAfterCompletion = viewModel.repeatAfterCompletion.collectAsStateWithLifecycle().value, + recurrence = viewState.task.recurrence?.let { repeatRuleToString.toString(it) }, + repeatFrom = viewState.task.repeatFrom, onClick = { - val accountType = viewModel.selectedList.value - .let { - when (it) { - is CaldavFilter -> it.account - else -> null - } - } - ?.accountType - ?: CaldavAccount.TYPE_LOCAL + val accountType = viewState.list.account.accountType BasicRecurrenceDialog.newBasicRecurrenceDialog( target = this@RepeatControlSet, rc = REQUEST_RECURRENCE, - rrule = viewModel.recurrence.value, + rrule = viewState.task.recurrence, dueDate = viewModel.dueDate.value, accountType = accountType, ) .show(parentFragmentManager, FRAG_TAG_BASIC_RECURRENCE) }, - onRepeatFromChanged = { viewModel.repeatAfterCompletion.value = it } + onRepeatFromChanged = { viewModel.setRepeatFrom(it) } ) } } diff --git a/app/src/main/java/com/todoroo/astrid/repeats/RepeatTaskHelper.kt b/app/src/main/java/com/todoroo/astrid/repeats/RepeatTaskHelper.kt index f0f1c742d..407a4babd 100644 --- a/app/src/main/java/com/todoroo/astrid/repeats/RepeatTaskHelper.kt +++ b/app/src/main/java/com/todoroo/astrid/repeats/RepeatTaskHelper.kt @@ -16,6 +16,7 @@ import org.tasks.data.createDueDate import org.tasks.data.entity.Alarm import org.tasks.data.entity.Alarm.Companion.TYPE_SNOOZE import org.tasks.data.entity.Task +import org.tasks.data.entity.Task.RepeatFrom import org.tasks.data.setRecurrence import org.tasks.date.DateTimeUtils.newDateTime import org.tasks.repeats.RecurrenceUtils.newRecur @@ -39,7 +40,7 @@ class RepeatTaskHelper @Inject constructor( if (recurrence.isNullOrBlank()) { return } - val repeatAfterCompletion = task.repeatAfterCompletion() + val repeatAfterCompletion = task.repeatFrom == RepeatFrom.COMPLETION_DATE val newDueDate: Long val rrule: Recur val count: Int diff --git a/app/src/main/java/com/todoroo/astrid/tags/TagsControlSet.kt b/app/src/main/java/com/todoroo/astrid/tags/TagsControlSet.kt index 6df04aac0..0029be172 100644 --- a/app/src/main/java/com/todoroo/astrid/tags/TagsControlSet.kt +++ b/app/src/main/java/com/todoroo/astrid/tags/TagsControlSet.kt @@ -5,13 +5,15 @@ import android.content.Intent import android.view.View import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView +import androidx.core.content.IntentCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.update +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toPersistentSet import org.tasks.R import org.tasks.compose.edit.TagsRow +import org.tasks.data.entity.TagData import org.tasks.tags.TagPickerActivity -import org.tasks.themes.TasksTheme import org.tasks.ui.ChipProvider import org.tasks.ui.TaskEditControlFragment import javax.inject.Inject @@ -21,21 +23,21 @@ class TagsControlSet : TaskEditControlFragment() { @Inject lateinit var chipProvider: ChipProvider private fun onRowClick() { - val intent = Intent(context, TagPickerActivity::class.java) - intent.putParcelableArrayListExtra(TagPickerActivity.EXTRA_SELECTED, viewModel.selectedTags.value) - startActivityForResult(intent, REQUEST_TAG_PICKER_ACTIVITY) } override fun bind(parent: ViewGroup?): View = (parent as ComposeView).apply { setContent { + val viewState = viewModel.viewState.collectAsStateWithLifecycle().value TagsRow( - tags = viewModel.selectedTags.collectAsStateWithLifecycle().value, + tags = viewState.tags, colorProvider = { chipProvider.getColor(it) }, - onClick = this@TagsControlSet::onRowClick, - onClear = { tag -> - viewModel.selectedTags.update { ArrayList(it.minus(tag)) } + onClick = { + val intent = Intent(context, TagPickerActivity::class.java) + intent.putParcelableArrayListExtra(TagPickerActivity.EXTRA_SELECTED, ArrayList(viewState.tags)) + startActivityForResult(intent, REQUEST_TAG_PICKER_ACTIVITY) }, + onClear = { viewModel.setTags(viewState.tags.minus(it)) }, ) } } @@ -45,9 +47,16 @@ class TagsControlSet : TaskEditControlFragment() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode == REQUEST_TAG_PICKER_ACTIVITY) { if (resultCode == Activity.RESULT_OK && data != null) { - viewModel.selectedTags.value = - data.getParcelableArrayListExtra(TagPickerActivity.EXTRA_SELECTED) - ?: ArrayList() + viewModel.setTags( + IntentCompat + .getParcelableArrayListExtra( + data, + TagPickerActivity.EXTRA_SELECTED, + TagData::class.java + ) + ?.toPersistentSet() + ?: persistentSetOf() + ) } } else { super.onActivityResult(requestCode, resultCode, data) diff --git a/app/src/main/java/com/todoroo/astrid/timers/TimerControlSet.kt b/app/src/main/java/com/todoroo/astrid/timers/TimerControlSet.kt index 54be380a0..be46137ad 100644 --- a/app/src/main/java/com/todoroo/astrid/timers/TimerControlSet.kt +++ b/app/src/main/java/com/todoroo/astrid/timers/TimerControlSet.kt @@ -104,7 +104,7 @@ class TimerControlSet : TaskEditControlFragment() { private fun timerActive() = viewModel.timerStarted.value > 0 private suspend fun stopTimer(): Task { - val model = viewModel.task + val model = viewModel.viewState.value.task timerPlugin.stopTimer(model) val elapsedTime = DateUtils.formatElapsedTime(model.elapsedSeconds.toLong()) viewModel.addComment(String.format( @@ -119,7 +119,7 @@ class TimerControlSet : TaskEditControlFragment() { } private suspend fun startTimer(): Task { - val model = viewModel.task + val model = viewModel.viewState.value.task timerPlugin.startTimer(model) viewModel.addComment(String.format( "%s %s", diff --git a/app/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.kt b/app/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.kt index cf6d0b7ba..4705cce42 100644 --- a/app/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.kt +++ b/app/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.kt @@ -99,8 +99,9 @@ class ReminderControlSet : TaskEditControlFragment() { replace?.let { viewModel.removeAlarm(it) } viewModel.addAlarm(Alarm(time = timestamp, type = TYPE_DATE_TIME)) } + val viewState = viewModel.viewState.collectAsStateWithLifecycle().value AlarmRow( - alarms = viewModel.selectedAlarms.collectAsStateWithLifecycle().value, + alarms = viewState.alarms, hasNotificationPermissions = hasReminderPermissions && (notificationPermissions == null || notificationPermissions.status == PermissionStatus.Granted), fixNotificationPermissions = { diff --git a/app/src/main/java/com/todoroo/astrid/ui/StartDateControlSet.kt b/app/src/main/java/com/todoroo/astrid/ui/StartDateControlSet.kt index 441b723d0..2df498905 100644 --- a/app/src/main/java/com/todoroo/astrid/ui/StartDateControlSet.kt +++ b/app/src/main/java/com/todoroo/astrid/ui/StartDateControlSet.kt @@ -36,7 +36,11 @@ class StartDateControlSet : TaskEditControlFragment() { override fun createView(savedInstanceState: Bundle?) { if (savedInstanceState == null) { - vm.init(viewModel.dueDate.value, viewModel.startDate.value, viewModel.isNew) + vm.init( + dueDate = viewModel.dueDate.value, + startDate = viewModel.startDate.value, + isNew = viewModel.viewState.value.isNew + ) } lifecycleScope.launchWhenResumed { viewModel.dueDate.collect { diff --git a/app/src/main/java/org/tasks/compose/AddReminderDialog.kt b/app/src/main/java/org/tasks/compose/AddReminderDialog.kt index 8924cfef1..56e1b3bfa 100644 --- a/app/src/main/java/org/tasks/compose/AddReminderDialog.kt +++ b/app/src/main/java/org/tasks/compose/AddReminderDialog.kt @@ -46,6 +46,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.content.res.ResourcesCompat import com.todoroo.astrid.ui.ReminderControlSetViewModel.ViewState +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.android.awaitFrame import org.tasks.R import org.tasks.data.entity.Alarm @@ -485,7 +487,7 @@ fun BodyText(modifier: Modifier = Modifier, text: String) { @Composable fun AddAlarmDialog( viewState: ViewState, - existingAlarms: List, + existingAlarms: ImmutableSet, addAlarm: (Alarm) -> Unit, addRandom: () -> Unit, addCustom: () -> Unit, @@ -637,11 +639,11 @@ fun AddReminderDialog() = TasksTheme { AddAlarmDialog( viewState = ViewState(showAddAlarm = true), - existingAlarms = emptyList(), + existingAlarms = persistentSetOf(), addAlarm = {}, addRandom = {}, addCustom = {}, pickDateAndTime = {}, dismiss = {}, ) - } \ No newline at end of file + } diff --git a/app/src/main/java/org/tasks/compose/edit/AlarmRow.kt b/app/src/main/java/org/tasks/compose/edit/AlarmRow.kt index 39fa8873f..868fef5f0 100644 --- a/app/src/main/java/org/tasks/compose/edit/AlarmRow.kt +++ b/app/src/main/java/org/tasks/compose/edit/AlarmRow.kt @@ -22,6 +22,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.todoroo.astrid.ui.ReminderControlSetViewModel +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf import org.tasks.R import org.tasks.compose.AddAlarmDialog import org.tasks.compose.AddReminderDialog @@ -38,7 +40,7 @@ fun AlarmRow( vm: ReminderControlSetViewModel = viewModel(), hasNotificationPermissions: Boolean, fixNotificationPermissions: () -> Unit, - alarms: List, + alarms: ImmutableSet, ringMode: Int, addAlarm: (Alarm) -> Unit, deleteAlarm: (Alarm) -> Unit, @@ -119,7 +121,7 @@ fun AlarmRow( @Composable fun Alarms( - alarms: List, + alarms: ImmutableSet, ringMode: Int, replaceAlarm: (Alarm) -> Unit, addAlarm: () -> Unit, @@ -194,7 +196,7 @@ private fun AlarmRow( fun NoAlarms() { TasksTheme { AlarmRow( - alarms = emptyList(), + alarms = persistentSetOf(), ringMode = 0, addAlarm = {}, deleteAlarm = {}, @@ -212,7 +214,7 @@ fun NoAlarms() { fun PermissionDenied() { TasksTheme { AlarmRow( - alarms = emptyList(), + alarms = persistentSetOf(), ringMode = 0, addAlarm = {}, deleteAlarm = {}, diff --git a/app/src/main/java/org/tasks/compose/edit/AttachmentRow.kt b/app/src/main/java/org/tasks/compose/edit/AttachmentRow.kt index fe76ffc44..026a7dda2 100644 --- a/app/src/main/java/org/tasks/compose/edit/AttachmentRow.kt +++ b/app/src/main/java/org/tasks/compose/edit/AttachmentRow.kt @@ -53,6 +53,8 @@ import coil.decode.VideoFrameDecoder import coil.request.CachePolicy import coil.request.ImageRequest import com.todoroo.andlib.utility.AndroidUtilities +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf import org.tasks.R import org.tasks.compose.DisabledText import org.tasks.compose.TaskEditRow @@ -65,7 +67,7 @@ private val SIZE = 128.dp @OptIn(ExperimentalLayoutApi::class) @Composable fun AttachmentRow( - attachments: List, + attachments: ImmutableSet, openAttachment: (TaskAttachment) -> Unit, deleteAttachment: (TaskAttachment) -> Unit, addAttachment: () -> Unit, @@ -260,7 +262,7 @@ fun BoxScope.DeleteAttachment( fun NoAttachments() { TasksTheme { AttachmentRow( - attachments = emptyList(), + attachments = persistentSetOf(), openAttachment = {}, deleteAttachment = {}, addAttachment = {}, @@ -274,7 +276,7 @@ fun NoAttachments() { fun AttachmentPreview() { TasksTheme { AttachmentRow( - attachments = listOf( + attachments = persistentSetOf( TaskAttachment( uri = "file://attachment.txt", name = "attachment.txt", diff --git a/app/src/main/java/org/tasks/compose/edit/RepeatRow.kt b/app/src/main/java/org/tasks/compose/edit/RepeatRow.kt index d03530b22..73e77d81f 100644 --- a/app/src/main/java/org/tasks/compose/edit/RepeatRow.kt +++ b/app/src/main/java/org/tasks/compose/edit/RepeatRow.kt @@ -28,21 +28,22 @@ import androidx.compose.ui.unit.dp import org.tasks.R import org.tasks.compose.DisabledText import org.tasks.compose.TaskEditRow +import org.tasks.data.entity.Task import org.tasks.themes.TasksTheme @Composable fun RepeatRow( recurrence: String?, - repeatAfterCompletion: Boolean, + @Task.RepeatFrom repeatFrom: Int, onClick: () -> Unit, - onRepeatFromChanged: (Boolean) -> Unit, + onRepeatFromChanged: (@Task.RepeatFrom Int) -> Unit, ) { TaskEditRow( iconRes = R.drawable.ic_outline_repeat_24px, content = { Repeat( recurrence = recurrence, - repeatFromCompletion = repeatAfterCompletion, + repeatFrom = repeatFrom, onRepeatFromChanged = onRepeatFromChanged, ) }, @@ -53,8 +54,8 @@ fun RepeatRow( @Composable fun Repeat( recurrence: String?, - repeatFromCompletion: Boolean, - onRepeatFromChanged: (Boolean) -> Unit, + repeatFrom: @Task.RepeatFrom Int, + onRepeatFromChanged: (@Task.RepeatFrom Int) -> Unit, ) { Column { Spacer(modifier = Modifier.height(20.dp)) @@ -77,10 +78,10 @@ fun Repeat( var expanded by remember { mutableStateOf(false) } Text( text = stringResource( - id = if (repeatFromCompletion) - R.string.repeat_type_completion - else - R.string.repeat_type_due + id = when (repeatFrom) { + Task.RepeatFrom.COMPLETION_DATE -> R.string.repeat_type_completion + else -> R.string.repeat_type_due + } ), style = MaterialTheme.typography.bodyLarge.copy( textDecoration = TextDecoration.Underline, @@ -92,7 +93,7 @@ fun Repeat( DropdownMenuItem( onClick = { expanded = false - onRepeatFromChanged(false) + onRepeatFromChanged(Task.RepeatFrom.DUE_DATE) }, text = { Text( @@ -104,7 +105,7 @@ fun Repeat( DropdownMenuItem( onClick = { expanded = false - onRepeatFromChanged(true) + onRepeatFromChanged(Task.RepeatFrom.COMPLETION_DATE) }, text = { Text( @@ -128,7 +129,7 @@ fun RepeatPreview() { TasksTheme { RepeatRow( recurrence = "Repeats weekly on Mon, Tue, Wed, Thu, Fri", - repeatAfterCompletion = false, + repeatFrom = Task.RepeatFrom.DUE_DATE, onClick = {}, onRepeatFromChanged = {}, ) @@ -143,7 +144,7 @@ fun NoRepeatPreview() { TasksTheme { RepeatRow( recurrence = null, - repeatAfterCompletion = false, + repeatFrom = Task.RepeatFrom.DUE_DATE, onClick = {}, onRepeatFromChanged = {}, ) diff --git a/app/src/main/java/org/tasks/compose/edit/TagsRow.kt b/app/src/main/java/org/tasks/compose/edit/TagsRow.kt index bdfb3a19f..21f802cbd 100644 --- a/app/src/main/java/org/tasks/compose/edit/TagsRow.kt +++ b/app/src/main/java/org/tasks/compose/edit/TagsRow.kt @@ -7,6 +7,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf import org.tasks.R import org.tasks.compose.Chip import org.tasks.compose.ChipGroup @@ -19,7 +21,7 @@ import org.tasks.themes.TasksTheme @Composable fun TagsRow( - tags: List, + tags: ImmutableSet, colorProvider: (Int) -> Int, onClick: () -> Unit, onClear: (TagData) -> Unit, @@ -56,7 +58,7 @@ fun TagsRow( fun NoTags() { TasksTheme { TagsRow( - tags = emptyList(), + tags = persistentSetOf(), colorProvider = { 0 }, onClick = {}, onClear = {}, @@ -70,7 +72,7 @@ fun NoTags() { fun SingleTag() { TasksTheme { TagsRow( - tags = listOf( + tags = persistentSetOf( TagData( name = "Home", icon = "home", @@ -89,7 +91,7 @@ fun SingleTag() { fun BunchOfTags() { TasksTheme { TagsRow( - tags = listOf( + tags = persistentSetOf( TagData(name = "One"), TagData(name = "Two"), TagData(name = "Three"), @@ -108,7 +110,7 @@ fun BunchOfTags() { fun TagWithReallyLongName() { TasksTheme { TagsRow( - tags = listOf( + tags = persistentSetOf( TagData( name = "This is a tag with a really really long name", icon = "home", diff --git a/app/src/main/java/org/tasks/compose/edit/TaskEditScreen.kt b/app/src/main/java/org/tasks/compose/edit/TaskEditScreen.kt new file mode 100644 index 000000000..1065a5da5 --- /dev/null +++ b/app/src/main/java/org/tasks/compose/edit/TaskEditScreen.kt @@ -0,0 +1,141 @@ +package org.tasks.compose.edit + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Save +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.viewinterop.AndroidViewBinding +import com.todoroo.astrid.activity.TaskEditFragment.Companion.gesturesDisabled +import org.tasks.R +import org.tasks.compose.BeastModeBanner +import org.tasks.data.entity.UserActivity +import org.tasks.databinding.TaskEditCommentBarBinding +import org.tasks.extensions.Context.findActivity +import org.tasks.files.FileHelper +import org.tasks.themes.TasksTheme +import org.tasks.ui.TaskEditViewModel + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) +@Composable +fun TaskEditScreen( + viewState: TaskEditViewModel.ViewState, + comments: List, + save: () -> Unit, + discard: () -> Unit, + onBackPressed: () -> Unit, + delete: () -> Unit, + openBeastModeSettings: () -> Unit, + dismissBeastMode: () -> Unit, + deleteComment: (UserActivity) -> Unit, + content: @Composable (Int) -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + if (viewState.isReadOnly) { + IconButton(onClick = { onBackPressed() }) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + } else { + IconButton(onClick = { save() }) { + Icon( + imageVector = Icons.Outlined.Save, + contentDescription = stringResource(R.string.save) + ) + } + } + }, + title = {}, + actions = { + if (viewState.isReadOnly) { + return@TopAppBar + } + if (!viewState.isNew) { + IconButton(onClick = { delete() }) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.delete_task), + ) + } + } + if (viewState.backButtonSavesTask) { + IconButton(onClick = { discard() }) { + Icon( + imageVector = Icons.Outlined.Clear, + contentDescription = stringResource(R.string.menu_discard_changes), + ) + } + } + }, + ) + }, + bottomBar = { + if (viewState.showComments && !viewState.isReadOnly) { + AndroidViewBinding(TaskEditCommentBarBinding::inflate) + } + }, + ) { paddingValues -> + Column( + modifier = Modifier + .gesturesDisabled(viewState.isReadOnly) + .padding(paddingValues) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + viewState.displayOrder.forEach { tag -> + content(tag) + HorizontalDivider() + } + if (viewState.showComments) { + val context = LocalContext.current + CommentsRow( + comments = comments, + deleteComment = deleteComment, + openImage = { + val activity = context.findActivity() ?: return@CommentsRow + FileHelper.startActionView(activity, it) + } + ) + } + BeastModeBanner( + visible = viewState.showBeastModeHint, + showSettings = openBeastModeSettings, + dismiss = dismissBeastMode, + ) + } + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES) +@Composable +fun TaskEditScreenPreview() { + TasksTheme { +// TaskEditScreen( +// +// ) + } +} diff --git a/app/src/main/java/org/tasks/data/PlaceExtensions.kt b/app/src/main/java/org/tasks/data/PlaceExtensions.kt index aaab96b09..6783ac9f0 100644 --- a/app/src/main/java/org/tasks/data/PlaceExtensions.kt +++ b/app/src/main/java/org/tasks/data/PlaceExtensions.kt @@ -3,11 +3,12 @@ package org.tasks.data import android.content.Context import android.net.Uri import org.tasks.data.entity.Place +import org.tasks.extensions.Context.findActivity import org.tasks.extensions.Context.openUri import org.tasks.location.MapPosition fun Place.open(context: Context?) = - context?.openUri("geo:$latitude,$longitude?q=${Uri.encode(displayName)}") + context?.findActivity()?.openUri("geo:$latitude,$longitude?q=${Uri.encode(displayName)}") val Place.mapPosition: MapPosition get() = MapPosition(latitude, longitude) diff --git a/app/src/main/java/org/tasks/fragments/CommentBarFragment.kt b/app/src/main/java/org/tasks/fragments/CommentBarFragment.kt index 17d801485..775847646 100644 --- a/app/src/main/java/org/tasks/fragments/CommentBarFragment.kt +++ b/app/src/main/java/org/tasks/fragments/CommentBarFragment.kt @@ -17,7 +17,6 @@ import android.widget.LinearLayout import androidx.core.widget.addTextChangedListener import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider -import com.todoroo.andlib.utility.AndroidUtilities import dagger.hilt.android.AndroidEntryPoint import org.tasks.R import org.tasks.Strings.isNullOrEmpty @@ -71,7 +70,7 @@ class CommentBarFragment : Fragment() { commentField.maxLines = Int.MAX_VALUE if ( preferences.getBoolean(R.string.p_show_task_edit_comments, false) && - viewModel.isWritable + !viewModel.viewState.value.isReadOnly ) { commentBar.visibility = View.VISIBLE } diff --git a/app/src/main/java/org/tasks/fragments/TaskEditControlSetFragmentManager.kt b/app/src/main/java/org/tasks/fragments/TaskEditControlSetFragmentManager.kt deleted file mode 100644 index 5f1cefe45..000000000 --- a/app/src/main/java/org/tasks/fragments/TaskEditControlSetFragmentManager.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.tasks.fragments - -import android.content.Context -import com.todoroo.astrid.activity.BeastModePreferences -import com.todoroo.astrid.files.FilesControlSet -import com.todoroo.astrid.repeats.RepeatControlSet -import com.todoroo.astrid.tags.TagsControlSet -import com.todoroo.astrid.timers.TimerControlSet -import com.todoroo.astrid.ui.ReminderControlSet -import com.todoroo.astrid.ui.StartDateControlSet -import dagger.hilt.android.qualifiers.ActivityContext -import org.tasks.R -import org.tasks.preferences.Preferences -import org.tasks.ui.CalendarControlSet -import org.tasks.ui.LocationControlSet -import org.tasks.ui.SubtaskControlSet -import javax.inject.Inject - -class TaskEditControlSetFragmentManager @Inject constructor( - @ActivityContext context: Context, - preferences: Preferences? -) { - val controlSetFragments: MutableMap = LinkedHashMap() - val displayOrder: List - var visibleSize = 0 - - init { - displayOrder = BeastModePreferences.constructOrderedControlList(preferences, context) - val hideAlwaysTrigger = context.getString(R.string.TEA_ctrl_hide_section_pref) - visibleSize = 0 - while (visibleSize < displayOrder.size) { - if (displayOrder[visibleSize] == hideAlwaysTrigger) { - displayOrder.removeAt(visibleSize) - break - } - visibleSize++ - } - for (resId in TASK_EDIT_CONTROL_SET_FRAGMENTS) { - controlSetFragments[context.getString(resId)] = resId - } - } - - companion object { - val TAG_DESCRIPTION = R.string.TEA_ctrl_notes_pref - val TAG_CREATION = R.string.TEA_ctrl_creation_date - val TAG_LIST = R.string.TEA_ctrl_google_task_list - val TAG_PRIORITY = R.string.TEA_ctrl_importance_pref - val TAG_DUE_DATE = R.string.TEA_ctrl_when_pref - - private val TASK_EDIT_CONTROL_SET_FRAGMENTS = intArrayOf( - TAG_DUE_DATE, - TimerControlSet.TAG, - TAG_DESCRIPTION, - CalendarControlSet.TAG, - TAG_PRIORITY, - StartDateControlSet.TAG, - ReminderControlSet.TAG, - LocationControlSet.TAG, - FilesControlSet.TAG, - TagsControlSet.TAG, - RepeatControlSet.TAG, - TAG_CREATION, - TAG_LIST, - SubtaskControlSet.TAG - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/ui/CalendarControlSet.kt b/app/src/main/java/org/tasks/ui/CalendarControlSet.kt index d79079ea7..c8c9c7f1d 100644 --- a/app/src/main/java/org/tasks/ui/CalendarControlSet.kt +++ b/app/src/main/java/org/tasks/ui/CalendarControlSet.kt @@ -7,6 +7,7 @@ import android.provider.CalendarContract import android.view.View import android.view.ViewGroup import android.widget.Toast.LENGTH_SHORT +import androidx.compose.runtime.remember import androidx.compose.ui.platform.ComposeView import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.todoroo.astrid.activity.TaskEditFragment @@ -37,17 +38,18 @@ class CalendarControlSet : TaskEditControlFragment() { } } if (!canAccessCalendars) { - viewModel.selectedCalendar.value = null + viewModel.setCalendar(null) } } override fun bind(parent: ViewGroup?): View = (parent as ComposeView).apply { setContent { + val viewState = viewModel.viewState.collectAsStateWithLifecycle().value CalendarRow( eventUri = viewModel.eventUri.collectAsStateWithLifecycle().value, - selectedCalendar = viewModel.selectedCalendar.collectAsStateWithLifecycle().value?.let { - calendarProvider.getCalendar(it)?.name + selectedCalendar = remember (viewState.calendar) { + calendarProvider.getCalendar(viewState.calendar)?.name }, onClick = { if (viewModel.eventUri.value.isNullOrBlank()) { @@ -55,7 +57,7 @@ class CalendarControlSet : TaskEditControlFragment() { .newCalendarPicker( requireParentFragment(), TaskEditFragment.REQUEST_CODE_PICK_CALENDAR, - viewModel.selectedCalendar.value, + viewState.calendar, ) .show( requireParentFragment().parentFragmentManager, @@ -66,7 +68,7 @@ class CalendarControlSet : TaskEditControlFragment() { } }, clear = { - viewModel.selectedCalendar.value = null + viewModel.setCalendar(null) viewModel.eventUri.value = null } ) diff --git a/app/src/main/java/org/tasks/ui/LocationControlSet.kt b/app/src/main/java/org/tasks/ui/LocationControlSet.kt index d9643dea9..a1684106d 100644 --- a/app/src/main/java/org/tasks/ui/LocationControlSet.kt +++ b/app/src/main/java/org/tasks/ui/LocationControlSet.kt @@ -7,6 +7,7 @@ import android.os.Parcelable import android.view.View import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView +import androidx.core.content.IntentCompat import androidx.core.util.Pair import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -37,60 +38,58 @@ class LocationControlSet : TaskEditControlFragment() { @Inject lateinit var dialogBuilder: DialogBuilder @Inject lateinit var permissionChecker: PermissionChecker - private fun setLocation(location: Location?) { - viewModel.selectedLocation.value = location - } - - private fun onRowClick() { - val location = viewModel.selectedLocation.value - if (location == null) { - chooseLocation() - } else { - val options: MutableList Unit>> = ArrayList() - options.add(Pair.create(R.string.open_map) { location.open(activity) }) - if (!isNullOrEmpty(location.phone)) { - options.add(Pair.create(R.string.action_call) { call() }) - } - if (!isNullOrEmpty(location.url)) { - options.add(Pair.create(R.string.visit_website) { openWebsite() }) - } - options.add(Pair.create(R.string.choose_new_location) { chooseLocation() }) - options.add(Pair.create(R.string.delete) { setLocation(null) }) - val items = options.map { requireContext().getString(it.first!!) } - dialogBuilder - .newDialog(location.displayName) - .setItems(items) { _, which: Int -> - options[which].second!!() - } - .show() - } + private fun showGeofenceOptions() { + val dialog = GeofenceDialog.newGeofenceDialog(viewModel.viewState.value.location) + dialog.setTargetFragment(this, REQUEST_GEOFENCE_DETAILS) + dialog.show(parentFragmentManager, FRAG_TAG_LOCATION_DIALOG) } private fun chooseLocation() { val intent = Intent(activity, LocationPickerActivity::class.java) - viewModel.selectedLocation.value?.let { + viewModel.viewState.value.location?.let { intent.putExtra(LocationPickerActivity.EXTRA_PLACE, it.place as Parcelable) } startActivityForResult(intent, REQUEST_LOCATION_REMINDER) } - private fun showGeofenceOptions() { - val dialog = GeofenceDialog.newGeofenceDialog(viewModel.selectedLocation.value) - dialog.setTargetFragment(this, REQUEST_GEOFENCE_DETAILS) - dialog.show(parentFragmentManager, FRAG_TAG_LOCATION_DIALOG) - } - @OptIn(ExperimentalPermissionsApi::class) override fun bind(parent: ViewGroup?): View = (parent as ComposeView).apply { setContent { + val viewState = viewModel.viewState.collectAsStateWithLifecycle().value val hasPermissions = rememberMultiplePermissionsState(permissions = backgroundPermissions()) .allPermissionsGranted LocationRow( - location = viewModel.selectedLocation.collectAsStateWithLifecycle().value, + location = viewState.location, hasPermissions = hasPermissions, - onClick = this@LocationControlSet::onRowClick, + onClick = { + viewState.location + ?.let { location -> + val options: MutableList Unit>> = ArrayList() + options.add(Pair.create(R.string.open_map) { location.open(activity) }) + if (!isNullOrEmpty(location.phone)) { + options.add(Pair.create(R.string.action_call) { call() }) + } + if (!isNullOrEmpty(location.url)) { + options.add(Pair.create(R.string.visit_website) { openWebsite() }) + } + options.add(Pair.create(R.string.choose_new_location) { chooseLocation() }) + options.add(Pair.create(R.string.delete) { + viewModel.setLocation( + null + ) + }) + val items = options.map { requireContext().getString(it.first!!) } + dialogBuilder + .newDialog(location.displayName) + .setItems(items) { _, which: Int -> + options[which].second!!() + } + .show() + } + ?: chooseLocation() + }, openGeofenceOptions = { if (hasPermissions) { showGeofenceOptions() @@ -109,11 +108,11 @@ class LocationControlSet : TaskEditControlFragment() { override fun controlId() = TAG private fun openWebsite() { - viewModel.selectedLocation.value?.let { context?.openUri(it.url) } + viewModel.viewState.value.location?.let { context?.openUri(it.url) } } private fun call() { - viewModel.selectedLocation.value?.let { + viewModel.viewState.value.location?.let { startActivity(Intent(Intent.ACTION_DIAL, Uri.parse("tel:" + it.phone))) } } @@ -126,7 +125,7 @@ class LocationControlSet : TaskEditControlFragment() { } else if (requestCode == REQUEST_LOCATION_REMINDER) { if (resultCode == Activity.RESULT_OK) { val place: Place = data!!.getParcelableExtra(LocationPickerActivity.EXTRA_PLACE)!! - val location = viewModel.selectedLocation.value + val location = viewModel.viewState.value.location val geofence = if (location == null) { createGeofence(place.uid, preferences) } else { @@ -137,14 +136,25 @@ class LocationControlSet : TaskEditControlFragment() { isDeparture = existing.isDeparture, ) } - setLocation(Location(geofence, place)) + viewModel.setLocation(Location(geofence, place)) } } else if (requestCode == REQUEST_GEOFENCE_DETAILS) { if (resultCode == Activity.RESULT_OK) { - setLocation(Location( - data?.getParcelableExtra(GeofenceDialog.EXTRA_GEOFENCE) ?: return, - viewModel.selectedLocation.value?.place ?: return - )) + val geofence = data + ?.let { + IntentCompat.getParcelableExtra( + it, + GeofenceDialog.EXTRA_GEOFENCE, + Geofence::class.java + ) + } + ?: return + viewModel.setLocation( + Location( + geofence, + viewModel.viewState.value.location?.place ?: return + ) + ) } } else { super.onActivityResult(requestCode, resultCode, data) diff --git a/app/src/main/java/org/tasks/ui/SubtaskControlSet.kt b/app/src/main/java/org/tasks/ui/SubtaskControlSet.kt index ff0eaa13c..f232c1790 100644 --- a/app/src/main/java/org/tasks/ui/SubtaskControlSet.kt +++ b/app/src/main/java/org/tasks/ui/SubtaskControlSet.kt @@ -43,7 +43,7 @@ class SubtaskControlSet : TaskEditControlFragment() { private val mainViewModel: MainActivityViewModel by activityViewModels() override fun createView(savedInstanceState: Bundle?) { - viewModel.task.takeIf { it.id > 0 }?.let { + viewModel.viewState.value.task.takeIf { it.id > 0 }?.let { listViewModel.setFilter(SubtaskFilter(it.id)) } } @@ -52,46 +52,44 @@ class SubtaskControlSet : TaskEditControlFragment() { (parent as ComposeView).apply { listViewModel = ViewModelProvider(requireParentFragment())[TaskListViewModel::class.java] setContent { + val viewState = viewModel.viewState.collectAsStateWithLifecycle().value SubtaskRow( - originalFilter = viewModel.originalList, - filter = viewModel.selectedList.collectAsStateWithLifecycle().value, - hasParent = viewModel.hasParent, - existingSubtasks = if (viewModel.isNew) { + originalFilter = viewModel.originalState.list, + filter = viewState.list, + hasParent = viewState.hasParent, + existingSubtasks = if (viewModel.viewState.collectAsStateWithLifecycle().value.isNew) { TasksResults.Results(SectionedDataSource()) } else { listViewModel.state.collectAsStateWithLifecycle().value.tasks }, - newSubtasks = viewModel.newSubtasks.collectAsStateWithLifecycle().value, + newSubtasks = viewState.newSubtasks, openSubtask = this@SubtaskControlSet::openSubtask, completeExistingSubtask = this@SubtaskControlSet::complete, toggleSubtask = this@SubtaskControlSet::toggleSubtask, - addSubtask = this@SubtaskControlSet::addSubtask, + addSubtask = { + lifecycleScope.launch { + viewModel.setSubtasks( + viewState.newSubtasks.plus(taskCreator.createWithValues("")) + ) + } + }, completeNewSubtask = { - viewModel.newSubtasks.value = - ArrayList(viewModel.newSubtasks.value).apply { + viewModel.setSubtasks( + viewState.newSubtasks.toMutableList().apply { val modified = it.copy( completionDate = if (it.isCompleted) 0 else currentTimeMillis() ) set(indexOf(it), modified) } + ) }, - deleteSubtask = { - viewModel.newSubtasks.value = - ArrayList(viewModel.newSubtasks.value).apply { - remove(it) - } - } + deleteSubtask = { viewModel.setSubtasks(viewState.newSubtasks - it) }, ) } } override fun controlId() = TAG - private fun addSubtask() = lifecycleScope.launch { - val task = taskCreator.createWithValues("") - viewModel.newSubtasks.value = viewModel.newSubtasks.value.plus(task) - } - private fun openSubtask(task: Task) = lifecycleScope.launch { mainViewModel.setTask(task) } diff --git a/app/src/main/java/org/tasks/ui/TaskEditViewModel.kt b/app/src/main/java/org/tasks/ui/TaskEditViewModel.kt index 25b50a649..fa7eacec1 100644 --- a/app/src/main/java/org/tasks/ui/TaskEditViewModel.kt +++ b/app/src/main/java/org/tasks/ui/TaskEditViewModel.kt @@ -7,19 +7,33 @@ import androidx.core.net.toUri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.todoroo.astrid.activity.BeastModePreferences import com.todoroo.astrid.activity.TaskEditFragment import com.todoroo.astrid.alarms.AlarmService import com.todoroo.astrid.dao.TaskDao +import com.todoroo.astrid.files.FilesControlSet import com.todoroo.astrid.gcal.GCalHelper +import com.todoroo.astrid.repeats.RepeatControlSet import com.todoroo.astrid.service.TaskCompleter import com.todoroo.astrid.service.TaskCreator.Companion.getDefaultAlarms import com.todoroo.astrid.service.TaskDeleter import com.todoroo.astrid.service.TaskMover +import com.todoroo.astrid.tags.TagsControlSet +import com.todoroo.astrid.timers.TimerControlSet import com.todoroo.astrid.timers.TimerPlugin +import com.todoroo.astrid.ui.ReminderControlSet +import com.todoroo.astrid.ui.StartDateControlSet import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -94,30 +108,105 @@ class TaskEditViewModel @Inject constructor( private val alarmDao: AlarmDao, private val taskAttachmentDao: TaskAttachmentDao, ) : ViewModel() { + data class ViewState( + val task: Task, + val displayOrder: ImmutableList, + val showBeastModeHint: Boolean, + val showComments: Boolean, + val showKeyboard: Boolean, + val backButtonSavesTask: Boolean, + val isReadOnly: Boolean, + val linkify: Boolean, + val list: CaldavFilter, + val location: Location?, + val tags: ImmutableSet, + val calendar: String?, + val attachments: ImmutableSet = persistentSetOf(), + val alarms: ImmutableSet, + val newSubtasks: ImmutableList = persistentListOf(), + ) { + val isNew: Boolean + get() = task.isNew + + val hasParent: Boolean + get() = task.parent > 0 + + val isCompleted: Boolean + get() = task.completionDate > 0 + } + private val resources = context.resources private var cleared = false - val task: Task = savedStateHandle[TaskEditFragment.EXTRA_TASK]!! - - val isNew = task.isNew + private val task: Task = savedStateHandle.get(TaskEditFragment.EXTRA_TASK) + ?.let { it.copy(notes = it.notes?.stripCarriageReturns()) } + ?: throw IllegalArgumentException("task is null") + + private var _originalState: ViewState + val originalState: ViewState + get() = _originalState + + private val _viewState = MutableStateFlow( + ViewState( + task = task, + showBeastModeHint = !preferences.shownBeastModeHint, + showComments = preferences.getBoolean(R.string.p_show_task_edit_comments, false), + showKeyboard = task.isNew && task.title.isNullOrBlank(), + backButtonSavesTask = preferences.backButtonSavesTask(), + isReadOnly = task.readOnly, + linkify = preferences.linkify, + list = savedStateHandle[TaskEditFragment.EXTRA_LIST]!!, + location = savedStateHandle[TaskEditFragment.EXTRA_LOCATION], + tags = savedStateHandle.get>(TaskEditFragment.EXTRA_TAGS) + ?.toPersistentSet() + ?: persistentSetOf(), + calendar = if (task.isNew && permissionChecker.canAccessCalendars()) { + preferences.defaultCalendar + } else { + null + }, + displayOrder = TASK_EDIT_CONTROL_SET_FRAGMENTS + .associateBy(context::getString) { it } + .let { controlSetStrings -> + BeastModePreferences + .constructOrderedControlList(preferences, context) + .let { items -> + items + .subList( + 0, + items.indexOf(context.getString(R.string.TEA_ctrl_hide_section_pref)) + ) + .also { it.add(0, context.getString(R.string.TEA_ctrl_title)) } + } + .mapNotNull { controlSetStrings[it] } + .toPersistentList() + }, + alarms = if (task.isNew) { + ArrayList().apply { + if (task.isNotifyAtStart) { + add(whenStarted(0)) + } + if (task.isNotifyAtDeadline) { + add(whenDue(0)) + } + if (task.isNotifyAfterDeadline) { + add(whenOverdue(0)) + } + if (task.randomReminder > 0) { + add(Alarm(time = task.randomReminder, type = Alarm.TYPE_RANDOM)) + } + } + } else { + savedStateHandle[TaskEditFragment.EXTRA_ALARMS]!! + }.toPersistentSet(), + ) + ) + val viewState: StateFlow = _viewState - var showBeastModeHint = MutableStateFlow(!preferences.shownBeastModeHint) - var creationDate: Long = task.creationDate - var modificationDate: Long = task.modificationDate - var completionDate: Long = task.completionDate - var title: String? = task.title - var completed = MutableStateFlow(task.isCompleted) - var priority = MutableStateFlow(task.priority) - var description: String? = task.notes.stripCarriageReturns() - val recurrence = MutableStateFlow(task.recurrence) - val repeatAfterCompletion = MutableStateFlow(task.repeatAfterCompletion()) var eventUri = MutableStateFlow(task.calendarURI) val timerStarted = MutableStateFlow(task.timerStart) val estimatedSeconds = MutableStateFlow(task.estimatedSeconds) val elapsedSeconds = MutableStateFlow(task.elapsedSeconds) - var newSubtasks = MutableStateFlow(emptyList()) - val hasParent: Boolean - get() = task.parent > 0 val dueDate = MutableStateFlow(task.dueDate) @@ -131,10 +220,14 @@ class TaskEditViewModel @Inject constructor( if (addedDueDate) { val reminderFlags = preferences.defaultReminders if (reminderFlags.isFlagSet(Task.NOTIFY_AT_DEADLINE)) { - selectedAlarms.value = selectedAlarms.value.plusAlarm(whenDue(task.id)) + _viewState.update { state -> + state.copy(alarms = state.alarms.plusAlarm(whenDue(task.id))) + } } if (reminderFlags.isFlagSet(Task.NOTIFY_AFTER_DEADLINE)) { - selectedAlarms.value = selectedAlarms.value.plusAlarm(whenOverdue(task.id)) + _viewState.update { state -> + state.copy(alarms = state.alarms.plusAlarm(whenOverdue(task.id))) + } } } } @@ -150,51 +243,12 @@ class TaskEditViewModel @Inject constructor( else -> value.startOfDay() } if (addedStartDate && preferences.defaultReminders.isFlagSet(Task.NOTIFY_AT_START)) { - selectedAlarms.value = selectedAlarms.value.plusAlarm(whenStarted(task.id)) - } - } - - private var originalCalendar: String? = if (isNew && permissionChecker.canAccessCalendars()) { - preferences.defaultCalendar - } else { - null - } - var selectedCalendar = MutableStateFlow(originalCalendar) - - val originalList: CaldavFilter = savedStateHandle[TaskEditFragment.EXTRA_LIST]!! - var selectedList = MutableStateFlow(originalList) - - private var originalLocation: Location? = savedStateHandle[TaskEditFragment.EXTRA_LOCATION] - var selectedLocation = MutableStateFlow(originalLocation) - - private val originalTags: List = - savedStateHandle.get>(TaskEditFragment.EXTRA_TAGS) ?: emptyList() - val selectedTags = MutableStateFlow(ArrayList(originalTags)) - - private lateinit var originalAttachments: List - val selectedAttachments = MutableStateFlow(emptyList()) - - private val originalAlarms: List = if (isNew) { - ArrayList().apply { - if (task.isNotifyAtStart) { - add(whenStarted(0)) - } - if (task.isNotifyAtDeadline) { - add(whenDue(0)) - } - if (task.isNotifyAfterDeadline) { - add(whenOverdue(0)) - } - if (task.randomReminder > 0) { - add(Alarm(time = task.randomReminder, type = Alarm.TYPE_RANDOM)) + _viewState.update { state -> + state.copy(alarms = state.alarms.plusAlarm(whenStarted(task.id))) } } - } else { - savedStateHandle[TaskEditFragment.EXTRA_ALARMS]!! } - var selectedAlarms = MutableStateFlow(originalAlarms) - var ringNonstop: Boolean = task.isNotifyModeNonstop set(value) { field = value @@ -211,28 +265,12 @@ class TaskEditViewModel @Inject constructor( } } - val isReadOnly = task.readOnly - - val isWritable = !isReadOnly - - fun hasChanges(): Boolean = - (task.title != title || (isNew && title?.isNotBlank() == true)) || - task.isCompleted != completed.value || + fun hasChanges(): Boolean { + val viewState = _viewState.value + return originalState != viewState || + (viewState.isNew && viewState.task.title?.isNotBlank() == true) || // text shared to tasks task.dueDate != dueDate.value || - task.priority != priority.value || - if (task.notes.isNullOrBlank()) { - !description.isNullOrBlank() - } else { - task.notes != description - } || task.hideUntil != startDate.value || - if (task.recurrence.isNullOrBlank()) { - !recurrence.value.isNullOrBlank() - } else { - task.recurrence != recurrence.value - } || - task.repeatAfterCompletion() != repeatAfterCompletion.value || - originalCalendar != selectedCalendar.value || if (task.calendarURI.isNullOrBlank()) { !eventUri.value.isNullOrBlank() } else { @@ -240,58 +278,49 @@ class TaskEditViewModel @Inject constructor( } || task.elapsedSeconds != elapsedSeconds.value || task.estimatedSeconds != estimatedSeconds.value || - originalList != selectedList.value || - originalLocation != selectedLocation.value || - originalTags.toHashSet() != selectedTags.value.toHashSet() || - (::originalAttachments.isInitialized && - originalAttachments.toHashSet() != selectedAttachments.value.toHashSet()) || - newSubtasks.value.isNotEmpty() || getRingFlags() != when { - task.isNotifyModeFive -> NOTIFY_MODE_FIVE - task.isNotifyModeNonstop -> NOTIFY_MODE_NONSTOP - else -> 0 - } || - originalAlarms.toHashSet() != selectedAlarms.value.toHashSet() + task.isNotifyModeFive -> NOTIFY_MODE_FIVE + task.isNotifyModeNonstop -> NOTIFY_MODE_NONSTOP + else -> 0 + } + } @MainThread suspend fun save(remove: Boolean = true): Boolean = withContext(NonCancellable) { if (cleared) { return@withContext false } - if (!hasChanges() || isReadOnly) { + if (!hasChanges() || viewState.value.isReadOnly) { discard(remove) return@withContext false } clear(remove) - task.title = if (title.isNullOrBlank()) resources.getString(R.string.no_title) else title + val viewState = _viewState.value + val isNew = viewState.isNew + task.title = if (viewState.task.title.isNullOrBlank()) resources.getString(R.string.no_title) else viewState.task.title task.dueDate = dueDate.value - task.priority = priority.value - task.notes = description + task.priority = viewState.task.priority + task.notes = viewState.task.notes task.hideUntil = startDate.value - task.recurrence = recurrence.value - task.repeatFrom = if (repeatAfterCompletion.value) { - Task.RepeatFrom.COMPLETION_DATE - } else { - Task.RepeatFrom.DUE_DATE - } + task.recurrence = viewState.task.recurrence + task.repeatFrom = viewState.task.repeatFrom task.elapsedSeconds = elapsedSeconds.value task.estimatedSeconds = estimatedSeconds.value task.ringFlags = getRingFlags() applyCalendarChanges() - if (isNew) { taskDao.createNew(task) } - - if ((isNew && selectedLocation.value != null) || originalLocation != selectedLocation.value) { - originalLocation?.let { location -> + val selectedLocation = _viewState.value.location + if ((isNew && selectedLocation != null) || originalState.location != selectedLocation) { + originalState.location?.let { location -> if (location.geofence.id > 0) { locationDao.delete(location.geofence) geofenceApi.update(location.place) } } - selectedLocation.value?.let { location -> + selectedLocation?.let { location -> val place = location.place locationDao.insert( location.geofence.copy( @@ -305,36 +334,45 @@ class TaskEditViewModel @Inject constructor( task.putTransitory(FORCE_MICROSOFT_SYNC, true) task.modificationDate = currentTimeMillis() } - - if ((isNew && selectedTags.value.isNotEmpty()) || originalTags.toHashSet() != selectedTags.value.toHashSet()) { - tagDao.applyTags(task, tagDataDao, selectedTags.value) + val selectedTags = _viewState.value.tags + if ((isNew && selectedTags.isNotEmpty()) || originalState.tags.toHashSet() != selectedTags.toHashSet()) { + tagDao.applyTags(task, tagDataDao, selectedTags) + task.putTransitory(FORCE_CALDAV_SYNC, true) task.modificationDate = currentTimeMillis() } if (!task.hasStartDate()) { - selectedAlarms.value = selectedAlarms.value.filterNot { a -> a.type == TYPE_REL_START } + _viewState.update { state -> + state.copy( + alarms = state.alarms.filterNot { it.type == TYPE_REL_START }.toPersistentSet() + ) + } } if (!task.hasDueDate()) { - selectedAlarms.value = selectedAlarms.value.filterNot { a -> a.type == TYPE_REL_END } + _viewState.update { state -> + state.copy( + alarms = state.alarms.filterNot { it.type == TYPE_REL_END }.toPersistentSet() + ) + } } if ( - selectedAlarms.value.toHashSet() != originalAlarms.toHashSet() || - (isNew && selectedAlarms.value.isNotEmpty()) + (isNew && _viewState.value.alarms.isNotEmpty()) || + originalState.alarms != _viewState.value.alarms ) { - alarmService.synchronizeAlarms(task.id, selectedAlarms.value.toMutableSet()) + alarmService.synchronizeAlarms(task.id, _viewState.value.alarms.toMutableSet()) task.putTransitory(FORCE_CALDAV_SYNC, true) task.modificationDate = currentTimeMillis() } taskDao.save(task, null) - - if (isNew || originalList != selectedList.value) { + val selectedList = _viewState.value.list + if (isNew || originalState.list != selectedList) { task.parent = 0 - taskMover.move(listOf(task.id), selectedList.value) + taskMover.move(listOf(task.id), selectedList) } - for (subtask in newSubtasks.value) { + for (subtask in viewState.newSubtasks) { if (Strings.isNullOrEmpty(subtask.title)) { continue } @@ -344,7 +382,7 @@ class TaskEditViewModel @Inject constructor( taskDao.createNew(subtask) alarmDao.insert(subtask.getDefaultAlarms()) firebase?.addTask("subtasks") - val filter = selectedList.value + val filter = selectedList when { filter.isGoogleTasks -> { val googleTask = CaldavTask( @@ -363,7 +401,7 @@ class TaskEditViewModel @Inject constructor( else -> { val caldavTask = CaldavTask( task = subtask.id, - calendar = filter.uuid, + calendar = selectedList.uuid, ) subtask.parent = task.id caldavTask.remoteParent = caldavDao.getRemoteIdForTask(task.id) @@ -377,16 +415,13 @@ class TaskEditViewModel @Inject constructor( } } - if ( - this@TaskEditViewModel::originalAttachments.isInitialized && - selectedAttachments.value.toHashSet() != originalAttachments.toHashSet() - ) { - originalAttachments - .minus(selectedAttachments.value.toSet()) + if (originalState.attachments != _viewState.value.attachments) { + originalState.attachments + .minus(_viewState.value.attachments) .map { it.remoteId } .let { taskAttachmentDao.delete(task.id, it) } - selectedAttachments.value - .minus(originalAttachments.toSet()) + _viewState.value.attachments + .minus(originalState.attachments) .map { Attachment( task = task.id, @@ -397,9 +432,9 @@ class TaskEditViewModel @Inject constructor( .let { taskAttachmentDao.insert(it) } } - if (task.isCompleted != completed.value) { - taskCompleter.setComplete(task, completed.value) - if (task.isCompleted) { + if (task.isCompleted != _viewState.value.isCompleted) { + taskCompleter.setComplete(task, _viewState.value.isCompleted) + if (_viewState.value.isCompleted) { firebase?.completeTask("edit_screen_v2") } } @@ -424,7 +459,7 @@ class TaskEditViewModel @Inject constructor( if (!task.hasDueDate()) { return } - selectedCalendar.value?.let { + _viewState.value.calendar?.let { try { task.calendarURI = gCalHelper.createTaskEvent(task, it)?.toString() } catch (e: Exception) { @@ -445,11 +480,11 @@ class TaskEditViewModel @Inject constructor( } suspend fun discard(remove: Boolean = true) { - if (isNew) { + if (_viewState.value.isNew) { timerPlugin.stopTimer(task) - originalAttachments.plus(selectedAttachments.value).toSet().takeIf { it.isNotEmpty() } - ?.onEach { FileHelper.delete(context, it.uri.toUri()) } - ?.let { taskAttachmentDao.delete(it.toList()) } + (originalState.attachments + _viewState.value.attachments) + .onEach { attachment -> FileHelper.delete(context, attachment.uri.toUri()) } + .let { taskAttachmentDao.delete(it.toList()) } } clear(remove) } @@ -474,14 +509,14 @@ class TaskEditViewModel @Inject constructor( } fun removeAlarm(alarm: Alarm) { - selectedAlarms.update { it.minus(alarm) } + _viewState.update { state -> + state.copy(alarms = state.alarms.minus(alarm).toPersistentSet()) + } } fun addAlarm(alarm: Alarm) { - with (selectedAlarms) { - if (value.none { it.same(alarm) }) { - value = value.plus(alarm) - } + _viewState.update { state -> + state.copy(alarms = state.alarms.plusAlarm(alarm)) } } @@ -502,26 +537,126 @@ class TaskEditViewModel @Inject constructor( } fun hideBeastModeHint(click: Boolean) { - showBeastModeHint.value = false + _viewState.update { + it.copy(showBeastModeHint = false) + } preferences.shownBeastModeHint = true firebase?.logEvent(R.string.event_banner_beast, R.string.param_click to click) } + fun setPriority(priority: Int) { + _viewState.update { state -> state.copy(task = state.task.copy(priority = priority)) } + } + + fun setTitle(title: String) { + _viewState.update { state -> state.copy(task = state.task.copy(title = title)) } + } + + fun setRecurrence(recurrence: String?) { + _viewState.update { state -> + state.copy( + task = state.task.copy( + recurrence = recurrence, + dueDate = if (recurrence?.isNotBlank() == true && task.dueDate == 0L) { + currentTimeMillis().startOfDay() + } else { + task.dueDate + } + ) + ) + } + } + + fun setDescription(description: String) { + _viewState.update { state -> state.copy(task = state.task.copy(notes = description)) } + } + + fun setList(list: CaldavFilter) { + _viewState.update { it.copy(list = list) } + } + + fun setTags(tags: Set) { + _viewState.update { it.copy(tags = tags.toPersistentSet()) } + } + + fun setLocation(location: Location?) { + _viewState.update { it.copy(location = location) } + } + + fun setCalendar(calendar: String?) { + _viewState.update { it.copy(calendar = calendar) } + } + + fun setAttachments(attachments: Set) { + _viewState.update { it.copy(attachments = attachments.toPersistentSet()) } + } + + fun setSubtasks(subtasks: List) { + _viewState.update { it.copy(newSubtasks = subtasks.toPersistentList()) } + } + + fun setComplete(completed: Boolean) { + _viewState.update { state -> + state.copy( + task = task.copy( + completionDate = when { + !completed -> 0 + task.isCompleted -> task.completionDate + else -> currentTimeMillis() + } + ) + ) + } + } + + fun setRepeatFrom(repeatFrom: @Task.RepeatFrom Int) { + _viewState.update { state -> + state.copy(task = state.task.copy(repeatFrom = repeatFrom)) + } + } + init { + _originalState = _viewState.value.copy() viewModelScope.launch { - taskAttachmentDao.getAttachments(task.id).let { attachments -> - selectedAttachments.update { attachments } - originalAttachments = attachments + taskAttachmentDao.getAttachments(task.id).toPersistentSet().let { attachments -> + _originalState = _originalState.copy(attachments = attachments) + _viewState.value = _viewState.value.copy(attachments = attachments) } } } companion object { + // one spark tasks for windows adds these fun String?.stripCarriageReturns(): String? = this?.replace("\\r\\n?".toRegex(), "\n") private fun Int.isFlagSet(flag: Int): Boolean = this and flag > 0 - private fun List.plusAlarm(alarm: Alarm): List = - if (any { it.same(alarm) }) this else this + alarm + private fun ImmutableSet.plusAlarm(alarm: Alarm): ImmutableSet = + if (any { it.same(alarm) }) this else this.plus(alarm).toPersistentSet() + + val TAG_TITLE = R.string.TEA_ctrl_title + val TAG_DESCRIPTION = R.string.TEA_ctrl_notes_pref + val TAG_CREATION = R.string.TEA_ctrl_creation_date + val TAG_LIST = R.string.TEA_ctrl_google_task_list + val TAG_PRIORITY = R.string.TEA_ctrl_importance_pref + val TAG_DUE_DATE = R.string.TEA_ctrl_when_pref + + val TASK_EDIT_CONTROL_SET_FRAGMENTS = intArrayOf( + TAG_TITLE, + TAG_DUE_DATE, + TimerControlSet.TAG, + TAG_DESCRIPTION, + CalendarControlSet.TAG, + TAG_PRIORITY, + StartDateControlSet.TAG, + ReminderControlSet.TAG, + LocationControlSet.TAG, + FilesControlSet.TAG, + TagsControlSet.TAG, + RepeatControlSet.TAG, + TAG_CREATION, + TAG_LIST, + SubtaskControlSet.TAG + ) } } diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml index 37f76faba..cb698ffd1 100644 --- a/app/src/main/res/values/keys.xml +++ b/app/src/main/res/values/keys.xml @@ -258,6 +258,7 @@ @string/TEA_ctrl_hide_section_pref + TEA_ctrl_title TEA_ctrl_when_pref TEA_ctrl_repeat_pref TEA_ctrl_importance_pref diff --git a/data/src/commonMain/kotlin/org/tasks/data/dao/TagDao.kt b/data/src/commonMain/kotlin/org/tasks/data/dao/TagDao.kt index 00bcd2e93..5b6e52852 100644 --- a/data/src/commonMain/kotlin/org/tasks/data/dao/TagDao.kt +++ b/data/src/commonMain/kotlin/org/tasks/data/dao/TagDao.kt @@ -36,11 +36,11 @@ abstract class TagDao(private val database: Database) { @Delete abstract suspend fun delete(tags: List) - open suspend fun applyTags(task: Task, tagDataDao: TagDataDao, current: List) { + open suspend fun applyTags(task: Task, tagDataDao: TagDataDao, current: Collection) { database.withTransaction { val taskId = task.id val existing = HashSet(tagDataDao.getTagDataForTask(taskId)) - val selected = HashSet(current) + val selected = current.toMutableSet() val added = selected subtract existing val removed = existing subtract selected deleteTags(taskId, removed.map { td -> td.remoteId!! }) diff --git a/data/src/commonMain/kotlin/org/tasks/data/entity/Task.kt b/data/src/commonMain/kotlin/org/tasks/data/entity/Task.kt index 19b10baa1..05d6e2f35 100644 --- a/data/src/commonMain/kotlin/org/tasks/data/entity/Task.kt +++ b/data/src/commonMain/kotlin/org/tasks/data/entity/Task.kt @@ -113,8 +113,6 @@ data class Task @OptIn(ExperimentalSerializationApi::class) constructor( /** Checks whether this due date has a due time or only a date */ fun hasDueTime(): Boolean = hasDueTime(dueDate) - fun repeatAfterCompletion(): Boolean = repeatFrom == RepeatFrom.COMPLETION_DATE - fun setDueDateAdjustingHideUntil(newDueDate: Long) { if (dueDate > 0) { if (hideUntil > 0) { @@ -283,6 +281,7 @@ data class Task @OptIn(ExperimentalSerializationApi::class) constructor( } } + @Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.TYPE) @Retention(AnnotationRetention.SOURCE) @IntDef(RepeatFrom.DUE_DATE, RepeatFrom.COMPLETION_DATE) annotation class RepeatFrom {