From fbd26ebd7d2c4aeb014036e0237a0eaddfd24cc5 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 28 Mar 2026 05:00:36 +0000 Subject: [PATCH 1/3] 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 --- .../ReminderTroubleshootingFragment.kt | 105 +++++++++++++++ .../anki/reviewreminders/ScheduleReminders.kt | 126 +++++++++++------- .../java/com/ichi2/anki/utils/ext/View.kt | 14 ++ .../res/drawable/bg_troubleshooting_icon.xml | 5 + .../src/main/res/drawable/ic_cancel_24.xml | 14 ++ .../main/res/drawable/ic_check_circle_24.xml | 14 ++ .../res/drawable/ic_outline_troubleshoot.xml | 5 + .../src/main/res/drawable/ic_warning_24.xml | 9 ++ .../fragment_reminder_troubleshooting.xml | 99 ++++++++++++++ .../src/main/res/menu/schedule_reminders.xml | 11 ++ AnkiDroid/src/main/res/values/attrs.xml | 4 + AnkiDroid/src/main/res/values/colors.xml | 7 + AnkiDroid/src/main/res/values/theme_dark.xml | 4 + AnkiDroid/src/main/res/values/theme_light.xml | 4 + 14 files changed, 373 insertions(+), 48 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingFragment.kt create mode 100644 AnkiDroid/src/main/res/drawable/bg_troubleshooting_icon.xml create mode 100644 AnkiDroid/src/main/res/drawable/ic_cancel_24.xml create mode 100644 AnkiDroid/src/main/res/drawable/ic_check_circle_24.xml create mode 100644 AnkiDroid/src/main/res/drawable/ic_outline_troubleshoot.xml create mode 100644 AnkiDroid/src/main/res/drawable/ic_warning_24.xml create mode 100644 AnkiDroid/src/main/res/layout/fragment_reminder_troubleshooting.xml create mode 100644 AnkiDroid/src/main/res/menu/schedule_reminders.xml diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingFragment.kt new file mode 100644 index 000000000000..d1029f179c8b --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingFragment.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.reviewreminders + +import android.content.Context +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.ichi2.anki.R +import com.ichi2.anki.databinding.FragmentReminderTroubleshootingBinding +import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope +import com.ichi2.anki.utils.ext.setBackgroundTint +import com.ichi2.themes.Themes +import dev.androidbroadcast.vbpd.viewBinding + +/** %alpha to use for the circular background of icons */ +private const val BACKGROUND_ALPHA = 0.12f + +class ReminderTroubleshootingFragment : Fragment(R.layout.fragment_reminder_troubleshooting) { + private val viewModel: ReminderTroubleshootingViewModel by activityViewModels { + reminderTroubleshootingViewModelFactory(requireContext()) + } + + private val binding by viewBinding(FragmentReminderTroubleshootingBinding::bind) + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + binding.toolbar.setNavigationOnClickListener { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + + setupSummary() + } + + private fun setupSummary() { + viewModel.state.launchCollectionInLifecycleScope { state -> + val context = requireContext() + val (iconRes, tintColor, summaryText) = + when (state.summaryStatus) { + SummaryStatus.Error -> + Triple( + R.drawable.ic_cancel_24, + context.getColor(android.R.color.holo_red_dark), + "Reminders are unavailable.", + ) + + SummaryStatus.Warning -> + Triple( + R.drawable.ic_warning_24, + Themes.getColorFromAttr(context, R.attr.reminderTroubleshootingWarning), + "Reminders may not work correctly.", + ) + + SummaryStatus.Ok -> + Triple( + R.drawable.ic_check_circle_24, + Themes.getColorFromAttr(context, R.attr.reminderTroubleshootingOk), + "Your reminders should work as expected.", + ) + } + binding.summaryIcon.setImageResource(iconRes) + binding.summaryIcon.setColorFilter(tintColor) + binding.summaryIconContainer.setBackgroundTint(tintColor, alpha = BACKGROUND_ALPHA) + binding.summaryText.text = summaryText + } + } +} + +/** + * Shared factory for [ReminderTroubleshootingViewModel]. + * + * Lives outside the ViewModel itself to keep the ViewModel free of `Context`. Both + * [ScheduleReminders] and [ReminderTroubleshootingFragment] use this with + * `by activityViewModels { … }` so they observe a single VM + repository instance. + */ +internal fun reminderTroubleshootingViewModelFactory(context: Context): ViewModelProvider.Factory { + val appContext = context.applicationContext + return viewModelFactory { + initializer { + ReminderTroubleshootingViewModel(ReminderTroubleshootingRepository(appContext)) + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleReminders.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleReminders.kt index 880ac8db3bcb..83e87d077d9b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleReminders.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleReminders.kt @@ -16,20 +16,25 @@ package com.ichi2.anki.reviewreminders -import android.Manifest import android.content.Context import android.content.Intent -import android.os.Build import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.core.os.BundleCompat +import androidx.core.view.MenuProvider import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.commit import androidx.fragment.app.setFragmentResult import androidx.fragment.app.setFragmentResultListener +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.snackbar.Snackbar @@ -43,15 +48,13 @@ import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.model.SelectableDeck import com.ichi2.anki.services.AlarmManagerService -import com.ichi2.anki.settings.Prefs import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider import com.ichi2.anki.snackbar.SnackbarBuilder import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.utils.ext.showDialogFragment import com.ichi2.anki.withProgress -import com.ichi2.utils.Permissions -import com.ichi2.utils.Permissions.requestPermissionThroughDialogOrSettings import dev.androidbroadcast.vbpd.viewBinding +import kotlinx.coroutines.launch import timber.log.Timber /** @@ -75,24 +78,18 @@ class ScheduleReminders : private val binding by viewBinding(FragmentScheduleRemindersBinding::bind) + private val troubleshootingViewModel: ReminderTroubleshootingViewModel by activityViewModels { + reminderTroubleshootingViewModelFactory(requireContext()) + } + private lateinit var adapter: ScheduleRemindersAdapter + private var troubleshootingSnackbar: Snackbar? = null + override val baseSnackbarBuilder: SnackbarBuilder = { anchorView = binding.floatingActionButtonAdd } - private var notificationPermissionSnackbar: Snackbar? = null - - /** - * Launches the OS dialog for requesting notification permissions. - * If notification permissions are not granted, a small persistent Snackbar reminder about it shows up. - * When the user clicks the "Enable" action on the Snackbar, this launcher is used. - */ - private val notificationPermissionLauncher = - registerForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { isGranted -> Timber.i("Notification permission result: $isGranted") } - /** * The reminders currently being displayed in the UI. To make changes to this list show up on screen, * use [triggerUIUpdate]. Note that editing this map does not also automatically write to the database. @@ -116,10 +113,55 @@ class ScheduleReminders : // Set up toolbar reloadToolbarText() (requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar) + requireActivity().addMenuProvider( + object : MenuProvider { + override fun onCreateMenu( + menu: Menu, + menuInflater: MenuInflater, + ) { + menuInflater.inflate(R.menu.schedule_reminders, menu) + } + override fun onMenuItemSelected(menuItem: MenuItem): Boolean = + when (menuItem.itemId) { + R.id.action_troubleshoot -> { + openTroubleshootingScreen() + true + } + else -> false + } + }, + viewLifecycleOwner, + ) // Set up add button binding.floatingActionButtonAdd.setOnClickListener { addReminder() } + // Troubleshoot snackbar: shown persistently when checks find a warning/error. + // Tapping "Fix" opens the full troubleshooting screen. + lifecycleScope.launch { + troubleshootingViewModel.state + .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED) + .collect { state -> + val message = + when (state.summaryStatus) { + SummaryStatus.Ok, SummaryStatus.Warning -> { + troubleshootingSnackbar?.dismiss() + troubleshootingSnackbar = null + return@collect + } + SummaryStatus.Error -> "Reminders are unavailable" + } + if (troubleshootingSnackbar?.isShown == true) { + troubleshootingSnackbar?.setText(message) + return@collect + } + troubleshootingSnackbar = + showSnackbar(text = message, duration = Snackbar.LENGTH_INDEFINITE) { + setAction("Fix") { openTroubleshootingScreen() } + } + } + } + // Set up recycler view val layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = layoutManager @@ -365,6 +407,22 @@ class ScheduleReminders : } } + /** + * Opens a screen where the user can see why reminders may not fire as expected + * @see ReminderTroubleshootingFragment + */ + private fun openTroubleshootingScreen() { + troubleshootingSnackbar?.dismiss() + parentFragmentManager.commit { + replace( + R.id.fragment_container, + ReminderTroubleshootingFragment(), + SingleFragmentActivity.FRAGMENT_TAG, + ) + addToBackStack(null) + } + } + /** * The method that runs when the "+" icon is pressed, allowing the user to create a new review reminder. * Opens [AddEditReminderDialog] in [AddEditReminderDialog.DialogMode.Add] mode. @@ -423,35 +481,7 @@ class ScheduleReminders : override fun onResume() { super.onResume() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - checkForNotificationPermissions() - } - } - - /** - * Shows a persistent snackbar if the user has not granted notification permissions. - */ - @RequiresApi(Build.VERSION_CODES.TIRAMISU) - private fun checkForNotificationPermissions() { - if (!Prefs.reminderNotifsRequestShown || Permissions.canPostNotifications(requireContext())) { - notificationPermissionSnackbar?.dismiss() - return - } - - notificationPermissionSnackbar = - showSnackbar( - text = "Notifications are disabled", - duration = Snackbar.LENGTH_INDEFINITE, - ) { - setAction("Enable") { - requestPermissionThroughDialogOrSettings( - activity = requireActivity(), - permission = Manifest.permission.POST_NOTIFICATIONS, - permissionRequestedFlag = Prefs::notificationsPermissionRequested, - permissionRequestLauncher = notificationPermissionLauncher, - ) - } - } + troubleshootingViewModel.refreshChecks() } companion object { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/View.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/View.kt index 4f747673a7d0..f489fd2012ec 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/View.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/View.kt @@ -1,5 +1,6 @@ /* * Copyright (c) 2025 Danilo Mendes + * Copyright (c) 2026 David Allison * * This program is free software; you can redistribute it and/or modify it under * the terms of the GNU General Public License as published by the Free Software @@ -16,11 +17,24 @@ package com.ichi2.anki.utils.ext +import android.content.res.ColorStateList import android.graphics.Rect import android.view.MotionEvent import android.view.View +import com.google.android.material.color.MaterialColors fun View.isTouchWithinBounds(event: MotionEvent): Boolean { val rect = Rect().apply { getDrawingRect(this) } return rect.contains((left + event.x).toInt(), (top + event.y).toInt()) } + +/** Tints the background to [color] at the given opacity (0f..1f) */ +fun View.setBackgroundTint( + color: Int, + alpha: Float, +) { + backgroundTintList = + ColorStateList.valueOf( + MaterialColors.compositeARGBWithAlpha(color, (alpha * 255).toInt()), + ) +} diff --git a/AnkiDroid/src/main/res/drawable/bg_troubleshooting_icon.xml b/AnkiDroid/src/main/res/drawable/bg_troubleshooting_icon.xml new file mode 100644 index 000000000000..101dc75e2791 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/bg_troubleshooting_icon.xml @@ -0,0 +1,5 @@ + + + + diff --git a/AnkiDroid/src/main/res/drawable/ic_cancel_24.xml b/AnkiDroid/src/main/res/drawable/ic_cancel_24.xml new file mode 100644 index 000000000000..b51c3d93dd58 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/ic_cancel_24.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/AnkiDroid/src/main/res/drawable/ic_check_circle_24.xml b/AnkiDroid/src/main/res/drawable/ic_check_circle_24.xml new file mode 100644 index 000000000000..96f4c50f9fdd --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/ic_check_circle_24.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/AnkiDroid/src/main/res/drawable/ic_outline_troubleshoot.xml b/AnkiDroid/src/main/res/drawable/ic_outline_troubleshoot.xml new file mode 100644 index 000000000000..82c5d27ea706 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/ic_outline_troubleshoot.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/AnkiDroid/src/main/res/drawable/ic_warning_24.xml b/AnkiDroid/src/main/res/drawable/ic_warning_24.xml new file mode 100644 index 000000000000..74fcc6f74720 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/ic_warning_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/AnkiDroid/src/main/res/layout/fragment_reminder_troubleshooting.xml b/AnkiDroid/src/main/res/layout/fragment_reminder_troubleshooting.xml new file mode 100644 index 000000000000..e52f7d9b1070 --- /dev/null +++ b/AnkiDroid/src/main/res/layout/fragment_reminder_troubleshooting.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AnkiDroid/src/main/res/menu/schedule_reminders.xml b/AnkiDroid/src/main/res/menu/schedule_reminders.xml new file mode 100644 index 000000000000..407bfd41cd1f --- /dev/null +++ b/AnkiDroid/src/main/res/menu/schedule_reminders.xml @@ -0,0 +1,11 @@ + + + + diff --git a/AnkiDroid/src/main/res/values/attrs.xml b/AnkiDroid/src/main/res/values/attrs.xml index 46279259d740..d137e5f65e10 100644 --- a/AnkiDroid/src/main/res/values/attrs.xml +++ b/AnkiDroid/src/main/res/values/attrs.xml @@ -233,4 +233,8 @@ + + + + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/values/colors.xml b/AnkiDroid/src/main/res/values/colors.xml index e4afddfe3a95..8b8ca907a056 100644 --- a/AnkiDroid/src/main/res/values/colors.xml +++ b/AnkiDroid/src/main/res/values/colors.xml @@ -106,6 +106,13 @@ #303030 + + #81c995 + #388E3C + + #fdd633 + #DE4900 + #D1D0CE #000000 diff --git a/AnkiDroid/src/main/res/values/theme_dark.xml b/AnkiDroid/src/main/res/values/theme_dark.xml index 1969f5f0f388..dbc51612d6c4 100644 --- a/AnkiDroid/src/main/res/values/theme_dark.xml +++ b/AnkiDroid/src/main/res/values/theme_dark.xml @@ -172,6 +172,10 @@ @style/ThemeOverlay.App.BottomSheetDialog @color/material_theme_grey + + + @color/google_green_300 + @color/google_yellow_300