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..fbb471be4477 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingFragment.kt @@ -0,0 +1,381 @@ +/* + * 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.Manifest +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.net.toUri +import androidx.core.view.isVisible +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 androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.ichi2.anki.R +import com.ichi2.anki.databinding.FragmentReminderTroubleshootingBinding +import com.ichi2.anki.databinding.ItemTroubleshootingCheckBinding +import com.ichi2.anki.settings.Prefs +import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope +import com.ichi2.anki.utils.ext.onWindowFocusChanged +import com.ichi2.anki.utils.ext.setBackgroundTint +import com.ichi2.themes.Themes +import com.ichi2.utils.Permissions.requestPermissionThroughDialogOrSettings +import com.ichi2.utils.dp +import dev.androidbroadcast.vbpd.viewBinding +import timber.log.Timber + +/** + * An action which a user can take to resolve a [check][TroubleshootingCheck]. + * + * Example: Disabling power saving mode. + * + * `null` for checks which are not immediately resolvable: we should not offer to turn + * 'Do not disturb' off, as this is a user preference. + */ +private data class ResolveCheckAction( + val label: String, + /** A hardcoded (non-translatable) description of the action for logging purposes. */ + val logDescription: String, + val action: () -> Unit, +) + +/** %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) + + internal val notificationPermissionLauncher: ActivityResultLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { + viewModel.refreshChecks() + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + binding.toolbar.setNavigationOnClickListener { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + + setupSummary() + setupTroubleshootingChecks() + setupSettingChangeDetector() + } + + 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 + } + } + + private fun setupTroubleshootingChecks() { + val checksAdapter = + TroubleshootingChecksAdapter { check -> + with(this) { check.resolveAction() } + } + binding.checksList.adapter = checksAdapter + viewModel.state.launchCollectionInLifecycleScope { state -> + val visibleChecks = state.checks.filter { it.result !is CheckResult.Unavailable } + checksAdapter.submitList(visibleChecks) + } + } + + /** + * Refreshes checks when the window regains focus. + * + * This handles cases that [onResume] misses, such as toggling battery saver + * from the notification shade, which doesn't pause/resume the activity. + */ + private fun setupSettingChangeDetector() { + onWindowFocusChanged { hasFocus -> if (hasFocus) viewModel.refreshChecks() } + } +} + +/** + * 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)) + } + } +} + +private class TroubleshootingChecksAdapter( + private val getResolveAction: (TroubleshootingCheck) -> ResolveCheckAction?, +) : ListAdapter(DIFF_CALLBACK) { + class ViewHolder( + val binding: ItemTroubleshootingCheckBinding, + ) : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): ViewHolder { + val binding = + ItemTroubleshootingCheckBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ViewHolder(binding) + } + + override fun onBindViewHolder( + holder: ViewHolder, + position: Int, + ) { + val check = getItem(position) + val context = holder.itemView.context + val tintColor = check.result.tintColor(context) + val lastIndex = itemCount - 1 + + holder.binding.title.text = check.title() + holder.binding.statusName.text = check.statusName() + + val explanationText = check.explanation() + holder.binding.explanation.text = explanationText + holder.binding.explanation.isVisible = explanationText != null + + holder.binding.statusIcon.setImageResource(check.result.iconRes()) + holder.binding.statusIcon.setColorFilter(tintColor) + // The warning triangle is visually top-heavy; nudge it up to appear centered + holder.binding.statusIcon.translationY = if (check.result is CheckResult.Warning) -1.dp.toPx(context).toFloat() else 0f + holder.binding.iconContainer.setBackgroundTint(tintColor, alpha = BACKGROUND_ALPHA) + + // Rounded card-group: top/middle/bottom/single shapes with 2dp gaps + val backgroundRes = + when { + itemCount == 1 -> R.drawable.bg_troubleshooting_item_single + position == 0 -> R.drawable.bg_troubleshooting_item_top + position == lastIndex -> R.drawable.bg_troubleshooting_item_bottom + else -> R.drawable.bg_troubleshooting_item_middle + } + holder.binding.itemContainer.setBackgroundResource(backgroundRes) + + // Action link + item click: only actionable for failed/warning checks + val needsFix = check.result.hasIssue + val resolveAction = if (needsFix) getResolveAction(check) else null + holder.binding.actionLink.isVisible = resolveAction != null + if (resolveAction != null) { + holder.binding.actionLink.text = resolveAction.label + val clickListener = + View.OnClickListener { + Timber.i("Launching fix for '%s': %s", check.title(), resolveAction.logDescription) + resolveAction.action() + } + holder.binding.itemContainer.setOnClickListener(clickListener) + holder.binding.actionLink.setOnClickListener(clickListener) + } else { + holder.binding.itemContainer.setOnClickListener(null) + holder.binding.itemContainer.isClickable = false + } + + val gap = if (position < lastIndex) 2.dp.toPx(context) else 0 + val lp = holder.itemView.layoutParams as? ViewGroup.MarginLayoutParams + lp?.bottomMargin = gap + } + + companion object { + private val DIFF_CALLBACK = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: TroubleshootingCheck, + newItem: TroubleshootingCheck, + ) = oldItem::class == newItem::class + + override fun areContentsTheSame( + oldItem: TroubleshootingCheck, + newItem: TroubleshootingCheck, + ) = oldItem == newItem + } + } +} + +// TODO: move to string resources +private fun TroubleshootingCheck.title(): String = + when (this) { + is TroubleshootingCheck.NotificationPermission -> "Notification permission" + is TroubleshootingCheck.DoNotDisturbOff -> "Do not disturb" + is TroubleshootingCheck.UnrestrictedOptimizationEnabled -> "Battery optimization" + is TroubleshootingCheck.PowerSavingModeOff -> "Power saving mode" + is TroubleshootingCheck.ExactAlarmPermission -> "Alarms & reminders permission" + } + +// TODO: move to string resources +private fun TroubleshootingCheck.statusName(): String? = + when (this) { + is TroubleshootingCheck.NotificationPermission -> if (result == CheckResult.Passed) "Granted" else "Denied" + is TroubleshootingCheck.DoNotDisturbOff -> if (result == CheckResult.Passed) "Off" else "On" + is TroubleshootingCheck.UnrestrictedOptimizationEnabled -> + when (result) { + is CheckResult.Passed -> "Unrestricted" + is CheckResult.Warning -> "Optimized" + is CheckResult.Failed -> "Restricted" + else -> null + } + is TroubleshootingCheck.PowerSavingModeOff -> if (result == CheckResult.Passed) "Off" else "On" + is TroubleshootingCheck.ExactAlarmPermission -> if (result == CheckResult.Passed) "Granted" else "Denied" + } + +// TODO: move to string resources +private fun TroubleshootingCheck.explanation(): String? = + when (this) { + // no need for an explanation: the 'grant permission' action should be sufficient + is TroubleshootingCheck.NotificationPermission -> null + is TroubleshootingCheck.DoNotDisturbOff -> + if (result.hasIssue) "Do Not Disturb may mute reminder notifications" else null + is TroubleshootingCheck.UnrestrictedOptimizationEnabled -> + when (result) { + is CheckResult.Warning -> "Battery optimization may delay reminders" + is CheckResult.Failed -> "Background usage is disabled. Reminders may not be delivered" + else -> null + } + is TroubleshootingCheck.PowerSavingModeOff -> + if (result.hasIssue) "Power saving mode may prevent timely delivery of reminders" else null + is TroubleshootingCheck.ExactAlarmPermission -> + if (result.hasIssue) "Required to schedule reminders at exact times" else null + } + +private fun CheckResult.iconRes(): Int = + when (this) { + is CheckResult.Loading -> R.drawable.ic_sync + is CheckResult.Passed -> R.drawable.ic_check_circle_24 + is CheckResult.Failed -> R.drawable.ic_cancel_24 + is CheckResult.Warning -> R.drawable.ic_warning_24 + is CheckResult.Unavailable -> R.drawable.ic_error_outline + is CheckResult.Error -> R.drawable.ic_error_outline + } + +private fun CheckResult.tintColor(context: Context): Int = + when (this) { + is CheckResult.Passed -> Themes.getColorFromAttr(context, R.attr.reminderTroubleshootingOk) + is CheckResult.Warning -> Themes.getColorFromAttr(context, R.attr.reminderTroubleshootingWarning) + is CheckResult.Failed -> context.getColor(android.R.color.holo_red_dark) + is CheckResult.Loading -> context.getColor(android.R.color.darker_gray) + is CheckResult.Unavailable -> context.getColor(android.R.color.darker_gray) + is CheckResult.Error -> context.getColor(android.R.color.holo_red_dark) + } + +/** + * Returns a [ResolveCheckAction] for the check. + */ +context(fragment: ReminderTroubleshootingFragment) +private fun TroubleshootingCheck.resolveAction(): ResolveCheckAction? { + val context = fragment.requireContext() + + // TODO: move labels to string resources + fun requestNotificationPermission(): ResolveCheckAction? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return null + return ResolveCheckAction( + label = "Grant permission", + logDescription = "requesting POST_NOTIFICATIONS via system dialog or app settings", + ) { + fragment.requestPermissionThroughDialogOrSettings( + activity = fragment.requireActivity(), + permission = Manifest.permission.POST_NOTIFICATIONS, + permissionRequestedFlag = Prefs::notificationsPermissionRequested, + permissionRequestLauncher = fragment.notificationPermissionLauncher, + ) + } + } + + // Opens the full battery optimization list. The user must manually find the app. + // For 'full' (non-Play) builds, ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS could be used + // with the REQUEST_IGNORE_BATTERY_OPTIMIZATIONS manifest permission for a direct dialog, + // but Google Play restricts that permission. + fun requestUnrestrictedBackgroundUsage() = + ResolveCheckAction(label = "Open battery settings", logDescription = "opening ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS") { + context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + } + + fun openBatterySaverSettings() = + ResolveCheckAction(label = "Open battery settings", logDescription = "opening ACTION_BATTERY_SAVER_SETTINGS") { + context.startActivity(Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS)) + } + + fun openExactAlarmSettings(): ResolveCheckAction? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null + return ResolveCheckAction(label = "Grant permission", logDescription = "opening ACTION_REQUEST_SCHEDULE_EXACT_ALARM") { + context.startActivity( + Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply { + data = "package:${context.packageName}".toUri() + }, + ) + } + } + + return when (this) { + is TroubleshootingCheck.NotificationPermission -> requestNotificationPermission() + is TroubleshootingCheck.DoNotDisturbOff -> null + is TroubleshootingCheck.UnrestrictedOptimizationEnabled -> requestUnrestrictedBackgroundUsage() + is TroubleshootingCheck.PowerSavingModeOff -> openBatterySaverSettings() + is TroubleshootingCheck.ExactAlarmPermission -> openExactAlarmSettings() + } +} 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/Fragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Fragment.kt index 632663fc62a2..aae60187a6ce 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Fragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Fragment.kt @@ -17,9 +17,13 @@ package com.ichi2.anki.utils.ext import android.content.SharedPreferences import android.content.pm.PackageManager +import android.view.ViewTreeObserver +import android.view.ViewTreeObserver.OnWindowFocusChangeListener import android.view.Window import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import com.ichi2.anki.preferences.sharedPrefs import com.ichi2.anki.utils.showDialogFragmentImpl @@ -55,3 +59,22 @@ val Fragment.isCompactWidth: Boolean val currentWidthInDp = requireContext().resources.configuration.screenWidthDp return currentWidthInDp < COMPACT_WIDTH_DP_MAX } + +/** + * Registers a lifecycle-aware [ViewTreeObserver.OnWindowFocusChangeListener]. + * The listener is automatically removed when the fragment's view is destroyed. + * + * Must be called after the view is created (e.g. in [Fragment.onViewCreated]). + */ +fun Fragment.onWindowFocusChanged(action: (hasFocus: Boolean) -> Unit) { + val listener = OnWindowFocusChangeListener(action) + val viewTreeObserver = requireView().viewTreeObserver + viewTreeObserver.addOnWindowFocusChangeListener(listener) + viewLifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + viewTreeObserver.removeOnWindowFocusChangeListener(listener) + } + }, + ) +} 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/bg_troubleshooting_item_bottom.xml b/AnkiDroid/src/main/res/drawable/bg_troubleshooting_item_bottom.xml new file mode 100644 index 000000000000..d637316b17b2 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/bg_troubleshooting_item_bottom.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/AnkiDroid/src/main/res/drawable/bg_troubleshooting_item_middle.xml b/AnkiDroid/src/main/res/drawable/bg_troubleshooting_item_middle.xml new file mode 100644 index 000000000000..5c1849be85b3 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/bg_troubleshooting_item_middle.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/AnkiDroid/src/main/res/drawable/bg_troubleshooting_item_single.xml b/AnkiDroid/src/main/res/drawable/bg_troubleshooting_item_single.xml new file mode 100644 index 000000000000..f2ca9f8640f7 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/bg_troubleshooting_item_single.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/AnkiDroid/src/main/res/drawable/bg_troubleshooting_item_top.xml b/AnkiDroid/src/main/res/drawable/bg_troubleshooting_item_top.xml new file mode 100644 index 000000000000..ed59113d4c2e --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/bg_troubleshooting_item_top.xml @@ -0,0 +1,11 @@ + + + + + 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_error_outline.xml b/AnkiDroid/src/main/res/drawable/ic_error_outline.xml new file mode 100644 index 000000000000..af9ef1830128 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/ic_error_outline.xml @@ -0,0 +1,5 @@ + + + + + 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..6ebf5905aaf3 --- /dev/null +++ b/AnkiDroid/src/main/res/layout/fragment_reminder_troubleshooting.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AnkiDroid/src/main/res/layout/item_troubleshooting_check.xml b/AnkiDroid/src/main/res/layout/item_troubleshooting_check.xml new file mode 100644 index 000000000000..8ffab9de4a4d --- /dev/null +++ b/AnkiDroid/src/main/res/layout/item_troubleshooting_check.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + 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