Skip to content

Commit e14d616

Browse files
committed
feat: media check viewmodel
feat: add media check fragment file - feat: adapter for media check result - refactor: confirm media check dialog
1 parent 555201c commit e14d616

File tree

6 files changed

+442
-0
lines changed

6 files changed

+442
-0
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Copyright (c) 2025 Ashish Yadav <mailtoashish693@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11+
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12+
* details.
13+
*
14+
* You should have received a copy of the GNU General Public License along with
15+
* this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.ichi2.anki.mediacheck
19+
20+
import android.content.Context
21+
import android.view.LayoutInflater
22+
import android.view.View
23+
import android.view.ViewGroup
24+
import android.widget.TextView
25+
import androidx.recyclerview.widget.DiffUtil
26+
import androidx.recyclerview.widget.ListAdapter
27+
import androidx.recyclerview.widget.RecyclerView
28+
import com.ichi2.anki.R
29+
30+
class MediaCheckAdapter(
31+
context: Context,
32+
) : ListAdapter<String, MediaCheckAdapter.ViewHolder>(DiffCallback()) {
33+
private val inflater = LayoutInflater.from(context)
34+
35+
class ViewHolder(
36+
itemView: View,
37+
) : RecyclerView.ViewHolder(itemView) {
38+
val textView: TextView = itemView.findViewById(R.id.file_name_textview)
39+
}
40+
41+
override fun onCreateViewHolder(
42+
parent: ViewGroup,
43+
viewType: Int,
44+
) = ViewHolder(inflater.inflate(R.layout.item_media_check, parent, false))
45+
46+
override fun onBindViewHolder(
47+
holder: ViewHolder,
48+
position: Int,
49+
) {
50+
holder.textView.text = getItem(position)
51+
}
52+
53+
class DiffCallback : DiffUtil.ItemCallback<String>() {
54+
override fun areItemsTheSame(
55+
oldItem: String,
56+
newItem: String,
57+
) = oldItem == newItem
58+
59+
override fun areContentsTheSame(
60+
oldItem: String,
61+
newItem: String,
62+
) = oldItem == newItem
63+
}
64+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/*
2+
* Copyright (c) 2025 Ashish Yadav <mailtoashish693@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11+
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12+
* details.
13+
*
14+
* You should have received a copy of the GNU General Public License along with
15+
* this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.ichi2.anki.mediacheck
19+
20+
import android.content.Context
21+
import android.content.Intent
22+
import android.os.Bundle
23+
import android.view.View
24+
import android.widget.TextView
25+
import androidx.appcompat.app.AlertDialog
26+
import androidx.fragment.app.Fragment
27+
import androidx.fragment.app.viewModels
28+
import androidx.lifecycle.lifecycleScope
29+
import androidx.recyclerview.widget.RecyclerView
30+
import com.google.android.material.appbar.MaterialToolbar
31+
import com.google.android.material.button.MaterialButton
32+
import com.ichi2.anki.CollectionManager.TR
33+
import com.ichi2.anki.R
34+
import com.ichi2.anki.SingleFragmentActivity
35+
import com.ichi2.anki.launchCatchingTask
36+
import com.ichi2.anki.ui.internationalization.toSentenceCase
37+
import com.ichi2.anki.withProgress
38+
import com.ichi2.libanki.MediaCheckResult
39+
import com.ichi2.utils.cancelable
40+
import com.ichi2.utils.message
41+
import com.ichi2.utils.positiveButton
42+
import com.ichi2.utils.show
43+
import com.ichi2.utils.title
44+
import kotlinx.coroutines.flow.collectLatest
45+
import kotlinx.coroutines.launch
46+
47+
/**
48+
* MediaCheckFragment for displaying a list of media files that are either unused or missing.
49+
* It allows users to tag missing media files or delete unused ones.
50+
**/
51+
class MediaCheckFragment : Fragment(R.layout.fragment_media_check) {
52+
private val viewModel: MediaCheckViewModel by viewModels()
53+
private lateinit var adapter: MediaCheckAdapter
54+
55+
private lateinit var deleteMediaButton: MaterialButton
56+
private lateinit var tagMissingButton: MaterialButton
57+
58+
override fun onViewCreated(
59+
view: View,
60+
savedInstanceState: Bundle?,
61+
) {
62+
super.onViewCreated(view, savedInstanceState)
63+
64+
view.findViewById<MaterialToolbar>(R.id.toolbar).apply {
65+
setTitle(TR.mediaCheckCheckMediaAction().toSentenceCase(requireContext(), R.string.check_media))
66+
setNavigationOnClickListener {
67+
requireActivity().onBackPressedDispatcher.onBackPressed()
68+
}
69+
}
70+
71+
deleteMediaButton = view.findViewById(R.id.delete_used_media_button)
72+
tagMissingButton = view.findViewById(R.id.tag_missing_media_button)
73+
74+
val recyclerView = view.findViewById<RecyclerView>(R.id.recyclerView)
75+
76+
adapter = MediaCheckAdapter(requireContext())
77+
recyclerView.adapter = adapter
78+
79+
launchCatchingTask {
80+
withProgress(R.string.check_media_message) {
81+
viewModel.checkMedia().join()
82+
}
83+
}
84+
85+
lifecycleScope.launch {
86+
viewModel.mediaCheckResult.collectLatest { result ->
87+
view.findViewById<TextView>(R.id.unused_media_count)?.apply {
88+
text = (TR.mediaCheckUnusedCount(result?.unusedFileNames?.size ?: 0))
89+
}
90+
91+
view.findViewById<TextView>(R.id.missing_media_count)?.apply {
92+
text = (TR.mediaCheckMissingCount(result?.missingMediaNotes?.size ?: 0))
93+
}
94+
95+
result?.let { files ->
96+
handleMediaResult(files)
97+
}
98+
}
99+
}
100+
101+
setupButtonListeners()
102+
}
103+
104+
/**
105+
* Processes media check results and updates the UI.
106+
*
107+
* @param mediaCheckResult The result containing missing and unused media file names.
108+
*/
109+
private fun handleMediaResult(mediaCheckResult: MediaCheckResult) {
110+
val fileList = mutableListOf<String>()
111+
112+
if (mediaCheckResult.missingFileNames.isNotEmpty()) {
113+
tagMissingButton.visibility = View.VISIBLE
114+
115+
fileList.add(TR.mediaCheckMissingHeader())
116+
fileList.addAll(
117+
mediaCheckResult.missingFileNames.map { missingMedia ->
118+
TR.mediaCheckMissingFile(missingMedia)
119+
},
120+
)
121+
}
122+
123+
if (mediaCheckResult.unusedFileNames.isNotEmpty()) {
124+
deleteMediaButton.visibility = View.VISIBLE
125+
126+
fileList.add("\n")
127+
fileList.add(TR.mediaCheckUnusedHeader())
128+
fileList.addAll(
129+
mediaCheckResult.unusedFileNames.map { unusedMedia ->
130+
TR.mediaCheckUnusedFile(unusedMedia)
131+
},
132+
)
133+
}
134+
135+
adapter.submitList(fileList)
136+
}
137+
138+
private fun setupButtonListeners() {
139+
tagMissingButton.apply {
140+
text =
141+
TR
142+
.mediaCheckAddTag()
143+
.toSentenceCase(requireContext(), R.string.tag_missing)
144+
145+
setOnClickListener {
146+
launchCatchingTask {
147+
withProgress(getString(R.string.check_media_adding_missing_tag)) {
148+
viewModel.tagMissing(TR.mediaCheckMissingMediaTag()).join()
149+
showResultDialog(
150+
R.string.check_media_tags_added,
151+
TR.browsingNotesUpdated(viewModel.taggedFiles),
152+
)
153+
}
154+
}
155+
}
156+
}
157+
158+
deleteMediaButton.apply {
159+
text =
160+
TR.mediaCheckDeleteUnused().toSentenceCase(
161+
requireContext(),
162+
R.string.check_media_delete_unused,
163+
)
164+
165+
setOnClickListener {
166+
launchCatchingTask {
167+
withProgress(resources.getString(R.string.delete_media_message)) {
168+
viewModel.deleteUnusedMedia().join()
169+
showResultDialog(
170+
R.string.delete_media_result_title,
171+
resources.getQuantityString(
172+
R.plurals.delete_media_result_message,
173+
viewModel.deletedFiles,
174+
viewModel.deletedFiles,
175+
),
176+
)
177+
}
178+
}
179+
}
180+
}
181+
}
182+
183+
private fun showResultDialog(
184+
titleRes: Int,
185+
message: String,
186+
) {
187+
AlertDialog.Builder(requireContext()).show {
188+
title(titleRes)
189+
message(text = message)
190+
positiveButton(R.string.dialog_ok) {
191+
requireActivity().finish()
192+
}
193+
cancelable(false)
194+
}
195+
}
196+
197+
companion object {
198+
fun getIntent(context: Context): Intent = SingleFragmentActivity.getIntent(context, MediaCheckFragment::class)
199+
}
200+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright (c) 2025 Ashish Yadav <mailtoashish693@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11+
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12+
* details.
13+
*
14+
* You should have received a copy of the GNU General Public License along with
15+
* this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
package com.ichi2.anki.mediacheck
19+
20+
import androidx.lifecycle.ViewModel
21+
import androidx.lifecycle.viewModelScope
22+
import com.ichi2.anki.CollectionManager.withCol
23+
import com.ichi2.async.deleteMedia
24+
import com.ichi2.libanki.MediaCheckResult
25+
import com.ichi2.libanki.undoableOp
26+
import kotlinx.coroutines.Job
27+
import kotlinx.coroutines.flow.MutableStateFlow
28+
import kotlinx.coroutines.flow.StateFlow
29+
import kotlinx.coroutines.launch
30+
31+
class MediaCheckViewModel : ViewModel() {
32+
private val _mediaCheckResult = MutableStateFlow<MediaCheckResult?>(null)
33+
val mediaCheckResult: StateFlow<MediaCheckResult?> = _mediaCheckResult
34+
35+
private val deletedFilesCount: MutableStateFlow<Int> = MutableStateFlow(0)
36+
private val taggedFilesCount: MutableStateFlow<Int> = MutableStateFlow(0)
37+
38+
val deletedFiles: Int
39+
get() = deletedFilesCount.value
40+
41+
val taggedFiles: Int
42+
get() = taggedFilesCount.value
43+
44+
// TODO: Move progress notifications here
45+
fun tagMissing(tag: String): Job =
46+
viewModelScope.launch {
47+
val taggedNotes =
48+
undoableOp {
49+
tags.bulkAdd(_mediaCheckResult.value?.missingMediaNotes ?: listOf(), tag)
50+
}
51+
taggedFilesCount.value = taggedNotes.count
52+
}
53+
54+
fun checkMedia(): Job =
55+
viewModelScope.launch {
56+
val result = withCol { media.check() }
57+
_mediaCheckResult.value = result
58+
}
59+
60+
fun deleteUnusedMedia(): Job =
61+
viewModelScope.launch {
62+
val deletedMedia = withCol { deleteMedia(this@withCol, _mediaCheckResult.value?.unusedFileNames ?: listOf()) }
63+
deletedFilesCount.value = deletedMedia
64+
}
65+
}

0 commit comments

Comments
 (0)