@@ -18,17 +18,26 @@ package com.ichi2.anki.reviewreminders
1818
1919import android.content.Context
2020import android.os.Bundle
21+ import android.view.LayoutInflater
2122import android.view.View
23+ import android.view.ViewGroup
24+ import androidx.core.view.isVisible
2225import androidx.fragment.app.Fragment
2326import androidx.fragment.app.activityViewModels
2427import androidx.lifecycle.ViewModelProvider
2528import androidx.lifecycle.viewmodel.initializer
2629import androidx.lifecycle.viewmodel.viewModelFactory
30+ import androidx.recyclerview.widget.DiffUtil
31+ import androidx.recyclerview.widget.ListAdapter
32+ import androidx.recyclerview.widget.RecyclerView
2733import com.ichi2.anki.R
2834import com.ichi2.anki.databinding.FragmentReminderTroubleshootingBinding
35+ import com.ichi2.anki.databinding.ItemTroubleshootingCheckBinding
2936import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope
37+ import com.ichi2.anki.utils.ext.onWindowFocusChanged
3038import com.ichi2.anki.utils.ext.setBackgroundTint
3139import com.ichi2.themes.Themes
40+ import com.ichi2.utils.dp
3241import 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+ }
0 commit comments