Skip to content

Commit cb2f818

Browse files
david-allisonmikehardy
authored andcommitted
feat(reminders): display troubleshooting checks
Assisted-by: Claude Opus 4.6
1 parent 8b8c687 commit cb2f818

9 files changed

Lines changed: 317 additions & 2 deletions

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

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,26 @@ package com.ichi2.anki.reviewreminders
1818

1919
import android.content.Context
2020
import android.os.Bundle
21+
import android.view.LayoutInflater
2122
import android.view.View
23+
import android.view.ViewGroup
24+
import androidx.core.view.isVisible
2225
import androidx.fragment.app.Fragment
2326
import androidx.fragment.app.activityViewModels
2427
import androidx.lifecycle.ViewModelProvider
2528
import androidx.lifecycle.viewmodel.initializer
2629
import androidx.lifecycle.viewmodel.viewModelFactory
30+
import androidx.recyclerview.widget.DiffUtil
31+
import androidx.recyclerview.widget.ListAdapter
32+
import androidx.recyclerview.widget.RecyclerView
2733
import com.ichi2.anki.R
2834
import com.ichi2.anki.databinding.FragmentReminderTroubleshootingBinding
35+
import com.ichi2.anki.databinding.ItemTroubleshootingCheckBinding
2936
import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope
37+
import com.ichi2.anki.utils.ext.onWindowFocusChanged
3038
import com.ichi2.anki.utils.ext.setBackgroundTint
3139
import com.ichi2.themes.Themes
40+
import com.ichi2.utils.dp
3241
import dev.androidbroadcast.vbpd.viewBinding
3342

3443
/** %alpha to use for the circular background of icons */
@@ -52,6 +61,8 @@ class ReminderTroubleshootingFragment : Fragment(R.layout.fragment_reminder_trou
5261
}
5362

5463
setupSummary()
64+
setupTroubleshootingChecks()
65+
setupSettingChangeDetector()
5566
}
5667

5768
private fun setupSummary() {
@@ -86,6 +97,25 @@ class ReminderTroubleshootingFragment : Fragment(R.layout.fragment_reminder_trou
8697
binding.summaryText.text = summaryText
8798
}
8899
}
100+
101+
private fun setupTroubleshootingChecks() {
102+
val checksAdapter = TroubleshootingChecksAdapter()
103+
binding.checksList.adapter = checksAdapter
104+
viewModel.state.launchCollectionInLifecycleScope { state ->
105+
val visibleChecks = state.checks.filter { it.result !is CheckResult.Unavailable }
106+
checksAdapter.submitList(visibleChecks)
107+
}
108+
}
109+
110+
/**
111+
* Refreshes checks when the window regains focus.
112+
*
113+
* This handles cases that [onResume] misses, such as toggling battery saver
114+
* from the notification shade, which doesn't pause/resume the activity.
115+
*/
116+
private fun setupSettingChangeDetector() {
117+
onWindowFocusChanged { hasFocus -> if (hasFocus) viewModel.refreshChecks() }
118+
}
89119
}
90120

