From e14d6167ed0732758dccc3754eecb8b416794c6a Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Thu, 20 Feb 2025 04:42:58 +0530 Subject: [PATCH] feat: media check viewmodel feat: add media check fragment file - feat: adapter for media check result - refactor: confirm media check dialog --- .../anki/mediacheck/MediaCheckAdapter.kt | 64 ++++++ .../anki/mediacheck/MediaCheckFragment.kt | 200 ++++++++++++++++++ .../anki/mediacheck/MediaCheckViewModel.kt | 65 ++++++ .../main/res/layout/fragment_media_check.xml | 88 ++++++++ .../src/main/res/layout/item_media_check.xml | 23 ++ AnkiDroid/src/main/res/values/03-dialogs.xml | 2 + 6 files changed, 442 insertions(+) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckAdapter.kt create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckViewModel.kt create mode 100644 AnkiDroid/src/main/res/layout/fragment_media_check.xml create mode 100644 AnkiDroid/src/main/res/layout/item_media_check.xml diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckAdapter.kt new file mode 100644 index 000000000000..894964067092 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckAdapter.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Ashish Yadav + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.mediacheck + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.ichi2.anki.R + +class MediaCheckAdapter( + context: Context, +) : ListAdapter(DiffCallback()) { + private val inflater = LayoutInflater.from(context) + + class ViewHolder( + itemView: View, + ) : RecyclerView.ViewHolder(itemView) { + val textView: TextView = itemView.findViewById(R.id.file_name_textview) + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ) = ViewHolder(inflater.inflate(R.layout.item_media_check, parent, false)) + + override fun onBindViewHolder( + holder: ViewHolder, + position: Int, + ) { + holder.textView.text = getItem(position) + } + + class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: String, + newItem: String, + ) = oldItem == newItem + + override fun areContentsTheSame( + oldItem: String, + newItem: String, + ) = oldItem == newItem + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt new file mode 100644 index 000000000000..41877df4b213 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2025 Ashish Yadav + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.mediacheck + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.button.MaterialButton +import com.ichi2.anki.CollectionManager.TR +import com.ichi2.anki.R +import com.ichi2.anki.SingleFragmentActivity +import com.ichi2.anki.launchCatchingTask +import com.ichi2.anki.ui.internationalization.toSentenceCase +import com.ichi2.anki.withProgress +import com.ichi2.libanki.MediaCheckResult +import com.ichi2.utils.cancelable +import com.ichi2.utils.message +import com.ichi2.utils.positiveButton +import com.ichi2.utils.show +import com.ichi2.utils.title +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * MediaCheckFragment for displaying a list of media files that are either unused or missing. + * It allows users to tag missing media files or delete unused ones. + **/ +class MediaCheckFragment : Fragment(R.layout.fragment_media_check) { + private val viewModel: MediaCheckViewModel by viewModels() + private lateinit var adapter: MediaCheckAdapter + + private lateinit var deleteMediaButton: MaterialButton + private lateinit var tagMissingButton: MaterialButton + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + view.findViewById(R.id.toolbar).apply { + setTitle(TR.mediaCheckCheckMediaAction().toSentenceCase(requireContext(), R.string.check_media)) + setNavigationOnClickListener { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + } + + deleteMediaButton = view.findViewById(R.id.delete_used_media_button) + tagMissingButton = view.findViewById(R.id.tag_missing_media_button) + + val recyclerView = view.findViewById(R.id.recyclerView) + + adapter = MediaCheckAdapter(requireContext()) + recyclerView.adapter = adapter + + launchCatchingTask { + withProgress(R.string.check_media_message) { + viewModel.checkMedia().join() + } + } + + lifecycleScope.launch { + viewModel.mediaCheckResult.collectLatest { result -> + view.findViewById(R.id.unused_media_count)?.apply { + text = (TR.mediaCheckUnusedCount(result?.unusedFileNames?.size ?: 0)) + } + + view.findViewById(R.id.missing_media_count)?.apply { + text = (TR.mediaCheckMissingCount(result?.missingMediaNotes?.size ?: 0)) + } + + result?.let { files -> + handleMediaResult(files) + } + } + } + + setupButtonListeners() + } + + /** + * Processes media check results and updates the UI. + * + * @param mediaCheckResult The result containing missing and unused media file names. + */ + private fun handleMediaResult(mediaCheckResult: MediaCheckResult) { + val fileList = mutableListOf() + + if (mediaCheckResult.missingFileNames.isNotEmpty()) { + tagMissingButton.visibility = View.VISIBLE + + fileList.add(TR.mediaCheckMissingHeader()) + fileList.addAll( + mediaCheckResult.missingFileNames.map { missingMedia -> + TR.mediaCheckMissingFile(missingMedia) + }, + ) + } + + if (mediaCheckResult.unusedFileNames.isNotEmpty()) { + deleteMediaButton.visibility = View.VISIBLE + + fileList.add("\n") + fileList.add(TR.mediaCheckUnusedHeader()) + fileList.addAll( + mediaCheckResult.unusedFileNames.map { unusedMedia -> + TR.mediaCheckUnusedFile(unusedMedia) + }, + ) + } + + adapter.submitList(fileList) + } + + private fun setupButtonListeners() { + tagMissingButton.apply { + text = + TR + .mediaCheckAddTag() + .toSentenceCase(requireContext(), R.string.tag_missing) + + setOnClickListener { + launchCatchingTask { + withProgress(getString(R.string.check_media_adding_missing_tag)) { + viewModel.tagMissing(TR.mediaCheckMissingMediaTag()).join() + showResultDialog( + R.string.check_media_tags_added, + TR.browsingNotesUpdated(viewModel.taggedFiles), + ) + } + } + } + } + + deleteMediaButton.apply { + text = + TR.mediaCheckDeleteUnused().toSentenceCase( + requireContext(), + R.string.check_media_delete_unused, + ) + + setOnClickListener { + launchCatchingTask { + withProgress(resources.getString(R.string.delete_media_message)) { + viewModel.deleteUnusedMedia().join() + showResultDialog( + R.string.delete_media_result_title, + resources.getQuantityString( + R.plurals.delete_media_result_message, + viewModel.deletedFiles, + viewModel.deletedFiles, + ), + ) + } + } + } + } + } + + private fun showResultDialog( + titleRes: Int, + message: String, + ) { + AlertDialog.Builder(requireContext()).show { + title(titleRes) + message(text = message) + positiveButton(R.string.dialog_ok) { + requireActivity().finish() + } + cancelable(false) + } + } + + companion object { + fun getIntent(context: Context): Intent = SingleFragmentActivity.getIntent(context, MediaCheckFragment::class) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckViewModel.kt new file mode 100644 index 000000000000..5d406c0c3be2 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckViewModel.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Ashish Yadav + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.mediacheck + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.async.deleteMedia +import com.ichi2.libanki.MediaCheckResult +import com.ichi2.libanki.undoableOp +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class MediaCheckViewModel : ViewModel() { + private val _mediaCheckResult = MutableStateFlow(null) + val mediaCheckResult: StateFlow = _mediaCheckResult + + private val deletedFilesCount: MutableStateFlow = MutableStateFlow(0) + private val taggedFilesCount: MutableStateFlow = MutableStateFlow(0) + + val deletedFiles: Int + get() = deletedFilesCount.value + + val taggedFiles: Int + get() = taggedFilesCount.value + + // TODO: Move progress notifications here + fun tagMissing(tag: String): Job = + viewModelScope.launch { + val taggedNotes = + undoableOp { + tags.bulkAdd(_mediaCheckResult.value?.missingMediaNotes ?: listOf(), tag) + } + taggedFilesCount.value = taggedNotes.count + } + + fun checkMedia(): Job = + viewModelScope.launch { + val result = withCol { media.check() } + _mediaCheckResult.value = result + } + + fun deleteUnusedMedia(): Job = + viewModelScope.launch { + val deletedMedia = withCol { deleteMedia(this@withCol, _mediaCheckResult.value?.unusedFileNames ?: listOf()) } + deletedFilesCount.value = deletedMedia + } +} diff --git a/AnkiDroid/src/main/res/layout/fragment_media_check.xml b/AnkiDroid/src/main/res/layout/fragment_media_check.xml new file mode 100644 index 000000000000..bad6192da521 --- /dev/null +++ b/AnkiDroid/src/main/res/layout/fragment_media_check.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/layout/item_media_check.xml b/AnkiDroid/src/main/res/layout/item_media_check.xml new file mode 100644 index 000000000000..f8f682db9303 --- /dev/null +++ b/AnkiDroid/src/main/res/layout/item_media_check.xml @@ -0,0 +1,23 @@ + + + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/values/03-dialogs.xml b/AnkiDroid/src/main/res/values/03-dialogs.xml index aa70ba3bab57..335599366d07 100644 --- a/AnkiDroid/src/main/res/values/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values/03-dialogs.xml @@ -95,6 +95,8 @@ Files with invalid encoding: %d No unused or missing files found Delete unused + Adding tags… + Tags added Deleting media… Deletion result