Skip to content

Commit 0af4614

Browse files
committed
refactor(shared-decks): extract a ViewModel and move into shareddeck package
Creates com.ichi2.anki.shareddeck and moves SharedDecksActivity, SharedDecksDownloadFragment and the DownloadFile data class into it, so all shared-decks code lives in one place. External references in the manifest, IntentHandler, DeckPicker, ActivityList and the two tests are updated to the new import path. SharedDecksDownloadViewModel exposes a StateFlow<SharedDecksDownloadUiState> covering every view-visible field on the XML screen. The fragment subscribes once in onViewCreated and renders the whole state in renderState(); progress updates, broadcast results and user actions are sent as SharedDecksDownloadEvent instances through a single onEvent(event) entry point rather than a per-mutation method. System work (DownloadManager, BroadcastReceiver, polling, file ops) stays in the fragment. No behaviour change.
1 parent 40e43b9 commit 0af4614

9 files changed

Lines changed: 182 additions & 35 deletions

File tree

AnkiDroid/src/androidTest/java/com/ichi2/anki/DownloadFileTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package com.ichi2.anki
1818

1919
import androidx.test.ext.junit.runners.AndroidJUnit4
20+
import com.ichi2.anki.shareddeck.DownloadFile
2021
import com.ichi2.anki.tests.InstrumentedTest
2122
import org.hamcrest.CoreMatchers.equalTo
2223
import org.hamcrest.MatcherAssert.assertThat

AnkiDroid/src/main/AndroidManifest.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,7 @@
492492
android:configChanges="keyboardHidden|orientation|screenSize"
493493
/>
494494
<activity
495-
android:name=".SharedDecksActivity"
495+
android:name=".shareddeck.SharedDecksActivity"
496496
android:configChanges="keyboardHidden|orientation|screenSize"
497497
android:label="@string/download_deck"
498498
android:theme="@style/Theme.MaterialComponents.NoActionBar" />

AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ import com.ichi2.anki.receiver.SdCardReceiver
158158
import com.ichi2.anki.reviewreminders.ReviewRemindersDatabase
159159
import com.ichi2.anki.servicelayer.ScopedStorageService
160160
import com.ichi2.anki.settings.Prefs
161+
import com.ichi2.anki.shareddeck.SharedDecksActivity
161162
import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider
162163
import com.ichi2.anki.snackbar.SnackbarBuilder
163164
import com.ichi2.anki.snackbar.showSnackbar

AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import com.ichi2.anki.libanki.DeckId
3939
import com.ichi2.anki.noteeditor.NoteEditorLauncher
4040
import com.ichi2.anki.servicelayer.ScopedStorageService
4141
import com.ichi2.anki.settings.Prefs
42+
import com.ichi2.anki.shareddeck.SharedDecksDownloadFragment
4243
import com.ichi2.anki.ui.windows.reviewer.ReviewerFragment
4344
import com.ichi2.anki.utils.MimeTypeUtils
4445
import com.ichi2.anki.worker.SyncWorker

AnkiDroid/src/main/java/com/ichi2/anki/SharedDecksActivity.kt renamed to AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* this program. If not, see <http://www.gnu.org/licenses/>.
1515
*/
1616

17-
package com.ichi2.anki
17+
package com.ichi2.anki.shareddeck
1818

1919
import android.app.DownloadManager
2020
import android.content.Context
@@ -32,8 +32,11 @@ import androidx.activity.OnBackPressedCallback
3232
import androidx.core.os.bundleOf
3333
import androidx.fragment.app.commit
3434
import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_INDEFINITE
35+
import com.ichi2.anki.AnkiActivity
36+
import com.ichi2.anki.R
3537
import com.ichi2.anki.common.annotations.NeedsTest
3638
import com.ichi2.anki.databinding.ActivitySharedDecksBinding
39+
import com.ichi2.anki.isLoggedIn
3740
import com.ichi2.anki.snackbar.showSnackbar
3841
import com.ichi2.anki.workarounds.SafeWebViewLayout
3942
import com.ichi2.utils.FileNameAndExtension

