Skip to content

Commit 2ca2b40

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 2ca2b40

File tree

7 files changed

+488
-0
lines changed

7 files changed

+488
-0
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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.app.Dialog
21+
import android.os.Bundle
22+
import androidx.appcompat.app.AlertDialog
23+
import androidx.fragment.app.DialogFragment
24+
import androidx.fragment.app.activityViewModels
25+
import com.ichi2.anki.R
26+
27+
class ConfirmMediaCheckDialog : DialogFragment() {
28+
private val viewModel: MediaCheckViewModel by activityViewModels()
29+
30+
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
31+
AlertDialog
32+
.Builder(requireContext())
33+
.setTitle(getString(R.string.check_media_title))
34+
.setMessage(getString(R.string.check_media_warning))
35+
.setPositiveButton(R.string.dialog_ok) { _, _ ->
36+
startActivity(MediaCheckFragment.getIntent(requireContext()))
37+
}.setNegativeButton(R.string.dialog_cancel) { _, _ ->
38+
dismiss()
39+
}.create()
40+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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.view.LayoutInflater
21+
import android.view.View
22+
import android.view.ViewGroup
23+
import android.widget.TextView
24+
import androidx.recyclerview.widget.DiffUtil
25+
import androidx.recyclerview.widget.ListAdapter
26+
import androidx.recyclerview.widget.RecyclerView
27+
import com.ichi2.anki.R
28+
29+
class MediaCheckAdapter : ListAdapter<String, MediaCheckAdapter.ViewHolder>(DiffCallback()) {
30+
class ViewHolder(
31+
itemView: View,
32+
) : RecyclerView.ViewHolder(itemView) {
33+
private val textView: TextView = itemView.findViewById(R.id.file_name_textview)
34+
35+
fun bind(fileName: String) {
36+
textView.text = fileName
37+
}
38+
}
39+
40+
override fun onCreateViewHolder(
41+
parent: ViewGroup,
42+
viewType: Int,
43+
): ViewHolder {
44+
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_media_check, parent, false)
45+
return ViewHolder(view)
46+
}
47+
48+
override fun onBindViewHolder(
49+
holder: ViewHolder,
50+
position: Int,
51+
) {
52+
holder.bind(getItem(position))
53+
}
54+
55+
class DiffCallback : DiffUtil.ItemCallback<String>() {
56+
override fun areItemsTheSame(
57+
oldItem: String,
58+
newItem: String,
59+
) = oldItem == newItem
60+
61+
override fun areContentsTheSame(
62+
oldItem: String,
63+
newItem: String,
64+
) = oldItem == newItem
65+
}
66+
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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.LinearLayoutManager
30+
import androidx.recyclerview.widget.RecyclerView
31+
import com.google.android.material.appbar.MaterialToolbar
32+
import com.google.android.material.button.MaterialButton
33+
import com.ichi2.anki.CollectionManager.TR
34+
import com.ichi2.anki.R
35+
import com.ichi2.anki.SingleFragmentActivity
36+
import com.ichi2.anki.launchCatchingTask
37+
import com.ichi2.anki.ui.internationalization.toSentenceCase
38+
import com.ichi2.anki.withProgress
39+
import com.ichi2.libanki.MediaCheckResult
40+
import com.ichi2.utils.cancelable
41+
import com.ichi2.utils.message
42+
import com.ichi2.utils.positiveButton
43+
import com.ichi2.utils.show
44+
import com.ichi2.utils.title
45+
import kotlinx.coroutines.flow.collectLatest
46+
import kotlinx.coroutines.launch
47+
48+
/**
49+
* MediaCheckFragment for displaying a list of media files that are either unused or missing.
50+
* It allows users to tag missing media files or delete unused ones.
51+
**/
52+
class MediaCheckFragment : Fragment(R.layout.fragment_media_check) {
53+
private val viewModel: MediaCheckViewModel by viewModels()
54+
private lateinit var adapter: MediaCheckAdapter
55+
56+
private lateinit var deleteMediaButton: MaterialButton
57+
private lateinit var tagMissingButton: MaterialButton
58+
59+
override fun onViewCreated(
60+
view: View,
61+
savedInstanceState: Bundle?,
62+
) {
63+
super.onViewCreated(view, savedInstanceState)
64+
65+
view.findViewById<MaterialToolbar>(R.id.toolbar).apply {
66+
setTitle(TR.mediaCheckCheckMediaAction())
67+
setNavigationOnClickListener {
68+
requireActivity().onBackPressedDispatcher.onBackPressed()
69+
}
70+
}
71+
72+
deleteMediaButton = view.findViewById(R.id.delete_used_media_button)
73+
tagMissingButton = view.findViewById(R.id.tag_missing_media_button)
74+
75+
val recyclerView = view.findViewById<RecyclerView>(R.id.recyclerView)
76+
77+
adapter = MediaCheckAdapter()
78+
recyclerView.layoutManager = LinearLayoutManager(requireContext())
79+
recyclerView.adapter = adapter
80+
81+
launchCatchingTask {
82+
withProgress(R.string.check_media_message) {
83+
viewModel.checkMedia().join()
84+
}
85+
}
86+
87+
lifecycleScope.launch {
88+
viewModel.mediaCheckResult.collectLatest { result ->
89+
view.findViewById<TextView>(R.id.unused_media_count)?.apply {
90+
visibility = View.VISIBLE
91+
text = (TR.mediaCheckUnusedCount(result?.unusedFileNames?.size ?: 0))
92+
}
93+
94+
view.findViewById<TextView>(R.id.missing_media_count)?.apply {
95+
visibility = View.VISIBLE
96+
text = (TR.mediaCheckMissingCount(result?.missingMediaNotes?.size ?: 0))
97+
}
98+
99+
result?.let { files ->
100+
handleMediaResult(files)
101+
}
102+
}
103+
}
104+
105+
setupButtonListeners()
106+
}
107+
108+
/**
109+
* Processes media check results and updates the UI.
110+
*
111+
* @param mediaCheckResult The result containing missing and unused media file names.
112+
*/
113+
private fun handleMediaResult(mediaCheckResult: MediaCheckResult) {
114+
val fileList = mutableListOf<String>()
115+
116+
if (mediaCheckResult.missingFileNames.isNotEmpty()) {
117+
tagMissingButton.visibility = View.VISIBLE
118+
119+
fileList.add(TR.mediaCheckMissingHeader())
120+
fileList.addAll(
121+
mediaCheckResult.missingFileNames.map { missingMedia ->
122+
TR.mediaCheckMissingFile(missingMedia)
123+
},
124+
)
125+
}
126+
127+
if (mediaCheckResult.unusedFileNames.isNotEmpty()) {
128+
deleteMediaButton.visibility = View.VISIBLE
129+
130+
fileList.add("\n")
131+
fileList.add(TR.mediaCheckUnusedHeader())
132+
fileList.addAll(
133+
mediaCheckResult.unusedFileNames.map { unusedMedia ->
134+
TR.mediaCheckUnusedFile(unusedMedia)
135+
},
136+
)
137+
}
138+
139+
adapter.submitList(fileList)
140+
}
141+
142+
private fun setupButtonListeners() {
143+
tagMissingButton.apply {
144+
text =
145+
TR
146+
.mediaCheckAddTag()
147+
.toSentenceCase(requireContext(), R.string.tag_missing)
148+
149+
setOnClickListener {
150+
launchCatchingTask {
151+
withProgress(getString(R.string.adding_missing_tag)) {
152+
viewModel.tagMissing(TR.mediaCheckMissingMediaTag()).join()
153+
showResultDialog(
154+
R.string.tags_added,
155+
TR.browsingNotesUpdated(viewModel.taggedFiles),
156+
)
157+
}
158+
}
159+
}
160+
}
161+
162+
deleteMediaButton.apply {
163+
text =
164+
TR.mediaCheckDeleteUnused().toSentenceCase(
165+
requireContext(),
166+
R.string.check_media_delete_unused,
167+
)
168+
169+
setOnClickListener {
170+
launchCatchingTask {
171+
withProgress(resources.getString(R.string.delete_media_message)) {
172+
viewModel.deleteUnusedMedia().join()
173+
showResultDialog(
174+
R.string.delete_media_result_title,
175+
resources.getQuantityString(
176+
R.plurals.delete_media_result_message,
177+
viewModel.deletedFiles,
178+
viewModel.deletedFiles,
179+
),
180+
)
181+
}
182+
}
183+
}
184+
}
185+
}
186+
187+
private fun showResultDialog(
188+
titleRes: Int,
189+
message: String,
190+
) {
191+
AlertDialog.Builder(requireContext()).show {
192+
title(titleRes)
193+
message(text = message)
194+
positiveButton(R.string.dialog_ok) {
195+
requireActivity().finish()
196+
}
197+
cancelable(false)
198+
}
199+
}
200+
201+
companion object {
202+
fun getIntent(context: Context): Intent = SingleFragmentActivity.getIntent(context, MediaCheckFragment::class)
203+
}
204+
}
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 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 kotlinx.coroutines.Job
26+
import kotlinx.coroutines.flow.MutableStateFlow
27+
import kotlinx.coroutines.flow.StateFlow
28+
import kotlinx.coroutines.launch
29+
30+
class MediaCheckViewModel : ViewModel() {
31+
private val _mediaCheckResult = MutableStateFlow<MediaCheckResult?>(null)
32+
val mediaCheckResult: StateFlow<MediaCheckResult?> = _mediaCheckResult
33+
34+
private val deletedFilesCount: MutableStateFlow<Int> = MutableStateFlow(0)
35+
private val taggedFilesCount: MutableStateFlow<Int> = MutableStateFlow(0)
36+
37+
val deletedFiles: Int
38+
get() = deletedFilesCount.value
39+
40+
val taggedFiles: Int
41+
get() = taggedFilesCount.value
42+
43+
// TODO: Move progress notifications here
44+
fun tagMissing(tag: String): Job =
45+
viewModelScope.launch {
46+
val taggedNotes =
47+
withCol {
48+
tags.bulkAdd(_mediaCheckResult.value?.missingMediaNotes ?: listOf(), tag)
49+
}
50+
taggedFilesCount.value = taggedNotes.count
51+
}
52+
53+
fun checkMedia(): Job =
54+
viewModelScope.launch {
55+
val result = withCol { media.check() }
56+
_mediaCheckResult.value = result
57+
}
58+
59+
fun deleteUnusedMedia(): Job =
60+
viewModelScope.launch {
61+
val deletedMedia = withCol { deleteMedia(this@withCol, _mediaCheckResult.value?.unusedFileNames ?: listOf()) }
62+
deletedFilesCount.value = deletedMedia
63+
}
64+
}

0 commit comments

Comments
 (0)