Skip to content

Commit a7e1d08

Browse files
committed
feat(reminders): add troubleshooting screen
For now, this only shows the status, not the checks * Adds a menu item on reminders to open troubleshooting * Display the overall status of checks Assisted-by: Claude Opus 4.6
1 parent 7902984 commit a7e1d08

14 files changed

Lines changed: 373 additions & 48 deletions
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* Copyright (c) 2026 David Allison <davidallisongithub@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 FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
17+
package com.ichi2.anki.reviewreminders
18+
19+
import android.content.Context
20+
import android.os.Bundle
21+
import android.view.View
22+
import androidx.fragment.app.Fragment
23+
import androidx.fragment.app.activityViewModels
24+
import androidx.lifecycle.ViewModelProvider
25+
import androidx.lifecycle.viewmodel.initializer
26+
import androidx.lifecycle.viewmodel.viewModelFactory
27+
import com.ichi2.anki.R
28+
import com.ichi2.anki.databinding.FragmentReminderTroubleshootingBinding
29+
import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope
30+
import com.ichi2.anki.utils.ext.setBackgroundTint
31+
import com.ichi2.themes.Themes
32+
import dev.androidbroadcast.vbpd.viewBinding
33+
34+
/** %alpha to use for the circular background of icons */
35+
private const val BACKGROUND_ALPHA = 0.12f
36+
37+
class ReminderTroubleshootingFragment : Fragment(R.layout.fragment_reminder_troubleshooting) {
38+
private val viewModel: ReminderTroubleshootingViewModel by activityViewModels {
39+
reminderTroubleshootingViewModelFactory(requireContext())
40+
}
41+
42+
private val binding by viewBinding(FragmentReminderTroubleshootingBinding::bind)
43+
44+
override fun onViewCreated(
45+
view: View,
46+
savedInstanceState: Bundle?,
47+
) {
48+
super.onViewCreated(view, savedInstanceState)
49+
50+
binding.toolbar.setNavigationOnClickListener {
51+
requireActivity().onBackPressedDispatcher.onBackPressed()
52+
}
53+
54+
setupSummary()
55+
}
56+
57+
private fun setupSummary() {
58+
viewModel.state.launchCollectionInLifecycleScope { state ->
59+
val context = requireContext()
60+
val (iconRes, tintColor, summaryText) =
61+
when (state.summaryStatus) {
62+
SummaryStatus.Error ->
63+
Triple(
64+
R.drawable.ic_cancel_24,
65+
context.getColor(android.R.color.holo_red_dark),
66+
"Reminders are unavailable.",
67+
)
68+
69+
SummaryStatus.Warning ->
70+
Triple(
71+
R.drawable.ic_warning_24,
72+
Themes.getColorFromAttr(context, R.attr.reminderTroubleshootingWarning),
73+
"Reminders may not work correctly.",
74+
)
75+
76+
SummaryStatus.Ok ->
77+
Triple(
78+
R.drawable.ic_check_circle_24,
79+
Themes.getColorFromAttr(context, R.attr.reminderTroubleshootingOk),
80+
"Your reminders should work as expected.",
81+
)
82+
}
83+
binding.summaryIcon.setImageResource(iconRes)
84+
binding.summaryIcon.setColorFilter(tintColor)
85+
binding.summaryIconContainer.setBackgroundTint(tintColor, alpha = BACKGROUND_ALPHA)
86+
binding.summaryText.text = summaryText
87+
}
88+
}
89+
}
90+
91+
/**
92+
* Shared factory for [ReminderTroubleshootingViewModel].
93+
*
94+
* Lives outside the ViewModel itself to keep the ViewModel free of `Context`. Both
95+
* [ScheduleReminders] and [ReminderTroubleshootingFragment] use this with
96+
* `by activityViewModels { … }` so they observe a single VM + repository instance.
97+
*/
98+
internal fun reminderTroubleshootingViewModelFactory(context: Context): ViewModelProvider.Factory {
99+
val appContext = context.applicationContext
100+
return viewModelFactory {
101+
initializer {
102+
ReminderTroubleshootingViewModel(ReminderTroubleshootingRepository(appContext))
103+
}
104+
}
105+
}

AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleReminders.kt

Lines changed: 75 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,25 @@
1616

1717
package com.ichi2.anki.reviewreminders
1818

