Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

/**
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Fragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
},
)
}
14 changes: 14 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/View.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/*
* Copyright (c) 2025 Danilo Mendes <danilodanicomendes@gmail.com>
* Copyright (c) 2026 David Allison <davidallisongithub@gmail.com>
*
* 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
Expand All @@ -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()),
)
}
5 changes: 5 additions & 0 deletions AnkiDroid/src/main/res/drawable/bg_troubleshooting_icon.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="?attr/colorSurfaceVariant" />
</shape>
10 changes: 10 additions & 0 deletions AnkiDroid/src/main/res/drawable/bg_troubleshooting_item_bottom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?attr/colorSurfaceContainer" />
<corners
android:topLeftRadius="4dp"
android:topRightRadius="4dp"
android:bottomLeftRadius="16dp"
android:bottomRightRadius="16dp" />
</shape>
11 changes: 11 additions & 0 deletions AnkiDroid/src/main/res/drawable/bg_troubleshooting_item_middle.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?attr/colorSurfaceContainer" />
<corners
android:topLeftRadius="4dp"
android:topRightRadius="4dp"
android:bottomRightRadius="4dp"
android:bottomLeftRadius="4dp"
/>
</shape>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?attr/colorSurfaceContainer" />
<corners android:radius="16dp" />
</shape>
11 changes: 11 additions & 0 deletions AnkiDroid/src/main/res/drawable/bg_troubleshooting_item_top.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?attr/colorSurfaceContainer" />
<corners
android:topLeftRadius="16dp"
android:topRightRadius="16dp"
android:bottomRightRadius="4dp"
android:bottomLeftRadius="4dp"
/>
</shape>
14 changes: 14 additions & 0 deletions AnkiDroid/src/main/res/drawable/ic_cancel_24.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Circle outline -->
<path
android:fillColor="@android:color/white"
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" />
<!-- X mark -->
<path
android:fillColor="@android:color/white"
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" />
</vector>
14 changes: 14 additions & 0 deletions AnkiDroid/src/main/res/drawable/ic_check_circle_24.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Circle outline -->
<path
android:fillColor="@android:color/white"
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" />
<!-- Checkmark (inset to match X size, thicker stroke) -->
<path
android:fillColor="@android:color/white"
android:pathData="M10.8,13.9l-2.3,-2.3L7.2,12.9l3.6,3.6 6.4,-6.4 -1.3,-1.3z" />
</vector>
5 changes: 5 additions & 0 deletions AnkiDroid/src/main/res/drawable/ic_error_outline.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<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">

<path android:fillColor="@android:color/white" android:pathData="M508.5,668.5Q520,657 520,640Q520,623 508.5,611.5Q497,600 480,600Q463,600 451.5,611.5Q440,623 440,640Q440,657 451.5,668.5Q463,680 480,680Q497,680 508.5,668.5ZM440,520L520,520L520,280L440,280L440,520ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>

</vector>
5 changes: 5 additions & 0 deletions AnkiDroid/src/main/res/drawable/ic_outline_troubleshoot.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<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">

<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"/>

</vector>
9 changes: 9 additions & 0 deletions AnkiDroid/src/main/res/drawable/ic_warning_24.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z" />
</vector>
Loading
Loading