From 37c6efcfa19e40991ff118d90c41966f7f276164 Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Mon, 29 Jan 2024 10:54:13 +0800 Subject: [PATCH 01/31] Create the image behavior --- app-base/src/main/res/drawable/ic_image.xml | 9 ++++++++ app-base/src/main/res/values/strings.xml | 3 +++ .../key/behaviour/BehaviourTypeUtils.kt | 3 +++ .../timer/domain/entities/BehaviourEntity.kt | 23 ++++++++++++++++--- 4 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 app-base/src/main/res/drawable/ic_image.xml diff --git a/app-base/src/main/res/drawable/ic_image.xml b/app-base/src/main/res/drawable/ic_image.xml new file mode 100644 index 00000000..b9710395 --- /dev/null +++ b/app-base/src/main/res/drawable/ic_image.xml @@ -0,0 +1,9 @@ + + + diff --git a/app-base/src/main/res/values/strings.xml b/app-base/src/main/res/values/strings.xml index 014fa5d6..cb347eea 100644 --- a/app-base/src/main/res/values/strings.xml +++ b/app-base/src/main/res/values/strings.xml @@ -279,6 +279,9 @@ Flashlight Turn the flashlight on and off + Image + Attach an image to the step + Timer Template Prepare diff --git a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourTypeUtils.kt b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourTypeUtils.kt index 3ed0c745..adbf16e3 100644 --- a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourTypeUtils.kt +++ b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourTypeUtils.kt @@ -18,6 +18,7 @@ val BehaviourType.iconRes: Int BehaviourType.COUNT -> RBase.drawable.ic_count BehaviourType.NOTIFICATION -> RBase.drawable.ic_notification BehaviourType.FLASHLIGHT -> RBase.drawable.ic_flashlight + BehaviourType.IMAGE -> RBase.drawable.ic_image } val BehaviourType.nameRes: Int @@ -33,6 +34,7 @@ val BehaviourType.nameRes: Int BehaviourType.COUNT -> RBase.string.behaviour_count BehaviourType.NOTIFICATION -> RBase.string.behaviour_notification BehaviourType.FLASHLIGHT -> RBase.string.behaviour_flashlight + BehaviourType.IMAGE -> RBase.string.behaviour_image } val BehaviourType.despRes: Int @@ -48,4 +50,5 @@ val BehaviourType.despRes: Int BehaviourType.COUNT -> RBase.string.behaviour_count_help BehaviourType.NOTIFICATION -> RBase.string.behaviour_notification_help BehaviourType.FLASHLIGHT -> RBase.string.behaviour_flashlight_help + BehaviourType.IMAGE -> RBase.string.behaviour_image_help } diff --git a/domain/src/main/java/xyz/aprildown/timer/domain/entities/BehaviourEntity.kt b/domain/src/main/java/xyz/aprildown/timer/domain/entities/BehaviourEntity.kt index 84efdf09..7f41ad47 100644 --- a/domain/src/main/java/xyz/aprildown/timer/domain/entities/BehaviourEntity.kt +++ b/domain/src/main/java/xyz/aprildown/timer/domain/entities/BehaviourEntity.kt @@ -24,9 +24,11 @@ package xyz.aprildown.timer.domain.entities * str1: last n seconds, 0 for always * FLASHLIGHT: * empty for now + * IMAGE: + * str1: The image path */ enum class BehaviourType { - MUSIC, VIBRATION, SCREEN, HALT, VOICE, BEEP, HALF, COUNT, NOTIFICATION, FLASHLIGHT; + MUSIC, VIBRATION, SCREEN, HALT, VOICE, BEEP, HALF, COUNT, NOTIFICATION, FLASHLIGHT, IMAGE; val hasBoolValue: Boolean get() = this == MUSIC || this == BEEP @@ -358,7 +360,7 @@ fun BehaviourEntity.toNotificationAction(): NotificationAction { // endregion Notification -// region Notification +// region Flashlight data class FlashlightAction(val step: Long = 500L) : Action { override fun toBehaviourEntity(): BehaviourEntity { @@ -371,4 +373,19 @@ fun BehaviourEntity.toFlashlightAction(): FlashlightAction { return FlashlightAction() } -// endregion Notification +// endregion Flashlight + +// region Image + +data class ImageAction(val path: String) : Action { + override fun toBehaviourEntity(): BehaviourEntity { + return BehaviourEntity(type = BehaviourType.IMAGE, str1 = path) + } +} + +fun BehaviourEntity.toImageAction(): ImageAction { + require(type == BehaviourType.IMAGE) + return ImageAction(path = str1) +} + +// endregion Image From cda1ae2eb6d15746ebea9ff2becf8a376134c406 Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Mon, 5 Feb 2024 08:29:26 +0800 Subject: [PATCH 02/31] Pick an image for a step --- app-base/src/main/res/values/strings.xml | 2 + .../app/timer/edit/BehaviourSettingsView.kt | 12 ++++ .../timer/app/timer/edit/EditActivity.kt | 38 +++++++++++- .../timer/app/timer/edit/EditableStep.kt | 7 +++ component-key/build.gradle | 1 + .../key/behaviour/EditableBehaviourLayout.kt | 58 ++++++++++++++----- .../res/layout/layout_editable_behaviour.xml | 10 +++- .../layout_editable_behaviour_image.xml | 8 +++ gradle/libs.versions.toml | 1 + .../timer/presentation/edit/EditViewModel.kt | 11 ++++ .../presentation/edit/EditViewModelTest.kt | 1 + 11 files changed, 133 insertions(+), 16 deletions(-) create mode 100644 component-key/src/main/res/layout/layout_editable_behaviour_image.xml diff --git a/app-base/src/main/res/values/strings.xml b/app-base/src/main/res/values/strings.xml index cb347eea..3c6aa86b 100644 --- a/app-base/src/main/res/values/strings.xml +++ b/app-base/src/main/res/values/strings.xml @@ -282,6 +282,8 @@ Image Attach an image to the step + Pick an image + Timer Template Prepare diff --git a/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/BehaviourSettingsView.kt b/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/BehaviourSettingsView.kt index ce561ab7..284c4e00 100644 --- a/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/BehaviourSettingsView.kt +++ b/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/BehaviourSettingsView.kt @@ -295,3 +295,15 @@ internal fun MaterialPopupMenuBuilder.addNotificationItems( } } } + +internal fun MaterialPopupMenuBuilder.addImageItems( + context: Context, + onPick: () -> Unit, +) { + section { + item { + label = context.getString(RBase.string.image_pick) + callback = onPick + } + } +} diff --git a/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditActivity.kt b/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditActivity.kt index dfae6215..c8f43245 100644 --- a/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditActivity.kt +++ b/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditActivity.kt @@ -12,6 +12,7 @@ import android.view.View import androidx.activity.addCallback import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.annotation.VisibleForTesting @@ -59,6 +60,7 @@ import xyz.aprildown.timer.domain.entities.TimerEntity import xyz.aprildown.timer.domain.entities.toBeepAction import xyz.aprildown.timer.domain.entities.toCountAction import xyz.aprildown.timer.domain.entities.toHalfAction +import xyz.aprildown.timer.domain.entities.toImageAction import xyz.aprildown.timer.domain.entities.toMusicAction import xyz.aprildown.timer.domain.entities.toNotificationAction import xyz.aprildown.timer.domain.entities.toScreenAction @@ -95,6 +97,18 @@ class EditActivity : getRingtonePickerResultCallback() ) + private val pickImageLauncher = registerForActivityResult( + ActivityResultContracts.PickVisualMedia() + ) { uri -> + if (uri == null || uri == Uri.EMPTY) return@registerForActivityResult + val position = viewModel.imagePosition + if (position == -1) return@registerForActivityResult + changeBehaviour(type = BehaviourType.IMAGE, position = position) { + it.toImageAction().copy(path = uri.toString()).toBehaviourEntity() + } + viewModel.imagePosition = -1 + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityEditTimerBinding.inflate(layoutInflater) @@ -759,6 +773,12 @@ class EditActivity : } ) } + BehaviourType.IMAGE -> { + addImageItems( + context = this@EditActivity, + onPick = { onImageClick(position) }, + ) + } else -> Unit } section { @@ -802,8 +822,17 @@ class EditActivity : ) { val step = getStepFromFastAdapter(position) val newBehaviours = mutableListOf() + var found = false step.behaviour.forEach { - newBehaviours.add(if (it.type == type) transform(it) else it) + if (it.type == type) { + newBehaviours += transform(it) + found = true + } else { + newBehaviours += it + } + } + if (!found) { + newBehaviours += transform(BehaviourEntity(type)) } // The code after this causes setBehaviors which doesn't call onBehaviourAddedOrRemoved // so we check here. @@ -820,6 +849,13 @@ class EditActivity : } } + override fun onImageClick(position: Int) { + viewModel.imagePosition = position + pickImageLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + private fun postUpdateTotalTime() { if (shouldUpdateTotalTime) { binding.viewEditStepInfo.run { diff --git a/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditableStep.kt b/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditableStep.kt index 0de22965..8d0809c6 100644 --- a/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditableStep.kt +++ b/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditableStep.kt @@ -58,6 +58,8 @@ class EditableStep( position: Int ) + fun onImageClick(position: Int) + fun onBehaviourAddedOrRemoved(position: Int, newBehaviours: List) } @@ -118,6 +120,11 @@ class EditableStep( bindingAdapterPosition ) } + + override fun onImageClick() { + super.onImageClick() + handler.onImageClick(bindingAdapterPosition) + } } ) } diff --git a/component-key/build.gradle b/component-key/build.gradle index a41957d5..ddd5f78c 100644 --- a/component-key/build.gradle +++ b/component-key/build.gradle @@ -17,5 +17,6 @@ dependencies { implementation libs.hmsPicker implementation libs.materialPopupMenu implementation libs.scrollHmsPicker + implementation libs.glide } diff --git a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt index 71bb8919..38c40d0d 100644 --- a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt +++ b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt @@ -4,25 +4,29 @@ import android.content.Context import android.graphics.Color import android.util.AttributeSet import android.view.Gravity +import android.view.LayoutInflater import android.view.View import android.view.View.OnClickListener import android.widget.ImageButton -import android.widget.TextView import androidx.annotation.ColorInt import androidx.appcompat.widget.TooltipCompat import androidx.appcompat.widget.TooltipCompatFix +import androidx.core.view.isVisible +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.github.deweyreed.tools.helper.drawable -import com.github.deweyreed.tools.helper.gone import com.github.deweyreed.tools.helper.setTextIfChanged -import com.github.deweyreed.tools.helper.show import com.github.deweyreed.tools.helper.toColorStateList import com.github.zawadz88.materialpopupmenu.popupMenu import com.google.android.flexbox.FlexWrap import com.google.android.flexbox.FlexboxLayout import com.google.android.material.chip.Chip import xyz.aprildown.timer.component.key.R +import xyz.aprildown.timer.component.key.databinding.LayoutEditableBehaviourBinding +import xyz.aprildown.timer.component.key.databinding.LayoutEditableBehaviourImageBinding import xyz.aprildown.timer.domain.entities.BehaviourEntity import xyz.aprildown.timer.domain.entities.BehaviourType +import xyz.aprildown.timer.domain.entities.toImageAction class EditableBehaviourLayout( context: Context, @@ -36,8 +40,13 @@ class EditableBehaviourLayout( layout: EditableBehaviourLayout, current: BehaviourEntity ) + + fun onImageClick(): Unit = Unit } + private val binding = + LayoutEditableBehaviourBinding.inflate(LayoutInflater.from(context), this) + @ColorInt private var colorEnabled: Int = Color.RED @@ -45,8 +54,7 @@ class EditableBehaviourLayout( private var listener: Listener? = null private var onBehaviourAddedOrRemovedCallback: (() -> Unit)? = null - val addButton: ImageButton - private val emptyText: TextView + val addButton: ImageButton get() = binding.btnBehaviourAdd private val enabledBehaviourTypes = BehaviourType.entries @@ -55,11 +63,7 @@ class EditableBehaviourLayout( setShowDivider(SHOW_DIVIDER_MIDDLE) setDividerDrawable(context.drawable(R.drawable.divider_normal_flexbox)) - View.inflate(context, R.layout.layout_editable_behaviour, this) - - emptyText = findViewById(R.id.textBehaviourEmpty) - addButton = findViewById(R.id.btnBehaviourAdd) - addButton.setOnClickListener { view -> + binding.btnBehaviourAdd.setOnClickListener { view -> val currentTypes = data.keys val showTypes = enabledBehaviourTypes.filter { type -> type !in currentTypes } if (showTypes.isNotEmpty()) { @@ -77,7 +81,11 @@ class EditableBehaviourLayout( ) } callback = { - addStubBehaviour(BehaviourEntity(type), true) + if (type == BehaviourType.IMAGE) { + listener?.onImageClick() + } else { + addStubBehaviour(BehaviourEntity(type), true) + } } } } @@ -115,6 +123,24 @@ class EditableBehaviourLayout( addStubBehaviour(it, false) setViewWithEntity(it) } + + val imageAction = bs.find { it.type == BehaviourType.IMAGE }?.toImageAction() + if (imageAction != null) { + val imageView = if (binding.layoutImage.childCount == 0) { + LayoutEditableBehaviourImageBinding.inflate( + LayoutInflater.from(context), binding.layoutImage, true + ).root + } else { + LayoutEditableBehaviourImageBinding.bind(binding.layoutImage).root + } + Glide.with(imageView) + .load(imageAction.path) + .transition(DrawableTransitionOptions.withCrossFade()) + .into(imageView) + } else { + binding.layoutImage.removeAllViews() + } + updateHelperViews() } @@ -125,7 +151,7 @@ class EditableBehaviourLayout( val type = behaviour.type removeBehaviour(type) val newView = generateBehaviourView(type) - addView(newView, childCount - 1) + addView(newView, indexOfChild(binding.layoutBottomBar)) data[type] = newView to behaviour if (fromUser) { @@ -141,6 +167,10 @@ class EditableBehaviourLayout( data.remove(behaviourType) updateHelperViews() + if (behaviourType == BehaviourType.IMAGE) { + binding.layoutImage.removeAllViews() + } + onBehaviourAddedOrRemovedCallback?.invoke() } @@ -167,8 +197,8 @@ class EditableBehaviourLayout( private fun updateHelperViews() { val dataSize = data.size - if (dataSize >= enabledBehaviourTypes.size) addButton.gone() else addButton.show() - if (dataSize == 0) emptyText.show() else emptyText.gone() + binding.btnBehaviourAdd.isVisible = dataSize < enabledBehaviourTypes.size + binding.textBehaviourEmpty.isVisible = dataSize == 0 } private fun createNewBehaviourChip( diff --git a/component-key/src/main/res/layout/layout_editable_behaviour.xml b/component-key/src/main/res/layout/layout_editable_behaviour.xml index 70a00262..bd0b50c4 100644 --- a/component-key/src/main/res/layout/layout_editable_behaviour.xml +++ b/component-key/src/main/res/layout/layout_editable_behaviour.xml @@ -10,6 +10,7 @@ tools:showDivider="middle"> @@ -36,4 +37,11 @@ - \ No newline at end of file + + + diff --git a/component-key/src/main/res/layout/layout_editable_behaviour_image.xml b/component-key/src/main/res/layout/layout_editable_behaviour_image.xml new file mode 100644 index 00000000..43e6d1a5 --- /dev/null +++ b/component-key/src/main/res/layout/layout_editable_behaviour_image.xml @@ -0,0 +1,8 @@ + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8239e4f6..6a15f268 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -132,6 +132,7 @@ ultimateRingtonePicker = "com.github.DeweyReed:UltimateRingtonePicker:3.3.0" verticalStepperForm = "com.ernestoyaquello.stepperform:vertical-stepper-form:2.5.0" zip4j = "net.lingala.zip4j:zip4j:2.11.5" taskerPlugin = "com.joaomgcd:taskerpluginlibrary:0.4.10" +glide = "com.github.bumptech.glide:glide:4.16.0" diskLruCache = "com.github.bumptech.glide:disklrucache:4.16.0" [bundles] diff --git a/presentation/src/main/java/xyz/aprildown/timer/presentation/edit/EditViewModel.kt b/presentation/src/main/java/xyz/aprildown/timer/presentation/edit/EditViewModel.kt index 2e861296..c51087d5 100644 --- a/presentation/src/main/java/xyz/aprildown/timer/presentation/edit/EditViewModel.kt +++ b/presentation/src/main/java/xyz/aprildown/timer/presentation/edit/EditViewModel.kt @@ -2,6 +2,7 @@ package xyz.aprildown.timer.presentation.edit import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.github.deweyreed.tools.arch.Event import dagger.Lazy @@ -41,6 +42,8 @@ import javax.inject.Named */ @HiltViewModel class EditViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + @MainDispatcher mainDispatcher: CoroutineDispatcher, private val addTimer: AddTimer, private val saveTimer: SaveTimer, @@ -56,6 +59,12 @@ class EditViewModel @Inject constructor( private var timerHash: Int = 0 + var imagePosition: Int + get() = savedStateHandle[KEY_IMAGE_POSITION] ?: -1 + set(value) { + savedStateHandle[KEY_IMAGE_POSITION] = value + } + // Updated instantly val name = MutableLiveData() val loop = MutableLiveData() @@ -287,5 +296,7 @@ class EditViewModel @Inject constructor( const val UPDATE_CREATE = 0 const val UPDATE_UPDATE = 1 const val UPDATE_DELETE = 2 + + private const val KEY_IMAGE_POSITION = "image_position" } } diff --git a/presentation/src/test/java/xyz/aprildown/timer/presentation/edit/EditViewModelTest.kt b/presentation/src/test/java/xyz/aprildown/timer/presentation/edit/EditViewModelTest.kt index 77dcd9a5..4ce21811 100644 --- a/presentation/src/test/java/xyz/aprildown/timer/presentation/edit/EditViewModelTest.kt +++ b/presentation/src/test/java/xyz/aprildown/timer/presentation/edit/EditViewModelTest.kt @@ -58,6 +58,7 @@ class EditViewModelTest { private fun TestScope.getViewModel(): EditViewModel { val viewModel = EditViewModel( + savedStateHandle = mock(), mainDispatcher = StandardTestDispatcher(testScheduler), addTimer = addTimer, saveTimer = saveTimer, From 37c50b8e037abe0d4dd5a455a7addbcc71382bf7 Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Mon, 5 Feb 2024 09:08:38 +0800 Subject: [PATCH 03/31] Show the image in the list --- .../timer/app/timer/edit/EditActivity.kt | 2 + .../timer/app/timer/one/step/VisibleStep.kt | 3 +- .../key/behaviour/BehaviourLayout.kt | 43 +++++++++++++++---- .../key/behaviour/EditableBehaviourLayout.kt | 5 ++- .../src/main/res/layout/layout_behaviour.xml | 22 ++++++++++ .../layout_editable_behaviour_image.xml | 1 - 6 files changed, 63 insertions(+), 13 deletions(-) create mode 100644 component-key/src/main/res/layout/layout_behaviour.xml diff --git a/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditActivity.kt b/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditActivity.kt index c8f43245..e50e4193 100644 --- a/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditActivity.kt +++ b/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditActivity.kt @@ -2,6 +2,7 @@ package xyz.aprildown.timer.app.timer.edit import android.app.Activity import android.content.ClipboardManager +import android.content.Intent import android.graphics.Typeface import android.net.Uri import android.os.Bundle @@ -103,6 +104,7 @@ class EditActivity : if (uri == null || uri == Uri.EMPTY) return@registerForActivityResult val position = viewModel.imagePosition if (position == -1) return@registerForActivityResult + contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) changeBehaviour(type = BehaviourType.IMAGE, position = position) { it.toImageAction().copy(path = uri.toString()).toBehaviourEntity() } diff --git a/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/step/VisibleStep.kt b/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/step/VisibleStep.kt index d4a385d5..891ec60e 100644 --- a/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/step/VisibleStep.kt +++ b/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/step/VisibleStep.kt @@ -10,7 +10,6 @@ import androidx.core.widget.ImageViewCompat import androidx.core.widget.TextViewCompat import androidx.recyclerview.widget.RecyclerView import com.github.deweyreed.tools.helper.attachToView -import com.github.deweyreed.tools.helper.gone import com.github.deweyreed.tools.helper.onLongClick import com.github.deweyreed.tools.helper.toColorStateList import com.mikepenz.fastadapter.items.AbstractItem @@ -80,7 +79,7 @@ internal class VisibleStep( binding.textTime, R.style.TextAppearance_Stepper_NonSelected ) - binding.layoutBehaviour.gone() + binding.layoutBehaviour.isVisible = false } val typeColor = step.type.getTypeColor(context) diff --git a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt index 50f9990b..b59f0f6e 100644 --- a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt +++ b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt @@ -3,27 +3,35 @@ package xyz.aprildown.timer.component.key.behaviour import android.content.Context import android.content.res.ColorStateList import android.util.AttributeSet +import android.view.LayoutInflater import android.view.View +import android.widget.LinearLayout import androidx.annotation.ColorInt import androidx.appcompat.widget.TooltipCompat -import com.github.deweyreed.tools.anko.dp +import androidx.core.view.isEmpty +import androidx.core.view.isVisible +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.github.deweyreed.tools.helper.setTextIfChanged import com.google.android.material.chip.Chip -import com.google.android.material.chip.ChipGroup import xyz.aprildown.timer.component.key.R +import xyz.aprildown.timer.component.key.databinding.LayoutBehaviourBinding +import xyz.aprildown.timer.component.key.databinding.LayoutEditableBehaviourImageBinding import xyz.aprildown.timer.domain.entities.BehaviourEntity +import xyz.aprildown.timer.domain.entities.BehaviourType +import xyz.aprildown.timer.domain.entities.toImageAction class BehaviourLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null -) : ChipGroup(context, attrs) { +) : LinearLayout(context, attrs) { + + private val binding = LayoutBehaviourBinding.inflate(LayoutInflater.from(context), this) private val currentBehaviours = mutableListOf() init { - val spacing = context.dp(4).toInt() - chipSpacingHorizontal = spacing - chipSpacingVertical = spacing + orientation = VERTICAL } fun setBehaviours(list: List) { @@ -34,7 +42,7 @@ class BehaviourLayout @JvmOverloads constructor( repeat(removeCount) { val index = currentSize - it - 1 val chipView = currentBehaviours[index] - removeView(chipView.chip) + binding.chipGroup.removeView(chipView.chip) currentBehaviours.remove(chipView) } } @@ -50,6 +58,25 @@ class BehaviourLayout @JvmOverloads constructor( } require(currentBehaviours.size == list.size) + + val imageBehaviour = list.find { it.type == BehaviourType.IMAGE } + if (imageBehaviour != null) { + binding.layoutImage.isVisible = true + val imageView = if (binding.layoutImage.isEmpty()) { + LayoutEditableBehaviourImageBinding.inflate( + LayoutInflater.from(context), binding.layoutImage, true + ).root + } else { + LayoutEditableBehaviourImageBinding.bind(binding.layoutImage.getChildAt(0)).root + } + Glide.with(imageView) + .load(imageBehaviour.toImageAction().path) + .transition(DrawableTransitionOptions.withCrossFade()) + .into(imageView) + } else { + binding.layoutImage.isVisible = false + binding.layoutImage.removeAllViews() + } } fun setEnabledColor(@ColorInt color: Int) { @@ -60,7 +87,7 @@ class BehaviourLayout @JvmOverloads constructor( private fun createNewChip(): Chip { val chip = View.inflate(context, R.layout.view_behavior_chip, null) as Chip - addView(chip) + binding.chipGroup.addView(chip) return chip } diff --git a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt index 38c40d0d..e0ed864a 100644 --- a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt +++ b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt @@ -11,6 +11,7 @@ import android.widget.ImageButton import androidx.annotation.ColorInt import androidx.appcompat.widget.TooltipCompat import androidx.appcompat.widget.TooltipCompatFix +import androidx.core.view.isEmpty import androidx.core.view.isVisible import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions @@ -126,12 +127,12 @@ class EditableBehaviourLayout( val imageAction = bs.find { it.type == BehaviourType.IMAGE }?.toImageAction() if (imageAction != null) { - val imageView = if (binding.layoutImage.childCount == 0) { + val imageView = if (binding.layoutImage.isEmpty()) { LayoutEditableBehaviourImageBinding.inflate( LayoutInflater.from(context), binding.layoutImage, true ).root } else { - LayoutEditableBehaviourImageBinding.bind(binding.layoutImage).root + LayoutEditableBehaviourImageBinding.bind(binding.layoutImage.getChildAt(0)).root } Glide.with(imageView) .load(imageAction.path) diff --git a/component-key/src/main/res/layout/layout_behaviour.xml b/component-key/src/main/res/layout/layout_behaviour.xml new file mode 100644 index 00000000..5936d321 --- /dev/null +++ b/component-key/src/main/res/layout/layout_behaviour.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/component-key/src/main/res/layout/layout_editable_behaviour_image.xml b/component-key/src/main/res/layout/layout_editable_behaviour_image.xml index 43e6d1a5..2abe05ab 100644 --- a/component-key/src/main/res/layout/layout_editable_behaviour_image.xml +++ b/component-key/src/main/res/layout/layout_editable_behaviour_image.xml @@ -4,5 +4,4 @@ android:layout_width="match_parent" android:layout_height="256dp" android:importantForAccessibility="no" - android:scaleType="fitCenter" tools:src="@tools:sample/backgrounds/scenic" /> From c1962985282cccef2c9edc764b9fe69143519319 Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Wed, 7 Feb 2024 10:14:31 +0800 Subject: [PATCH 04/31] Create ImagePreviewActivity with Jetpack Compose --- .../timer/app/base/ui/AppNavigator.kt | 1 + .../deweyreed/timer/ui/AppNavigatorImpl.kt | 5 ++++ component-key/build.gradle | 11 +++++++ component-key/src/main/AndroidManifest.xml | 4 ++- .../component/key/ImagePreviewActivity.kt | 30 +++++++++++++++++++ gradle/libs.versions.toml | 16 +++++++++- 6 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 component-key/src/main/java/xyz/aprildown/timer/component/key/ImagePreviewActivity.kt diff --git a/app-base/src/main/java/xyz/aprildown/timer/app/base/ui/AppNavigator.kt b/app-base/src/main/java/xyz/aprildown/timer/app/base/ui/AppNavigator.kt index 31e022c2..42281047 100644 --- a/app-base/src/main/java/xyz/aprildown/timer/app/base/ui/AppNavigator.kt +++ b/app-base/src/main/java/xyz/aprildown/timer/app/base/ui/AppNavigator.kt @@ -18,6 +18,7 @@ interface AppNavigator { fun getStartTimerShortcutIntent(timerId: Int, openOnClick: Boolean): Intent fun getShortcutCreatedIntent(): Intent fun getIntroIntent(isOnBoarding: Boolean = false): Intent + fun getImagePreviewIntent(path: String): Intent /** * @param [timerInfo] is always not empty. diff --git a/app/src/main/java/io/github/deweyreed/timer/ui/AppNavigatorImpl.kt b/app/src/main/java/io/github/deweyreed/timer/ui/AppNavigatorImpl.kt index 83aa5d87..a9fa4247 100644 --- a/app/src/main/java/io/github/deweyreed/timer/ui/AppNavigatorImpl.kt +++ b/app/src/main/java/io/github/deweyreed/timer/ui/AppNavigatorImpl.kt @@ -11,6 +11,7 @@ import xyz.aprildown.timer.app.timer.edit.EditActivity import xyz.aprildown.timer.app.timer.list.TimerPicker import xyz.aprildown.timer.app.timer.one.OneActivity import xyz.aprildown.timer.app.timer.run.PhantomActivity +import xyz.aprildown.timer.component.key.ImagePreviewActivity import xyz.aprildown.timer.domain.utils.Constants import javax.inject.Inject @@ -54,6 +55,10 @@ class AppNavigatorImpl @Inject constructor( return IntroActivity.getIntent(context, isOnBoarding) } + override fun getImagePreviewIntent(path: String): Intent { + return ImagePreviewActivity.getIntent(context, path) + } + override fun pickTimer( fm: FragmentManager, multiple: Boolean, diff --git a/component-key/build.gradle b/component-key/build.gradle index ddd5f78c..3f6b4de9 100644 --- a/component-key/build.gradle +++ b/component-key/build.gradle @@ -6,6 +6,12 @@ plugins { android { namespace 'xyz.aprildown.timer.component.key' + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() + } } dependencies { @@ -13,6 +19,11 @@ dependencies { implementation libs.androidx.preference + implementation platform(libs.compose.bom) + implementation libs.bundles.compose + implementation libs.compose.tooling.preview + debugImplementation libs.compose.tooling.debug + implementation libs.flexbox implementation libs.hmsPicker implementation libs.materialPopupMenu diff --git a/component-key/src/main/AndroidManifest.xml b/component-key/src/main/AndroidManifest.xml index d92cf126..de90252b 100644 --- a/component-key/src/main/AndroidManifest.xml +++ b/component-key/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ - + + + diff --git a/component-key/src/main/java/xyz/aprildown/timer/component/key/ImagePreviewActivity.kt b/component-key/src/main/java/xyz/aprildown/timer/component/key/ImagePreviewActivity.kt new file mode 100644 index 00000000..2da5c4c9 --- /dev/null +++ b/component-key/src/main/java/xyz/aprildown/timer/component/key/ImagePreviewActivity.kt @@ -0,0 +1,30 @@ +package xyz.aprildown.timer.component.key + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.material3.Text +import xyz.aprildown.timer.app.base.ui.BaseActivity + +class ImagePreviewActivity : BaseActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val path = intent?.getStringExtra(EXTRA_PATH) + if (path.isNullOrBlank()) { + finish() + return + } + setContent { + Text(text = path) + } + } + + companion object { + private const val EXTRA_PATH = "path" + fun getIntent(context: Context, path: String): Intent { + return Intent(context, ImagePreviewActivity::class.java) + .putExtra(EXTRA_PATH, path) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6a15f268..5223cb17 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,8 +10,8 @@ versionCode = "7050100" versionName = "7.5.1" kotlin = "1.9.24" - dagger = "2.51.1" +composeCompiler = "1.5.14" detekt = "1.23.6" @@ -80,6 +80,14 @@ androidx-navigation-ui = "androidx.navigation:navigation-ui-ktx:2.7.7" androidx-work = "androidx.work:work-runtime-ktx:2.9.0" +compose-bom = "androidx.compose:compose-bom:2023.06.01" +compose-material = { module = "androidx.compose.material3:material3" } +compose-activity = "androidx.activity:activity-compose:1.8.2" +compose-liveData = { module = "androidx.compose.runtime:runtime-livedata" } +compose-constraintLayout = "androidx.constraintlayout:constraintlayout-compose:1.0.1" +compose-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +compose-tooling-debug = { module = "androidx.compose.ui:ui-tooling" } + material = "com.google.android.material:material:1.4.0" leakCannary = "com.squareup.leakcanary:leakcanary-android-startup:2.14" @@ -149,3 +157,9 @@ androidx-test-espresso = [ "androidx-test-espresso-contrib", "androidx-test-espresso-intents", ] +compose = [ + "compose-material", + "compose-activity", + "compose-liveData", + "compose-constraintLayout", +] From fa436717ed255f82ebc7d2034be001ae9b43cbf1 Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Fri, 9 Feb 2024 17:30:41 +0800 Subject: [PATCH 05/31] Replace Glide with Coil --- app/build.gradle | 2 ++ .../java/io/github/deweyreed/timer/App.kt | 19 ++++++++++++++++++- component-key/build.gradle | 3 ++- .../key/behaviour/BehaviourLayout.kt | 10 ++++------ .../key/behaviour/EditableBehaviourLayout.kt | 10 ++++------ gradle/libs.versions.toml | 4 +++- 6 files changed, 33 insertions(+), 15 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 97f4b720..e390abe9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -148,6 +148,8 @@ dependencies { implementation libs.toggleButtonGroup implementation libs.ultimateRingtonePicker implementation libs.verticalStepperForm + implementation libs.coil + implementation libs.coil.gif androidTestCompileOnly libs.taskerPlugin } diff --git a/app/src/main/java/io/github/deweyreed/timer/App.kt b/app/src/main/java/io/github/deweyreed/timer/App.kt index 8d4e2ad6..cb719d43 100644 --- a/app/src/main/java/io/github/deweyreed/timer/App.kt +++ b/app/src/main/java/io/github/deweyreed/timer/App.kt @@ -11,6 +11,10 @@ import androidx.core.content.edit import androidx.hilt.work.HiltWorkerFactory import androidx.preference.PreferenceManager import androidx.work.DelegatingWorkerFactory +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.decode.GifDecoder +import coil.decode.ImageDecoderDecoder import com.github.deweyreed.tools.helper.hasPermissions import dagger.Lazy import dagger.hilt.android.HiltAndroidApp @@ -36,7 +40,7 @@ import androidx.work.Configuration as WorkManagerConfiguration import xyz.aprildown.timer.app.base.R as RBase @HiltAndroidApp -class App : Application(), WorkManagerConfiguration.Provider { +class App : Application(), WorkManagerConfiguration.Provider, ImageLoaderFactory { @Inject lateinit var sharedPreferences: SharedPreferences @@ -201,6 +205,19 @@ class App : Application(), WorkManagerConfiguration.Provider { } .build() } + + override fun newImageLoader(): ImageLoader { + return ImageLoader.Builder(this) + .components { + // https://coil-kt.github.io/coil/gifs/ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + } + .build() + } } private const val PREF_FIRST_START = "pref_first_start_app" diff --git a/component-key/build.gradle b/component-key/build.gradle index 3f6b4de9..55575f24 100644 --- a/component-key/build.gradle +++ b/component-key/build.gradle @@ -28,6 +28,7 @@ dependencies { implementation libs.hmsPicker implementation libs.materialPopupMenu implementation libs.scrollHmsPicker - implementation libs.glide + implementation libs.coil + implementation libs.coil.compose } diff --git a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt index b59f0f6e..f44cfef2 100644 --- a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt +++ b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt @@ -10,8 +10,7 @@ import androidx.annotation.ColorInt import androidx.appcompat.widget.TooltipCompat import androidx.core.view.isEmpty import androidx.core.view.isVisible -import com.bumptech.glide.Glide -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +import coil.load import com.github.deweyreed.tools.helper.setTextIfChanged import com.google.android.material.chip.Chip import xyz.aprildown.timer.component.key.R @@ -69,10 +68,9 @@ class BehaviourLayout @JvmOverloads constructor( } else { LayoutEditableBehaviourImageBinding.bind(binding.layoutImage.getChildAt(0)).root } - Glide.with(imageView) - .load(imageBehaviour.toImageAction().path) - .transition(DrawableTransitionOptions.withCrossFade()) - .into(imageView) + imageView.load(imageBehaviour.toImageAction().path) { + crossfade(true) + } } else { binding.layoutImage.isVisible = false binding.layoutImage.removeAllViews() diff --git a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt index e0ed864a..6b0ca595 100644 --- a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt +++ b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt @@ -13,8 +13,7 @@ import androidx.appcompat.widget.TooltipCompat import androidx.appcompat.widget.TooltipCompatFix import androidx.core.view.isEmpty import androidx.core.view.isVisible -import com.bumptech.glide.Glide -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +import coil.load import com.github.deweyreed.tools.helper.drawable import com.github.deweyreed.tools.helper.setTextIfChanged import com.github.deweyreed.tools.helper.toColorStateList @@ -134,10 +133,9 @@ class EditableBehaviourLayout( } else { LayoutEditableBehaviourImageBinding.bind(binding.layoutImage.getChildAt(0)).root } - Glide.with(imageView) - .load(imageAction.path) - .transition(DrawableTransitionOptions.withCrossFade()) - .into(imageView) + imageView.load(imageAction.path) { + crossfade(true) + } } else { binding.layoutImage.removeAllViews() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5223cb17..3fc18c00 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -140,8 +140,10 @@ ultimateRingtonePicker = "com.github.DeweyReed:UltimateRingtonePicker:3.3.0" verticalStepperForm = "com.ernestoyaquello.stepperform:vertical-stepper-form:2.5.0" zip4j = "net.lingala.zip4j:zip4j:2.11.5" taskerPlugin = "com.joaomgcd:taskerpluginlibrary:0.4.10" -glide = "com.github.bumptech.glide:glide:4.16.0" diskLruCache = "com.github.bumptech.glide:disklrucache:4.16.0" +coil = "io.coil-kt:coil:2.5.0" +coil-compose = "io.coil-kt:coil-compose:2.5.0" +coil-gif = "io.coil-kt:coil-gif:2.5.0" [bundles] mockito-core = ["mockito-kotlin", "mockito-core"] From 69d7633ea514036f3151e3c7bf996517c9614e63 Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Sun, 11 Feb 2024 12:03:52 +0800 Subject: [PATCH 06/31] Show the image preview --- .../timer/app/timer/edit/EditActivity.kt | 13 +++- .../timer/app/timer/edit/EditableStep.kt | 15 +++- .../timer/app/timer/one/OneFragment.kt | 3 + .../timer/app/timer/one/step/StepListView.kt | 22 +++++- .../timer/app/timer/one/step/VisibleStep.kt | 11 ++- .../dogReleaseRuntimeClasspath.txt | 1 + .../googleReleaseRuntimeClasspath.txt | 1 + .../otherReleaseRuntimeClasspath.txt | 1 + component-key/build.gradle | 1 + component-key/src/main/AndroidManifest.xml | 5 +- .../component/key/ImagePreviewActivity.kt | 78 ++++++++++++++++++- .../key/behaviour/BehaviourLayout.kt | 7 +- .../key/behaviour/EditableBehaviourLayout.kt | 7 +- component-key/src/main/res/values/themes.xml | 8 ++ gradle/libs.versions.toml | 1 + 15 files changed, 152 insertions(+), 22 deletions(-) create mode 100644 component-key/src/main/res/values/themes.xml diff --git a/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditActivity.kt b/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditActivity.kt index e50e4193..83d8ca7c 100644 --- a/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditActivity.kt +++ b/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditActivity.kt @@ -55,6 +55,7 @@ import xyz.aprildown.timer.component.key.behaviour.EditableBehaviourLayout import xyz.aprildown.timer.domain.entities.BehaviourEntity import xyz.aprildown.timer.domain.entities.BehaviourType import xyz.aprildown.timer.domain.entities.FolderEntity +import xyz.aprildown.timer.domain.entities.ImageAction import xyz.aprildown.timer.domain.entities.StepEntity import xyz.aprildown.timer.domain.entities.StepType import xyz.aprildown.timer.domain.entities.TimerEntity @@ -101,14 +102,14 @@ class EditActivity : private val pickImageLauncher = registerForActivityResult( ActivityResultContracts.PickVisualMedia() ) { uri -> - if (uri == null || uri == Uri.EMPTY) return@registerForActivityResult val position = viewModel.imagePosition + viewModel.imagePosition = -1 + if (uri == null || uri == Uri.EMPTY) return@registerForActivityResult if (position == -1) return@registerForActivityResult contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) changeBehaviour(type = BehaviourType.IMAGE, position = position) { it.toImageAction().copy(path = uri.toString()).toBehaviourEntity() } - viewModel.imagePosition = -1 } override fun onCreate(savedInstanceState: Bundle?) { @@ -778,7 +779,7 @@ class EditActivity : BehaviourType.IMAGE -> { addImageItems( context = this@EditActivity, - onPick = { onImageClick(position) }, + onPick = { onImageAdding(position) }, ) } else -> Unit @@ -851,13 +852,17 @@ class EditActivity : } } - override fun onImageClick(position: Int) { + override fun onImageAdding(position: Int) { viewModel.imagePosition = position pickImageLauncher.launch( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) ) } + override fun onImageCheck(position: Int, action: ImageAction) { + startActivity(appNavigator.getImagePreviewIntent(action.path)) + } + private fun postUpdateTotalTime() { if (shouldUpdateTotalTime) { binding.viewEditStepInfo.run { diff --git a/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditableStep.kt b/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditableStep.kt index 8d0809c6..8c727439 100644 --- a/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditableStep.kt +++ b/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditableStep.kt @@ -22,6 +22,7 @@ import xyz.aprildown.timer.app.base.utils.setTime import xyz.aprildown.timer.component.key.RoundTextView import xyz.aprildown.timer.component.key.behaviour.EditableBehaviourLayout import xyz.aprildown.timer.domain.entities.BehaviourEntity +import xyz.aprildown.timer.domain.entities.ImageAction import xyz.aprildown.timer.domain.entities.StepType import xyz.aprildown.timer.app.base.R as RBase @@ -58,7 +59,8 @@ class EditableStep( position: Int ) - fun onImageClick(position: Int) + fun onImageAdding(position: Int) + fun onImageCheck(position: Int, action: ImageAction) fun onBehaviourAddedOrRemoved(position: Int, newBehaviours: List) } @@ -121,9 +123,14 @@ class EditableStep( ) } - override fun onImageClick() { - super.onImageClick() - handler.onImageClick(bindingAdapterPosition) + override fun onImageAdding() { + super.onImageAdding() + handler.onImageAdding(bindingAdapterPosition) + } + + override fun onImageContentClick(action: ImageAction) { + super.onImageContentClick(action) + handler.onImageCheck(bindingAdapterPosition, action) } } ) diff --git a/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/OneFragment.kt b/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/OneFragment.kt index b9463336..dbaa4166 100644 --- a/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/OneFragment.kt +++ b/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/OneFragment.kt @@ -136,6 +136,9 @@ class OneFragment : actionUpdateStepTime(index) } } + imageCheckListener = { + startActivity(appNavigator.getImagePreviewIntent(it.path)) + } } viewModel.timer.observe(viewLifecycleOwner) { diff --git a/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/step/StepListView.kt b/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/step/StepListView.kt index edef0997..beed5abe 100644 --- a/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/step/StepListView.kt +++ b/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/step/StepListView.kt @@ -7,6 +7,7 @@ import androidx.recyclerview.widget.RecyclerView import com.mikepenz.fastadapter.FastAdapter import com.mikepenz.fastadapter.IItem import com.mikepenz.fastadapter.adapters.ItemAdapter +import xyz.aprildown.timer.domain.entities.ImageAction import xyz.aprildown.timer.domain.entities.StepEntity import xyz.aprildown.timer.domain.entities.TimerEntity import xyz.aprildown.timer.presentation.stream.TimerIndex @@ -41,6 +42,7 @@ class StepListView @JvmOverloads constructor( private var currentIndex: TimerIndex = TimerIndex.Start var listener: StepLongClickListener? = null + var imageCheckListener: ((ImageAction) -> Unit)? = null init { layoutManager = stepLayoutManager @@ -128,7 +130,16 @@ class StepListView @JvmOverloads constructor( endAdapter.clear() timer.startStep?.let { - startAdapter.add(VisibleStep(it, 0, indexesToIdentifier(0), this, this)) + startAdapter.add( + VisibleStep( + step = it, + number = 0, + id = indexesToIdentifier(0), + currentPositionCallback = this, + stepLongClickListener = this, + imageCheckListener = imageCheckListener, + ) + ) } val stepItems = mutableListOf>() @@ -141,7 +152,8 @@ class StepListView @JvmOverloads constructor( number = i + 1, id = indexesToIdentifier(i + 1), currentPositionCallback = this, - stepLongClickListener = this + stepLongClickListener = this, + imageCheckListener = imageCheckListener, ) ) } @@ -163,7 +175,8 @@ class StepListView @JvmOverloads constructor( number = gi + 1, id = indexesToIdentifier(i + 1, gi), currentPositionCallback = this, - stepLongClickListener = this + stepLongClickListener = this, + imageCheckListener = imageCheckListener, ) } ) @@ -181,7 +194,8 @@ class StepListView @JvmOverloads constructor( number = stepCount + 1, id = indexesToIdentifier(stepCount + 1), currentPositionCallback = this, - stepLongClickListener = this + stepLongClickListener = this, + imageCheckListener = imageCheckListener, ) ) } diff --git a/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/step/VisibleStep.kt b/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/step/VisibleStep.kt index 891ec60e..b9c99bea 100644 --- a/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/step/VisibleStep.kt +++ b/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/step/VisibleStep.kt @@ -17,6 +17,7 @@ import xyz.aprildown.timer.app.base.data.PreferenceData.getTypeColor import xyz.aprildown.timer.app.base.utils.produceTime import xyz.aprildown.timer.app.timer.one.R import xyz.aprildown.timer.app.timer.one.databinding.ItemStepStepBinding +import xyz.aprildown.timer.domain.entities.ImageAction import xyz.aprildown.timer.domain.entities.StepEntity import xyz.aprildown.timer.app.base.R as RBase @@ -25,12 +26,18 @@ internal class VisibleStep( private val number: Int, id: Long, private val currentPositionCallback: CurrentPositionCallback, - private val stepLongClickListener: OnStepLongClickListener + private val stepLongClickListener: OnStepLongClickListener, + private val imageCheckListener: ((ImageAction) -> Unit)?, ) : AbstractItem() { override val layoutRes: Int = R.layout.item_step_step override val type: Int = RBase.id.type_step_step - override fun getViewHolder(v: View): ViewHolder = ViewHolder(v) + override fun getViewHolder(v: View): ViewHolder { + return ViewHolder(v).also { + it.binding.layoutBehaviour.onImageCheck = imageCheckListener + } + } + override var identifier: Long = id override fun bindView(holder: ViewHolder, payloads: List) { diff --git a/app/dependencies/dogReleaseRuntimeClasspath.txt b/app/dependencies/dogReleaseRuntimeClasspath.txt index 5238380b..f294910e 100644 --- a/app/dependencies/dogReleaseRuntimeClasspath.txt +++ b/app/dependencies/dogReleaseRuntimeClasspath.txt @@ -136,6 +136,7 @@ com.squareup.okio:okio-jvm:3.9.0 com.squareup.okio:okio:3.9.0 javax.inject:javax.inject:1 me.zhanghai.android.materialprogressbar:library:1.4.2 +net.engawapg.lib:zoomable:1.6.0 nl.dionsegijn:konfetti:1.3.2 org.jetbrains.kotlin:kotlin-android-extensions-runtime:1.9.24 org.jetbrains.kotlin:kotlin-parcelize-runtime:1.9.24 diff --git a/app/dependencies/googleReleaseRuntimeClasspath.txt b/app/dependencies/googleReleaseRuntimeClasspath.txt index fac88336..189eec96 100644 --- a/app/dependencies/googleReleaseRuntimeClasspath.txt +++ b/app/dependencies/googleReleaseRuntimeClasspath.txt @@ -182,6 +182,7 @@ com.squareup.okio:okio-jvm:3.9.0 com.squareup.okio:okio:3.9.0 javax.inject:javax.inject:1 me.zhanghai.android.materialprogressbar:library:1.4.2 +net.engawapg.lib:zoomable:1.6.0 net.lingala.zip4j:zip4j:2.11.5 nl.dionsegijn:konfetti:1.3.2 org.jetbrains.kotlin:kotlin-android-extensions-runtime:1.9.24 diff --git a/app/dependencies/otherReleaseRuntimeClasspath.txt b/app/dependencies/otherReleaseRuntimeClasspath.txt index 5238380b..f294910e 100644 --- a/app/dependencies/otherReleaseRuntimeClasspath.txt +++ b/app/dependencies/otherReleaseRuntimeClasspath.txt @@ -136,6 +136,7 @@ com.squareup.okio:okio-jvm:3.9.0 com.squareup.okio:okio:3.9.0 javax.inject:javax.inject:1 me.zhanghai.android.materialprogressbar:library:1.4.2 +net.engawapg.lib:zoomable:1.6.0 nl.dionsegijn:konfetti:1.3.2 org.jetbrains.kotlin:kotlin-android-extensions-runtime:1.9.24 org.jetbrains.kotlin:kotlin-parcelize-runtime:1.9.24 diff --git a/component-key/build.gradle b/component-key/build.gradle index 55575f24..1ee36226 100644 --- a/component-key/build.gradle +++ b/component-key/build.gradle @@ -30,5 +30,6 @@ dependencies { implementation libs.scrollHmsPicker implementation libs.coil implementation libs.coil.compose + implementation libs.zoomable } diff --git a/component-key/src/main/AndroidManifest.xml b/component-key/src/main/AndroidManifest.xml index de90252b..fbc4491d 100644 --- a/component-key/src/main/AndroidManifest.xml +++ b/component-key/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ - + diff --git a/component-key/src/main/java/xyz/aprildown/timer/component/key/ImagePreviewActivity.kt b/component-key/src/main/java/xyz/aprildown/timer/component/key/ImagePreviewActivity.kt index 2da5c4c9..a31b7084 100644 --- a/component-key/src/main/java/xyz/aprildown/timer/component/key/ImagePreviewActivity.kt +++ b/component-key/src/main/java/xyz/aprildown/timer/component/key/ImagePreviewActivity.kt @@ -3,11 +3,34 @@ package xyz.aprildown.timer.component.key import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent -import androidx.compose.material3.Text -import xyz.aprildown.timer.app.base.ui.BaseActivity +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.zoomable +import android.graphics.Color as AndroidColor +import androidx.compose.ui.graphics.Color.Companion as ComposeColor + +class ImagePreviewActivity : ComponentActivity() { -class ImagePreviewActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val path = intent?.getStringExtra(EXTRA_PATH) @@ -15,8 +38,30 @@ class ImagePreviewActivity : BaseActivity() { finish() return } + + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(AndroidColor.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark( + AndroidColor.argb(0x80, 0x1b, 0x1b, 0x1b) + ) // DefaultDarkScrim + ) + setContent { - Text(text = path) + var data: String? by rememberSaveable { mutableStateOf(path) } + + LaunchedEffect(Unit) { + addOnNewIntentListener { intent -> + intent?.getStringExtra(EXTRA_PATH)?.takeIf { it.isNotBlank() }?.let { + data = it + } + } + } + + ImagePreview( + data = data, + onDismiss = ::finish, + modifier = Modifier.fillMaxSize(), + ) } } @@ -28,3 +73,28 @@ class ImagePreviewActivity : BaseActivity() { } } } + +@Composable +private fun ImagePreview( + data: Any?, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Image( + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(data) + .crossfade(true) + .build(), + ), + contentDescription = null, + modifier = modifier + .fillMaxSize() + .background(ComposeColor.Black.copy(alpha = 0.5f)) + .pointerInput(onDismiss) { + detectTapGestures(onTap = { onDismiss() }) + } + .padding(16.dp) + .zoomable(zoomState = rememberZoomState()), + ) +} diff --git a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt index f44cfef2..5d9ec98f 100644 --- a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt +++ b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt @@ -18,6 +18,7 @@ import xyz.aprildown.timer.component.key.databinding.LayoutBehaviourBinding import xyz.aprildown.timer.component.key.databinding.LayoutEditableBehaviourImageBinding import xyz.aprildown.timer.domain.entities.BehaviourEntity import xyz.aprildown.timer.domain.entities.BehaviourType +import xyz.aprildown.timer.domain.entities.ImageAction import xyz.aprildown.timer.domain.entities.toImageAction class BehaviourLayout @JvmOverloads constructor( @@ -29,6 +30,8 @@ class BehaviourLayout @JvmOverloads constructor( private val currentBehaviours = mutableListOf() + var onImageCheck: ((ImageAction) -> Unit)? = null + init { orientation = VERTICAL } @@ -68,7 +71,9 @@ class BehaviourLayout @JvmOverloads constructor( } else { LayoutEditableBehaviourImageBinding.bind(binding.layoutImage.getChildAt(0)).root } - imageView.load(imageBehaviour.toImageAction().path) { + val action = imageBehaviour.toImageAction() + imageView.setOnClickListener { onImageCheck?.invoke(action) } + imageView.load(action.path) { crossfade(true) } } else { diff --git a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt index 6b0ca595..995eb47b 100644 --- a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt +++ b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt @@ -26,6 +26,7 @@ import xyz.aprildown.timer.component.key.databinding.LayoutEditableBehaviourBind import xyz.aprildown.timer.component.key.databinding.LayoutEditableBehaviourImageBinding import xyz.aprildown.timer.domain.entities.BehaviourEntity import xyz.aprildown.timer.domain.entities.BehaviourType +import xyz.aprildown.timer.domain.entities.ImageAction import xyz.aprildown.timer.domain.entities.toImageAction class EditableBehaviourLayout( @@ -41,7 +42,8 @@ class EditableBehaviourLayout( current: BehaviourEntity ) - fun onImageClick(): Unit = Unit + fun onImageAdding(): Unit = Unit + fun onImageContentClick(action: ImageAction): Unit = Unit } private val binding = @@ -82,7 +84,7 @@ class EditableBehaviourLayout( } callback = { if (type == BehaviourType.IMAGE) { - listener?.onImageClick() + listener?.onImageAdding() } else { addStubBehaviour(BehaviourEntity(type), true) } @@ -133,6 +135,7 @@ class EditableBehaviourLayout( } else { LayoutEditableBehaviourImageBinding.bind(binding.layoutImage.getChildAt(0)).root } + imageView.setOnClickListener { listener?.onImageContentClick(imageAction) } imageView.load(imageAction.path) { crossfade(true) } diff --git a/component-key/src/main/res/values/themes.xml b/component-key/src/main/res/values/themes.xml new file mode 100644 index 00000000..332ee82f --- /dev/null +++ b/component-key/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3fc18c00..bb75cd8a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -144,6 +144,7 @@ diskLruCache = "com.github.bumptech.glide:disklrucache:4.16.0" coil = "io.coil-kt:coil:2.5.0" coil-compose = "io.coil-kt:coil-compose:2.5.0" coil-gif = "io.coil-kt:coil-gif:2.5.0" +zoomable = "net.engawapg.lib:zoomable:1.6.0" [bundles] mockito-core = ["mockito-kotlin", "mockito-core"] From ba83670f2a052d5258d18d652667ba7ec654a9aa Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Tue, 20 Feb 2024 08:30:45 +0800 Subject: [PATCH 07/31] Copy images into the app's internal storage --- .../SchedulerRepositoryImplTest.kt | 13 +- .../repositories/TimerRepositoryImplTest.kt | 13 +- .../data/repositories/TimerRepositoryImpl.kt | 162 +++++++++++++++++- 3 files changed, 171 insertions(+), 17 deletions(-) diff --git a/data/src/androidTest/java/xyz/aprildown/timer/data/repositories/SchedulerRepositoryImplTest.kt b/data/src/androidTest/java/xyz/aprildown/timer/data/repositories/SchedulerRepositoryImplTest.kt index 51b7303c..9d5cb396 100644 --- a/data/src/androidTest/java/xyz/aprildown/timer/data/repositories/SchedulerRepositoryImplTest.kt +++ b/data/src/androidTest/java/xyz/aprildown/timer/data/repositories/SchedulerRepositoryImplTest.kt @@ -1,5 +1,6 @@ package xyz.aprildown.timer.data.repositories +import android.content.Context import androidx.test.core.app.ApplicationProvider import kotlinx.coroutines.test.runTest import org.junit.After @@ -24,13 +25,13 @@ import xyz.aprildown.timer.domain.repositories.TimerRepository class SchedulerRepositoryImplTest { - private val database = MachineDatabase.createInMemoryDatabase( - ApplicationProvider.getApplicationContext() - ) + private val context = ApplicationProvider.getApplicationContext() + private val database = MachineDatabase.createInMemoryDatabase(context) private val timerRepository: TimerRepository = TimerRepositoryImpl( - database.timerDao(), - TimerMapper(StepMapper(StepOnlyMapper(BehaviourMapper())), TimerMoreMapper()), - TimerInfoMapper() + context = context, + timerDao = database.timerDao(), + timerMapper = TimerMapper(StepMapper(StepOnlyMapper(BehaviourMapper())), TimerMoreMapper()), + timerInfoMapper = TimerInfoMapper(), ) private var timerId: Int = TimerEntity.NULL_ID private val schedulerRepository: SchedulerRepository = diff --git a/data/src/androidTest/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImplTest.kt b/data/src/androidTest/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImplTest.kt index ade395e0..097c3937 100644 --- a/data/src/androidTest/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImplTest.kt +++ b/data/src/androidTest/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImplTest.kt @@ -1,5 +1,6 @@ package xyz.aprildown.timer.data.repositories +import android.content.Context import androidx.test.core.app.ApplicationProvider import kotlinx.coroutines.test.runTest import org.junit.After @@ -20,13 +21,13 @@ import xyz.aprildown.timer.domain.repositories.TimerRepository class TimerRepositoryImplTest { - private val database = MachineDatabase.createInMemoryDatabase( - ApplicationProvider.getApplicationContext() - ) + private val context = ApplicationProvider.getApplicationContext() + private val database = MachineDatabase.createInMemoryDatabase(context) private val timerRepository: TimerRepository = TimerRepositoryImpl( - database.timerDao(), - TimerMapper(StepMapper(StepOnlyMapper(BehaviourMapper())), TimerMoreMapper()), - TimerInfoMapper() + context = context, + timerDao = database.timerDao(), + timerMapper = TimerMapper(StepMapper(StepOnlyMapper(BehaviourMapper())), TimerMoreMapper()), + timerInfoMapper = TimerInfoMapper(), ) @After diff --git a/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt b/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt index 2cfb6444..09d07b47 100644 --- a/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt +++ b/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt @@ -1,44 +1,115 @@ package xyz.aprildown.timer.data.repositories +import android.content.ContentResolver +import android.content.Context +import android.webkit.MimeTypeMap +import androidx.core.net.toFile +import androidx.core.net.toUri import dagger.Reusable +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import xyz.aprildown.timer.data.db.TimerDao import xyz.aprildown.timer.data.mappers.TimerInfoMapper import xyz.aprildown.timer.data.mappers.TimerMapper import xyz.aprildown.timer.data.mappers.fromWithMapper +import xyz.aprildown.timer.domain.entities.BehaviourType +import xyz.aprildown.timer.domain.entities.ImageAction +import xyz.aprildown.timer.domain.entities.StepEntity import xyz.aprildown.timer.domain.entities.TimerEntity import xyz.aprildown.timer.domain.entities.TimerInfo +import xyz.aprildown.timer.domain.entities.toImageAction import xyz.aprildown.timer.domain.repositories.TimerRepository +import java.io.File +import java.util.UUID import javax.inject.Inject @Reusable internal class TimerRepositoryImpl @Inject constructor( + @ApplicationContext context: Context, private val timerDao: TimerDao, private val timerMapper: TimerMapper, - private val timerInfoMapper: TimerInfoMapper + private val timerInfoMapper: TimerInfoMapper, ) : TimerRepository { + private val imageSaver: TimerImageSaver by lazy { TimerImageSaver(context) } override suspend fun items(): List { - return timerDao.getTimers().map { timerMapper.mapFrom(it) } + return timerDao.getTimers().map { timerMapper.mapFrom(it).withRealImagePath() } } override suspend fun item(id: Int): TimerEntity? { return timerDao.getTimer(id).let { - return@let if (it == null) null else timerMapper.mapFrom(it) + return@let if (it == null) null else timerMapper.mapFrom(it).withRealImagePath() + } + } + + private fun TimerEntity.withRealImagePath(): TimerEntity { + return transformImageActions { + it.copy(path = imageSaver.getFullPath(id, it.path)) } } override suspend fun add(item: TimerEntity): Int { - return timerDao.addTimer(timerMapper.mapTo(item)).toInt() + val tempTimerFolder = imageSaver.getTimerFolder(item.id) + val timerId = timerDao.addTimer(timerMapper.mapTo(item.saveImages())).toInt() + if (tempTimerFolder.exists()) { + val newTimerFolder = imageSaver.getTimerFolder(timerId) + tempTimerFolder.copyRecursively(newTimerFolder) + tempTimerFolder.deleteRecursively() + } + return timerId } override suspend fun save(item: TimerEntity): Boolean { - return timerDao.updateTimer(timerMapper.mapTo(item)) == 1 + try { + imageSaver.trackSavedImages() + return timerDao.updateTimer(timerMapper.mapTo(item.saveImages())) == 1 + } finally { + imageSaver.deleteUnusedImages(item.id) + } + } + + private fun TimerEntity.saveImages(): TimerEntity { + return transformImageActions { + it.copy(path = imageSaver.save(id, it.path)) + } + } + + private fun TimerEntity.transformImageActions( + transform: (ImageAction) -> ImageAction + ): TimerEntity { + fun StepEntity.transformStep(): StepEntity { + return when (this) { + is StepEntity.Step -> { + if (behaviour.any { it.type == BehaviourType.IMAGE }) { + copy( + behaviour = behaviour.map { + if (it.type == BehaviourType.IMAGE) { + transform(it.toImageAction()).toBehaviourEntity() + } else { + it + } + } + ) + } else { + this + } + } + is StepEntity.Group -> { + copy(steps = steps.map { it.transformStep() }) + } + } + } + return copy( + steps = steps.map { it.transformStep() }, + startStep = startStep?.transformStep() as? StepEntity.Step, + endStep = endStep?.transformStep() as? StepEntity.Step, + ) } override suspend fun delete(id: Int) { timerDao.deleteTimer(id) + imageSaver.deleteAll(id) } override suspend fun getTimerInfoByTimerId(timerId: Int): TimerInfo? { @@ -61,3 +132,84 @@ internal class TimerRepositoryImpl @Inject constructor( timerDao.moveFolderTimersToAnother(originalFolderId, targetFolderId) } } + +private class TimerImageSaver(private val context: Context) { + private val imageFolder = File(context.filesDir, FOLDER) + + private var savedFilenames = mutableListOf() + + fun getTimerFolder(timerId: Int): File { + return File(imageFolder, timerId.toString()) + } + + fun getFullPath(timerId: Int, path: String): String { + return File(getTimerFolder(timerId), path).toUri().toString() + } + + /** + * @return The saved image filename + */ + fun save(timerId: Int, path: String): String { + val uri = path.toUri() + val timerFolder = getTimerFolder(timerId) + if (uri.scheme == ContentResolver.SCHEME_FILE) { + val imageFile = uri.toFile() + if (imageFile.parentFile?.canonicalPath == timerFolder.canonicalPath) { + return imageFile.name.also { + savedFilenames += it + } + } + } + + if (!imageFolder.exists()) imageFolder.mkdirs() + if (!timerFolder.exists()) timerFolder.mkdirs() + + return when (uri.scheme) { + ContentResolver.SCHEME_CONTENT -> { + val extension = MimeTypeMap.getSingleton() + .getExtensionFromMimeType(context.contentResolver.getType(uri)) ?: "jpg" + val imageFile = File(timerFolder, "${UUID.randomUUID()}.$extension") + if (imageFile.exists()) { + imageFile.delete() + imageFile.createNewFile() + } + context.contentResolver.openInputStream(uri)?.use { inputStream -> + imageFile.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + require(imageFile.exists() && imageFile.length() > 0L) + imageFile.name.also { + savedFilenames += it + } + } + else -> error("Unsupported path $path") + } + } + + fun trackSavedImages() { + savedFilenames.clear() + } + + fun deleteUnusedImages(timerId: Int) { + val timerFolder = getTimerFolder(timerId) + if (!timerFolder.exists()) return + timerFolder.listFiles()?.toList()?.forEach { file -> + if (file.name !in savedFilenames) { + file.delete() + } + } + savedFilenames.clear() + } + + fun deleteAll(timerId: Int) { + val timerFolder = getTimerFolder(timerId) + if (timerFolder.exists()) { + timerFolder.deleteRecursively() + } + } + + companion object { + private const val FOLDER = "timer_images" + } +} From ccfa3c209884a359343a880b0f53e62bf1e878ed Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Tue, 20 Feb 2024 08:59:09 +0800 Subject: [PATCH 08/31] Reduce the height of images --- .../src/main/res/layout/layout_editable_behaviour_image.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/component-key/src/main/res/layout/layout_editable_behaviour_image.xml b/component-key/src/main/res/layout/layout_editable_behaviour_image.xml index 2abe05ab..8054a503 100644 --- a/component-key/src/main/res/layout/layout_editable_behaviour_image.xml +++ b/component-key/src/main/res/layout/layout_editable_behaviour_image.xml @@ -2,6 +2,6 @@ From 563e4e46f9d147a8df702718b6477e740f8e1858 Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Mon, 4 Mar 2024 08:57:08 +0800 Subject: [PATCH 09/31] Enable Jetpack Compose in the backup module --- app-backup/build.gradle | 9 +++++++++ component-key/build.gradle | 2 -- gradle/libs.versions.toml | 2 -- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app-backup/build.gradle b/app-backup/build.gradle index 19b73d25..fa860409 100644 --- a/app-backup/build.gradle +++ b/app-backup/build.gradle @@ -7,6 +7,12 @@ plugins { android { namespace 'xyz.aprildown.timer.app.backup' + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() + } } dependencies { @@ -15,6 +21,9 @@ dependencies { implementation libs.androidx.preference + implementation platform(libs.compose.bom) + implementation libs.bundles.compose + implementation libs.hilt.android kapt libs.hilt.compiler diff --git a/component-key/build.gradle b/component-key/build.gradle index 1ee36226..69e4c085 100644 --- a/component-key/build.gradle +++ b/component-key/build.gradle @@ -21,8 +21,6 @@ dependencies { implementation platform(libs.compose.bom) implementation libs.bundles.compose - implementation libs.compose.tooling.preview - debugImplementation libs.compose.tooling.debug implementation libs.flexbox implementation libs.hmsPicker diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bb75cd8a..4184d4b5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -85,8 +85,6 @@ compose-material = { module = "androidx.compose.material3:material3" } compose-activity = "androidx.activity:activity-compose:1.8.2" compose-liveData = { module = "androidx.compose.runtime:runtime-livedata" } compose-constraintLayout = "androidx.constraintlayout:constraintlayout-compose:1.0.1" -compose-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } -compose-tooling-debug = { module = "androidx.compose.ui:ui-tooling" } material = "com.google.android.material:material:1.4.0" From 16bf2612ef5cdd9174558341475a42ec12da6305 Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Sat, 9 Mar 2024 13:19:41 +0800 Subject: [PATCH 10/31] Create Export2Fragment --- .../timer/app/backup/Export2Fragment.kt | 312 ++++++++++++++++++ .../timer/app/backup/Export2ViewModel.kt | 109 ++++++ app/src/main/res/navigation/nav_graph.xml | 5 +- gradle/libs.versions.toml | 2 + 4 files changed, 425 insertions(+), 3 deletions(-) create mode 100644 app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt create mode 100644 app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2ViewModel.kt diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt new file mode 100644 index 00000000..4b079e4c --- /dev/null +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt @@ -0,0 +1,312 @@ +package xyz.aprildown.timer.app.backup + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.rounded.NavigateNext +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.github.deweyreed.tools.helper.requireCallback +import dagger.hilt.android.AndroidEntryPoint +import xyz.aprildown.timer.app.base.data.PreferenceData.lastBackupUri +import xyz.aprildown.timer.app.base.ui.MainCallback +import xyz.aprildown.timer.domain.utils.AppTracker +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject +import xyz.aprildown.timer.app.base.R as RBase + +@AndroidEntryPoint +class Export2Fragment : Fragment() { + + private val viewModel: Export2ViewModel by viewModels() + + private lateinit var mainCallback: MainCallback.ActivityCallback + + @Inject + lateinit var appTracker: AppTracker + + private val launcher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + handleActivityResult(resultCode = it.resultCode, data = it.data) + } + + override fun onAttach(context: Context) { + super.onAttach(context) + mainCallback = requireCallback() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + (view as ComposeView).setContent { + Export2( + screen = viewModel.screen.collectAsState().value, + onLocationChange = ::onLocationChange, + modifier = Modifier.fillMaxSize(), + ) + } + } + + private fun onLocationChange() { + val date = Date() + val timeString = SimpleDateFormat("yyyy-MM-dd-kk-mm", Locale.getDefault()).format(date) + val initialFilename = "timer-machine-$timeString.json" + SafIntentSafeBelt( + context = requireContext(), + appTracker = appTracker, + viewForSnackbar = mainCallback.snackbarView + ).drive( + launcher = launcher, + intent = Intent(Intent.ACTION_CREATE_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("*/*") + .putExtra(Intent.EXTRA_TITLE, initialFilename) + ) + } + + private fun handleActivityResult(resultCode: Int, data: Intent?) { + if (resultCode == Activity.RESULT_OK) { + val uri = data?.data ?: return + if (uri.toString().isBlank() || uri == Uri.EMPTY) return + val context = requireContext() + context.lastBackupUri = uri + viewModel.setSaveLocation(uri.toString()) + } + } +} + +@Composable +private fun Export2( + screen: Export2ViewModel.Screen, + onLocationChange: () -> Unit, + modifier: Modifier = Modifier, +) { + MaterialTheme { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Header(text = stringResource(id = RBase.string.export_path_title)) + + SaveLocation( + location = screen.saveLocation, + modifier = Modifier.clickable(onClick = onLocationChange), + ) + + Header(text = stringResource(id = RBase.string.backup_app_data)) + + DataEntry( + iconRes = RBase.drawable.ic_timer, + nameRes = RBase.string.main_action_timers, + include = screen.includeTimers, + onIncludedChange = screen.onTimersChange, + ) + DataEntry( + iconRes = RBase.drawable.ic_scheduler, + nameRes = RBase.string.main_action_schedulers, + include = screen.includeSchedulers, + onIncludedChange = screen.onSchedulersChange, + ) + DataEntry( + iconRes = RBase.drawable.ic_stat, + nameRes = RBase.string.main_action_record, + include = screen.includeRecords, + onIncludedChange = screen.onRecordsChange, + ) + DataEntry( + iconRes = RBase.drawable.ic_settings, + nameRes = RBase.string.main_action_settings, + include = screen.includeSettings, + onIncludedChange = screen.onSettingsChange, + ) + + Button( + onClick = screen.onExport, + modifier = Modifier.align(Alignment.CenterHorizontally), + enabled = !screen.saveLocation.isNullOrBlank() && + (screen.includeTimers || screen.includeSettings) && + !screen.exporting, + ) { + Text(text = stringResource(id = RBase.string.export_action)) + } + } + + if (screen.exportError != null) { + ExportError( + error = screen.exportError, + consume = screen.consumeExportError, + ) + } + + if (screen.exporting) { + ExportingDialog() + } + } +} + +@Composable +private fun Header( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + modifier = modifier.padding(horizontal = 12.dp, vertical = 12.dp), + style = MaterialTheme.typography.titleSmall, + ) +} + +@Composable +private fun SaveLocation( + location: String?, + modifier: Modifier = Modifier, +) { + if (!location.isNullOrBlank()) { + ListItem( + headlineContent = { + Text(text = location) + }, + modifier = modifier, + overlineContent = { + Text(text = stringResource(id = RBase.string.export_select_location)) + }, + leadingContent = { + Icon(imageVector = Icons.Rounded.Description, contentDescription = null) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.NavigateNext, + contentDescription = null, + ) + }, + ) + } else { + ListItem( + headlineContent = { + Text(text = stringResource(id = RBase.string.export_select_location)) + }, + modifier = modifier, + leadingContent = { + Icon(imageVector = Icons.Rounded.Description, contentDescription = null) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.NavigateNext, + contentDescription = null, + ) + }, + ) + } +} + +@Composable +private fun DataEntry( + @DrawableRes iconRes: Int, + @StringRes nameRes: Int, + include: Boolean, + onIncludedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { + Text(text = stringResource(id = nameRes)) + }, + modifier = modifier.clickable { onIncludedChange(!include) }, + leadingContent = { + Icon(painter = painterResource(id = iconRes), contentDescription = null) + }, + trailingContent = { + Switch(checked = include, onCheckedChange = onIncludedChange) + }, + ) +} + +@Composable +private fun ExportError( + error: Throwable, + consume: () -> Unit, +) { + AlertDialog( + onDismissRequest = consume, + confirmButton = { + TextButton(onClick = consume) { + Text(text = stringResource(id = RBase.string.ok)) + } + }, + text = { + val context = LocalContext.current + Text( + text = buildString { + append(context.getString(RBase.string.export_error)) + + (error.localizedMessage ?: error.message) + ?.takeIf { it.isNotBlank() } + ?.let { message -> + append("\n\n") + append(message) + } + }, + ) + } + ) +} + +@Composable +private fun ExportingDialog() { + Dialog(onDismissRequest = {}) { + Surface(shape = MaterialTheme.shapes.medium) { + CircularProgressIndicator(modifier = Modifier.padding(24.dp)) + } + } +} diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2ViewModel.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2ViewModel.kt new file mode 100644 index 00000000..fd2142ac --- /dev/null +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2ViewModel.kt @@ -0,0 +1,109 @@ +package xyz.aprildown.timer.app.backup + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class Export2ViewModel @Inject constructor() : ViewModel() { + @Immutable + data class Screen( + val saveLocation: String? = null, + + val includeTimers: Boolean = true, + val onTimersChange: (Boolean) -> Unit, + + val includeRecords: Boolean = true, + val onRecordsChange: (Boolean) -> Unit, + + val includeSchedulers: Boolean = true, + val onSchedulersChange: (Boolean) -> Unit, + + val includeSettings: Boolean = true, + val onSettingsChange: (Boolean) -> Unit, + + val onExport: () -> Unit, + val exporting: Boolean = false, + + val exportError: Throwable? = null, + val consumeExportError: () -> Unit, + ) + + private val _screen: MutableStateFlow = MutableStateFlow( + Screen( + onTimersChange = ::onTimersChange, + onRecordsChange = ::onRecordsChange, + onSchedulersChange = ::onSchedulersChange, + onSettingsChange = ::onSettingsChange, + onExport = ::onExport, + consumeExportError = ::consumeExportError, + ) + ) + val screen: StateFlow = _screen.asStateFlow() + + fun setSaveLocation(location: String) { + _screen.update { it.copy(saveLocation = location) } + } + + private fun onTimersChange(include: Boolean) { + _screen.update { + it.copy( + includeTimers = include, + includeRecords = include, + includeSchedulers = include, + ) + } + } + + private fun onRecordsChange(include: Boolean) { + _screen.update { + it.copy( + includeTimers = if (include) true else it.includeTimers, + includeRecords = include, + ) + } + } + + private fun onSchedulersChange(include: Boolean) { + _screen.update { + it.copy( + includeTimers = if (include) true else it.includeTimers, + includeSchedulers = include, + ) + } + } + + private fun onSettingsChange(include: Boolean) { + _screen.update { + it.copy(includeSettings = include) + } + } + + private fun onExport() { + val screen = _screen.value + if (screen.saveLocation.isNullOrBlank()) return + if (!screen.includeTimers && !screen.includeSettings) return + if (screen.exporting) return + viewModelScope.launch { + _screen.update { it.copy(exporting = true) } + try { + delay(5500) + _screen.update { it.copy(exportError = Throwable("Hey")) } + } finally { + _screen.update { it.copy(exporting = false) } + } + } + } + + private fun consumeExportError() { + _screen.update { it.copy(exportError = null) } + } +} diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index fbb17252..e8621021 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -61,9 +61,8 @@ + android:name="xyz.aprildown.timer.app.backup.Export2Fragment" + android:label="@string/export_data_title" /> Date: Sun, 7 Apr 2024 09:37:28 +0800 Subject: [PATCH 11/31] Upgrade the compose dependencies --- gradle/libs.versions.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9f6ced14..f1e107b8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -140,10 +140,10 @@ verticalStepperForm = "com.ernestoyaquello.stepperform:vertical-stepper-form:2.5 zip4j = "net.lingala.zip4j:zip4j:2.11.5" taskerPlugin = "com.joaomgcd:taskerpluginlibrary:0.4.10" diskLruCache = "com.github.bumptech.glide:disklrucache:4.16.0" -coil = "io.coil-kt:coil:2.5.0" -coil-compose = "io.coil-kt:coil-compose:2.5.0" -coil-gif = "io.coil-kt:coil-gif:2.5.0" -zoomable = "net.engawapg.lib:zoomable:1.6.0" +coil = "io.coil-kt:coil:2.6.0" +coil-compose = "io.coil-kt:coil-compose:2.6.0" +coil-gif = "io.coil-kt:coil-gif:2.6.0" +zoomable = "net.engawapg.lib:zoomable:1.6.1" [bundles] mockito-core = ["mockito-kotlin", "mockito-core"] From 45b1b222e7e7b7b408731a41dbf438b177a95be6 Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Sun, 7 Apr 2024 12:08:44 +0800 Subject: [PATCH 12/31] Improve the stability of the composables --- .../timer/app/backup/Export2Fragment.kt | 94 ++++++++++--------- .../timer/app/backup/Export2ViewModel.kt | 8 +- 2 files changed, 56 insertions(+), 46 deletions(-) diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt index 4b079e4c..82c8ac53 100644 --- a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt @@ -182,9 +182,9 @@ private fun Export2( } } - if (screen.exportError != null) { + if (screen.exportErrorMessage != null) { ExportError( - error = screen.exportError, + message = screen.exportErrorMessage, consume = screen.consumeExportError, ) } @@ -212,44 +212,54 @@ private fun SaveLocation( location: String?, modifier: Modifier = Modifier, ) { - if (!location.isNullOrBlank()) { - ListItem( - headlineContent = { - Text(text = location) - }, - modifier = modifier, - overlineContent = { - Text(text = stringResource(id = RBase.string.export_select_location)) - }, - leadingContent = { - Icon(imageVector = Icons.Rounded.Description, contentDescription = null) - }, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Rounded.NavigateNext, - contentDescription = null, - ) - }, - ) + if (location.isNullOrBlank()) { + SaveLocationEmpty(modifier = modifier) } else { - ListItem( - headlineContent = { - Text(text = stringResource(id = RBase.string.export_select_location)) - }, - modifier = modifier, - leadingContent = { - Icon(imageVector = Icons.Rounded.Description, contentDescription = null) - }, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Rounded.NavigateNext, - contentDescription = null, - ) - }, - ) + SaveLocationPath(location = location, modifier = modifier) } } +@Composable +private fun SaveLocationPath(location: String, modifier: Modifier) { + ListItem( + headlineContent = { + Text(text = location) + }, + modifier = modifier, + overlineContent = { + Text(text = stringResource(id = RBase.string.export_select_location)) + }, + leadingContent = { + Icon(imageVector = Icons.Rounded.Description, contentDescription = null) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.NavigateNext, + contentDescription = null, + ) + }, + ) +} + +@Composable +private fun SaveLocationEmpty(modifier: Modifier) { + ListItem( + headlineContent = { + Text(text = stringResource(id = RBase.string.export_select_location)) + }, + modifier = modifier, + leadingContent = { + Icon(imageVector = Icons.Rounded.Description, contentDescription = null) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.NavigateNext, + contentDescription = null, + ) + }, + ) +} + @Composable private fun DataEntry( @DrawableRes iconRes: Int, @@ -274,7 +284,7 @@ private fun DataEntry( @Composable private fun ExportError( - error: Throwable, + message: String, consume: () -> Unit, ) { AlertDialog( @@ -290,12 +300,10 @@ private fun ExportError( text = buildString { append(context.getString(RBase.string.export_error)) - (error.localizedMessage ?: error.message) - ?.takeIf { it.isNotBlank() } - ?.let { message -> - append("\n\n") - append(message) - } + if (message.isNotBlank()) { + append("\n\n") + append(message) + } }, ) } diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2ViewModel.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2ViewModel.kt index fd2142ac..7ef7ce3f 100644 --- a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2ViewModel.kt +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2ViewModel.kt @@ -33,7 +33,7 @@ internal class Export2ViewModel @Inject constructor() : ViewModel() { val onExport: () -> Unit, val exporting: Boolean = false, - val exportError: Throwable? = null, + val exportErrorMessage: String? = null, val consumeExportError: () -> Unit, ) @@ -96,7 +96,9 @@ internal class Export2ViewModel @Inject constructor() : ViewModel() { _screen.update { it.copy(exporting = true) } try { delay(5500) - _screen.update { it.copy(exportError = Throwable("Hey")) } + _screen.update { + it.copy(exportErrorMessage = "Hey") + } } finally { _screen.update { it.copy(exporting = false) } } @@ -104,6 +106,6 @@ internal class Export2ViewModel @Inject constructor() : ViewModel() { } private fun consumeExportError() { - _screen.update { it.copy(exportError = null) } + _screen.update { it.copy(exportErrorMessage = null) } } } From 392eeadd8ff61f628262e55a461049c54298014a Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Sun, 14 Apr 2024 10:37:25 +0800 Subject: [PATCH 13/31] Create Import2Fragment --- .../timer/app/backup/BackupComposables.kt | 243 ++++++++++++++++++ .../timer/app/backup/BaseBackupViewModel.kt | 116 +++++++++ .../timer/app/backup/Export2Fragment.kt | 241 ++--------------- .../timer/app/backup/Export2ViewModel.kt | 107 +------- .../timer/app/backup/Import2Fragment.kt | 150 +++++++++++ .../timer/app/backup/Import2ViewModel.kt | 36 +++ app/src/main/res/navigation/nav_graph.xml | 5 +- 7 files changed, 572 insertions(+), 326 deletions(-) create mode 100644 app-backup/src/main/java/xyz/aprildown/timer/app/backup/BackupComposables.kt create mode 100644 app-backup/src/main/java/xyz/aprildown/timer/app/backup/BaseBackupViewModel.kt create mode 100644 app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2Fragment.kt create mode 100644 app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2ViewModel.kt diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BackupComposables.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BackupComposables.kt new file mode 100644 index 00000000..81f4a151 --- /dev/null +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BackupComposables.kt @@ -0,0 +1,243 @@ +package xyz.aprildown.timer.app.backup + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.rounded.NavigateNext +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import xyz.aprildown.timer.app.base.R as RBase + +@Composable +internal fun Backup( + screen: BaseBackupViewModel.Screen, + contentLocationTitle: String, + contentLocationButtonText: String, + onChangeContentLocation: () -> Unit, + backupButtonText: String, + backupErrorHint: String, + modifier: Modifier = Modifier, + extraOptions: @Composable () -> Unit = {}, +) { + MaterialTheme { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Header(text = contentLocationTitle) + + ContentLocation( + title = contentLocationButtonText, + location = screen.contentName ?: screen.contentLocation, + modifier = Modifier.clickable(onClick = onChangeContentLocation), + ) + + Header(text = stringResource(id = RBase.string.backup_app_data)) + + extraOptions() + + DataEntry( + iconRes = RBase.drawable.ic_timer, + nameRes = RBase.string.main_action_timers, + include = screen.includeTimers, + onIncludedChange = screen.onTimersChange, + ) + DataEntry( + iconRes = RBase.drawable.ic_scheduler, + nameRes = RBase.string.main_action_schedulers, + include = screen.includeSchedulers, + onIncludedChange = screen.onSchedulersChange, + ) + DataEntry( + iconRes = RBase.drawable.ic_stat, + nameRes = RBase.string.main_action_record, + include = screen.includeRecords, + onIncludedChange = screen.onRecordsChange, + ) + DataEntry( + iconRes = RBase.drawable.ic_settings, + nameRes = RBase.string.main_action_settings, + include = screen.includeSettings, + onIncludedChange = screen.onSettingsChange, + ) + + Button( + onClick = screen.onBackup, + modifier = Modifier.align(Alignment.CenterHorizontally), + enabled = !screen.contentLocation.isNullOrBlank() && + (screen.includeTimers || screen.includeSettings) && + !screen.backupOngoing, + ) { + Text(text = backupButtonText) + } + } + + if (screen.backupErrorMessage != null) { + BackupError( + hint = backupErrorHint, + message = screen.backupErrorMessage, + consume = screen.consumeBackupError, + ) + } + + if (screen.backupOngoing) { + BackupOngoingDialog() + } + } +} + +@Composable +private fun Header( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + modifier = modifier.padding(horizontal = 12.dp, vertical = 12.dp), + style = MaterialTheme.typography.titleSmall, + ) +} + +@Composable +private fun ContentLocation( + title: String, + location: String?, + modifier: Modifier = Modifier, +) { + if (location.isNullOrBlank()) { + ContentLocationEmpty(title = title, modifier = modifier) + } else { + ContentLocationPath(title = title, location = location, modifier = modifier) + } +} + +@Composable +private fun ContentLocationPath( + title: String, + location: String, + modifier: Modifier +) { + ListItem( + headlineContent = { + Text(text = location) + }, + modifier = modifier, + overlineContent = { + Text(text = title) + }, + leadingContent = { + Icon(imageVector = Icons.Rounded.Description, contentDescription = null) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.NavigateNext, + contentDescription = null, + ) + }, + ) +} + +@Composable +private fun ContentLocationEmpty( + title: String, + modifier: Modifier +) { + ListItem( + headlineContent = { + Text(text = title) + }, + modifier = modifier, + leadingContent = { + Icon(imageVector = Icons.Rounded.Description, contentDescription = null) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.NavigateNext, + contentDescription = null, + ) + }, + ) +} + +@Composable +private fun DataEntry( + @DrawableRes iconRes: Int, + @StringRes nameRes: Int, + include: Boolean, + onIncludedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { + Text(text = stringResource(id = nameRes)) + }, + modifier = modifier.clickable { onIncludedChange(!include) }, + leadingContent = { + Icon(painter = painterResource(id = iconRes), contentDescription = null) + }, + trailingContent = { + Switch(checked = include, onCheckedChange = onIncludedChange) + }, + ) +} + +@Composable +private fun BackupError( + hint: String, + message: String, + consume: () -> Unit, +) { + AlertDialog( + onDismissRequest = consume, + confirmButton = { + TextButton(onClick = consume) { + Text(text = stringResource(id = RBase.string.ok)) + } + }, + text = { + Text( + text = buildString { + append(hint) + + if (message.isNotBlank()) { + append("\n\n") + append(message) + } + }, + ) + } + ) +} + +@Composable +private fun BackupOngoingDialog() { + Dialog(onDismissRequest = {}) { + Surface(shape = MaterialTheme.shapes.medium) { + CircularProgressIndicator(modifier = Modifier.padding(24.dp)) + } + } +} diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BaseBackupViewModel.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BaseBackupViewModel.kt new file mode 100644 index 00000000..24000019 --- /dev/null +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BaseBackupViewModel.kt @@ -0,0 +1,116 @@ +package xyz.aprildown.timer.app.backup + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import xyz.aprildown.timer.domain.usecases.Fruit + +internal abstract class BaseBackupViewModel : ViewModel() { + @Immutable + data class Screen( + val contentLocation: String? = null, + val contentName: String? = null, + + val includeTimers: Boolean = true, + val onTimersChange: (Boolean) -> Unit, + + val includeRecords: Boolean = true, + val onRecordsChange: (Boolean) -> Unit, + + val includeSchedulers: Boolean = true, + val onSchedulersChange: (Boolean) -> Unit, + + val includeSettings: Boolean = true, + val onSettingsChange: (Boolean) -> Unit, + + val onBackup: () -> Unit, + val backupOngoing: Boolean = false, + + val backupErrorMessage: String? = null, + val consumeBackupError: () -> Unit, + ) + + private val _screen: MutableStateFlow = MutableStateFlow( + Screen( + onTimersChange = ::onTimersChange, + onRecordsChange = ::onRecordsChange, + onSchedulersChange = ::onSchedulersChange, + onSettingsChange = ::onSettingsChange, + onBackup = ::onBackup, + consumeBackupError = ::consumeBackupError, + ) + ) + val screen: StateFlow = _screen.asStateFlow() + + fun changeContentLocation(location: String, name: String?) { + _screen.update { it.copy(contentLocation = location, contentName = name) } + } + + private fun onTimersChange(include: Boolean) { + _screen.update { + it.copy( + includeTimers = include, + includeRecords = include, + includeSchedulers = include, + ) + } + } + + private fun onRecordsChange(include: Boolean) { + _screen.update { + it.copy( + includeTimers = if (include) true else it.includeTimers, + includeRecords = include, + ) + } + } + + private fun onSchedulersChange(include: Boolean) { + _screen.update { + it.copy( + includeTimers = if (include) true else it.includeTimers, + includeSchedulers = include, + ) + } + } + + private fun onSettingsChange(include: Boolean) { + _screen.update { + it.copy(includeSettings = include) + } + } + + abstract suspend fun backup(screen: Screen): Fruit + + private fun onBackup() { + val screen = _screen.value + if (screen.contentLocation.isNullOrBlank()) return + if (!screen.includeTimers && !screen.includeSettings) return + if (screen.backupOngoing) return + + viewModelScope.launch { + _screen.update { it.copy(backupOngoing = true) } + try { + when (val fruit = backup(screen)) { + is Fruit.Ripe -> Unit + is Fruit.Rotten -> { + _screen.update { + it.copy(backupErrorMessage = fruit.exception.message) + } + } + } + } finally { + _screen.update { it.copy(backupOngoing = false) } + } + } + } + + private fun consumeBackupError() { + _screen.update { it.copy(backupErrorMessage = null) } + } +} diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt index 82c8ac53..9aac75c7 100644 --- a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt @@ -9,39 +9,14 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -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.rounded.NavigateNext -import androidx.compose.material.icons.rounded.Description -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog +import androidx.documentfile.provider.DocumentFile import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import com.github.deweyreed.tools.helper.requireCallback @@ -88,7 +63,7 @@ class Export2Fragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) (view as ComposeView).setContent { - Export2( + Export( screen = viewModel.screen.collectAsState().value, onLocationChange = ::onLocationChange, modifier = Modifier.fillMaxSize(), @@ -114,207 +89,31 @@ class Export2Fragment : Fragment() { } private fun handleActivityResult(resultCode: Int, data: Intent?) { - if (resultCode == Activity.RESULT_OK) { - val uri = data?.data ?: return - if (uri.toString().isBlank() || uri == Uri.EMPTY) return - val context = requireContext() - context.lastBackupUri = uri - viewModel.setSaveLocation(uri.toString()) - } + if (resultCode != Activity.RESULT_OK) return + val uri = data?.data ?: return + if (uri.toString().isBlank() || uri == Uri.EMPTY) return + val context = requireContext() + context.lastBackupUri = uri + viewModel.changeContentLocation( + location = uri.toString(), + name = DocumentFile.fromSingleUri(context, uri)?.name, + ) } } @Composable -private fun Export2( - screen: Export2ViewModel.Screen, +private fun Export( + screen: BaseBackupViewModel.Screen, onLocationChange: () -> Unit, modifier: Modifier = Modifier, ) { - MaterialTheme { - Column( - modifier = modifier - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Header(text = stringResource(id = RBase.string.export_path_title)) - - SaveLocation( - location = screen.saveLocation, - modifier = Modifier.clickable(onClick = onLocationChange), - ) - - Header(text = stringResource(id = RBase.string.backup_app_data)) - - DataEntry( - iconRes = RBase.drawable.ic_timer, - nameRes = RBase.string.main_action_timers, - include = screen.includeTimers, - onIncludedChange = screen.onTimersChange, - ) - DataEntry( - iconRes = RBase.drawable.ic_scheduler, - nameRes = RBase.string.main_action_schedulers, - include = screen.includeSchedulers, - onIncludedChange = screen.onSchedulersChange, - ) - DataEntry( - iconRes = RBase.drawable.ic_stat, - nameRes = RBase.string.main_action_record, - include = screen.includeRecords, - onIncludedChange = screen.onRecordsChange, - ) - DataEntry( - iconRes = RBase.drawable.ic_settings, - nameRes = RBase.string.main_action_settings, - include = screen.includeSettings, - onIncludedChange = screen.onSettingsChange, - ) - - Button( - onClick = screen.onExport, - modifier = Modifier.align(Alignment.CenterHorizontally), - enabled = !screen.saveLocation.isNullOrBlank() && - (screen.includeTimers || screen.includeSettings) && - !screen.exporting, - ) { - Text(text = stringResource(id = RBase.string.export_action)) - } - } - - if (screen.exportErrorMessage != null) { - ExportError( - message = screen.exportErrorMessage, - consume = screen.consumeExportError, - ) - } - - if (screen.exporting) { - ExportingDialog() - } - } -} - -@Composable -private fun Header( - text: String, - modifier: Modifier = Modifier, -) { - Text( - text = text, - modifier = modifier.padding(horizontal = 12.dp, vertical = 12.dp), - style = MaterialTheme.typography.titleSmall, - ) -} - -@Composable -private fun SaveLocation( - location: String?, - modifier: Modifier = Modifier, -) { - if (location.isNullOrBlank()) { - SaveLocationEmpty(modifier = modifier) - } else { - SaveLocationPath(location = location, modifier = modifier) - } -} - -@Composable -private fun SaveLocationPath(location: String, modifier: Modifier) { - ListItem( - headlineContent = { - Text(text = location) - }, + Backup( + screen = screen, + contentLocationTitle = stringResource(id = RBase.string.export_path_title), + contentLocationButtonText = stringResource(id = RBase.string.export_select_location), + onChangeContentLocation = onLocationChange, + backupButtonText = stringResource(id = RBase.string.export_action), + backupErrorHint = stringResource(id = RBase.string.export_error), modifier = modifier, - overlineContent = { - Text(text = stringResource(id = RBase.string.export_select_location)) - }, - leadingContent = { - Icon(imageVector = Icons.Rounded.Description, contentDescription = null) - }, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Rounded.NavigateNext, - contentDescription = null, - ) - }, ) } - -@Composable -private fun SaveLocationEmpty(modifier: Modifier) { - ListItem( - headlineContent = { - Text(text = stringResource(id = RBase.string.export_select_location)) - }, - modifier = modifier, - leadingContent = { - Icon(imageVector = Icons.Rounded.Description, contentDescription = null) - }, - trailingContent = { - Icon( - imageVector = Icons.AutoMirrored.Rounded.NavigateNext, - contentDescription = null, - ) - }, - ) -} - -@Composable -private fun DataEntry( - @DrawableRes iconRes: Int, - @StringRes nameRes: Int, - include: Boolean, - onIncludedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, -) { - ListItem( - headlineContent = { - Text(text = stringResource(id = nameRes)) - }, - modifier = modifier.clickable { onIncludedChange(!include) }, - leadingContent = { - Icon(painter = painterResource(id = iconRes), contentDescription = null) - }, - trailingContent = { - Switch(checked = include, onCheckedChange = onIncludedChange) - }, - ) -} - -@Composable -private fun ExportError( - message: String, - consume: () -> Unit, -) { - AlertDialog( - onDismissRequest = consume, - confirmButton = { - TextButton(onClick = consume) { - Text(text = stringResource(id = RBase.string.ok)) - } - }, - text = { - val context = LocalContext.current - Text( - text = buildString { - append(context.getString(RBase.string.export_error)) - - if (message.isNotBlank()) { - append("\n\n") - append(message) - } - }, - ) - } - ) -} - -@Composable -private fun ExportingDialog() { - Dialog(onDismissRequest = {}) { - Surface(shape = MaterialTheme.shapes.medium) { - CircularProgressIndicator(modifier = Modifier.padding(24.dp)) - } - } -} diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2ViewModel.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2ViewModel.kt index 7ef7ce3f..ba242d44 100644 --- a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2ViewModel.kt +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2ViewModel.kt @@ -1,111 +1,14 @@ package xyz.aprildown.timer.app.backup -import androidx.compose.runtime.Immutable -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch +import xyz.aprildown.timer.domain.usecases.Fruit import javax.inject.Inject @HiltViewModel -internal class Export2ViewModel @Inject constructor() : ViewModel() { - @Immutable - data class Screen( - val saveLocation: String? = null, - - val includeTimers: Boolean = true, - val onTimersChange: (Boolean) -> Unit, - - val includeRecords: Boolean = true, - val onRecordsChange: (Boolean) -> Unit, - - val includeSchedulers: Boolean = true, - val onSchedulersChange: (Boolean) -> Unit, - - val includeSettings: Boolean = true, - val onSettingsChange: (Boolean) -> Unit, - - val onExport: () -> Unit, - val exporting: Boolean = false, - - val exportErrorMessage: String? = null, - val consumeExportError: () -> Unit, - ) - - private val _screen: MutableStateFlow = MutableStateFlow( - Screen( - onTimersChange = ::onTimersChange, - onRecordsChange = ::onRecordsChange, - onSchedulersChange = ::onSchedulersChange, - onSettingsChange = ::onSettingsChange, - onExport = ::onExport, - consumeExportError = ::consumeExportError, - ) - ) - val screen: StateFlow = _screen.asStateFlow() - - fun setSaveLocation(location: String) { - _screen.update { it.copy(saveLocation = location) } - } - - private fun onTimersChange(include: Boolean) { - _screen.update { - it.copy( - includeTimers = include, - includeRecords = include, - includeSchedulers = include, - ) - } - } - - private fun onRecordsChange(include: Boolean) { - _screen.update { - it.copy( - includeTimers = if (include) true else it.includeTimers, - includeRecords = include, - ) - } - } - - private fun onSchedulersChange(include: Boolean) { - _screen.update { - it.copy( - includeTimers = if (include) true else it.includeTimers, - includeSchedulers = include, - ) - } - } - - private fun onSettingsChange(include: Boolean) { - _screen.update { - it.copy(includeSettings = include) - } - } - - private fun onExport() { - val screen = _screen.value - if (screen.saveLocation.isNullOrBlank()) return - if (!screen.includeTimers && !screen.includeSettings) return - if (screen.exporting) return - viewModelScope.launch { - _screen.update { it.copy(exporting = true) } - try { - delay(5500) - _screen.update { - it.copy(exportErrorMessage = "Hey") - } - } finally { - _screen.update { it.copy(exporting = false) } - } - } - } - - private fun consumeExportError() { - _screen.update { it.copy(exportErrorMessage = null) } +internal class Export2ViewModel @Inject constructor() : BaseBackupViewModel() { + override suspend fun backup(screen: Screen): Fruit { + delay(5000) + return Fruit.Rotten("Hey") } } diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2Fragment.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2Fragment.kt new file mode 100644 index 00000000..835f8202 --- /dev/null +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2Fragment.kt @@ -0,0 +1,150 @@ +package xyz.aprildown.timer.app.backup + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.documentfile.provider.DocumentFile +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.github.deweyreed.tools.helper.requireCallback +import dagger.hilt.android.AndroidEntryPoint +import xyz.aprildown.timer.app.base.data.PreferenceData.lastBackupUri +import xyz.aprildown.timer.app.base.ui.MainCallback +import xyz.aprildown.timer.domain.utils.AppTracker +import javax.inject.Inject +import xyz.aprildown.timer.app.base.R as RBase + +@AndroidEntryPoint +class Import2Fragment : Fragment() { + + private val viewModel: Import2ViewModel by viewModels() + + private lateinit var mainCallback: MainCallback.ActivityCallback + + @Inject + lateinit var appTracker: AppTracker + + private val launcher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + handleActivityResult(resultCode = it.resultCode, data = it.data) + } + + override fun onAttach(context: Context) { + super.onAttach(context) + mainCallback = requireCallback() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + (view as ComposeView).setContent { + Import( + screen = viewModel.screen.collectAsState().value, + importScreen = viewModel.importScreen.collectAsState().value, + onLocationChange = ::onLocationChange, + modifier = Modifier.fillMaxSize(), + ) + } + } + + private fun onLocationChange() { + SafIntentSafeBelt( + context = requireContext(), + appTracker = appTracker, + viewForSnackbar = mainCallback.snackbarView + ).drive( + launcher = launcher, + intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("*/*") + ) + } + + private fun handleActivityResult(resultCode: Int, data: Intent?) { + if (resultCode != Activity.RESULT_OK) return + val uri = data?.data ?: return + if (uri.toString().isBlank() || uri == Uri.EMPTY) return + val context = requireContext() + context.lastBackupUri = uri + viewModel.changeContentLocation( + location = uri.toString(), + name = DocumentFile.fromSingleUri(context, uri)?.name, + ) + } +} + +@Composable +private fun Import( + screen: BaseBackupViewModel.Screen, + importScreen: Import2ViewModel.ImportScreen, + onLocationChange: () -> Unit, + modifier: Modifier = Modifier, +) { + Backup( + screen = screen, + contentLocationTitle = stringResource(id = RBase.string.import_path_title), + contentLocationButtonText = stringResource(id = RBase.string.import_select_location), + onChangeContentLocation = onLocationChange, + backupButtonText = stringResource(id = RBase.string.import_action), + backupErrorHint = stringResource(id = RBase.string.import_error), + modifier = modifier, + extraOptions = { + WipeContent( + wipe = importScreen.wipe, + onWipeChanged = importScreen.onWipeChanged, + modifier = Modifier.fillMaxSize(), + ) + }, + ) +} + +@Composable +private fun WipeContent( + wipe: Boolean, + onWipeChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + ListItem( + headlineContent = { + Text(text = stringResource(id = RBase.string.import_wipe_first)) + }, + modifier = modifier.clickable { onWipeChanged(!wipe) }, + leadingContent = { + Icon( + painter = painterResource(id = RBase.drawable.ic_delete), + contentDescription = null, + ) + }, + trailingContent = { + Switch(checked = wipe, onCheckedChange = onWipeChanged) + }, + ) +} diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2ViewModel.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2ViewModel.kt new file mode 100644 index 00000000..311dc810 --- /dev/null +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2ViewModel.kt @@ -0,0 +1,36 @@ +package xyz.aprildown.timer.app.backup + +import androidx.compose.runtime.Immutable +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import xyz.aprildown.timer.domain.usecases.Fruit +import javax.inject.Inject + +@HiltViewModel +internal class Import2ViewModel @Inject constructor() : BaseBackupViewModel() { + @Immutable + data class ImportScreen( + val wipe: Boolean = false, + val onWipeChanged: (Boolean) -> Unit, + ) + + private val _importScreen: MutableStateFlow = MutableStateFlow( + ImportScreen( + onWipeChanged = ::onWipeChanged, + ) + ) + val importScreen: StateFlow = _importScreen.asStateFlow() + + private fun onWipeChanged(wipe: Boolean) { + _importScreen.update { it.copy(wipe = wipe) } + } + + override suspend fun backup(screen: Screen): Fruit { + delay(5000) + return Fruit.Rotten("Hey") + } +} diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index e8621021..d2ce3717 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -66,9 +66,8 @@ + android:name="xyz.aprildown.timer.app.backup.Import2Fragment" + android:label="@string/import_data_title" /> Date: Wed, 17 Apr 2024 11:09:28 +0800 Subject: [PATCH 14/31] Export and import the data --- .../timer/app/backup/BackupComposables.kt | 22 +++-- .../timer/app/backup/BaseBackupViewModel.kt | 41 ++++---- .../timer/app/backup/Export2Fragment.kt | 38 ++++++- .../timer/app/backup/Export2ViewModel.kt | 55 ++++++++++- .../timer/app/backup/Import2Fragment.kt | 98 +++++++++++++++++-- .../timer/app/backup/Import2ViewModel.kt | 33 ++++++- domain/build.gradle | 2 + 7 files changed, 240 insertions(+), 49 deletions(-) diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BackupComposables.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BackupComposables.kt index 81f4a151..6eaa287c 100644 --- a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BackupComposables.kt +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BackupComposables.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.NavigateNext @@ -26,13 +27,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import xyz.aprildown.timer.domain.usecases.Fruit import xyz.aprildown.timer.app.base.R as RBase @Composable internal fun Backup( - screen: BaseBackupViewModel.Screen, + screen: BaseBackupViewModel.Screen<*>, contentLocationTitle: String, contentLocationButtonText: String, onChangeContentLocation: () -> Unit, @@ -52,7 +55,7 @@ internal fun Backup( ContentLocation( title = contentLocationButtonText, - location = screen.contentName ?: screen.contentLocation, + location = screen.contentName, modifier = Modifier.clickable(onClick = onChangeContentLocation), ) @@ -88,7 +91,7 @@ internal fun Backup( Button( onClick = screen.onBackup, modifier = Modifier.align(Alignment.CenterHorizontally), - enabled = !screen.contentLocation.isNullOrBlank() && + enabled = screen.content != null && (screen.includeTimers || screen.includeSettings) && !screen.backupOngoing, ) { @@ -96,10 +99,10 @@ internal fun Backup( } } - if (screen.backupErrorMessage != null) { + if (screen.backupResult?.fruit is Fruit.Rotten) { BackupError( hint = backupErrorHint, - message = screen.backupErrorMessage, + message = (screen.backupResult.fruit as Fruit.Rotten).exception.message.toString(), consume = screen.consumeBackupError, ) } @@ -195,12 +198,17 @@ private fun DataEntry( headlineContent = { Text(text = stringResource(id = nameRes)) }, - modifier = modifier.clickable { onIncludedChange(!include) }, + modifier = modifier + .toggleable( + value = include, + role = Role.Switch, + onValueChange = onIncludedChange + ), leadingContent = { Icon(painter = painterResource(id = iconRes), contentDescription = null) }, trailingContent = { - Switch(checked = include, onCheckedChange = onIncludedChange) + Switch(checked = include, onCheckedChange = null) }, ) } diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BaseBackupViewModel.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BaseBackupViewModel.kt index 24000019..e820bcea 100644 --- a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BaseBackupViewModel.kt +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BaseBackupViewModel.kt @@ -10,10 +10,11 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import xyz.aprildown.timer.domain.usecases.Fruit -internal abstract class BaseBackupViewModel : ViewModel() { +internal abstract class BaseBackupViewModel : + ViewModel() { @Immutable - data class Screen( - val contentLocation: String? = null, + data class Screen( + val content: ContentType? = null, val contentName: String? = null, val includeTimers: Boolean = true, @@ -31,11 +32,17 @@ internal abstract class BaseBackupViewModel : ViewModel() { val onBackup: () -> Unit, val backupOngoing: Boolean = false, - val backupErrorMessage: String? = null, + val backupResult: Result? = null, val consumeBackupError: () -> Unit, - ) + ) { + @Immutable + interface Content + + @Immutable + data class Result(val fruit: Fruit) + } - private val _screen: MutableStateFlow = MutableStateFlow( + private val _screen: MutableStateFlow> = MutableStateFlow( Screen( onTimersChange = ::onTimersChange, onRecordsChange = ::onRecordsChange, @@ -45,10 +52,10 @@ internal abstract class BaseBackupViewModel : ViewModel() { consumeBackupError = ::consumeBackupError, ) ) - val screen: StateFlow = _screen.asStateFlow() + val screen: StateFlow> = _screen.asStateFlow() - fun changeContentLocation(location: String, name: String?) { - _screen.update { it.copy(contentLocation = location, contentName = name) } + fun changeContent(content: ContentType, name: String?) { + _screen.update { it.copy(content = content, contentName = name) } } private fun onTimersChange(include: Boolean) { @@ -85,24 +92,20 @@ internal abstract class BaseBackupViewModel : ViewModel() { } } - abstract suspend fun backup(screen: Screen): Fruit + abstract suspend fun backup(screen: Screen): Fruit private fun onBackup() { val screen = _screen.value - if (screen.contentLocation.isNullOrBlank()) return + if (screen.content == null) return if (!screen.includeTimers && !screen.includeSettings) return if (screen.backupOngoing) return viewModelScope.launch { _screen.update { it.copy(backupOngoing = true) } try { - when (val fruit = backup(screen)) { - is Fruit.Ripe -> Unit - is Fruit.Rotten -> { - _screen.update { - it.copy(backupErrorMessage = fruit.exception.message) - } - } + val result = Screen.Result(backup(screen)) + _screen.update { + it.copy(backupResult = result) } } finally { _screen.update { it.copy(backupOngoing = false) } @@ -111,6 +114,6 @@ internal abstract class BaseBackupViewModel : ViewModel() { } private fun consumeBackupError() { - _screen.update { it.copy(backupErrorMessage = null) } + _screen.update { it.copy(backupResult = null) } } } diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt index 9aac75c7..5acd6f7c 100644 --- a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt @@ -11,7 +11,9 @@ import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy @@ -19,10 +21,14 @@ import androidx.compose.ui.res.stringResource import androidx.documentfile.provider.DocumentFile import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.github.deweyreed.tools.anko.longSnackbar import com.github.deweyreed.tools.helper.requireCallback import dagger.hilt.android.AndroidEntryPoint +import okio.sink import xyz.aprildown.timer.app.base.data.PreferenceData.lastBackupUri import xyz.aprildown.timer.app.base.ui.MainCallback +import xyz.aprildown.timer.domain.usecases.Fruit import xyz.aprildown.timer.domain.utils.AppTracker import java.text.SimpleDateFormat import java.util.Date @@ -62,9 +68,21 @@ class Export2Fragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + val navController = findNavController() (view as ComposeView).setContent { + val screen by viewModel.screen.collectAsState() + LaunchedEffect(screen.backupResult) { + val result = screen.backupResult + if (result?.fruit is Fruit.Ripe) { + mainCallback.snackbarView.longSnackbar(RBase.string.export_done) + navController.popBackStack( + destinationId = RBase.id.dest_backup_restore, + inclusive = false, + ) + } + } Export( - screen = viewModel.screen.collectAsState().value, + screen = screen, onLocationChange = ::onLocationChange, modifier = Modifier.fillMaxSize(), ) @@ -94,16 +112,26 @@ class Export2Fragment : Fragment() { if (uri.toString().isBlank() || uri == Uri.EMPTY) return val context = requireContext() context.lastBackupUri = uri - viewModel.changeContentLocation( - location = uri.toString(), - name = DocumentFile.fromSingleUri(context, uri)?.name, + val contentResolver = context.contentResolver + val documentFile = DocumentFile.fromSingleUri(context, uri) + viewModel.changeContent( + content = Export2ViewModel.WritableContent( + getSink = { + checkNotNull(contentResolver.openOutputStream(uri)).sink() + }, + delete = { + // ContentResolver.delete doesn't work + documentFile?.delete() + }, + ), + name = documentFile?.name?.takeIf { it.isNotBlank() } ?: uri.toString(), ) } } @Composable private fun Export( - screen: BaseBackupViewModel.Screen, + screen: BaseBackupViewModel.Screen<*>, onLocationChange: () -> Unit, modifier: Modifier = Modifier, ) { diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2ViewModel.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2ViewModel.kt index ba242d44..4b193e80 100644 --- a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2ViewModel.kt +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2ViewModel.kt @@ -1,14 +1,59 @@ package xyz.aprildown.timer.app.backup +import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay +import okio.Sink +import okio.buffer import xyz.aprildown.timer.domain.usecases.Fruit +import xyz.aprildown.timer.domain.usecases.data.ExportAppData +import java.io.IOException import javax.inject.Inject @HiltViewModel -internal class Export2ViewModel @Inject constructor() : BaseBackupViewModel() { - override suspend fun backup(screen: Screen): Fruit { - delay(5000) - return Fruit.Rotten("Hey") +internal class Export2ViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val exportAppData: ExportAppData, +) : BaseBackupViewModel() { + class WritableContent( + val getSink: () -> Sink, + val delete: () -> Unit, + ) : Screen.Content + + override suspend fun backup(screen: Screen): Fruit { + return try { + val string = exportAppData( + ExportAppData.Params( + exportTimers = screen.includeTimers, + exportTimerStamps = screen.includeRecords, + exportSchedulers = screen.includeSchedulers, + exportPreferences = screen.includeSettings, + ) + ) + checkNotNull(screen.content).getSink().use { + it.buffer().run { + writeUtf8(string) + flush() + } + } + savedStateHandle[KEY_HAS_EXPORTED] = true + Fruit.Ripe(Unit) + } catch (e: IOException) { + Fruit.Rotten(e) + } + } + + override fun onCleared() { + super.onCleared() + if (savedStateHandle.get(KEY_HAS_EXPORTED) != true) { + try { + screen.value.content?.delete?.invoke() + } catch (_: Exception) { + // Ignore + } + } + } + + companion object { + private const val KEY_HAS_EXPORTED = "has_exported" } } diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2Fragment.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2Fragment.kt index 835f8202..59e0f6b3 100644 --- a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2Fragment.kt +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2Fragment.kt @@ -9,26 +9,37 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.documentfile.provider.DocumentFile import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import com.github.deweyreed.tools.helper.requireCallback +import com.github.deweyreed.tools.helper.restartWithFading import dagger.hilt.android.AndroidEntryPoint +import okio.source import xyz.aprildown.timer.app.base.data.PreferenceData.lastBackupUri +import xyz.aprildown.timer.app.base.ui.AppNavigator import xyz.aprildown.timer.app.base.ui.MainCallback +import xyz.aprildown.timer.domain.usecases.Fruit import xyz.aprildown.timer.domain.utils.AppTracker import javax.inject.Inject import xyz.aprildown.timer.app.base.R as RBase @@ -40,6 +51,9 @@ class Import2Fragment : Fragment() { private lateinit var mainCallback: MainCallback.ActivityCallback + @Inject + lateinit var appNavigator: AppNavigator + @Inject lateinit var appTracker: AppTracker @@ -66,12 +80,21 @@ class Import2Fragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) (view as ComposeView).setContent { + val screen by viewModel.screen.collectAsState() Import( - screen = viewModel.screen.collectAsState().value, + screen = screen, importScreen = viewModel.importScreen.collectAsState().value, onLocationChange = ::onLocationChange, modifier = Modifier.fillMaxSize(), ) + + if (screen.backupResult?.fruit is Fruit.Ripe) { + RestartDialog( + onRestart = { + requireActivity().restartWithFading(appNavigator.getMainIntent()) + }, + ) + } } } @@ -94,16 +117,22 @@ class Import2Fragment : Fragment() { if (uri.toString().isBlank() || uri == Uri.EMPTY) return val context = requireContext() context.lastBackupUri = uri - viewModel.changeContentLocation( - location = uri.toString(), - name = DocumentFile.fromSingleUri(context, uri)?.name, + val contentResolver = context.contentResolver + val documentFile = DocumentFile.fromSingleUri(context, uri) + viewModel.changeContent( + content = Import2ViewModel.ReadableContent( + getSource = { + checkNotNull(contentResolver.openInputStream(uri)).source() + } + ), + name = documentFile?.name?.takeIf { it.isNotBlank() }, ) } } @Composable private fun Import( - screen: BaseBackupViewModel.Screen, + screen: BaseBackupViewModel.Screen<*>, importScreen: Import2ViewModel.ImportScreen, onLocationChange: () -> Unit, modifier: Modifier = Modifier, @@ -132,11 +161,27 @@ private fun WipeContent( onWipeChanged: (Boolean) -> Unit, modifier: Modifier = Modifier ) { + var showAlert by remember { mutableStateOf(false) } + val onWipeChangedAlert: (Boolean) -> Unit = remember(onWipeChanged) { + { wipe: Boolean -> + if (wipe) { + showAlert = true + } else { + onWipeChanged(false) + } + } + } + ListItem( headlineContent = { Text(text = stringResource(id = RBase.string.import_wipe_first)) }, - modifier = modifier.clickable { onWipeChanged(!wipe) }, + modifier = modifier + .toggleable( + value = wipe, + role = Role.Switch, + onValueChange = onWipeChangedAlert, + ), leadingContent = { Icon( painter = painterResource(id = RBase.drawable.ic_delete), @@ -144,7 +189,44 @@ private fun WipeContent( ) }, trailingContent = { - Switch(checked = wipe, onCheckedChange = onWipeChanged) + Switch(checked = wipe, onCheckedChange = null) + }, + ) + + if (showAlert) { + AlertDialog( + onDismissRequest = { showAlert = false }, + confirmButton = { + TextButton( + onClick = { + onWipeChanged(true) + showAlert = false + }, + ) { + Text(text = stringResource(id = RBase.string.ok)) + } + }, + text = { + Text(text = stringResource(id = RBase.string.import_wipe_warning)) + }, + ) + } +} + +@Composable +private fun RestartDialog(onRestart: () -> Unit) { + AlertDialog( + onDismissRequest = {}, + confirmButton = { + TextButton(onClick = onRestart) { + Text(text = stringResource(id = RBase.string.import_restart)) + } + }, + title = { + Text(text = stringResource(id = RBase.string.import_done)) + }, + text = { + Text(text = stringResource(id = RBase.string.import_restart_content)) }, ) } diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2ViewModel.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2ViewModel.kt index 311dc810..4dfe6593 100644 --- a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2ViewModel.kt +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2ViewModel.kt @@ -2,22 +2,31 @@ package xyz.aprildown.timer.app.backup import androidx.compose.runtime.Immutable import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import okio.Source +import okio.buffer import xyz.aprildown.timer.domain.usecases.Fruit +import xyz.aprildown.timer.domain.usecases.data.ImportAppData +import xyz.aprildown.timer.domain.usecases.data.NotifyDataChanged +import java.io.IOException import javax.inject.Inject @HiltViewModel -internal class Import2ViewModel @Inject constructor() : BaseBackupViewModel() { +internal class Import2ViewModel @Inject constructor( + private val importAppData: ImportAppData, + private val notifyDataChanged: NotifyDataChanged, +) : BaseBackupViewModel() { @Immutable data class ImportScreen( val wipe: Boolean = false, val onWipeChanged: (Boolean) -> Unit, ) + class ReadableContent(val getSource: () -> Source) : Screen.Content + private val _importScreen: MutableStateFlow = MutableStateFlow( ImportScreen( onWipeChanged = ::onWipeChanged, @@ -29,8 +38,22 @@ internal class Import2ViewModel @Inject constructor() : BaseBackupViewModel() { _importScreen.update { it.copy(wipe = wipe) } } - override suspend fun backup(screen: Screen): Fruit { - delay(5000) - return Fruit.Rotten("Hey") + override suspend fun backup(screen: Screen): Fruit { + return try { + importAppData( + ImportAppData.Params( + data = checkNotNull(screen.content).getSource().use { it.buffer().readUtf8() }, + wipeFirst = _importScreen.value.wipe, + importTimers = screen.includeTimers, + importTimerStamps = screen.includeRecords, + importSchedulers = screen.includeSchedulers, + importPreferences = screen.includeSettings, + ) + ) + notifyDataChanged() + Fruit.Ripe(Unit) + } catch (e: IOException) { + Fruit.Rotten(e) + } } } diff --git a/domain/build.gradle b/domain/build.gradle index 7e24e99d..afe988cc 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -43,4 +43,6 @@ dependencies { implementation libs.hilt.android kapt libs.hilt.compiler + api libs.okio + } From 23600ad749529bf0fbe1b0b8fbcc470603d516a1 Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Fri, 19 Apr 2024 09:10:35 +0800 Subject: [PATCH 15/31] Rename ImageAction.path to data --- .../xyz/aprildown/timer/app/timer/edit/EditActivity.kt | 4 ++-- .../xyz/aprildown/timer/app/timer/one/OneFragment.kt | 2 +- .../timer/component/key/behaviour/BehaviourLayout.kt | 2 +- .../component/key/behaviour/EditableBehaviourLayout.kt | 2 +- .../timer/data/repositories/TimerRepositoryImpl.kt | 4 ++-- .../aprildown/timer/domain/entities/BehaviourEntity.kt | 10 +++++++--- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditActivity.kt b/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditActivity.kt index 83d8ca7c..78b83360 100644 --- a/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditActivity.kt +++ b/app-timer-edit/src/main/java/xyz/aprildown/timer/app/timer/edit/EditActivity.kt @@ -108,7 +108,7 @@ class EditActivity : if (position == -1) return@registerForActivityResult contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) changeBehaviour(type = BehaviourType.IMAGE, position = position) { - it.toImageAction().copy(path = uri.toString()).toBehaviourEntity() + it.toImageAction().copy(data = uri.toString()).toBehaviourEntity() } } @@ -860,7 +860,7 @@ class EditActivity : } override fun onImageCheck(position: Int, action: ImageAction) { - startActivity(appNavigator.getImagePreviewIntent(action.path)) + startActivity(appNavigator.getImagePreviewIntent(action.data)) } private fun postUpdateTotalTime() { diff --git a/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/OneFragment.kt b/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/OneFragment.kt index dbaa4166..76dcdcf3 100644 --- a/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/OneFragment.kt +++ b/app-timer-one/src/main/java/xyz/aprildown/timer/app/timer/one/OneFragment.kt @@ -137,7 +137,7 @@ class OneFragment : } } imageCheckListener = { - startActivity(appNavigator.getImagePreviewIntent(it.path)) + startActivity(appNavigator.getImagePreviewIntent(it.data)) } } diff --git a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt index 5d9ec98f..687aa7bc 100644 --- a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt +++ b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt @@ -73,7 +73,7 @@ class BehaviourLayout @JvmOverloads constructor( } val action = imageBehaviour.toImageAction() imageView.setOnClickListener { onImageCheck?.invoke(action) } - imageView.load(action.path) { + imageView.load(action.data) { crossfade(true) } } else { diff --git a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt index 995eb47b..730a5609 100644 --- a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt +++ b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt @@ -136,7 +136,7 @@ class EditableBehaviourLayout( LayoutEditableBehaviourImageBinding.bind(binding.layoutImage.getChildAt(0)).root } imageView.setOnClickListener { listener?.onImageContentClick(imageAction) } - imageView.load(imageAction.path) { + imageView.load(imageAction.data) { crossfade(true) } } else { diff --git a/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt b/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt index 09d07b47..987e3c34 100644 --- a/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt +++ b/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt @@ -45,7 +45,7 @@ internal class TimerRepositoryImpl @Inject constructor( private fun TimerEntity.withRealImagePath(): TimerEntity { return transformImageActions { - it.copy(path = imageSaver.getFullPath(id, it.path)) + it.copy(data = imageSaver.getFullPath(id, it.data)) } } @@ -71,7 +71,7 @@ internal class TimerRepositoryImpl @Inject constructor( private fun TimerEntity.saveImages(): TimerEntity { return transformImageActions { - it.copy(path = imageSaver.save(id, it.path)) + it.copy(data = imageSaver.save(id, it.data)) } } diff --git a/domain/src/main/java/xyz/aprildown/timer/domain/entities/BehaviourEntity.kt b/domain/src/main/java/xyz/aprildown/timer/domain/entities/BehaviourEntity.kt index 7f41ad47..57708fa5 100644 --- a/domain/src/main/java/xyz/aprildown/timer/domain/entities/BehaviourEntity.kt +++ b/domain/src/main/java/xyz/aprildown/timer/domain/entities/BehaviourEntity.kt @@ -377,15 +377,19 @@ fun BehaviourEntity.toFlashlightAction(): FlashlightAction { // region Image -data class ImageAction(val path: String) : Action { +/** + * @data It can be a relative path when being stored in the database, a canonical path when showing, + * or an Uri string when editing + */ +data class ImageAction(val data: String) : Action { override fun toBehaviourEntity(): BehaviourEntity { - return BehaviourEntity(type = BehaviourType.IMAGE, str1 = path) + return BehaviourEntity(type = BehaviourType.IMAGE, str1 = data) } } fun BehaviourEntity.toImageAction(): ImageAction { require(type == BehaviourType.IMAGE) - return ImageAction(path = str1) + return ImageAction(data = str1) } // endregion Image From d2de885a934ec8c97dd4e52e2a11922f1f6691f3 Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Tue, 23 Apr 2024 13:47:43 +0800 Subject: [PATCH 16/31] Convert the image data to different types --- .../data/repositories/TimerRepositoryImpl.kt | 199 ++++++++++++------ .../timer/domain/entities/BehaviourEntity.kt | 6 +- .../timer/domain/entities/TimerEntity.kt | 22 +- .../domain/repositories/TimerRepository.kt | 5 + .../domain/utils/Base64BitmapConverter.kt | 36 ++++ .../aprildown/timer/domain/utils/FileUtils.kt | 15 ++ 6 files changed, 211 insertions(+), 72 deletions(-) create mode 100644 domain/src/main/java/xyz/aprildown/timer/domain/utils/Base64BitmapConverter.kt create mode 100644 domain/src/main/java/xyz/aprildown/timer/domain/utils/FileUtils.kt diff --git a/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt b/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt index 987e3c34..a797423b 100644 --- a/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt +++ b/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt @@ -1,9 +1,8 @@ package xyz.aprildown.timer.data.repositories -import android.content.ContentResolver import android.content.Context +import android.graphics.BitmapFactory import android.webkit.MimeTypeMap -import androidx.core.net.toFile import androidx.core.net.toUri import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext @@ -15,11 +14,17 @@ import xyz.aprildown.timer.data.mappers.TimerMapper import xyz.aprildown.timer.data.mappers.fromWithMapper import xyz.aprildown.timer.domain.entities.BehaviourType import xyz.aprildown.timer.domain.entities.ImageAction +import xyz.aprildown.timer.domain.entities.ResourceContentType import xyz.aprildown.timer.domain.entities.StepEntity import xyz.aprildown.timer.domain.entities.TimerEntity import xyz.aprildown.timer.domain.entities.TimerInfo +import xyz.aprildown.timer.domain.entities.inferResourcesContentType import xyz.aprildown.timer.domain.entities.toImageAction import xyz.aprildown.timer.domain.repositories.TimerRepository +import xyz.aprildown.timer.domain.utils.Base64BitmapConverter.decodeBitmapByteArrayFromBase64 +import xyz.aprildown.timer.domain.utils.Base64BitmapConverter.encodeToBase64 +import xyz.aprildown.timer.domain.utils.ensureDirExistence +import xyz.aprildown.timer.domain.utils.ensureNewFile import java.io.File import java.util.UUID import javax.inject.Inject @@ -31,29 +36,38 @@ internal class TimerRepositoryImpl @Inject constructor( private val timerMapper: TimerMapper, private val timerInfoMapper: TimerInfoMapper, ) : TimerRepository { - private val imageSaver: TimerImageSaver by lazy { TimerImageSaver(context) } + private val imageManager by lazy { TimerImageManager(context) } override suspend fun items(): List { - return timerDao.getTimers().map { timerMapper.mapFrom(it).withRealImagePath() } + return timerDao.getTimers().map { timerMapper.mapFrom(it).withImageCanonicalPath() } } override suspend fun item(id: Int): TimerEntity? { return timerDao.getTimer(id).let { - return@let if (it == null) null else timerMapper.mapFrom(it).withRealImagePath() + return@let if (it == null) null else timerMapper.mapFrom(it).withImageCanonicalPath() } } - private fun TimerEntity.withRealImagePath(): TimerEntity { - return transformImageActions { - it.copy(data = imageSaver.getFullPath(id, it.data)) + private fun TimerEntity.withImageCanonicalPath(): TimerEntity { + return transformImageActions { data -> + ImageAction(data = data.convertImageDataToCanonicalPath(id)) } } override suspend fun add(item: TimerEntity): Int { - val tempTimerFolder = imageSaver.getTimerFolder(item.id) - val timerId = timerDao.addTimer(timerMapper.mapTo(item.saveImages())).toInt() + val tempTimerFolder = imageManager.getTimerFolder(item.id) + val timerId = timerDao.addTimer( + timerMapper.mapTo( + item.transformImageActions { + ImageAction( + data = imageManager.save(timerId = item.id, data = it) + .convertImageCanonicalPathToDataType(ResourceContentType.RelativePath) + ) + } + ) + ).toInt() if (tempTimerFolder.exists()) { - val newTimerFolder = imageSaver.getTimerFolder(timerId) + val newTimerFolder = imageManager.getTimerFolder(timerId) tempTimerFolder.copyRecursively(newTimerFolder) tempTimerFolder.deleteRecursively() } @@ -61,22 +75,19 @@ internal class TimerRepositoryImpl @Inject constructor( } override suspend fun save(item: TimerEntity): Boolean { - try { - imageSaver.trackSavedImages() - return timerDao.updateTimer(timerMapper.mapTo(item.saveImages())) == 1 - } finally { - imageSaver.deleteUnusedImages(item.id) - } - } - - private fun TimerEntity.saveImages(): TimerEntity { - return transformImageActions { - it.copy(data = imageSaver.save(id, it.data)) + val savedItem = imageManager.trackSaving(timerId = item.id) { + item.transformImageActions { + ImageAction( + data = save(data = it) + .convertImageCanonicalPathToDataType(ResourceContentType.RelativePath) + ) + } } + return timerDao.updateTimer(timerMapper.mapTo(savedItem)) == 1 } private fun TimerEntity.transformImageActions( - transform: (ImageAction) -> ImageAction + transform: (data: String) -> ImageAction ): TimerEntity { fun StepEntity.transformStep(): StepEntity { return when (this) { @@ -85,7 +96,7 @@ internal class TimerRepositoryImpl @Inject constructor( copy( behaviour = behaviour.map { if (it.type == BehaviourType.IMAGE) { - transform(it.toImageAction()).toBehaviourEntity() + transform(it.toImageAction().data).toBehaviourEntity() } else { it } @@ -109,7 +120,7 @@ internal class TimerRepositoryImpl @Inject constructor( override suspend fun delete(id: Int) { timerDao.deleteTimer(id) - imageSaver.deleteAll(id) + imageManager.delete(id) } override suspend fun getTimerInfoByTimerId(timerId: Int): TimerInfo? { @@ -131,78 +142,132 @@ internal class TimerRepositoryImpl @Inject constructor( override suspend fun moveFolderTimersToAnother(originalFolderId: Long, targetFolderId: Long) { timerDao.moveFolderTimersToAnother(originalFolderId, targetFolderId) } + + override suspend fun changeContentType( + timers: List, + type: ResourceContentType, + ): List { + return timers.map { timer -> + timer.transformImageActions { data -> + ImageAction( + data = data.convertImageDataToCanonicalPath(timerId = timer.id) + .convertImageCanonicalPathToDataType(type = type), + ) + } + } + } + + private fun String.convertImageDataToCanonicalPath(timerId: Int): String { + return imageManager.save(timerId = timerId, data = this) + } + + private fun String.convertImageCanonicalPathToDataType(type: ResourceContentType): String { + return when (type) { + ResourceContentType.CanonicalPath -> this + ResourceContentType.RelativePath -> File(this).name + ResourceContentType.Uri -> error("Unsupported") + ResourceContentType.Base64 -> { + BitmapFactory.decodeFile(this).encodeToBase64(quality = 90) + } + } + } } -private class TimerImageSaver(private val context: Context) { - private val imageFolder = File(context.filesDir, FOLDER) +private class TimerImageManager(private val context: Context) { + interface TrackingScope { + /** + * [TimerImageManager.save] + */ + fun save(data: String): String + } - private var savedFilenames = mutableListOf() + private val imageFolder = File(context.filesDir, FOLDER) fun getTimerFolder(timerId: Int): File { return File(imageFolder, timerId.toString()) } - fun getFullPath(timerId: Int, path: String): String { - return File(getTimerFolder(timerId), path).toUri().toString() - } - /** - * @return The saved image filename + * @return [ResourceContentType.RelativePath], [File.getCanonicalPath] */ - fun save(timerId: Int, path: String): String { - val uri = path.toUri() + fun save(timerId: Int, data: String): String { + imageFolder.ensureDirExistence() + val timerFolder = getTimerFolder(timerId) - if (uri.scheme == ContentResolver.SCHEME_FILE) { - val imageFile = uri.toFile() - if (imageFile.parentFile?.canonicalPath == timerFolder.canonicalPath) { - return imageFile.name.also { - savedFilenames += it + val imageFile: File + when (data.inferResourcesContentType()) { + ResourceContentType.CanonicalPath -> { + val existingFile = File(data) + if (existingFile.parentFile?.canonicalPath == timerFolder.canonicalPath) { + imageFile = existingFile + } else { + imageFile = File( + imageFolder.ensureDirExistence(), + "${UUID.randomUUID()}.${existingFile.extension}" + ).ensureNewFile() + imageFile.inputStream().use { inputStream -> + existingFile.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } } } - } - - if (!imageFolder.exists()) imageFolder.mkdirs() - if (!timerFolder.exists()) timerFolder.mkdirs() - - return when (uri.scheme) { - ContentResolver.SCHEME_CONTENT -> { + ResourceContentType.RelativePath -> { + imageFile = File(timerFolder.ensureDirExistence(), data) + } + ResourceContentType.Uri -> { + val uri = data.toUri() val extension = MimeTypeMap.getSingleton() .getExtensionFromMimeType(context.contentResolver.getType(uri)) ?: "jpg" - val imageFile = File(timerFolder, "${UUID.randomUUID()}.$extension") - if (imageFile.exists()) { - imageFile.delete() - imageFile.createNewFile() - } + imageFile = + File(timerFolder.ensureDirExistence(), "${UUID.randomUUID()}.$extension") + .ensureNewFile() context.contentResolver.openInputStream(uri)?.use { inputStream -> imageFile.outputStream().use { outputStream -> inputStream.copyTo(outputStream) } } require(imageFile.exists() && imageFile.length() > 0L) - imageFile.name.also { - savedFilenames += it + imageFile.canonicalPath + } + ResourceContentType.Base64 -> { + imageFile = File(timerFolder.ensureDirExistence(), "${UUID.randomUUID()}.webp") + .ensureNewFile() + val byteArray = data.decodeBitmapByteArrayFromBase64() + imageFile.outputStream().use { + it.write(byteArray) + it.flush() } } - else -> error("Unsupported path $path") } + require(imageFile.exists() && imageFile.length() > 0L) + return imageFile.canonicalPath } - fun trackSavedImages() { - savedFilenames.clear() - } - - fun deleteUnusedImages(timerId: Int) { - val timerFolder = getTimerFolder(timerId) - if (!timerFolder.exists()) return - timerFolder.listFiles()?.toList()?.forEach { file -> - if (file.name !in savedFilenames) { - file.delete() + fun trackSaving(timerId: Int, block: TrackingScope.() -> T): T { + val saved = mutableListOf() + val scope = object : TrackingScope { + override fun save(data: String): String { + val savedData = save(timerId = timerId, data = data) + saved += savedData + return savedData + } + } + return try { + block(scope) + } finally { + val timerFolder = getTimerFolder(timerId) + if (timerFolder.exists()) { + timerFolder.listFiles()?.toList()?.forEach { file -> + if (file.canonicalPath !in saved) { + file.delete() + } + } } } - savedFilenames.clear() } - fun deleteAll(timerId: Int) { + fun delete(timerId: Int) { val timerFolder = getTimerFolder(timerId) if (timerFolder.exists()) { timerFolder.deleteRecursively() diff --git a/domain/src/main/java/xyz/aprildown/timer/domain/entities/BehaviourEntity.kt b/domain/src/main/java/xyz/aprildown/timer/domain/entities/BehaviourEntity.kt index 57708fa5..00809d1e 100644 --- a/domain/src/main/java/xyz/aprildown/timer/domain/entities/BehaviourEntity.kt +++ b/domain/src/main/java/xyz/aprildown/timer/domain/entities/BehaviourEntity.kt @@ -377,11 +377,9 @@ fun BehaviourEntity.toFlashlightAction(): FlashlightAction { // region Image -/** - * @data It can be a relative path when being stored in the database, a canonical path when showing, - * or an Uri string when editing - */ data class ImageAction(val data: String) : Action { + val type: ResourceContentType = data.inferResourcesContentType() + override fun toBehaviourEntity(): BehaviourEntity { return BehaviourEntity(type = BehaviourType.IMAGE, str1 = data) } diff --git a/domain/src/main/java/xyz/aprildown/timer/domain/entities/TimerEntity.kt b/domain/src/main/java/xyz/aprildown/timer/domain/entities/TimerEntity.kt index 9a28b430..9769a57a 100644 --- a/domain/src/main/java/xyz/aprildown/timer/domain/entities/TimerEntity.kt +++ b/domain/src/main/java/xyz/aprildown/timer/domain/entities/TimerEntity.kt @@ -1,5 +1,9 @@ package xyz.aprildown.timer.domain.entities +import android.content.ContentResolver +import okio.Path +import xyz.aprildown.timer.domain.utils.Base64BitmapConverter + /** * Whenever you add a new more item, you need to check * TestData, TimerMoreMapper, MappersTest, OneFragment, EditActivity @@ -20,12 +24,28 @@ data class TimerEntity( val more: TimerMoreEntity = TimerMoreEntity(), val folderId: Long = FolderEntity.FOLDER_DEFAULT ) { + val isNull get() = id == NULL_ID + companion object { const val NEW_ID = 0 const val NULL_ID = 0 } +} - val isNull get() = id == NULL_ID +enum class ResourceContentType { + CanonicalPath, + RelativePath, + Uri, + Base64; +} + +fun String.inferResourcesContentType(): ResourceContentType { + return when { + startsWith(Path.DIRECTORY_SEPARATOR) -> ResourceContentType.CanonicalPath + startsWith(ContentResolver.SCHEME_CONTENT) -> ResourceContentType.Uri + startsWith(Base64BitmapConverter.PREFIX) -> ResourceContentType.Base64 + else -> ResourceContentType.RelativePath + } } /** diff --git a/domain/src/main/java/xyz/aprildown/timer/domain/repositories/TimerRepository.kt b/domain/src/main/java/xyz/aprildown/timer/domain/repositories/TimerRepository.kt index 39fd01c6..0cb3c8e6 100644 --- a/domain/src/main/java/xyz/aprildown/timer/domain/repositories/TimerRepository.kt +++ b/domain/src/main/java/xyz/aprildown/timer/domain/repositories/TimerRepository.kt @@ -1,6 +1,7 @@ package xyz.aprildown.timer.domain.repositories import kotlinx.coroutines.flow.Flow +import xyz.aprildown.timer.domain.entities.ResourceContentType import xyz.aprildown.timer.domain.entities.TimerEntity import xyz.aprildown.timer.domain.entities.TimerInfo @@ -15,4 +16,8 @@ interface TimerRepository { suspend fun getTimerInfo(folderId: Long): List suspend fun changeTimerFolder(timerId: Int, folderId: Long) suspend fun moveFolderTimersToAnother(originalFolderId: Long, targetFolderId: Long) + suspend fun changeContentType( + timers: List, + type: ResourceContentType, + ): List } diff --git a/domain/src/main/java/xyz/aprildown/timer/domain/utils/Base64BitmapConverter.kt b/domain/src/main/java/xyz/aprildown/timer/domain/utils/Base64BitmapConverter.kt new file mode 100644 index 00000000..cd770c68 --- /dev/null +++ b/domain/src/main/java/xyz/aprildown/timer/domain/utils/Base64BitmapConverter.kt @@ -0,0 +1,36 @@ +package xyz.aprildown.timer.domain.utils + +import android.graphics.Bitmap +import android.os.Build +import okio.ByteString.Companion.decodeBase64 +import okio.ByteString.Companion.toByteString +import java.io.ByteArrayOutputStream + +object Base64BitmapConverter { + const val PREFIX = "base64," + + fun Bitmap.encodeToBase64(quality: Int = 100): String { + val outputStream = ByteArrayOutputStream() + compress( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Bitmap.CompressFormat.WEBP_LOSSY + } else { + @Suppress("DEPRECATION") + Bitmap.CompressFormat.WEBP + }, + quality, + outputStream + ) + val byteArray = outputStream.toByteArray() + return "$PREFIX${byteArray.toByteString().base64()}" + } + + fun String.decodeBitmapByteArrayFromBase64(): ByteArray { + return checkNotNull(substringAfter(PREFIX).decodeBase64()).toByteArray() + } + + // fun String.decodeBitmapFromBase64(): Bitmap { + // val data = decodeBitmapByteArrayFromBase64() + // return BitmapFactory.decodeByteArray(data, 0, data.size) + // } +} diff --git a/domain/src/main/java/xyz/aprildown/timer/domain/utils/FileUtils.kt b/domain/src/main/java/xyz/aprildown/timer/domain/utils/FileUtils.kt new file mode 100644 index 00000000..e88d5370 --- /dev/null +++ b/domain/src/main/java/xyz/aprildown/timer/domain/utils/FileUtils.kt @@ -0,0 +1,15 @@ +package xyz.aprildown.timer.domain.utils + +import java.io.File + +fun File.ensureDirExistence(): File { + if (!exists()) mkdirs() + return this +} + +fun File.ensureNewFile(): File { + parentFile?.ensureDirExistence() + if (exists()) delete() + createNewFile() + return this +} From 8422eb876cd99bbfafcb74c9aeec72608b8732be Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Tue, 23 Apr 2024 14:07:47 +0800 Subject: [PATCH 17/31] Fix the wrong base64 string's prefix --- .../xyz/aprildown/timer/domain/utils/Base64BitmapConverter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/domain/src/main/java/xyz/aprildown/timer/domain/utils/Base64BitmapConverter.kt b/domain/src/main/java/xyz/aprildown/timer/domain/utils/Base64BitmapConverter.kt index cd770c68..1b49952c 100644 --- a/domain/src/main/java/xyz/aprildown/timer/domain/utils/Base64BitmapConverter.kt +++ b/domain/src/main/java/xyz/aprildown/timer/domain/utils/Base64BitmapConverter.kt @@ -7,7 +7,7 @@ import okio.ByteString.Companion.toByteString import java.io.ByteArrayOutputStream object Base64BitmapConverter { - const val PREFIX = "base64," + const val PREFIX = "data:image/png;base64," fun Bitmap.encodeToBase64(quality: Int = 100): String { val outputStream = ByteArrayOutputStream() From 1338c6a9935f0d3fb219815c47da5b3255887cf6 Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Sat, 27 Apr 2024 11:59:06 +0800 Subject: [PATCH 18/31] Stabilize ImagePreview --- .../timer/component/key/ImagePreviewActivity.kt | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/component-key/src/main/java/xyz/aprildown/timer/component/key/ImagePreviewActivity.kt b/component-key/src/main/java/xyz/aprildown/timer/component/key/ImagePreviewActivity.kt index a31b7084..7f02b95a 100644 --- a/component-key/src/main/java/xyz/aprildown/timer/component/key/ImagePreviewActivity.kt +++ b/component-key/src/main/java/xyz/aprildown/timer/component/key/ImagePreviewActivity.kt @@ -7,7 +7,6 @@ import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.fillMaxSize @@ -22,7 +21,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import coil.compose.rememberAsyncImagePainter +import coil.compose.AsyncImage import coil.request.ImageRequest import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable @@ -76,17 +75,15 @@ class ImagePreviewActivity : ComponentActivity() { @Composable private fun ImagePreview( - data: Any?, + data: String?, onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - Image( - painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(LocalContext.current) - .data(data) - .crossfade(true) - .build(), - ), + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(data) + .crossfade(true) + .build(), contentDescription = null, modifier = modifier .fillMaxSize() From 85bbbff18770d5fb38ebcbc25d080ba7db56c8ba Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Sat, 27 Apr 2024 12:03:35 +0800 Subject: [PATCH 19/31] Load ImageAction with Coil --- .../java/io/github/deweyreed/timer/App.kt | 2 ++ .../timer/component/key/ImageActionMapper.kt | 23 +++++++++++++++++++ .../key/behaviour/BehaviourLayout.kt | 2 +- .../key/behaviour/EditableBehaviourLayout.kt | 2 +- 4 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 component-key/src/main/java/xyz/aprildown/timer/component/key/ImageActionMapper.kt diff --git a/app/src/main/java/io/github/deweyreed/timer/App.kt b/app/src/main/java/io/github/deweyreed/timer/App.kt index cb719d43..31a347da 100644 --- a/app/src/main/java/io/github/deweyreed/timer/App.kt +++ b/app/src/main/java/io/github/deweyreed/timer/App.kt @@ -29,6 +29,7 @@ import xyz.aprildown.timer.app.base.data.PreferenceData.shouldPausePhoneCall import xyz.aprildown.timer.app.base.data.PreferenceData.useVoiceContent2 import xyz.aprildown.timer.app.base.utils.AppThemeUtils import xyz.aprildown.timer.app.base.utils.LogToFileTree +import xyz.aprildown.timer.component.key.ImageActionMapper import xyz.aprildown.timer.data.job.initJob import xyz.aprildown.timer.domain.repositories.PreferencesRepository import xyz.aprildown.timer.domain.usecases.home.TipManager @@ -215,6 +216,7 @@ class App : Application(), WorkManagerConfiguration.Provider, ImageLoaderFactory } else { add(GifDecoder.Factory()) } + add(ImageActionMapper()) } .build() } diff --git a/component-key/src/main/java/xyz/aprildown/timer/component/key/ImageActionMapper.kt b/component-key/src/main/java/xyz/aprildown/timer/component/key/ImageActionMapper.kt new file mode 100644 index 00000000..54057918 --- /dev/null +++ b/component-key/src/main/java/xyz/aprildown/timer/component/key/ImageActionMapper.kt @@ -0,0 +1,23 @@ +package xyz.aprildown.timer.component.key + +import androidx.core.net.toUri +import coil.map.Mapper +import coil.request.Options +import xyz.aprildown.timer.domain.entities.ImageAction +import xyz.aprildown.timer.domain.entities.ResourceContentType +import xyz.aprildown.timer.domain.utils.Base64BitmapConverter.decodeBitmapByteArrayFromBase64 +import java.io.File +import java.nio.ByteBuffer + +class ImageActionMapper : Mapper { + override fun map(data: ImageAction, options: Options): Any? { + return when (data.type) { + ResourceContentType.CanonicalPath -> File(data.data) + ResourceContentType.RelativePath -> error("Relative path can't be loaded") + ResourceContentType.Uri -> data.data.toUri() + ResourceContentType.Base64 -> { + ByteBuffer.wrap(data.data.decodeBitmapByteArrayFromBase64()) + } + } + } +} diff --git a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt index 687aa7bc..46f1e051 100644 --- a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt +++ b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/BehaviourLayout.kt @@ -73,7 +73,7 @@ class BehaviourLayout @JvmOverloads constructor( } val action = imageBehaviour.toImageAction() imageView.setOnClickListener { onImageCheck?.invoke(action) } - imageView.load(action.data) { + imageView.load(action) { crossfade(true) } } else { diff --git a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt index 730a5609..629b016e 100644 --- a/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt +++ b/component-key/src/main/java/xyz/aprildown/timer/component/key/behaviour/EditableBehaviourLayout.kt @@ -136,7 +136,7 @@ class EditableBehaviourLayout( LayoutEditableBehaviourImageBinding.bind(binding.layoutImage.getChildAt(0)).root } imageView.setOnClickListener { listener?.onImageContentClick(imageAction) } - imageView.load(imageAction.data) { + imageView.load(imageAction) { crossfade(true) } } else { From 1cc66a36191d60e54a10b9253a309bf516fc7065 Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Sat, 4 May 2024 09:50:30 +0800 Subject: [PATCH 20/31] Remove the support for Base64 because of its excessive size --- .../timer/component/key/ImageActionMapper.kt | 7 +- .../data/repositories/TimerRepositoryImpl.kt | 141 +++++++++++------- .../timer/domain/entities/TimerEntity.kt | 3 - .../domain/utils/Base64BitmapConverter.kt | 36 ----- 4 files changed, 90 insertions(+), 97 deletions(-) delete mode 100644 domain/src/main/java/xyz/aprildown/timer/domain/utils/Base64BitmapConverter.kt diff --git a/component-key/src/main/java/xyz/aprildown/timer/component/key/ImageActionMapper.kt b/component-key/src/main/java/xyz/aprildown/timer/component/key/ImageActionMapper.kt index 54057918..72657ed6 100644 --- a/component-key/src/main/java/xyz/aprildown/timer/component/key/ImageActionMapper.kt +++ b/component-key/src/main/java/xyz/aprildown/timer/component/key/ImageActionMapper.kt @@ -5,19 +5,14 @@ import coil.map.Mapper import coil.request.Options import xyz.aprildown.timer.domain.entities.ImageAction import xyz.aprildown.timer.domain.entities.ResourceContentType -import xyz.aprildown.timer.domain.utils.Base64BitmapConverter.decodeBitmapByteArrayFromBase64 import java.io.File -import java.nio.ByteBuffer class ImageActionMapper : Mapper { - override fun map(data: ImageAction, options: Options): Any? { + override fun map(data: ImageAction, options: Options): Any { return when (data.type) { ResourceContentType.CanonicalPath -> File(data.data) ResourceContentType.RelativePath -> error("Relative path can't be loaded") ResourceContentType.Uri -> data.data.toUri() - ResourceContentType.Base64 -> { - ByteBuffer.wrap(data.data.decodeBitmapByteArrayFromBase64()) - } } } } diff --git a/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt b/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt index a797423b..b25396e1 100644 --- a/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt +++ b/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt @@ -1,7 +1,7 @@ package xyz.aprildown.timer.data.repositories +import android.content.ContentResolver import android.content.Context -import android.graphics.BitmapFactory import android.webkit.MimeTypeMap import androidx.core.net.toUri import dagger.Reusable @@ -21,8 +21,6 @@ import xyz.aprildown.timer.domain.entities.TimerInfo import xyz.aprildown.timer.domain.entities.inferResourcesContentType import xyz.aprildown.timer.domain.entities.toImageAction import xyz.aprildown.timer.domain.repositories.TimerRepository -import xyz.aprildown.timer.domain.utils.Base64BitmapConverter.decodeBitmapByteArrayFromBase64 -import xyz.aprildown.timer.domain.utils.Base64BitmapConverter.encodeToBase64 import xyz.aprildown.timer.domain.utils.ensureDirExistence import xyz.aprildown.timer.domain.utils.ensureNewFile import java.io.File @@ -39,7 +37,9 @@ internal class TimerRepositoryImpl @Inject constructor( private val imageManager by lazy { TimerImageManager(context) } override suspend fun items(): List { - return timerDao.getTimers().map { timerMapper.mapFrom(it).withImageCanonicalPath() } + return timerDao.getTimers().map { + timerMapper.mapFrom(it).withImageCanonicalPath() + } } override suspend fun item(id: Int): TimerEntity? { @@ -50,7 +50,13 @@ internal class TimerRepositoryImpl @Inject constructor( private fun TimerEntity.withImageCanonicalPath(): TimerEntity { return transformImageActions { data -> - ImageAction(data = data.convertImageDataToCanonicalPath(id)) + ImageAction( + data = data.convertImageData( + id = id, + imageManager = imageManager, + targetType = ResourceContentType.CanonicalPath, + ), + ) } } @@ -60,8 +66,13 @@ internal class TimerRepositoryImpl @Inject constructor( timerMapper.mapTo( item.transformImageActions { ImageAction( - data = imageManager.save(timerId = item.id, data = it) - .convertImageCanonicalPathToDataType(ResourceContentType.RelativePath) + data = imageManager + .save(id = item.id, data = it) + .convertImageData( + id = item.id, + imageManager = imageManager, + targetType = ResourceContentType.RelativePath, + ), ) } ) @@ -75,11 +86,14 @@ internal class TimerRepositoryImpl @Inject constructor( } override suspend fun save(item: TimerEntity): Boolean { - val savedItem = imageManager.trackSaving(timerId = item.id) { + val savedItem = imageManager.trackSaving(id = item.id) { item.transformImageActions { ImageAction( - data = save(data = it) - .convertImageCanonicalPathToDataType(ResourceContentType.RelativePath) + data = save(data = it).convertImageData( + id = item.id, + imageManager = imageManager, + targetType = ResourceContentType.RelativePath, + ), ) } } @@ -150,30 +164,18 @@ internal class TimerRepositoryImpl @Inject constructor( return timers.map { timer -> timer.transformImageActions { data -> ImageAction( - data = data.convertImageDataToCanonicalPath(timerId = timer.id) - .convertImageCanonicalPathToDataType(type = type), + data = data.convertImageData( + id = timer.id, + imageManager = imageManager, + targetType = type, + ) ) } } } - - private fun String.convertImageDataToCanonicalPath(timerId: Int): String { - return imageManager.save(timerId = timerId, data = this) - } - - private fun String.convertImageCanonicalPathToDataType(type: ResourceContentType): String { - return when (type) { - ResourceContentType.CanonicalPath -> this - ResourceContentType.RelativePath -> File(this).name - ResourceContentType.Uri -> error("Unsupported") - ResourceContentType.Base64 -> { - BitmapFactory.decodeFile(this).encodeToBase64(quality = 90) - } - } - } } -private class TimerImageManager(private val context: Context) { +private class TimerImageManager(context: Context) { interface TrackingScope { /** * [TimerImageManager.save] @@ -181,6 +183,8 @@ private class TimerImageManager(private val context: Context) { fun save(data: String): String } + val contentResolver: ContentResolver = context.contentResolver + private val imageFolder = File(context.filesDir, FOLDER) fun getTimerFolder(timerId: Int): File { @@ -190,10 +194,10 @@ private class TimerImageManager(private val context: Context) { /** * @return [ResourceContentType.RelativePath], [File.getCanonicalPath] */ - fun save(timerId: Int, data: String): String { + fun save(id: Int, data: String): String { imageFolder.ensureDirExistence() - val timerFolder = getTimerFolder(timerId) + val timerFolder = getTimerFolder(id) val imageFile: File when (data.inferResourcesContentType()) { ResourceContentType.CanonicalPath -> { @@ -202,8 +206,8 @@ private class TimerImageManager(private val context: Context) { imageFile = existingFile } else { imageFile = File( - imageFolder.ensureDirExistence(), - "${UUID.randomUUID()}.${existingFile.extension}" + timerFolder.ensureDirExistence(), + "${generateImageName()}.${existingFile.extension}" ).ensureNewFile() imageFile.inputStream().use { inputStream -> existingFile.outputStream().use { outputStream -> @@ -218,37 +222,32 @@ private class TimerImageManager(private val context: Context) { ResourceContentType.Uri -> { val uri = data.toUri() val extension = MimeTypeMap.getSingleton() - .getExtensionFromMimeType(context.contentResolver.getType(uri)) ?: "jpg" - imageFile = - File(timerFolder.ensureDirExistence(), "${UUID.randomUUID()}.$extension") - .ensureNewFile() - context.contentResolver.openInputStream(uri)?.use { inputStream -> + .getExtensionFromMimeType(contentResolver.getType(uri)) ?: "jpg" + imageFile = File( + timerFolder.ensureDirExistence(), + "${generateImageName()}.$extension" + ).ensureNewFile() + contentResolver.openInputStream(uri)?.use { inputStream -> imageFile.outputStream().use { outputStream -> inputStream.copyTo(outputStream) } } - require(imageFile.exists() && imageFile.length() > 0L) imageFile.canonicalPath } - ResourceContentType.Base64 -> { - imageFile = File(timerFolder.ensureDirExistence(), "${UUID.randomUUID()}.webp") - .ensureNewFile() - val byteArray = data.decodeBitmapByteArrayFromBase64() - imageFile.outputStream().use { - it.write(byteArray) - it.flush() - } - } } require(imageFile.exists() && imageFile.length() > 0L) return imageFile.canonicalPath } - fun trackSaving(timerId: Int, block: TrackingScope.() -> T): T { + private fun generateImageName(): String { + return UUID.randomUUID().toString() + } + + fun trackSaving(id: Int, block: TrackingScope.() -> T): T { val saved = mutableListOf() val scope = object : TrackingScope { override fun save(data: String): String { - val savedData = save(timerId = timerId, data = data) + val savedData = save(id = id, data = data) saved += savedData return savedData } @@ -256,7 +255,7 @@ private class TimerImageManager(private val context: Context) { return try { block(scope) } finally { - val timerFolder = getTimerFolder(timerId) + val timerFolder = getTimerFolder(id) if (timerFolder.exists()) { timerFolder.listFiles()?.toList()?.forEach { file -> if (file.canonicalPath !in saved) { @@ -267,8 +266,8 @@ private class TimerImageManager(private val context: Context) { } } - fun delete(timerId: Int) { - val timerFolder = getTimerFolder(timerId) + fun delete(id: Int) { + val timerFolder = getTimerFolder(id) if (timerFolder.exists()) { timerFolder.deleteRecursively() } @@ -278,3 +277,41 @@ private class TimerImageManager(private val context: Context) { private const val FOLDER = "timer_images" } } + +private fun String.convertImageData( + id: Int = TimerEntity.NULL_ID, + imageManager: TimerImageManager, + targetType: ResourceContentType, +): String { + fun String.save(): String { + return imageManager.save(id = id, data = this) + } + + fun String.convert(targetType: ResourceContentType): String { + return convertImageData(id = id, imageManager = imageManager, targetType) + } + + return when (inferResourcesContentType()) { + ResourceContentType.CanonicalPath -> { + when (targetType) { + ResourceContentType.CanonicalPath -> save() + ResourceContentType.RelativePath -> File(this).name + ResourceContentType.Uri -> error("Unable to convert CanonicalPath to Uri") + } + } + ResourceContentType.RelativePath -> { + when (targetType) { + ResourceContentType.CanonicalPath -> save() + ResourceContentType.RelativePath -> return this + ResourceContentType.Uri -> error("Unable to convert RelativePath to Uri") + } + } + ResourceContentType.Uri -> { + when (targetType) { + ResourceContentType.CanonicalPath -> save() + ResourceContentType.RelativePath -> save().convert(ResourceContentType.RelativePath) + ResourceContentType.Uri -> return this + } + } + } +} diff --git a/domain/src/main/java/xyz/aprildown/timer/domain/entities/TimerEntity.kt b/domain/src/main/java/xyz/aprildown/timer/domain/entities/TimerEntity.kt index 9769a57a..e576b338 100644 --- a/domain/src/main/java/xyz/aprildown/timer/domain/entities/TimerEntity.kt +++ b/domain/src/main/java/xyz/aprildown/timer/domain/entities/TimerEntity.kt @@ -2,7 +2,6 @@ package xyz.aprildown.timer.domain.entities import android.content.ContentResolver import okio.Path -import xyz.aprildown.timer.domain.utils.Base64BitmapConverter /** * Whenever you add a new more item, you need to check @@ -36,14 +35,12 @@ enum class ResourceContentType { CanonicalPath, RelativePath, Uri, - Base64; } fun String.inferResourcesContentType(): ResourceContentType { return when { startsWith(Path.DIRECTORY_SEPARATOR) -> ResourceContentType.CanonicalPath startsWith(ContentResolver.SCHEME_CONTENT) -> ResourceContentType.Uri - startsWith(Base64BitmapConverter.PREFIX) -> ResourceContentType.Base64 else -> ResourceContentType.RelativePath } } diff --git a/domain/src/main/java/xyz/aprildown/timer/domain/utils/Base64BitmapConverter.kt b/domain/src/main/java/xyz/aprildown/timer/domain/utils/Base64BitmapConverter.kt deleted file mode 100644 index 1b49952c..00000000 --- a/domain/src/main/java/xyz/aprildown/timer/domain/utils/Base64BitmapConverter.kt +++ /dev/null @@ -1,36 +0,0 @@ -package xyz.aprildown.timer.domain.utils - -import android.graphics.Bitmap -import android.os.Build -import okio.ByteString.Companion.decodeBase64 -import okio.ByteString.Companion.toByteString -import java.io.ByteArrayOutputStream - -object Base64BitmapConverter { - const val PREFIX = "data:image/png;base64," - - fun Bitmap.encodeToBase64(quality: Int = 100): String { - val outputStream = ByteArrayOutputStream() - compress( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Bitmap.CompressFormat.WEBP_LOSSY - } else { - @Suppress("DEPRECATION") - Bitmap.CompressFormat.WEBP - }, - quality, - outputStream - ) - val byteArray = outputStream.toByteArray() - return "$PREFIX${byteArray.toByteString().base64()}" - } - - fun String.decodeBitmapByteArrayFromBase64(): ByteArray { - return checkNotNull(substringAfter(PREFIX).decodeBase64()).toByteArray() - } - - // fun String.decodeBitmapFromBase64(): Bitmap { - // val data = decodeBitmapByteArrayFromBase64() - // return BitmapFactory.decodeByteArray(data, 0, data.size) - // } -} From 00ad47e5a674ed3e7d8778906dc3caed4f1b4680 Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Mon, 17 Jun 2024 21:27:50 +0800 Subject: [PATCH 21/31] Upgrade dependencies --- .../dogReleaseRuntimeClasspath.txt | 59 +++++++++++++++++-- .../googleReleaseRuntimeClasspath.txt | 59 +++++++++++++++++-- .../otherReleaseRuntimeClasspath.txt | 59 +++++++++++++++++-- gradle/libs.versions.toml | 4 +- 4 files changed, 164 insertions(+), 17 deletions(-) diff --git a/app/dependencies/dogReleaseRuntimeClasspath.txt b/app/dependencies/dogReleaseRuntimeClasspath.txt index f294910e..e12a1530 100644 --- a/app/dependencies/dogReleaseRuntimeClasspath.txt +++ b/app/dependencies/dogReleaseRuntimeClasspath.txt @@ -16,8 +16,9 @@ :data :domain :presentation -androidx.activity:activity-ktx:1.8.1 -androidx.activity:activity:1.8.1 +androidx.activity:activity-compose:1.9.0 +androidx.activity:activity-ktx:1.9.0 +androidx.activity:activity:1.9.0 androidx.annotation:annotation-experimental:1.4.0 androidx.annotation:annotation-jvm:1.8.0 androidx.annotation:annotation:1.8.0 @@ -25,12 +26,50 @@ androidx.appcompat:appcompat-resources:1.7.0 androidx.appcompat:appcompat:1.7.0 androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-runtime:2.2.0 +androidx.autofill:autofill:1.0.0 androidx.browser:browser:1.8.0 androidx.cardview:cardview:1.0.0 androidx.collection:collection-jvm:1.4.0 androidx.collection:collection-ktx:1.4.0 androidx.collection:collection:1.4.0 +androidx.compose.animation:animation-android:1.6.8 +androidx.compose.animation:animation-core-android:1.6.8 +androidx.compose.animation:animation-core:1.6.8 +androidx.compose.animation:animation:1.6.8 +androidx.compose.foundation:foundation-android:1.6.8 +androidx.compose.foundation:foundation-layout-android:1.6.8 +androidx.compose.foundation:foundation-layout:1.6.8 +androidx.compose.foundation:foundation:1.6.8 +androidx.compose.material3:material3-android:1.2.1 +androidx.compose.material3:material3:1.2.1 +androidx.compose.material:material-android:1.6.8 +androidx.compose.material:material-icons-core-android:1.6.8 +androidx.compose.material:material-icons-core:1.6.8 +androidx.compose.material:material-icons-extended-android:1.6.8 +androidx.compose.material:material-icons-extended:1.6.8 +androidx.compose.material:material-ripple-android:1.6.8 +androidx.compose.material:material-ripple:1.6.8 +androidx.compose.material:material:1.6.8 +androidx.compose.runtime:runtime-android:1.6.8 +androidx.compose.runtime:runtime-livedata:1.6.8 +androidx.compose.runtime:runtime-saveable-android:1.6.8 +androidx.compose.runtime:runtime-saveable:1.6.8 +androidx.compose.runtime:runtime:1.6.8 +androidx.compose.ui:ui-android:1.6.8 +androidx.compose.ui:ui-geometry-android:1.6.8 +androidx.compose.ui:ui-geometry:1.6.8 +androidx.compose.ui:ui-graphics-android:1.6.8 +androidx.compose.ui:ui-graphics:1.6.8 +androidx.compose.ui:ui-text-android:1.6.8 +androidx.compose.ui:ui-text:1.6.8 +androidx.compose.ui:ui-unit-android:1.6.8 +androidx.compose.ui:ui-unit:1.6.8 +androidx.compose.ui:ui-util-android:1.6.8 +androidx.compose.ui:ui-util:1.6.8 +androidx.compose.ui:ui:1.6.8 +androidx.compose:compose-bom:2024.06.00 androidx.concurrent:concurrent-futures:1.1.0 +androidx.constraintlayout:constraintlayout-compose:1.0.1 androidx.constraintlayout:constraintlayout-core:1.0.4 androidx.constraintlayout:constraintlayout:2.1.4 androidx.coordinatorlayout:coordinatorlayout:1.1.0 @@ -45,12 +84,14 @@ androidx.drawerlayout:drawerlayout:1.1.1 androidx.dynamicanimation:dynamicanimation:1.0.0 androidx.emoji2:emoji2-views-helper:1.3.0 androidx.emoji2:emoji2:1.3.0 +androidx.exifinterface:exifinterface:1.3.7 androidx.fragment:fragment-ktx:1.8.0 androidx.fragment:fragment:1.8.0 androidx.hilt:hilt-common:1.2.0 androidx.hilt:hilt-work:1.2.0 androidx.interpolator:interpolator:1.0.0 androidx.legacy:legacy-support-core-utils:1.0.0 +androidx.lifecycle:lifecycle-common-java8:2.8.2 androidx.lifecycle:lifecycle-common-jvm:2.8.2 androidx.lifecycle:lifecycle-common:2.8.2 androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.2 @@ -115,6 +156,7 @@ com.github.PhilJay:MPAndroidChart:v3.1.0 com.github.bumptech.glide:disklrucache:4.16.0 com.github.kizitonwose:CalendarView:1.0.4 com.github.zawadz88.materialpopupmenu:material-popup-menu:4.1.0 +com.google.accompanist:accompanist-drawablepainter:0.32.0 com.google.android.flexbox:flexbox:3.0.0 com.google.android.material:material:1.4.0 com.google.code.findbugs:jsr305:3.0.2 @@ -132,16 +174,23 @@ com.mikepenz:materialdrawer:8.4.5 com.mikepenz:materialize:1.2.1 com.nex3z:toggle-button-group:1.2.3 com.squareup.moshi:moshi:1.15.1 +com.squareup.okhttp3:okhttp:4.12.0 com.squareup.okio:okio-jvm:3.9.0 com.squareup.okio:okio:3.9.0 +io.coil-kt:coil-base:2.6.0 +io.coil-kt:coil-compose-base:2.6.0 +io.coil-kt:coil-compose:2.6.0 +io.coil-kt:coil-gif:2.6.0 +io.coil-kt:coil:2.6.0 javax.inject:javax.inject:1 me.zhanghai.android.materialprogressbar:library:1.4.2 -net.engawapg.lib:zoomable:1.6.0 +net.engawapg.lib:zoomable:1.6.1 nl.dionsegijn:konfetti:1.3.2 org.jetbrains.kotlin:kotlin-android-extensions-runtime:1.9.24 org.jetbrains.kotlin:kotlin-parcelize-runtime:1.9.24 -org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22 -org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0 org.jetbrains.kotlin:kotlin-stdlib:1.9.24 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1 diff --git a/app/dependencies/googleReleaseRuntimeClasspath.txt b/app/dependencies/googleReleaseRuntimeClasspath.txt index 189eec96..1fc58c2d 100644 --- a/app/dependencies/googleReleaseRuntimeClasspath.txt +++ b/app/dependencies/googleReleaseRuntimeClasspath.txt @@ -17,8 +17,9 @@ :domain :flavor-google :presentation -androidx.activity:activity-ktx:1.8.1 -androidx.activity:activity:1.8.1 +androidx.activity:activity-compose:1.9.0 +androidx.activity:activity-ktx:1.9.0 +androidx.activity:activity:1.9.0 androidx.annotation:annotation-experimental:1.4.0 androidx.annotation:annotation-jvm:1.8.0 androidx.annotation:annotation:1.8.0 @@ -27,12 +28,50 @@ androidx.appcompat:appcompat:1.7.0 androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-runtime:2.2.0 androidx.asynclayoutinflater:asynclayoutinflater:1.0.0 +androidx.autofill:autofill:1.0.0 androidx.browser:browser:1.8.0 androidx.cardview:cardview:1.0.0 androidx.collection:collection-jvm:1.4.0 androidx.collection:collection-ktx:1.4.0 androidx.collection:collection:1.4.0 +androidx.compose.animation:animation-android:1.6.8 +androidx.compose.animation:animation-core-android:1.6.8 +androidx.compose.animation:animation-core:1.6.8 +androidx.compose.animation:animation:1.6.8 +androidx.compose.foundation:foundation-android:1.6.8 +androidx.compose.foundation:foundation-layout-android:1.6.8 +androidx.compose.foundation:foundation-layout:1.6.8 +androidx.compose.foundation:foundation:1.6.8 +androidx.compose.material3:material3-android:1.2.1 +androidx.compose.material3:material3:1.2.1 +androidx.compose.material:material-android:1.6.8 +androidx.compose.material:material-icons-core-android:1.6.8 +androidx.compose.material:material-icons-core:1.6.8 +androidx.compose.material:material-icons-extended-android:1.6.8 +androidx.compose.material:material-icons-extended:1.6.8 +androidx.compose.material:material-ripple-android:1.6.8 +androidx.compose.material:material-ripple:1.6.8 +androidx.compose.material:material:1.6.8 +androidx.compose.runtime:runtime-android:1.6.8 +androidx.compose.runtime:runtime-livedata:1.6.8 +androidx.compose.runtime:runtime-saveable-android:1.6.8 +androidx.compose.runtime:runtime-saveable:1.6.8 +androidx.compose.runtime:runtime:1.6.8 +androidx.compose.ui:ui-android:1.6.8 +androidx.compose.ui:ui-geometry-android:1.6.8 +androidx.compose.ui:ui-geometry:1.6.8 +androidx.compose.ui:ui-graphics-android:1.6.8 +androidx.compose.ui:ui-graphics:1.6.8 +androidx.compose.ui:ui-text-android:1.6.8 +androidx.compose.ui:ui-text:1.6.8 +androidx.compose.ui:ui-unit-android:1.6.8 +androidx.compose.ui:ui-unit:1.6.8 +androidx.compose.ui:ui-util-android:1.6.8 +androidx.compose.ui:ui-util:1.6.8 +androidx.compose.ui:ui:1.6.8 +androidx.compose:compose-bom:2024.06.00 androidx.concurrent:concurrent-futures:1.1.0 +androidx.constraintlayout:constraintlayout-compose:1.0.1 androidx.constraintlayout:constraintlayout-core:1.0.4 androidx.constraintlayout:constraintlayout:2.1.4 androidx.coordinatorlayout:coordinatorlayout:1.1.0 @@ -49,6 +88,7 @@ androidx.drawerlayout:drawerlayout:1.1.1 androidx.dynamicanimation:dynamicanimation:1.0.0 androidx.emoji2:emoji2-views-helper:1.3.0 androidx.emoji2:emoji2:1.3.0 +androidx.exifinterface:exifinterface:1.3.7 androidx.fragment:fragment-ktx:1.8.0 androidx.fragment:fragment:1.8.0 androidx.hilt:hilt-common:1.2.0 @@ -59,6 +99,7 @@ androidx.interpolator:interpolator:1.0.0 androidx.legacy:legacy-support-core-ui:1.0.0 androidx.legacy:legacy-support-core-utils:1.0.0 androidx.legacy:legacy-support-v4:1.0.0 +androidx.lifecycle:lifecycle-common-java8:2.8.2 androidx.lifecycle:lifecycle-common-jvm:2.8.2 androidx.lifecycle:lifecycle-common:2.8.2 androidx.lifecycle:lifecycle-extensions:2.2.0 @@ -127,6 +168,7 @@ com.github.PhilJay:MPAndroidChart:v3.1.0 com.github.bumptech.glide:disklrucache:4.16.0 com.github.kizitonwose:CalendarView:1.0.4 com.github.zawadz88.materialpopupmenu:material-popup-menu:4.1.0 +com.google.accompanist:accompanist-drawablepainter:0.32.0 com.google.android.datatransport:transport-api:3.0.0 com.google.android.datatransport:transport-backend-cct:3.1.8 com.google.android.datatransport:transport-runtime:3.1.8 @@ -178,17 +220,24 @@ com.mikepenz:materialdrawer:8.4.5 com.mikepenz:materialize:1.2.1 com.nex3z:toggle-button-group:1.2.3 com.squareup.moshi:moshi:1.15.1 +com.squareup.okhttp3:okhttp:4.12.0 com.squareup.okio:okio-jvm:3.9.0 com.squareup.okio:okio:3.9.0 +io.coil-kt:coil-base:2.6.0 +io.coil-kt:coil-compose-base:2.6.0 +io.coil-kt:coil-compose:2.6.0 +io.coil-kt:coil-gif:2.6.0 +io.coil-kt:coil:2.6.0 javax.inject:javax.inject:1 me.zhanghai.android.materialprogressbar:library:1.4.2 -net.engawapg.lib:zoomable:1.6.0 +net.engawapg.lib:zoomable:1.6.1 net.lingala.zip4j:zip4j:2.11.5 nl.dionsegijn:konfetti:1.3.2 org.jetbrains.kotlin:kotlin-android-extensions-runtime:1.9.24 org.jetbrains.kotlin:kotlin-parcelize-runtime:1.9.24 -org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22 -org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0 org.jetbrains.kotlin:kotlin-stdlib:1.9.24 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1 diff --git a/app/dependencies/otherReleaseRuntimeClasspath.txt b/app/dependencies/otherReleaseRuntimeClasspath.txt index f294910e..e12a1530 100644 --- a/app/dependencies/otherReleaseRuntimeClasspath.txt +++ b/app/dependencies/otherReleaseRuntimeClasspath.txt @@ -16,8 +16,9 @@ :data :domain :presentation -androidx.activity:activity-ktx:1.8.1 -androidx.activity:activity:1.8.1 +androidx.activity:activity-compose:1.9.0 +androidx.activity:activity-ktx:1.9.0 +androidx.activity:activity:1.9.0 androidx.annotation:annotation-experimental:1.4.0 androidx.annotation:annotation-jvm:1.8.0 androidx.annotation:annotation:1.8.0 @@ -25,12 +26,50 @@ androidx.appcompat:appcompat-resources:1.7.0 androidx.appcompat:appcompat:1.7.0 androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-runtime:2.2.0 +androidx.autofill:autofill:1.0.0 androidx.browser:browser:1.8.0 androidx.cardview:cardview:1.0.0 androidx.collection:collection-jvm:1.4.0 androidx.collection:collection-ktx:1.4.0 androidx.collection:collection:1.4.0 +androidx.compose.animation:animation-android:1.6.8 +androidx.compose.animation:animation-core-android:1.6.8 +androidx.compose.animation:animation-core:1.6.8 +androidx.compose.animation:animation:1.6.8 +androidx.compose.foundation:foundation-android:1.6.8 +androidx.compose.foundation:foundation-layout-android:1.6.8 +androidx.compose.foundation:foundation-layout:1.6.8 +androidx.compose.foundation:foundation:1.6.8 +androidx.compose.material3:material3-android:1.2.1 +androidx.compose.material3:material3:1.2.1 +androidx.compose.material:material-android:1.6.8 +androidx.compose.material:material-icons-core-android:1.6.8 +androidx.compose.material:material-icons-core:1.6.8 +androidx.compose.material:material-icons-extended-android:1.6.8 +androidx.compose.material:material-icons-extended:1.6.8 +androidx.compose.material:material-ripple-android:1.6.8 +androidx.compose.material:material-ripple:1.6.8 +androidx.compose.material:material:1.6.8 +androidx.compose.runtime:runtime-android:1.6.8 +androidx.compose.runtime:runtime-livedata:1.6.8 +androidx.compose.runtime:runtime-saveable-android:1.6.8 +androidx.compose.runtime:runtime-saveable:1.6.8 +androidx.compose.runtime:runtime:1.6.8 +androidx.compose.ui:ui-android:1.6.8 +androidx.compose.ui:ui-geometry-android:1.6.8 +androidx.compose.ui:ui-geometry:1.6.8 +androidx.compose.ui:ui-graphics-android:1.6.8 +androidx.compose.ui:ui-graphics:1.6.8 +androidx.compose.ui:ui-text-android:1.6.8 +androidx.compose.ui:ui-text:1.6.8 +androidx.compose.ui:ui-unit-android:1.6.8 +androidx.compose.ui:ui-unit:1.6.8 +androidx.compose.ui:ui-util-android:1.6.8 +androidx.compose.ui:ui-util:1.6.8 +androidx.compose.ui:ui:1.6.8 +androidx.compose:compose-bom:2024.06.00 androidx.concurrent:concurrent-futures:1.1.0 +androidx.constraintlayout:constraintlayout-compose:1.0.1 androidx.constraintlayout:constraintlayout-core:1.0.4 androidx.constraintlayout:constraintlayout:2.1.4 androidx.coordinatorlayout:coordinatorlayout:1.1.0 @@ -45,12 +84,14 @@ androidx.drawerlayout:drawerlayout:1.1.1 androidx.dynamicanimation:dynamicanimation:1.0.0 androidx.emoji2:emoji2-views-helper:1.3.0 androidx.emoji2:emoji2:1.3.0 +androidx.exifinterface:exifinterface:1.3.7 androidx.fragment:fragment-ktx:1.8.0 androidx.fragment:fragment:1.8.0 androidx.hilt:hilt-common:1.2.0 androidx.hilt:hilt-work:1.2.0 androidx.interpolator:interpolator:1.0.0 androidx.legacy:legacy-support-core-utils:1.0.0 +androidx.lifecycle:lifecycle-common-java8:2.8.2 androidx.lifecycle:lifecycle-common-jvm:2.8.2 androidx.lifecycle:lifecycle-common:2.8.2 androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.2 @@ -115,6 +156,7 @@ com.github.PhilJay:MPAndroidChart:v3.1.0 com.github.bumptech.glide:disklrucache:4.16.0 com.github.kizitonwose:CalendarView:1.0.4 com.github.zawadz88.materialpopupmenu:material-popup-menu:4.1.0 +com.google.accompanist:accompanist-drawablepainter:0.32.0 com.google.android.flexbox:flexbox:3.0.0 com.google.android.material:material:1.4.0 com.google.code.findbugs:jsr305:3.0.2 @@ -132,16 +174,23 @@ com.mikepenz:materialdrawer:8.4.5 com.mikepenz:materialize:1.2.1 com.nex3z:toggle-button-group:1.2.3 com.squareup.moshi:moshi:1.15.1 +com.squareup.okhttp3:okhttp:4.12.0 com.squareup.okio:okio-jvm:3.9.0 com.squareup.okio:okio:3.9.0 +io.coil-kt:coil-base:2.6.0 +io.coil-kt:coil-compose-base:2.6.0 +io.coil-kt:coil-compose:2.6.0 +io.coil-kt:coil-gif:2.6.0 +io.coil-kt:coil:2.6.0 javax.inject:javax.inject:1 me.zhanghai.android.materialprogressbar:library:1.4.2 -net.engawapg.lib:zoomable:1.6.0 +net.engawapg.lib:zoomable:1.6.1 nl.dionsegijn:konfetti:1.3.2 org.jetbrains.kotlin:kotlin-android-extensions-runtime:1.9.24 org.jetbrains.kotlin:kotlin-parcelize-runtime:1.9.24 -org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22 -org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0 org.jetbrains.kotlin:kotlin-stdlib:1.9.24 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f1e107b8..4be5ebc4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -80,10 +80,10 @@ androidx-navigation-ui = "androidx.navigation:navigation-ui-ktx:2.7.7" androidx-work = "androidx.work:work-runtime-ktx:2.9.0" -compose-bom = "androidx.compose:compose-bom:2023.06.01" +compose-bom = "androidx.compose:compose-bom:2024.06.00" compose-material = { module = "androidx.compose.material3:material3" } compose-icons = { module = "androidx.compose.material:material-icons-extended" } -compose-activity = "androidx.activity:activity-compose:1.8.2" +compose-activity = "androidx.activity:activity-compose:1.9.0" compose-liveData = { module = "androidx.compose.runtime:runtime-livedata" } compose-constraintLayout = "androidx.constraintlayout:constraintlayout-compose:1.0.1" From 9bf6d20a2bd7ed9f35420c8cfc05b79fcf7d82f8 Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Mon, 17 Jun 2024 21:45:59 +0800 Subject: [PATCH 22/31] Disable the timer image manager to simplify the feature --- .../data/repositories/TimerRepositoryImpl.kt | 77 +++++++++---------- 1 file changed, 38 insertions(+), 39 deletions(-) diff --git a/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt b/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt index b25396e1..89a2d54c 100644 --- a/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt +++ b/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt @@ -12,14 +12,11 @@ import xyz.aprildown.timer.data.db.TimerDao import xyz.aprildown.timer.data.mappers.TimerInfoMapper import xyz.aprildown.timer.data.mappers.TimerMapper import xyz.aprildown.timer.data.mappers.fromWithMapper -import xyz.aprildown.timer.domain.entities.BehaviourType import xyz.aprildown.timer.domain.entities.ImageAction import xyz.aprildown.timer.domain.entities.ResourceContentType -import xyz.aprildown.timer.domain.entities.StepEntity import xyz.aprildown.timer.domain.entities.TimerEntity import xyz.aprildown.timer.domain.entities.TimerInfo import xyz.aprildown.timer.domain.entities.inferResourcesContentType -import xyz.aprildown.timer.domain.entities.toImageAction import xyz.aprildown.timer.domain.repositories.TimerRepository import xyz.aprildown.timer.domain.utils.ensureDirExistence import xyz.aprildown.timer.domain.utils.ensureNewFile @@ -49,15 +46,16 @@ internal class TimerRepositoryImpl @Inject constructor( } private fun TimerEntity.withImageCanonicalPath(): TimerEntity { - return transformImageActions { data -> - ImageAction( - data = data.convertImageData( - id = id, - imageManager = imageManager, - targetType = ResourceContentType.CanonicalPath, - ), - ) - } + return this + // return transformImageActions { data -> + // ImageAction( + // data = data.convertImageData( + // id = id, + // imageManager = imageManager, + // targetType = ResourceContentType.CanonicalPath, + // ), + // ) + // } } override suspend fun add(item: TimerEntity): Int { @@ -103,33 +101,34 @@ internal class TimerRepositoryImpl @Inject constructor( private fun TimerEntity.transformImageActions( transform: (data: String) -> ImageAction ): TimerEntity { - fun StepEntity.transformStep(): StepEntity { - return when (this) { - is StepEntity.Step -> { - if (behaviour.any { it.type == BehaviourType.IMAGE }) { - copy( - behaviour = behaviour.map { - if (it.type == BehaviourType.IMAGE) { - transform(it.toImageAction().data).toBehaviourEntity() - } else { - it - } - } - ) - } else { - this - } - } - is StepEntity.Group -> { - copy(steps = steps.map { it.transformStep() }) - } - } - } - return copy( - steps = steps.map { it.transformStep() }, - startStep = startStep?.transformStep() as? StepEntity.Step, - endStep = endStep?.transformStep() as? StepEntity.Step, - ) + return this + // fun StepEntity.transformStep(): StepEntity { + // return when (this) { + // is StepEntity.Step -> { + // if (behaviour.any { it.type == BehaviourType.IMAGE }) { + // copy( + // behaviour = behaviour.map { + // if (it.type == BehaviourType.IMAGE) { + // transform(it.toImageAction().data).toBehaviourEntity() + // } else { + // it + // } + // } + // ) + // } else { + // this + // } + // } + // is StepEntity.Group -> { + // copy(steps = steps.map { it.transformStep() }) + // } + // } + // } + // return copy( + // steps = steps.map { it.transformStep() }, + // startStep = startStep?.transformStep() as? StepEntity.Step, + // endStep = endStep?.transformStep() as? StepEntity.Step, + // ) } override suspend fun delete(id: Int) { From 1bd8ccfd7d1e0b2874a4db0a400e5fdd644d7b83 Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Tue, 18 Jun 2024 20:28:02 +0800 Subject: [PATCH 23/31] Create the theme for Jetpack Compose --- .../timer/app/backup/BackupComposables.kt | 123 +++++++++--------- .../timer/app/backup/Export2Fragment.kt | 21 +-- .../timer/app/backup/Import2Fragment.kt | 35 ++--- app-base/build.gradle | 10 ++ .../aprildown/timer/app/base/ui/AppTheme.kt | 108 +++++++++++++++ .../dogReleaseRuntimeClasspath.txt | 4 + .../googleReleaseRuntimeClasspath.txt | 4 + .../otherReleaseRuntimeClasspath.txt | 4 + gradle/libs.versions.toml | 1 + 9 files changed, 223 insertions(+), 87 deletions(-) create mode 100644 app-base/src/main/java/xyz/aprildown/timer/app/base/ui/AppTheme.kt diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BackupComposables.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BackupComposables.kt index 6eaa287c..9a6f7649 100644 --- a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BackupComposables.kt +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/BackupComposables.kt @@ -44,72 +44,70 @@ internal fun Backup( modifier: Modifier = Modifier, extraOptions: @Composable () -> Unit = {}, ) { - MaterialTheme { - Column( - modifier = modifier - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Header(text = contentLocationTitle) + + ContentLocation( + title = contentLocationButtonText, + location = screen.contentName, + modifier = Modifier.clickable(onClick = onChangeContentLocation), + ) + + Header(text = stringResource(id = RBase.string.backup_app_data)) + + extraOptions() + + DataEntry( + iconRes = RBase.drawable.ic_timer, + nameRes = RBase.string.main_action_timers, + include = screen.includeTimers, + onIncludedChange = screen.onTimersChange, + ) + DataEntry( + iconRes = RBase.drawable.ic_scheduler, + nameRes = RBase.string.main_action_schedulers, + include = screen.includeSchedulers, + onIncludedChange = screen.onSchedulersChange, + ) + DataEntry( + iconRes = RBase.drawable.ic_stat, + nameRes = RBase.string.main_action_record, + include = screen.includeRecords, + onIncludedChange = screen.onRecordsChange, + ) + DataEntry( + iconRes = RBase.drawable.ic_settings, + nameRes = RBase.string.main_action_settings, + include = screen.includeSettings, + onIncludedChange = screen.onSettingsChange, + ) + + Button( + onClick = screen.onBackup, + modifier = Modifier.align(Alignment.CenterHorizontally), + enabled = screen.content != null && + (screen.includeTimers || screen.includeSettings) && + !screen.backupOngoing, ) { - Header(text = contentLocationTitle) - - ContentLocation( - title = contentLocationButtonText, - location = screen.contentName, - modifier = Modifier.clickable(onClick = onChangeContentLocation), - ) - - Header(text = stringResource(id = RBase.string.backup_app_data)) - - extraOptions() - - DataEntry( - iconRes = RBase.drawable.ic_timer, - nameRes = RBase.string.main_action_timers, - include = screen.includeTimers, - onIncludedChange = screen.onTimersChange, - ) - DataEntry( - iconRes = RBase.drawable.ic_scheduler, - nameRes = RBase.string.main_action_schedulers, - include = screen.includeSchedulers, - onIncludedChange = screen.onSchedulersChange, - ) - DataEntry( - iconRes = RBase.drawable.ic_stat, - nameRes = RBase.string.main_action_record, - include = screen.includeRecords, - onIncludedChange = screen.onRecordsChange, - ) - DataEntry( - iconRes = RBase.drawable.ic_settings, - nameRes = RBase.string.main_action_settings, - include = screen.includeSettings, - onIncludedChange = screen.onSettingsChange, - ) - - Button( - onClick = screen.onBackup, - modifier = Modifier.align(Alignment.CenterHorizontally), - enabled = screen.content != null && - (screen.includeTimers || screen.includeSettings) && - !screen.backupOngoing, - ) { - Text(text = backupButtonText) - } + Text(text = backupButtonText) } + } - if (screen.backupResult?.fruit is Fruit.Rotten) { - BackupError( - hint = backupErrorHint, - message = (screen.backupResult.fruit as Fruit.Rotten).exception.message.toString(), - consume = screen.consumeBackupError, - ) - } + if (screen.backupResult?.fruit is Fruit.Rotten) { + BackupError( + hint = backupErrorHint, + message = (screen.backupResult.fruit as Fruit.Rotten).exception.message.toString(), + consume = screen.consumeBackupError, + ) + } - if (screen.backupOngoing) { - BackupOngoingDialog() - } + if (screen.backupOngoing) { + BackupOngoingDialog() } } @@ -121,6 +119,7 @@ private fun Header( Text( text = text, modifier = modifier.padding(horizontal = 12.dp, vertical = 12.dp), + color = MaterialTheme.colorScheme.onBackground, style = MaterialTheme.typography.titleSmall, ) } diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt index 5acd6f7c..5d192e9a 100644 --- a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt @@ -27,6 +27,7 @@ import com.github.deweyreed.tools.helper.requireCallback import dagger.hilt.android.AndroidEntryPoint import okio.sink import xyz.aprildown.timer.app.base.data.PreferenceData.lastBackupUri +import xyz.aprildown.timer.app.base.ui.AppTheme import xyz.aprildown.timer.app.base.ui.MainCallback import xyz.aprildown.timer.domain.usecases.Fruit import xyz.aprildown.timer.domain.utils.AppTracker @@ -135,13 +136,15 @@ private fun Export( onLocationChange: () -> Unit, modifier: Modifier = Modifier, ) { - Backup( - screen = screen, - contentLocationTitle = stringResource(id = RBase.string.export_path_title), - contentLocationButtonText = stringResource(id = RBase.string.export_select_location), - onChangeContentLocation = onLocationChange, - backupButtonText = stringResource(id = RBase.string.export_action), - backupErrorHint = stringResource(id = RBase.string.export_error), - modifier = modifier, - ) + AppTheme { + Backup( + screen = screen, + contentLocationTitle = stringResource(id = RBase.string.export_path_title), + contentLocationButtonText = stringResource(id = RBase.string.export_select_location), + onChangeContentLocation = onLocationChange, + backupButtonText = stringResource(id = RBase.string.export_action), + backupErrorHint = stringResource(id = RBase.string.export_error), + modifier = modifier, + ) + } } diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2Fragment.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2Fragment.kt index 59e0f6b3..100c272e 100644 --- a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2Fragment.kt +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2Fragment.kt @@ -38,6 +38,7 @@ import dagger.hilt.android.AndroidEntryPoint import okio.source import xyz.aprildown.timer.app.base.data.PreferenceData.lastBackupUri import xyz.aprildown.timer.app.base.ui.AppNavigator +import xyz.aprildown.timer.app.base.ui.AppTheme import xyz.aprildown.timer.app.base.ui.MainCallback import xyz.aprildown.timer.domain.usecases.Fruit import xyz.aprildown.timer.domain.utils.AppTracker @@ -137,22 +138,24 @@ private fun Import( onLocationChange: () -> Unit, modifier: Modifier = Modifier, ) { - Backup( - screen = screen, - contentLocationTitle = stringResource(id = RBase.string.import_path_title), - contentLocationButtonText = stringResource(id = RBase.string.import_select_location), - onChangeContentLocation = onLocationChange, - backupButtonText = stringResource(id = RBase.string.import_action), - backupErrorHint = stringResource(id = RBase.string.import_error), - modifier = modifier, - extraOptions = { - WipeContent( - wipe = importScreen.wipe, - onWipeChanged = importScreen.onWipeChanged, - modifier = Modifier.fillMaxSize(), - ) - }, - ) + AppTheme { + Backup( + screen = screen, + contentLocationTitle = stringResource(id = RBase.string.import_path_title), + contentLocationButtonText = stringResource(id = RBase.string.import_select_location), + onChangeContentLocation = onLocationChange, + backupButtonText = stringResource(id = RBase.string.import_action), + backupErrorHint = stringResource(id = RBase.string.import_error), + modifier = modifier, + extraOptions = { + WipeContent( + wipe = importScreen.wipe, + onWipeChanged = importScreen.onWipeChanged, + modifier = Modifier.fillMaxSize(), + ) + }, + ) + } } @Composable diff --git a/app-base/build.gradle b/app-base/build.gradle index b2b1211e..84694eda 100644 --- a/app-base/build.gradle +++ b/app-base/build.gradle @@ -7,6 +7,12 @@ plugins { android { namespace 'xyz.aprildown.timer.app.base' + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() + } } dependencies { @@ -25,6 +31,10 @@ dependencies { api libs.androidx.navigation.fragment api libs.androidx.navigation.ui + implementation platform(libs.compose.bom) + implementation libs.bundles.compose + implementation libs.materialColorUtilities + implementation libs.hilt.android kapt libs.hilt.compiler diff --git a/app-base/src/main/java/xyz/aprildown/timer/app/base/ui/AppTheme.kt b/app-base/src/main/java/xyz/aprildown/timer/app/base/ui/AppTheme.kt new file mode 100644 index 00000000..c7a8d48b --- /dev/null +++ b/app-base/src/main/java/xyz/aprildown/timer/app/base/ui/AppTheme.kt @@ -0,0 +1,108 @@ +package xyz.aprildown.timer.app.base.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import com.materialkolor.palettes.CorePalette + +@Composable +fun AppTheme( + isDarkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val dynamicTheme = remember { newDynamicTheme } + MaterialTheme( + colorScheme = remember( + isDarkTheme, + dynamicTheme.colorPrimary, + dynamicTheme.colorSecondary, + ) { + val core = CorePalette.of(dynamicTheme.colorPrimary) + val secondary = CorePalette.of(dynamicTheme.colorSecondary) + if (isDarkTheme) { + darkFromCorePalette(core = core, secondary = secondary) + } else { + lightFromCorePalette(core = core, secondary = secondary) + } + }, + content = content, + ) +} + +/** [com.materialkolor.scheme.Scheme] + * [androidx.compose.material3.lightColorScheme] + * */ +private fun lightFromCorePalette(core: CorePalette, secondary: CorePalette = core): ColorScheme { + return ColorScheme( + primary = Color(core.a1.tone(40)), + onPrimary = Color(core.a1.tone(100)), + primaryContainer = Color(core.a1.tone(90)), + onPrimaryContainer = Color(core.a1.tone(10)), + inversePrimary = Color(core.a1.tone(80)), + + secondary = Color(secondary.a1.tone(40)), + onSecondary = Color(secondary.a1.tone(100)), + secondaryContainer = Color(secondary.a1.tone(90)), + onSecondaryContainer = Color(secondary.a1.tone(10)), + + tertiary = Color(core.a3.tone(40)), + onTertiary = Color(core.a3.tone(100)), + tertiaryContainer = Color(core.a3.tone(90)), + onTertiaryContainer = Color(core.a3.tone(10)), + background = Color(core.n1.tone(99)), + onBackground = Color(core.n1.tone(10)), + surface = Color(core.n1.tone(99)), + onSurface = Color(core.n1.tone(10)), + surfaceVariant = Color(core.n2.tone(90)), + onSurfaceVariant = Color(core.n2.tone(30)), + surfaceTint = Color(core.a1.tone(40)), + inverseSurface = Color(core.n1.tone(20)), + inverseOnSurface = Color(core.n1.tone(95)), + error = Color(core.error.tone(40)), + onError = Color(core.error.tone(100)), + errorContainer = Color(core.error.tone(90)), + onErrorContainer = Color(core.error.tone(10)), + outline = Color(core.n2.tone(50)), + outlineVariant = Color(core.n2.tone(80)), + scrim = Color(core.n1.tone(0)), + ) +} + +private fun darkFromCorePalette(core: CorePalette, secondary: CorePalette = core): ColorScheme { + return ColorScheme( + primary = Color(core.a1.tone(80)), + onPrimary = Color(core.a1.tone(20)), + primaryContainer = Color(core.a1.tone(30)), + onPrimaryContainer = Color(core.a1.tone(90)), + inversePrimary = Color(core.a1.tone(40)), + + secondary = Color(secondary.a1.tone(80)), + onSecondary = Color(secondary.a1.tone(20)), + secondaryContainer = Color(secondary.a1.tone(30)), + onSecondaryContainer = Color(secondary.a1.tone(90)), + + tertiary = Color(core.a3.tone(80)), + onTertiary = Color(core.a3.tone(20)), + tertiaryContainer = Color(core.a3.tone(30)), + onTertiaryContainer = Color(core.a3.tone(90)), + background = Color(core.n1.tone(10)), + onBackground = Color(core.n1.tone(90)), + surface = Color(core.n1.tone(10)), + onSurface = Color(core.n1.tone(90)), + surfaceVariant = Color(core.n2.tone(30)), + onSurfaceVariant = Color(core.n2.tone(80)), + surfaceTint = Color(core.a1.tone(80)), + inverseSurface = Color(core.n1.tone(90)), + inverseOnSurface = Color(core.n1.tone(20)), + error = Color(core.error.tone(80)), + onError = Color(core.error.tone(20)), + errorContainer = Color(core.error.tone(30)), + onErrorContainer = Color(core.error.tone(80)), + outline = Color(core.n2.tone(60)), + outlineVariant = Color(core.n2.tone(30)), + scrim = Color(core.n1.tone(0)), + ) +} diff --git a/app/dependencies/dogReleaseRuntimeClasspath.txt b/app/dependencies/dogReleaseRuntimeClasspath.txt index e12a1530..04a35e8f 100644 --- a/app/dependencies/dogReleaseRuntimeClasspath.txt +++ b/app/dependencies/dogReleaseRuntimeClasspath.txt @@ -167,6 +167,8 @@ com.google.dagger:hilt-core:2.51.1 com.google.guava:listenablefuture:1.0 com.jakewharton.timber:timber:5.0.1 com.joaomgcd:taskerpluginlibrary:0.4.10 +com.materialkolor:material-color-utilities-android:1.5.1 +com.materialkolor:material-color-utilities:1.5.1 com.mikepenz:fastadapter-extensions-binding:5.7.0 com.mikepenz:fastadapter-extensions-expandable:5.7.0 com.mikepenz:fastadapter:5.7.0 @@ -177,6 +179,8 @@ com.squareup.moshi:moshi:1.15.1 com.squareup.okhttp3:okhttp:4.12.0 com.squareup.okio:okio-jvm:3.9.0 com.squareup.okio:okio:3.9.0 +dev.drewhamilton.poko:poko-annotations-jvm:0.15.2 +dev.drewhamilton.poko:poko-annotations:0.15.2 io.coil-kt:coil-base:2.6.0 io.coil-kt:coil-compose-base:2.6.0 io.coil-kt:coil-compose:2.6.0 diff --git a/app/dependencies/googleReleaseRuntimeClasspath.txt b/app/dependencies/googleReleaseRuntimeClasspath.txt index 1fc58c2d..05e113d8 100644 --- a/app/dependencies/googleReleaseRuntimeClasspath.txt +++ b/app/dependencies/googleReleaseRuntimeClasspath.txt @@ -211,6 +211,8 @@ com.google.firebase:firebase-storage:21.0.0 com.google.guava:listenablefuture:1.0 com.jakewharton.timber:timber:5.0.1 com.joaomgcd:taskerpluginlibrary:0.4.10 +com.materialkolor:material-color-utilities-android:1.5.1 +com.materialkolor:material-color-utilities:1.5.1 com.microsoft.appcenter:appcenter-crashes:5.0.4 com.microsoft.appcenter:appcenter:5.0.4 com.mikepenz:fastadapter-extensions-binding:5.7.0 @@ -223,6 +225,8 @@ com.squareup.moshi:moshi:1.15.1 com.squareup.okhttp3:okhttp:4.12.0 com.squareup.okio:okio-jvm:3.9.0 com.squareup.okio:okio:3.9.0 +dev.drewhamilton.poko:poko-annotations-jvm:0.15.2 +dev.drewhamilton.poko:poko-annotations:0.15.2 io.coil-kt:coil-base:2.6.0 io.coil-kt:coil-compose-base:2.6.0 io.coil-kt:coil-compose:2.6.0 diff --git a/app/dependencies/otherReleaseRuntimeClasspath.txt b/app/dependencies/otherReleaseRuntimeClasspath.txt index e12a1530..04a35e8f 100644 --- a/app/dependencies/otherReleaseRuntimeClasspath.txt +++ b/app/dependencies/otherReleaseRuntimeClasspath.txt @@ -167,6 +167,8 @@ com.google.dagger:hilt-core:2.51.1 com.google.guava:listenablefuture:1.0 com.jakewharton.timber:timber:5.0.1 com.joaomgcd:taskerpluginlibrary:0.4.10 +com.materialkolor:material-color-utilities-android:1.5.1 +com.materialkolor:material-color-utilities:1.5.1 com.mikepenz:fastadapter-extensions-binding:5.7.0 com.mikepenz:fastadapter-extensions-expandable:5.7.0 com.mikepenz:fastadapter:5.7.0 @@ -177,6 +179,8 @@ com.squareup.moshi:moshi:1.15.1 com.squareup.okhttp3:okhttp:4.12.0 com.squareup.okio:okio-jvm:3.9.0 com.squareup.okio:okio:3.9.0 +dev.drewhamilton.poko:poko-annotations-jvm:0.15.2 +dev.drewhamilton.poko:poko-annotations:0.15.2 io.coil-kt:coil-base:2.6.0 io.coil-kt:coil-compose-base:2.6.0 io.coil-kt:coil-compose:2.6.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4be5ebc4..bd7c87d1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -86,6 +86,7 @@ compose-icons = { module = "androidx.compose.material:material-icons-extended" } compose-activity = "androidx.activity:activity-compose:1.9.0" compose-liveData = { module = "androidx.compose.runtime:runtime-livedata" } compose-constraintLayout = "androidx.constraintlayout:constraintlayout-compose:1.0.1" +materialColorUtilities = "com.materialkolor:material-color-utilities:1.5.1" material = "com.google.android.material:material:1.4.0" From 29049917235cf49966e96e8faa8691259171d547 Mon Sep 17 00:00:00 2001 From: DeweyReed <32358486+DeweyReed@users.noreply.github.com> Date: Tue, 18 Jun 2024 20:47:15 +0800 Subject: [PATCH 24/31] Retire the old backup fragments --- app-backup/build.gradle | 4 +- .../timer/app/backup/AbstractStep.kt | 21 - .../timer/app/backup/Export2Fragment.kt | 150 ------- .../timer/app/backup/ExportFragment.kt | 263 ++++--------- ...Export2ViewModel.kt => ExportViewModel.kt} | 7 +- .../timer/app/backup/Import2Fragment.kt | 235 ----------- .../timer/app/backup/ImportFragment.kt | 369 ++++++++---------- ...Import2ViewModel.kt => ImportViewModel.kt} | 7 +- .../app/backup/SelectAppContentHelper.kt | 93 ----- .../main/res/layout/layout_vertical_form.xml | 6 - .../main/res/layout/step_select_content.xml | 55 --- .../src/main/res/layout/step_text_button.xml | 23 -- app/build.gradle | 1 - .../dogReleaseRuntimeClasspath.txt | 1 - .../googleReleaseRuntimeClasspath.txt | 1 - .../otherReleaseRuntimeClasspath.txt | 1 - app/src/main/res/navigation/nav_graph.xml | 4 +- gradle/libs.versions.toml | 1 - .../presentation/backup/ExportViewModel.kt | 51 --- .../presentation/backup/ImportViewModel.kt | 43 -- .../backup/ImportViewModelTest.kt | 45 --- 21 files changed, 247 insertions(+), 1134 deletions(-) delete mode 100644 app-backup/src/main/java/xyz/aprildown/timer/app/backup/AbstractStep.kt delete mode 100644 app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt rename app-backup/src/main/java/xyz/aprildown/timer/app/backup/{Export2ViewModel.kt => ExportViewModel.kt} (90%) delete mode 100644 app-backup/src/main/java/xyz/aprildown/timer/app/backup/Import2Fragment.kt rename app-backup/src/main/java/xyz/aprildown/timer/app/backup/{Import2ViewModel.kt => ImportViewModel.kt} (91%) delete mode 100644 app-backup/src/main/java/xyz/aprildown/timer/app/backup/SelectAppContentHelper.kt delete mode 100644 app-backup/src/main/res/layout/layout_vertical_form.xml delete mode 100644 app-backup/src/main/res/layout/step_select_content.xml delete mode 100644 app-backup/src/main/res/layout/step_text_button.xml delete mode 100644 presentation/src/main/java/xyz/aprildown/timer/presentation/backup/ExportViewModel.kt delete mode 100644 presentation/src/main/java/xyz/aprildown/timer/presentation/backup/ImportViewModel.kt delete mode 100644 presentation/src/test/java/xyz/aprildown/timer/presentation/backup/ImportViewModelTest.kt diff --git a/app-backup/build.gradle b/app-backup/build.gradle index fa860409..052bb9ea 100644 --- a/app-backup/build.gradle +++ b/app-backup/build.gradle @@ -19,6 +19,8 @@ dependencies { implementation project(':app-base') implementation project(':component-key') + testImplementation libs.junit + implementation libs.androidx.preference implementation platform(libs.compose.bom) @@ -30,6 +32,4 @@ dependencies { implementation libs.okio implementation libs.permission - implementation libs.verticalStepperForm - } diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/AbstractStep.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/AbstractStep.kt deleted file mode 100644 index 7b8ab8bd..00000000 --- a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/AbstractStep.kt +++ /dev/null @@ -1,21 +0,0 @@ -package xyz.aprildown.timer.app.backup - -import ernestoyaquello.com.verticalstepperform.Step - -internal abstract class AbstractStep( - title: String, - subTitle: String = "", - nextButtonText: String = "" -) : Step(title, subTitle, nextButtonText) { - override fun restoreStepData(data: T) = Unit - - override fun onStepMarkedAsCompleted(animated: Boolean) = Unit - - override fun onStepOpened(animated: Boolean) = Unit - - override fun onStepMarkedAsUncompleted(animated: Boolean) = Unit - - override fun onStepClosed(animated: Boolean) = Unit - - override fun getStepDataAsHumanReadableString(): String = "" -} diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt deleted file mode 100644 index 5d192e9a..00000000 --- a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/Export2Fragment.kt +++ /dev/null @@ -1,150 +0,0 @@ -package xyz.aprildown.timer.app.backup - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.res.stringResource -import androidx.documentfile.provider.DocumentFile -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.navigation.fragment.findNavController -import com.github.deweyreed.tools.anko.longSnackbar -import com.github.deweyreed.tools.helper.requireCallback -import dagger.hilt.android.AndroidEntryPoint -import okio.sink -import xyz.aprildown.timer.app.base.data.PreferenceData.lastBackupUri -import xyz.aprildown.timer.app.base.ui.AppTheme -import xyz.aprildown.timer.app.base.ui.MainCallback -import xyz.aprildown.timer.domain.usecases.Fruit -import xyz.aprildown.timer.domain.utils.AppTracker -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import javax.inject.Inject -import xyz.aprildown.timer.app.base.R as RBase - -@AndroidEntryPoint -class Export2Fragment : Fragment() { - - private val viewModel: Export2ViewModel by viewModels() - - private lateinit var mainCallback: MainCallback.ActivityCallback - - @Inject - lateinit var appTracker: AppTracker - - private val launcher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - handleActivityResult(resultCode = it.resultCode, data = it.data) - } - - override fun onAttach(context: Context) { - super.onAttach(context) - mainCallback = requireCallback() - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val navController = findNavController() - (view as ComposeView).setContent { - val screen by viewModel.screen.collectAsState() - LaunchedEffect(screen.backupResult) { - val result = screen.backupResult - if (result?.fruit is Fruit.Ripe) { - mainCallback.snackbarView.longSnackbar(RBase.string.export_done) - navController.popBackStack( - destinationId = RBase.id.dest_backup_restore, - inclusive = false, - ) - } - } - Export( - screen = screen, - onLocationChange = ::onLocationChange, - modifier = Modifier.fillMaxSize(), - ) - } - } - - private fun onLocationChange() { - val date = Date() - val timeString = SimpleDateFormat("yyyy-MM-dd-kk-mm", Locale.getDefault()).format(date) - val initialFilename = "timer-machine-$timeString.json" - SafIntentSafeBelt( - context = requireContext(), - appTracker = appTracker, - viewForSnackbar = mainCallback.snackbarView - ).drive( - launcher = launcher, - intent = Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("*/*") - .putExtra(Intent.EXTRA_TITLE, initialFilename) - ) - } - - private fun handleActivityResult(resultCode: Int, data: Intent?) { - if (resultCode != Activity.RESULT_OK) return - val uri = data?.data ?: return - if (uri.toString().isBlank() || uri == Uri.EMPTY) return - val context = requireContext() - context.lastBackupUri = uri - val contentResolver = context.contentResolver - val documentFile = DocumentFile.fromSingleUri(context, uri) - viewModel.changeContent( - content = Export2ViewModel.WritableContent( - getSink = { - checkNotNull(contentResolver.openOutputStream(uri)).sink() - }, - delete = { - // ContentResolver.delete doesn't work - documentFile?.delete() - }, - ), - name = documentFile?.name?.takeIf { it.isNotBlank() } ?: uri.toString(), - ) - } -} - -@Composable -private fun Export( - screen: BaseBackupViewModel.Screen<*>, - onLocationChange: () -> Unit, - modifier: Modifier = Modifier, -) { - AppTheme { - Backup( - screen = screen, - contentLocationTitle = stringResource(id = RBase.string.export_path_title), - contentLocationButtonText = stringResource(id = RBase.string.export_select_location), - onChangeContentLocation = onLocationChange, - backupButtonText = stringResource(id = RBase.string.export_action), - backupErrorHint = stringResource(id = RBase.string.export_error), - modifier = modifier, - ) - } -} diff --git a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/ExportFragment.kt b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/ExportFragment.kt index 08d579b6..a7c1c288 100644 --- a/app-backup/src/main/java/xyz/aprildown/timer/app/backup/ExportFragment.kt +++ b/app-backup/src/main/java/xyz/aprildown/timer/app/backup/ExportFragment.kt @@ -3,35 +3,34 @@ package xyz.aprildown.timer.app.backup import android.app.Activity import android.content.Context import android.content.Intent -import android.graphics.Color import android.net.Uri import android.os.Bundle -import android.provider.OpenableColumns +import android.view.LayoutInflater import android.view.View -import android.widget.Button -import android.widget.TextView +import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.database.getStringOrNull -import androidx.core.text.buildSpannedString +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.documentfile.provider.DocumentFile import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.fragment.findNavController import com.github.deweyreed.tools.anko.longSnackbar -import com.github.deweyreed.tools.arch.observeNonNull -import com.github.deweyreed.tools.helper.gone import com.github.deweyreed.tools.helper.requireCallback -import com.github.deweyreed.tools.helper.show -import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint -import ernestoyaquello.com.verticalstepperform.VerticalStepperFormView -import ernestoyaquello.com.verticalstepperform.listener.StepperFormListener +import okio.sink import xyz.aprildown.timer.app.base.data.PreferenceData.lastBackupUri +import xyz.aprildown.timer.app.base.ui.AppTheme import xyz.aprildown.timer.app.base.ui.MainCallback -import xyz.aprildown.timer.app.base.ui.newDynamicTheme import xyz.aprildown.timer.domain.usecases.Fruit -import xyz.aprildown.timer.domain.usecases.data.ExportAppData import xyz.aprildown.timer.domain.utils.AppTracker -import xyz.aprildown.timer.presentation.backup.ExportViewModel import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -39,17 +38,14 @@ import javax.inject.Inject import xyz.aprildown.timer.app.base.R as RBase @AndroidEntryPoint -class ExportFragment : Fragment(R.layout.layout_vertical_form), StepperFormListener { +class ExportFragment : Fragment() { private val viewModel: ExportViewModel by viewModels() - @Inject - lateinit var appTracker: AppTracker - private lateinit var mainCallback: MainCallback.ActivityCallback - private lateinit var locationStep: ExportLocationStep - private lateinit var contentStep: ExportContentStep + @Inject + lateinit var appTracker: AppTracker private val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { @@ -61,83 +57,40 @@ class ExportFragment : Fragment(R.layout.layout_vertical_form), StepperFormListe mainCallback = requireCallback() } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val context = view.context - val form = view as VerticalStepperFormView - locationStep = ExportLocationStep(resources.getString(RBase.string.export_path_title), this) - contentStep = ExportContentStep(resources.getString(RBase.string.export_content_title)) - form.setup(this, locationStep, contentStep) - .displayBottomNavigation(false) - .apply { - newDynamicTheme.run { - basicColorScheme(colorPrimary, colorPrimaryVariant, colorOnPrimary) - nextButtonColors(colorSecondary, colorSecondary, Color.WHITE, Color.WHITE) - } - } - .stepNextButtonText(getString(RBase.string.backup_next_step)) - .confirmationStepTitle(resources.getString(RBase.string.export_begin)) - .lastStepNextButtonText(getString(RBase.string.export_action)) - .displayCancelButtonInLastStep(true) - .lastStepCancelButtonText(getString(android.R.string.cancel)) - .init() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + } + } - viewModel.result.observeNonNull(viewLifecycleOwner) { fruit -> - when (fruit) { - is Fruit.Ripe -> { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val navController = findNavController() + (view as ComposeView).setContent { + val screen by viewModel.screen.collectAsState() + LaunchedEffect(screen.backupResult) { + val result = screen.backupResult + if (result?.fruit is Fruit.Ripe) { mainCallback.snackbarView.longSnackbar(RBase.string.export_done) - popBackToBackup() - } - is Fruit.Rotten -> { - val exception = fruit.exception - MaterialAlertDialogBuilder(context) - .setMessage( - buildSpannedString { - append(getText(RBase.string.export_error)) - - (exception.localizedMessage ?: exception.message) - ?.takeIf { it.isNotBlank() } - ?.let { message -> - append("\n\n") - append(message) - } - } - ) - .setPositiveButton(android.R.string.ok, null) - .setOnDismissListener { - popBackToBackup() - } - .show() + navController.popBackStack( + destinationId = RBase.id.dest_backup_restore, + inclusive = false, + ) } } - viewModel.consumeResult() - } - } - - override fun onCompletedForm() { - val context = requireContext() - val settings = contentStep.stepData - viewModel.export( - params = ExportAppData.Params( - exportTimers = settings.isTimersChecked, - exportTimerStamps = settings.isTimerStampsChecked, - exportSchedulers = settings.isSchedulersChecked, - exportPreferences = settings.isSettingsChecked - ), - outputStream = checkNotNull( - context.contentResolver.openOutputStream(locationStep.stepData, "rwt") + Export( + screen = screen, + onLocationChange = ::onLocationChange, + modifier = Modifier.fillMaxSize(), ) - ) - } - - override fun onCancelledForm() { - popBackToBackup() - } - - private fun popBackToBackup() { - NavHostFragment.findNavController(this).popBackStack(RBase.id.dest_backup_restore, false) + } } - fun pickExportPath() { + private fun onLocationChange() { val date = Date() val timeString = SimpleDateFormat("yyyy-MM-dd-kk-mm", Locale.getDefault()).format(date) val initialFilename = "timer-machine-$timeString.json" @@ -155,101 +108,43 @@ class ExportFragment : Fragment(R.layout.layout_vertical_form), StepperFormListe } private fun handleActivityResult(resultCode: Int, data: Intent?) { - if (resultCode == Activity.RESULT_OK) { - val uri = data?.data ?: return - val context = requireContext() - var name: String? = null - - try { - context.contentResolver.query( - uri, - arrayOf(OpenableColumns.DISPLAY_NAME), - null, - null, - null, - null - )?.use { cursor -> - if (cursor.moveToFirst()) { - name = - cursor.getStringOrNull(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)) - } - } - } catch (e: Exception) { - appTracker.trackError(e) - mainCallback.snackbarView.longSnackbar(e.message.toString()) - return - } - - context.lastBackupUri = uri - locationStep.locationPicked(uri, name) - } - } -} - -private class ExportLocationStep( - title: String, - private val parentFragment: ExportFragment -) : AbstractStep(title) { - - private var locationUri: Uri = Uri.EMPTY - private lateinit var locationTextView: TextView - - override fun createStepContentLayout(): View { - val context = context - val view = View.inflate(context, R.layout.step_text_button, null) - locationTextView = view.findViewById(R.id.textStepTextButton).apply { - gone() - } - view.findViewById