19-
import android.Manifest
2019
import android.content.Context
2120
import android.content.Intent
22-
import android.os.Build
2321
import android.os.Bundle
22+
import android.view.Menu
23+
import android.view.MenuInflater
24+
import android.view.MenuItem
2425
import android.view.View
25-
import androidx.activity.result.contract.ActivityResultContracts
26-
import androidx.annotation.RequiresApi
2726
import androidx.appcompat.app.AppCompatActivity
2827
import androidx.core.os.BundleCompat
28+
import androidx.core.view.MenuProvider
2929
import androidx.core.view.isVisible
3030
import androidx.fragment.app.Fragment
31+
import androidx.fragment.app.activityViewModels
32+
import androidx.fragment.app.commit
3133
import androidx.fragment.app.setFragmentResult
3234
import androidx.fragment.app.setFragmentResultListener
35+
import androidx.lifecycle.Lifecycle
36+
import androidx.lifecycle.flowWithLifecycle
37+
import androidx.lifecycle.lifecycleScope
3338
import androidx.recyclerview.widget.DividerItemDecoration
3439
import androidx.recyclerview.widget.LinearLayoutManager
3540
import com.google.android.material.snackbar.Snackbar
@@ -44,16 +49,14 @@ import com.ichi2.anki.launchCatchingTask
4449
import com.ichi2.anki.libanki.DeckId
4550
import com.ichi2.anki.model.SelectableDeck
4651
import com.ichi2.anki.services.AlarmManagerService
47-
import com.ichi2.anki.settings.Prefs
4852
import com.ichi2.anki.showError
4953
import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider
5054
import com.ichi2.anki.snackbar.SnackbarBuilder
5155
import com.ichi2.anki.snackbar.showSnackbar
5256
import com.ichi2.anki.utils.ext.showDialogFragment
5357
import com.ichi2.anki.withProgress
54-
import com.ichi2.utils.Permissions
55-
import com.ichi2.utils.Permissions.requestPermissionThroughDialogOrSettings
5658
import dev.androidbroadcast.vbpd.viewBinding
59+
import kotlinx.coroutines.launch
5760
import kotlinx.serialization.SerializationException
5861
import timber.log.Timber
5962

@@ -78,24 +81,18 @@ class ScheduleReminders :
7881

7982
private val binding by viewBinding(FragmentScheduleRemindersBinding::bind)
8083

84+
private val troubleshootingViewModel: ReminderTroubleshootingViewModel by activityViewModels {
85+
reminderTroubleshootingViewModelFactory(requireContext())
86+
}
87+
8188
private lateinit var adapter: ScheduleRemindersAdapter
8289

90+
private var troubleshootSnackbar: Snackbar? = null
91+
8392
override val baseSnackbarBuilder: SnackbarBuilder = {
8493
anchorView = binding.floatingActionButtonAdd
8594
}
8695

87-
private var notificationPermissionSnackbar: Snackbar? = null
88-
89-
/**
90-
* Launches the OS dialog for requesting notification permissions.
91-
* If notification permissions are not granted, a small persistent Snackbar reminder about it shows up.
92-
* When the user clicks the "Enable" action on the Snackbar, this launcher is used.
93-
*/
94-
private val notificationPermissionLauncher =
95-
registerForActivityResult(
96-
ActivityResultContracts.RequestPermission(),
97-
) { isGranted -> Timber.i("Notification permission result: $isGranted") }
98-
9996
/**
10097
* The reminders currently being displayed in the UI. To make changes to this list show up on screen,
10198
* use [triggerUIUpdate]. Note that editing this map does not also automatically write to the database.
@@ -119,10 +116,55 @@ class ScheduleReminders :
119116
// Set up toolbar
120117
reloadToolbarText()
121118
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
119+
requireActivity().addMenuProvider(
120+
object : MenuProvider {
121+
override fun onCreateMenu(
122+
menu: Menu,
123+
menuInflater: MenuInflater,
124+
) {
125+
menuInflater.inflate(R.menu.schedule_reminders, menu)
126+
}
122127

128+
override fun onMenuItemSelected(menuItem: MenuItem): Boolean =
129+
when (menuItem.itemId) {
130+
R.id.action_troubleshoot -> {
131+
openTroubleshootingScreen()
132+
true
133+
}
134+
else -> false
135+
}
136+
},
137+
viewLifecycleOwner,
138+
)
123139
// Set up add button
124140
binding.floatingActionButtonAdd.setOnClickListener { addReminder() }
125141

142+
// Troubleshoot snackbar: shown persistently when checks find a warning/error.
143+
// Tapping "Fix" opens the full troubleshooting screen.
144+
lifecycleScope.launch {
145+
troubleshootingViewModel.state
146+
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
147+
.collect { state ->
148+
val message =
149+
when (state.summaryStatus) {
150+
SummaryStatus.Ok, SummaryStatus.Warning -> {
151+
troubleshootSnackbar?.dismiss()
152+
troubleshootSnackbar = null
153+
return@collect
154+
}
155+
SummaryStatus.Error -> "Reminders are unavailable"
156+
}
157+
if (troubleshootSnackbar?.isShown == true) {
158+
troubleshootSnackbar?.setText(message)
159+
return@collect
160+
}
161+
troubleshootSnackbar =
162+
showSnackbar(text = message, duration = Snackbar.LENGTH_INDEFINITE) {
163+
setAction("Fix") { openTroubleshootingScreen() }
164+
}
165+
}
166+
}
167+
126168
// Set up recycler view
127169
val layoutManager = LinearLayoutManager(requireContext())
128170
binding.recyclerView.layoutManager = layoutManager
@@ -396,6 +438,19 @@ class ScheduleReminders :
396438
}
397439
}
398440

441+
// TODO: Docs
442+
private fun openTroubleshootingScreen() {
443+
troubleshootSnackbar?.dismiss()
444+
parentFragmentManager.commit {
445+
replace(
446+
R.id.fragment_container,
447+
ReminderTroubleshootingFragment(),
448+
SingleFragmentActivity.FRAGMENT_TAG,
449+
)
450+
addToBackStack(null)
451+
}
452+
}
453+
399454
/**
400455
* The method that runs when the "+" icon is pressed, allowing the user to create a new review reminder.
401456
* Opens [AddEditReminderDialog] in [AddEditReminderDialog.DialogMode.Add] mode.
@@ -454,35 +509,7 @@ class ScheduleReminders :
454509

455510
override fun onResume() {
456511
super.onResume()
457-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
458-
checkForNotificationPermissions()
459-
}
460-
}
461-
462-
/**
463-
* Shows a persistent snackbar if the user has not granted notification permissions.
464-
*/
465-
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
466-
private fun checkForNotificationPermissions() {
467-
if (!Prefs.reminderNotifsRequestShown || Permissions.canPostNotifications(requireContext())) {
468-
notificationPermissionSnackbar?.dismiss()
469-
return
470-
}
471-
472-
notificationPermissionSnackbar =
473-
showSnackbar(
474-
text = "Notifications are disabled",
475-
duration = Snackbar.LENGTH_INDEFINITE,
476-
) {
477-
setAction("Enable") {
478-
requestPermissionThroughDialogOrSettings(
479-
activity = requireActivity(),
480-
permission = Manifest.permission.POST_NOTIFICATIONS,
481-
permissionRequestedFlag = Prefs::notificationsPermissionRequested,
482-
permissionRequestLauncher = notificationPermissionLauncher,
483-
)
484-
}
485-
}
512+
troubleshootingViewModel.refreshChecks()
486513
}
487514