AnkiDroid/src/main/java/com/ichi2/anki/SharedDecksDownloadFragment.kt renamed to AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadFragment.kt

Lines changed: 64 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* this program. If not, see <http://www.gnu.org/licenses/>.
1515
*/
1616

17-
package com.ichi2.anki
17+
package com.ichi2.anki.shareddeck
1818

1919
import android.app.DownloadManager
2020
import android.content.ActivityNotFoundException
@@ -35,19 +35,26 @@ import androidx.core.content.ContextCompat
3535
import androidx.core.content.FileProvider
3636
import androidx.core.net.toUri
3737
import androidx.fragment.app.Fragment
38+
import androidx.fragment.app.viewModels
39+
import androidx.lifecycle.Lifecycle
40+
import androidx.lifecycle.lifecycleScope
41+
import androidx.lifecycle.repeatOnLifecycle
3842
import com.ichi2.anki.CollectionManager.TR
39-
import com.ichi2.anki.SharedDecksActivity.Companion.DOWNLOAD_FILE
43+
import com.ichi2.anki.IntentHandler
44+
import com.ichi2.anki.R
4045
import com.ichi2.anki.android.AnkiBroadcastReceiver
4146
import com.ichi2.anki.common.crashreporting.CrashReportService
4247
import com.ichi2.anki.common.utils.android.showThemedToast
4348
import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat
4449
import com.ichi2.anki.compat.CompatHelper.Companion.registerReceiverCompat
4550
import com.ichi2.anki.databinding.FragmentSharedDecksDownloadBinding
51+
import com.ichi2.anki.shareddeck.SharedDecksActivity.Companion.DOWNLOAD_FILE
4652
import com.ichi2.anki.snackbar.showSnackbar
4753
import com.ichi2.anki.utils.openUrl
4854
import com.ichi2.utils.ImportUtils
4955
import com.ichi2.utils.create
5056
import dev.androidbroadcast.vbpd.viewBinding
57+
import kotlinx.coroutines.launch
5158
import timber.log.Timber
5259
import java.io.File
5360
import java.net.URLConnection
@@ -62,6 +69,7 @@ import kotlin.math.abs
6269
*/
6370
class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_download) {
6471
private val binding by viewBinding(FragmentSharedDecksDownloadBinding::bind)
72+
private val viewModel: SharedDecksDownloadViewModel by viewModels()
6573

6674
private var downloadId: Long = 0
6775

@@ -145,6 +153,13 @@ class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_down
145153
) {
146154
super.onViewCreated(view, savedInstanceState)
147155

156+
// Single source of truth: render whatever the ViewModel emits.
157+
viewLifecycleOwner.lifecycleScope.launch {
158+
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
159+
viewModel.uiState.collect(::renderState)
160+
}
161+
}
162+
148163
val fileToBeDownloaded = arguments?.getSerializableCompat<DownloadFile>(DOWNLOAD_FILE)!!
149164
downloadManager = (activity as SharedDecksActivity).downloadManager
150165

@@ -170,13 +185,26 @@ class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_down
170185
binding.tryDownloadAgainButton.setOnClickListener {
171186
Timber.i("Try again button clicked, retry downloading of deck")
172187
downloadManager.remove(downloadId)
188+
viewModel.onEvent(SharedDecksDownloadEvent.RetryRequested)
173189
downloadFile(fileToBeDownloaded)
174-
binding.cancelDownloadButton.visibility = View.VISIBLE
175-
binding.tryDownloadAgainButton.visibility = View.GONE
176-
binding.openInWebBrowserButton.visibility = View.GONE
177190
}
178191
}
179192

