Skip to content

Commit fbd26eb

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 Influenced by Google 'Battery Health' Assisted-by: Claude Opus 4.6
1 parent 424951b commit fbd26eb

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: 78 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
@@ -43,15 +48,13 @@ import com.ichi2.anki.launchCatchingTask
4348
import com.ichi2.anki.libanki.DeckId
4449
import com.ichi2.anki.model.SelectableDeck
4550
import com.ichi2.anki.services.AlarmManagerService
46-
import com.ichi2.anki.settings.Prefs
4751
import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider
4852
import com.ichi2.anki.snackbar.SnackbarBuilder
4953
import com.ichi2.anki.snackbar.showSnackbar
5054
import com.ichi2.anki.utils.ext.showDialogFragment
5155
import com.ichi2.anki.withProgress
52-
import com.ichi2.utils.Permissions
53-
import com.ichi2.utils.Permissions.requestPermissionThroughDialogOrSettings
5456
import dev.androidbroadcast.vbpd.viewBinding
57+
import kotlinx.coroutines.launch
5558
import timber.log.Timber
5659

5760
/**
@@ -75,24 +78,18 @@ class ScheduleReminders :
7578

7679
private val binding by viewBinding(FragmentScheduleRemindersBinding::bind)
7780

81+
private val troubleshootingViewModel: ReminderTroubleshootingViewModel by activityViewModels {
82+
reminderTroubleshootingViewModelFactory(requireContext())
83+
}
84+
7885
private lateinit var adapter: ScheduleRemindersAdapter
7986

87+
private var troubleshootingSnackbar: Snackbar? = null
88+
8089
override val baseSnackbarBuilder: SnackbarBuilder = {
8190
anchorView = binding.floatingActionButtonAdd
8291
}
8392

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

125+
override fun onMenuItemSelected(menuItem: MenuItem): Boolean =
126+
when (menuItem.itemId) {
127+
R.id.action_troubleshoot -> {
128+
openTroubleshootingScreen()
129+
true
130+
}
131+
else -> false
132+
}
133+
},
134+
viewLifecycleOwner,
135+
)
120136
// Set up add button
121137
binding.floatingActionButtonAdd.setOnClickListener { addReminder() }
122138

139+
// Troubleshoot snackbar: shown persistently when checks find a warning/error.
140+
// Tapping "Fix" opens the full troubleshooting screen.
141+
lifecycleScope.launch {
142+
troubleshootingViewModel.state
143+
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
144+
.collect { state ->
145+
val message =
146+
when (state.summaryStatus) {
147+
SummaryStatus.Ok, SummaryStatus.Warning -> {
148+
troubleshootingSnackbar?.dismiss()
149+
troubleshootingSnackbar = null
150+
return@collect
151+
}
152+
SummaryStatus.Error -> "Reminders are unavailable"
153+
}
154+
if (troubleshootingSnackbar?.isShown == true) {
155+
troubleshootingSnackbar?.setText(message)
156+
return@collect
157+
}
158+
troubleshootingSnackbar =
159+
showSnackbar(text = message, duration = Snackbar.LENGTH_INDEFINITE) {
160+
setAction("Fix") { openTroubleshootingScreen() }
161+
}
162+
}
163+
}
164+
123165
// Set up recycler view
124166
val layoutManager = LinearLayoutManager(requireContext())
125167
binding.recyclerView.layoutManager = layoutManager
@@ -365,6 +407,22 @@ class ScheduleReminders :
365407
}
366408
}
367409

410+
/**
411+
* Opens a screen where the user can see why reminders may not fire as expected
412+
* @see ReminderTroubleshootingFragment
413+
*/
414+
private fun openTroubleshootingScreen() {
415+
troubleshootingSnackbar?.dismiss()
416+
parentFragmentManager.commit {
417+
replace(
418+
R.id.fragment_container,
419+
ReminderTroubleshootingFragment(),
420+
SingleFragmentActivity.FRAGMENT_TAG,
421+
)
422+
addToBackStack(null)
423+
}
424+
}
425+
368426
/**
369427
* The method that runs when the "+" icon is pressed, allowing the user to create a new review reminder.
370428
* Opens [AddEditReminderDialog] in [AddEditReminderDialog.DialogMode.Add] mode.
@@ -423,35 +481,7 @@ class ScheduleReminders :
423481

424482
override fun onResume() {
425483
super.onResume()
426-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
427-
checkForNotificationPermissions()
428-
}
429-
}
430-
431-
/**
432-
* Shows a persistent snackbar if the user has not granted notification permissions.
433-
*/
434-
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
435-
private fun checkForNotificationPermissions() {
436-
if (!Prefs.reminderNotifsRequestShown || Permissions.canPostNotifications(requireContext())) {
437-
notificationPermissionSnackbar?.dismiss()
438-
return
439-
}
440-
441-
notificationPermissionSnackbar =
442-
showSnackbar(
443-
text = "Notifications are disabled",
444-
duration = Snackbar.LENGTH_INDEFINITE,
445-
) {
446-
setAction("Enable") {
447-
requestPermissionThroughDialogOrSettings(
448-
activity = requireActivity(),
449-
permission = Manifest.permission.POST_NOTIFICATIONS,
450-
permissionRequestedFlag = Prefs::notificationsPermissionRequested,
451-
permissionRequestLauncher = notificationPermissionLauncher,
452-
)
453-
}
454-
}
484+
troubleshootingViewModel.refreshChecks()
455485
}
456486

457487
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)