488515
companion object {

AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/View.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/*
22
* Copyright (c) 2025 Danilo Mendes <danilodanicomendes@gmail.com>
3+
* Copyright (c) 2026 David Allison <davidallisongithub@gmail.com>
34
*
45
* This program is free software; you can redistribute it and/or modify it under
56
* the terms of the GNU General Public License as published by the Free Software
@@ -16,11 +17,24 @@
1617

1718
package com.ichi2.anki.utils.ext
1819

20+
import android.content.res.ColorStateList
1921
import android.graphics.Rect
2022
import android.view.MotionEvent
2123
import android.view.View
24+
import com.google.android.material.color.MaterialColors
2225

2326
fun View.isTouchWithinBounds(event: MotionEvent): Boolean {
2427
val rect = Rect().apply { getDrawingRect(this) }
2528
return rect.contains((left + event.x).toInt(), (top + event.y).toInt())
2629
}
30+
31+
/** Tints the background to [color] at the given opacity (0f..1f) */
32+
fun View.setBackgroundTint(
33+
color: Int,
34+
alpha: Float,
35+
) {
36+
backgroundTintList =
37+
ColorStateList.valueOf(
38+
MaterialColors.compositeARGBWithAlpha(color, (alpha * 255).toInt()),
39+
)
40+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<shape xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:shape="oval">
4+
<solid android:color="?attr/colorSurfaceVariant" />
5+
</shape>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="24"
5+
android:viewportHeight="24">
6+
<!-- Circle outline -->
7+
<path
8+
android:fillColor="@android:color/white"
9+
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
10+
<!-- X mark -->
11+
<path
12+
android:fillColor="@android:color/white"
13+
android:pathData="M14.59,8L12,10.59 9.41,8 8,9.41 10.59,12 8,14.59 9.41,16 12,13.41 14.59,16 16,14.59 13.41,12 16,9.41z" />
14+
</vector>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="24"
5+
android:viewportHeight="24">
6+
<!-- Circle outline -->
7+
<path
8+
android:fillColor="@android:color/white"
9+
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
10+
<!-- Checkmark (inset to match X size, thicker stroke) -->
11+
<path
12+
android:fillColor="@android:color/white"
13+
android:pathData="M10.8,13.9l-2.3,-2.3L7.2,12.9l3.6,3.6 6.4,-6.4 -1.3,-1.3z" />
14+
</vector>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="?attr/colorControlNormal" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
2+
3+
<path android:fillColor="@android:color/white" android:pathData="M824,840L636,652Q595,684 545.5,702Q496,720 440,720Q350,720 277.5,676Q205,632 163,560L261,560Q295,597 340.5,618.5Q386,640 440,640Q540,640 610,570Q680,500 680,400Q680,300 610,230Q540,160 440,160Q346,160 277.5,223.5Q209,287 201,380L121,380Q129,253 220.5,166.5Q312,80 440,80Q574,80 667,173Q760,266 760,400Q760,456 742,505.5Q724,555 692,596L880,784L824,840ZM397,560L334,352L282,500L80,500L80,440L240,440L306,250L366,250L427,454L470,320L530,320L590,440L620,440L620,500L553,500L506,406L456,560L397,560Z"/>
4+
5+
</vector>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="24"
5+
android:viewportHeight="24">
6+
<path
7+
android:fillColor="@android:color/white"
8+
android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z" />
9+
</vector>

0 commit comments

Comments
 (0)