193+
/**
194+
* Applies the latest [SharedDecksDownloadUiState] to the XML views.
195+
* The only place that touches `binding.*` for display.
196+
*/
197+
private fun renderState(state: SharedDecksDownloadUiState) {
198+
binding.downloadingTitle.text = state.title
199+
binding.downloadPercentageText.text = state.percentageText
200+
binding.downloadProgressBar.progress = state.progress
201+
binding.checkNetworkInfoText.visibility = if (state.showNetworkError) View.VISIBLE else View.GONE
202+
binding.cancelDownloadButton.visibility = if (state.showCancelButton) View.VISIBLE else View.GONE
203+
binding.importSharedDeckButton.visibility = if (state.showImportButton) View.VISIBLE else View.GONE
204+
binding.tryDownloadAgainButton.visibility = if (state.showTryAgainButton) View.VISIBLE else View.GONE
205+
binding.openInWebBrowserButton.visibility = if (state.showOpenInBrowserButton) View.VISIBLE else View.GONE
206+
}
207+
180208
/**
181209
* Register broadcast receiver for listening to download completion.
182210
* Set the request for downloading a deck, enqueue it in DownloadManager, store download ID and
@@ -214,7 +242,7 @@ class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_down
214242
onBackPressedCallback.isEnabled = isDownloadInProgress
215243
Timber.d("Download ID -> $downloadId")
216244
Timber.d("File name -> $fileName")
217-
binding.downloadingTitle.text = getString(R.string.downloading_file, fileName)
245+
viewModel.onEvent(SharedDecksDownloadEvent.TitleChanged(getString(R.string.downloading_file, fileName)))
218246
startDownloadProgressChecker()
219247
}
220248

@@ -326,12 +354,11 @@ class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_down
326354

327355
if (isVisible) {
328356
// Setting these since progress checker can stop before progress is updated to represent 100%
329-
binding.downloadPercentageText.text = getString(R.string.percentage, DOWNLOAD_COMPLETED_PROGRESS_PERCENTAGE)
330-
binding.downloadProgressBar.progress = DOWNLOAD_COMPLETED_PROGRESS_PERCENTAGE.toInt()
331-
332-
// Remove cancel button and show import deck button
333-
binding.cancelDownloadButton.visibility = View.GONE
334-
binding.importSharedDeckButton.visibility = View.VISIBLE
357+
viewModel.onEvent(
358+
SharedDecksDownloadEvent.DownloadCompleted(
359+
percentageText = getString(R.string.percentage, DOWNLOAD_COMPLETED_PROGRESS_PERCENTAGE),
360+
),
361+
)
335362
}
336363

337364
Timber.i("Opening downloaded deck for import")
@@ -399,8 +426,12 @@ class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_down
399426
Timber.d("Starting download progress checker")
400427
downloadProgressChecker.run()
401428
isProgressCheckerRunning = true
402-
binding.downloadPercentageText.text = getString(R.string.percentage, DOWNLOAD_STARTED_PROGRESS_PERCENTAGE)
403-
binding.downloadProgressBar.progress = DOWNLOAD_STARTED_PROGRESS_PERCENTAGE.toInt()
429+
viewModel.onEvent(
430+
SharedDecksDownloadEvent.ProgressUpdated(
431+
percent = DOWNLOAD_STARTED_PROGRESS_PERCENTAGE.toInt(),
432+
percentageText = getString(R.string.percentage, DOWNLOAD_STARTED_PROGRESS_PERCENTAGE),
433+
),
434+
)
404435
}
405436

406437
/**
@@ -424,8 +455,12 @@ class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_down
424455
downloadManager.query(query)
425456
} catch (_: IllegalArgumentException) {
426457
// 19812: column local_filename is not allowed in queries
427-
binding.downloadPercentageText.text = TR.syncDownloadingFromAnkiweb()
428-
binding.downloadProgressBar.progress = 0
458+
viewModel.onEvent(
459+
SharedDecksDownloadEvent.ProgressUpdated(
460+
percent = 0,
461+
percentageText = TR.syncDownloadingFromAnkiweb(),
462+
),
463+
)
429464
return
430465
}
431466

@@ -449,8 +484,12 @@ class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_down
449484
// Show download progress percentage up to 1 decimal place.
450485
"%.1f".format(downloadProgress)
451486
}
452-
binding.downloadPercentageText.text = getString(R.string.percentage, percentageValue)
453-
binding.downloadProgressBar.progress = downloadProgress.toInt()
487+
viewModel.onEvent(
488+
SharedDecksDownloadEvent.ProgressUpdated(
489+
percent = downloadProgress.toInt(),
490+
percentageText = getString(R.string.percentage, percentageValue),
491+
),
492+
)
454493

455494
val columnIndexForStatus = it.getColumnIndex(DownloadManager.COLUMN_STATUS)
456495
val columnIndexForReason = it.getColumnIndex(DownloadManager.COLUMN_REASON)
@@ -466,13 +505,10 @@ class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_down
466505
}
467506

468507
// Display message if download is waiting for network connection
469-
if (it.getInt(columnIndexForStatus) == DownloadManager.STATUS_PAUSED &&
470-
it.getInt(columnIndexForReason) == DownloadManager.PAUSED_WAITING_FOR_NETWORK
471-
) {
472-
binding.checkNetworkInfoText.visibility = View.VISIBLE
473-
} else {
474-
binding.checkNetworkInfoText.visibility = View.GONE
475-
}
508+
val waitingForNetwork =
509+
it.getInt(columnIndexForStatus) == DownloadManager.STATUS_PAUSED &&
510+
it.getInt(columnIndexForReason) == DownloadManager.PAUSED_WAITING_FOR_NETWORK
511+
viewModel.onEvent(SharedDecksDownloadEvent.NetworkErrorChanged(showing = waitingForNetwork))
476512
}
477513
}
478514

@@ -524,12 +560,9 @@ class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_down
524560
} else {
525561
Timber.i("Download failed, update UI and provide option to retry")
526562
context?.let { showThemedToast(it, R.string.something_wrong, false) }
527-
// Update UI if download could not be successful
528-
binding.tryDownloadAgainButton.visibility = View.VISIBLE
529-
binding.openInWebBrowserButton.visibility = View.VISIBLE
530-
binding.cancelDownloadButton.visibility = View.GONE
531-
binding.downloadPercentageText.text = getString(R.string.download_failed)
532-
binding.downloadProgressBar.progress = DOWNLOAD_STARTED_PROGRESS_PERCENTAGE.toInt()
563+
viewModel.onEvent(
564+
SharedDecksDownloadEvent.DownloadFailed(failedText = getString(R.string.download_failed)),
565+
)
533566
}
534567
}
535568

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// SPDX-FileCopyrightText: 2026 Ashish Yadav <mailtoashish693@gmail.com>
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
package com.ichi2.anki.shareddeck
5+
6+
import androidx.lifecycle.ViewModel
7+
import kotlinx.coroutines.flow.MutableStateFlow
8+
import kotlinx.coroutines.flow.StateFlow
9+
import kotlinx.coroutines.flow.asStateFlow
10+
import kotlinx.coroutines.flow.update
11+
12+
/**
13+
* Holds the UI state for [SharedDecksDownloadFragment].
14+
*/
15+
class SharedDecksDownloadViewModel : ViewModel() {
16+
private val _uiState = MutableStateFlow(SharedDecksDownloadUiState())
17+
val uiState: StateFlow<SharedDecksDownloadUiState> = _uiState.asStateFlow()
18+
19+
fun onEvent(event: SharedDecksDownloadEvent) {
20+
_uiState.update { current ->
21+
when (event) {
22+
is SharedDecksDownloadEvent.TitleChanged ->
23+
current.copy(title = event.title)
24+
is SharedDecksDownloadEvent.ProgressUpdated ->
25+
current.copy(
26+
progress = event.percent,
27+
percentageText = event.percentageText,
28+
)
29+
is SharedDecksDownloadEvent.PercentageTextChanged ->
30+
current.copy(percentageText = event.text)
31+
is SharedDecksDownloadEvent.NetworkErrorChanged ->
32+
current.copy(showNetworkError = event.showing)
33+
is SharedDecksDownloadEvent.DownloadCompleted ->
34+
current.copy(
35+
progress = 100,
36+
percentageText = event.percentageText,
37+
showCancelButton = false,
38+
showImportButton = true,
39+
)
40+
is SharedDecksDownloadEvent.DownloadFailed ->
41+
current.copy(
42+
progress = 0,
43+
percentageText = event.failedText,
44+
showCancelButton = false,
45+
showTryAgainButton = true,
46+
showOpenInBrowserButton = true,
47+
)
48+
SharedDecksDownloadEvent.RetryRequested ->
49+
current.copy(
50+
showCancelButton = true,
51+
showTryAgainButton = false,
52+
showOpenInBrowserButton = false,
53+
)
54+
}
55+
}
56+
}
57+
}
58+
59+
/**
60+
* Snapshot of every view-visible field on the shared-decks download screen.
61+
*/
62+
data class SharedDecksDownloadUiState(
63+
val title: String = "",
64+
val progress: Int = 0,
65+
val percentageText: String = "",
66+
val showNetworkError: Boolean = false,
67+
val showCancelButton: Boolean = true,
68+
val showImportButton: Boolean = false,
69+
val showTryAgainButton: Boolean = false,
70+
val showOpenInBrowserButton: Boolean = false,
71+
)
72+
73+
/**
74+
* Anything the screen can be told to do. The fragment dispatches these in
75+
* response to download progress, broadcasts, and user actions; the ViewModel
76+
* folds them into the next [SharedDecksDownloadUiState].
77+
*/
78+
sealed interface SharedDecksDownloadEvent {
79+
data class TitleChanged(
80+
val title: String,
81+
) : SharedDecksDownloadEvent
82+
83+
data class ProgressUpdated(
84+
val percent: Int,
85+
val percentageText: String,
86+
) : SharedDecksDownloadEvent
87+
88+
/** Used when the progress percentage is unavailable, e.g. a transient query error. */
89+
data class PercentageTextChanged(
90+
val text: String,
91+
) : SharedDecksDownloadEvent
92+
93+
data class NetworkErrorChanged(
94+
val showing: Boolean,
95+
) : SharedDecksDownloadEvent
96+
97+
data class DownloadCompleted(
98+
val percentageText: String,
99+
) : SharedDecksDownloadEvent
100+
101+
data class DownloadFailed(
102+
val failedText: String,
103+
) : SharedDecksDownloadEvent
104+
105+
/** Restore the Downloading button layout before re-running the download. */
106+
data object RetryRequested : SharedDecksDownloadEvent
107+
}