91121
/**
@@ -103,3 +133,139 @@ internal fun reminderTroubleshootingViewModelFactory(context: Context): ViewMode
103133
}
104134
}
105135
}
136+
137+
private class TroubleshootingChecksAdapter : ListAdapter<TroubleshootingCheck, TroubleshootingChecksAdapter.ViewHolder>(DIFF_CALLBACK) {
138+
class ViewHolder(
139+
val binding: ItemTroubleshootingCheckBinding,
140+
) : RecyclerView.ViewHolder(binding.root)
141+
142+
override fun onCreateViewHolder(
143+
parent: ViewGroup,
144+
viewType: Int,
145+
): ViewHolder {
146+
val binding =
147+
ItemTroubleshootingCheckBinding.inflate(
148+
LayoutInflater.from(parent.context),
149+
parent,
150+
false,
151+
)
152+
return ViewHolder(binding)
153+
}
154+
155+
override fun onBindViewHolder(
156+
holder: ViewHolder,
157+
position: Int,
158+
) {
159+
val check = getItem(position)
160+
val context = holder.itemView.context
161+
val tintColor = check.result.tintColor(context)
162+
val lastIndex = itemCount - 1
163+
164+
holder.binding.title.text = check.title()
165+
holder.binding.statusName.text = check.statusName()
166+
167+
val explanationText = check.explanation()
168+
holder.binding.explanation.text = explanationText
169+
holder.binding.explanation.isVisible = explanationText != null
170+
171+
holder.binding.statusIcon.setImageResource(check.result.iconRes())
172+
holder.binding.statusIcon.setColorFilter(tintColor)
173+
// The warning triangle is visually top-heavy; nudge it up to appear centered
174+
holder.binding.statusIcon.translationY = if (check.result is CheckResult.Warning) -1.dp.toPx(context).toFloat() else 0f
175+
holder.binding.iconContainer.setBackgroundTint(tintColor, alpha = BACKGROUND_ALPHA)
176+
177+
// Rounded card-group: top/middle/bottom/single shapes with 2dp gaps
178+
val backgroundRes =
179+
when {
180+
itemCount == 1 -> R.drawable.bg_troubleshooting_item_single
181+
position == 0 -> R.drawable.bg_troubleshooting_item_top
182+
position == lastIndex -> R.drawable.bg_troubleshooting_item_bottom
183+
else -> R.drawable.bg_troubleshooting_item_middle
184+
}
185+
holder.binding.itemContainer.setBackgroundResource(backgroundRes)
186+
187+
val gap = if (position < lastIndex) 2.dp.toPx(context) else 0
188+
val lp = holder.itemView.layoutParams as? ViewGroup.MarginLayoutParams
189+
lp?.bottomMargin = gap
190+
}
191+
192+
companion object {
193+
private val DIFF_CALLBACK =
194+
object : DiffUtil.ItemCallback<TroubleshootingCheck>() {
195+
override fun areItemsTheSame(
196+
oldItem: TroubleshootingCheck,
197+
newItem: TroubleshootingCheck,
198+
) = oldItem::class == newItem::class
199+
200+
override fun areContentsTheSame(
201+
oldItem: TroubleshootingCheck,
202+
newItem: TroubleshootingCheck,
203+
) = oldItem == newItem
204+
}
205+
}
206+
}
207+
208+
// TODO: move to string resources
209+
private fun TroubleshootingCheck.title(): String =
210+
when (this) {
211+
is TroubleshootingCheck.NotificationPermission -> "Notification permission"
212+
is TroubleshootingCheck.DoNotDisturbOff -> "Do not disturb"
213+
is TroubleshootingCheck.UnrestrictedOptimizationEnabled -> "Battery optimization"
214+
is TroubleshootingCheck.PowerSavingModeOff -> "Power saving mode"
215+
is TroubleshootingCheck.ExactAlarmPermission -> "Alarms & reminders permission"
216+
}
217+
218+
// TODO: move to string resources
219+
private fun TroubleshootingCheck.statusName(): String? =
220+
when (this) {
221+
is TroubleshootingCheck.NotificationPermission -> if (result == CheckResult.Passed) "Granted" else "Denied"
222+
is TroubleshootingCheck.DoNotDisturbOff -> if (result == CheckResult.Passed) "Off" else "On"
223+
is TroubleshootingCheck.UnrestrictedOptimizationEnabled ->
224+
when (result) {
225+
is CheckResult.Passed -> "Unrestricted"
226+
is CheckResult.Warning -> "Optimized"
227+
is CheckResult.Failed -> "Restricted"
228+
else -> null
229+
}
230+
is TroubleshootingCheck.PowerSavingModeOff -> if (result == CheckResult.Passed) "Off" else "On"
231+
is TroubleshootingCheck.ExactAlarmPermission -> if (result == CheckResult.Passed) "Granted" else "Denied"
232+
}
233+
234+
// TODO: move to string resources
235+
private fun TroubleshootingCheck.explanation(): String? =
236+
when (this) {
237+
// no need for an explanation: the 'grant permission' action should be sufficient
238+
is TroubleshootingCheck.NotificationPermission -> null
239+
is TroubleshootingCheck.DoNotDisturbOff ->
240+
if (result.hasIssue) "Do Not Disturb may mute reminder notifications" else null
241+
is TroubleshootingCheck.UnrestrictedOptimizationEnabled ->
242+
when (result) {
243+
is CheckResult.Warning -> "Battery optimization may delay reminders"
244+
is CheckResult.Failed -> "Background usage is disabled. Reminders may not be delivered"
245+
else -> null
246+
}
247+
is TroubleshootingCheck.PowerSavingModeOff ->
248+
if (result.hasIssue) "Power saving mode may prevent timely delivery of reminders" else null
249+
is TroubleshootingCheck.ExactAlarmPermission ->
250+
if (result.hasIssue) "Required to schedule reminders at exact times" else null
251+
}
252+
253+
private fun CheckResult.iconRes(): Int =
254+
when (this) {
255+
is CheckResult.Loading -> R.drawable.ic_sync
256+
is CheckResult.Passed -> R.drawable.ic_check_circle_24
257+
is CheckResult.Failed -> R.drawable.ic_cancel_24
258+
is CheckResult.Warning -> R.drawable.ic_warning_24
259+
is CheckResult.Unavailable -> R.drawable.ic_error_outline
260+
is CheckResult.Error -> R.drawable.ic_error_outline
261+
}
262+
263+
private fun CheckResult.tintColor(context: Context): Int =
264+
when (this) {
265+
is CheckResult.Passed -> Themes.getColorFromAttr(context, R.attr.reminderTroubleshootingOk)
266+
is CheckResult.Warning -> Themes.getColorFromAttr(context, R.attr.reminderTroubleshootingWarning)
267+
is CheckResult.Failed -> context.getColor(android.R.color.holo_red_dark)
268+
is CheckResult.Loading -> context.getColor(android.R.color.darker_gray)
269+
is CheckResult.Unavailable -> context.getColor(android.R.color.darker_gray)
270+
is CheckResult.Error -> context.getColor(android.R.color.holo_red_dark)
271+
}

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,13 @@ package com.ichi2.anki.utils.ext
1717

1818
import android.content.SharedPreferences
1919
import android.content.pm.PackageManager
20+
import android.view.ViewTreeObserver
21+
import android.view.ViewTreeObserver.OnWindowFocusChangeListener
2022
import android.view.Window
2123
import androidx.fragment.app.DialogFragment
2224
import androidx.fragment.app.Fragment
25+
import androidx.lifecycle.DefaultLifecycleObserver
26+
import androidx.lifecycle.LifecycleOwner
2327
import com.ichi2.anki.preferences.sharedPrefs
2428
import com.ichi2.anki.utils.showDialogFragmentImpl
2529

@@ -55,3 +59,22 @@ val Fragment.isCompactWidth: Boolean
5559
val currentWidthInDp = requireContext().resources.configuration.screenWidthDp
5660
return currentWidthInDp < COMPACT_WIDTH_DP_MAX
5761
}
62+
63+
/**
64+
* Registers a lifecycle-aware [ViewTreeObserver.OnWindowFocusChangeListener].
65+
* The listener is automatically removed when the fragment's view is destroyed.
66+
*
67+
* Must be called after the view is created (e.g. in [Fragment.onViewCreated]).
68+
*/
69+
fun Fragment.onWindowFocusChanged(action: (hasFocus: Boolean) -> Unit) {
70+
val listener = OnWindowFocusChangeListener(action)
71+
val viewTreeObserver = requireView().viewTreeObserver
72+
viewTreeObserver.addOnWindowFocusChangeListener(listener)
73+
viewLifecycleOwner.lifecycle.addObserver(
74+
object : DefaultLifecycleObserver {
75+
override fun onDestroy(owner: LifecycleOwner) {
76+
viewTreeObserver.removeOnWindowFocusChangeListener(listener)
77+
}
78+
},
79+
)
80+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<shape xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:shape="rectangle">
4+
<solid android:color="?attr/colorSurfaceContainer" />
5+
<corners
6+
android:topLeftRadius="4dp"
7+
android:topRightRadius="4dp"
8+
android:bottomLeftRadius="16dp"
9+
android:bottomRightRadius="16dp" />
10+
</shape>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<shape xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:shape="rectangle">
4+
<solid android:color="?attr/colorSurfaceContainer" />
5+
<corners
6+
android:topLeftRadius="4dp"
7+
android:topRightRadius="4dp"
8+
android:bottomRightRadius="4dp"
9+
android:bottomLeftRadius="4dp"
10+
/>
11+
</shape>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<shape xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:shape="rectangle">
4+
<solid android:color="?attr/colorSurfaceContainer" />
5+
<corners android:radius="16dp" />
6+
</shape>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<shape xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:shape="rectangle">
4+
<solid android:color="?attr/colorSurfaceContainer" />
5+
<corners
6+
android:topLeftRadius="16dp"
7+
android:topRightRadius="16dp"
8+
android:bottomRightRadius="4dp"
9+
android:bottomLeftRadius="4dp"
10+
/>
11+
</shape>
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="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"/>
4+
5+
</vector>

AnkiDroid/src/main/res/layout/fragment_reminder_troubleshooting.xml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,16 @@
6262
android:textColor="?android:attr/textColorSecondary"
6363
tools:text="Everything looks good! Your reminders should work as expected." />
6464

65-
66-
<!-- TODO: Display checks -->
65+
<androidx.recyclerview.widget.RecyclerView
66+
android:id="@+id/checks_list"
67+
android:layout_width="match_parent"
68+
android:layout_height="wrap_content"
69+
android:clipToPadding="false"
70+
android:nestedScrollingEnabled="false"
71+
android:paddingTop="8dp"
72+
android:paddingBottom="8dp"
73+
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
74+
tools:listitem="@layout/item_troubleshooting_check" />
6775

6876
<LinearLayout
6977
android:layout_width="match_parent"
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3+
xmlns:tools="http://schemas.android.com/tools"
4+
android:id="@+id/item_container"
5+
android:layout_width="match_parent"
6+
android:layout_height="wrap_content"
7+
android:gravity="center_vertical"
8+
android:minHeight="72dp"
9+
android:orientation="horizontal"
10+
android:paddingHorizontal="16dp"
11+
android:paddingTop="12dp"
12+
android:paddingBottom="14dp"
13+
android:layout_marginHorizontal="16dp"
14+
tools:background="@drawable/bg_troubleshooting_item_single"
15+
>
16+
17+
<!-- Circular icon background, Pixel settings style -->
18+
<!-- +1dp top margin compensates for asymmetric vertical padding (12dp top, 14dp bottom) -->
19+
<FrameLayout
20+
android:id="@+id/icon_container"
21+
android:layout_width="40dp"
22+
android:layout_height="40dp"
23+
android:layout_marginTop="1dp"
24+
android:background="@drawable/bg_troubleshooting_icon">
25+
26+
<ImageView
27+
android:id="@+id/status_icon"
28+
android:layout_width="24dp"
29+
android:layout_height="24dp"
30+
android:layout_gravity="center"
31+
android:importantForAccessibility="no"
32+
tools:tint="@android:color/holo_red_dark"
33+
tools:src="@drawable/ic_error_outline" />
34+
35+
</FrameLayout>
36+
37+
<!-- Title + status + explanation -->
38+
<LinearLayout
39+
android:layout_width="0dp"
40+
android:layout_height="wrap_content"
41+
android:layout_marginStart="16dp"
42+
android:layout_weight="1"
43+
android:orientation="vertical">
44+
45+
<TextView
46+
android:id="@+id/title"
47+
android:layout_width="match_parent"
48+
android:layout_height="wrap_content"
49+
android:textAppearance="?attr/textAppearanceBodyLarge"
50+
android:textStyle="bold"
51+
tools:text="Battery optimization" />
52+
53+
<TextView
54+
android:id="@+id/status_name"
55+
android:layout_width="match_parent"
56+
android:layout_height="wrap_content"
57+
android:layout_marginTop="2dp"
58+
android:textAppearance="?attr/textAppearanceBodyMedium"
59+
android:textColor="?android:attr/textColorSecondary"
60+
tools:text="Restricted" />
61+
62+
<TextView
63+
android:id="@+id/explanation"
64+
android:layout_width="match_parent"
65+
android:layout_height="wrap_content"
66+
android:layout_marginTop="2dp"
67+
android:textAppearance="?attr/textAppearanceBodySmall"
68+
android:textColor="?android:attr/textColorSecondary"
69+
android:visibility="gone"
70+
tools:text="Background usage is disabled. Reminders may not be delivered"
71+
tools:visibility="visible" />
72+
73+
</LinearLayout>
74+
75+
</LinearLayout>

0 commit comments

Comments
 (0)