AnkiDroid/src/test/java/com/ichi2/anki/SharedDecksDownloadFragmentTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
package com.ichi2.anki
44

55
import androidx.test.ext.junit.runners.AndroidJUnit4
6-
import com.ichi2.anki.SharedDecksDownloadFragment.Companion.getDeckPageUri
6+
import com.ichi2.anki.shareddeck.SharedDecksDownloadFragment
7+
import com.ichi2.anki.shareddeck.SharedDecksDownloadFragment.Companion.getDeckPageUri
78
import org.junit.Assert.assertEquals
89
import org.junit.Test
910
import org.junit.runner.RunWith

AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import com.ichi2.anki.IntroductionActivity
3333
import com.ichi2.anki.NoteEditorActivity
3434
import com.ichi2.anki.NoteTypeFieldEditor
3535
import com.ichi2.anki.Reviewer
36-
import com.ichi2.anki.SharedDecksActivity
3736
import com.ichi2.anki.SingleFragmentActivity
3837
import com.ichi2.anki.StudyOptionsActivity
3938
import com.ichi2.anki.account.AccountActivity
@@ -42,6 +41,7 @@ import com.ichi2.anki.multimedia.MultimediaActivity
4241
import com.ichi2.anki.notetype.ManageNotetypes
4342
import com.ichi2.anki.preferences.PreferencesActivity
4443
import com.ichi2.anki.previewer.CardViewerActivity
44+
import com.ichi2.anki.shareddeck.SharedDecksActivity
4545
import com.ichi2.anki.ui.windows.managespace.ManageSpaceActivity
4646
import com.ichi2.anki.ui.windows.permissions.AllPermissionsExplanationActivity
4747
import com.ichi2.anki.ui.windows.permissions.PermissionsActivity

0 commit comments

Comments
 (0)