diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestAki.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestAki.kt index d80902b072e1..bb49b8df4502 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestAki.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestAki.kt @@ -22,7 +22,7 @@ internal object AnkiquestAki { @DrawableRes val image: Int, @StringRes val message: Int, ) { - WELCOME(R.drawable.aki_welcome, R.string.aki_welcome), + WELCOME(R.drawable.aki_welcome, R.string.aki_review), REVIEW(R.drawable.aki_review, R.string.aki_review), CELEBRATE(R.drawable.aki_celebrate, R.string.aki_complete), STREAK(R.drawable.aki_streak, R.string.aki_streak), @@ -60,7 +60,7 @@ internal object AnkiquestAki { size: Int = 80, ): ImageView = ImageView(context).apply { - setImageResource(resource) + AnkiquestCompanion.show(this, resource) scaleType = ImageView.ScaleType.FIT_CENTER importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO layoutParams = LinearLayout.LayoutParams(dp(context, size), dp(context, size)) @@ -71,6 +71,7 @@ internal object AnkiquestAki { mood: Mood, ): LinearLayout = LinearLayout(context).apply { + visibility = if (AnkiquestCompanion.visible()) View.VISIBLE else View.GONE orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL setPadding(0, dp(context, 8), 0, dp(context, 8)) @@ -87,11 +88,15 @@ internal object AnkiquestAki { } fun notificationIcon(context: Context): Bitmap? = - BitmapFactory.decodeResource( - context.resources, - R.drawable.aki_face, - BitmapFactory.Options().apply { inSampleSize = 8 }, - ) + if (!AnkiquestCompanion.visible()) { + null + } else { + BitmapFactory.decodeResource( + context.resources, + AnkiquestCompanion.resource(R.drawable.aki_face), + BitmapFactory.Options().apply { inSampleSize = 8 }, + ) + } private fun dp( context: Context, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestCompanion.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestCompanion.kt new file mode 100644 index 000000000000..59f8e7473c39 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestCompanion.kt @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import android.widget.ImageView +import androidx.annotation.DrawableRes +import androidx.core.content.edit +import androidx.core.view.isVisible +import com.ichi2.anki.AnkiDroidApp +import com.ichi2.anki.R + +/** Account-scoped artwork cache. The server owns the choice; offline UI uses its last answer. */ +internal object AnkiquestCompanion { + private const val KEY_PREFIX = "ankiquestCompanion:" + private val choices = setOf("aki", "ankilope", "none") + + fun normalize(value: String?) = value?.takeIf { it in choices } ?: "aki" + + fun selected(): String { + val prefs = AnkiDroidApp.sharedPrefsOrNull() ?: return "aki" + val settings = prefs.all + val account = HomeAccount.from(settings, (settings["username"] as? String).orEmpty()) ?: return "aki" + return normalize(prefs.getString(KEY_PREFIX + account.scope, "aki")) + } + + fun remember( + account: HomeAccount, + value: String, + ): Boolean { + if (AnkiquestHomeData.account()?.scope != account.scope) return false + AnkiDroidApp.sharedPrefs().edit { putString(KEY_PREFIX + account.scope, normalize(value)) } + return true + } + + fun visible() = selected() != "none" + + @DrawableRes + fun resource( + @DrawableRes aki: Int, + ): Int = + if (selected() != "ankilope") { + aki + } else { + when (aki) { + R.drawable.aki_face -> R.drawable.ankilope_face + R.drawable.aki_welcome -> R.drawable.ankilope_welcome + R.drawable.aki_celebrate, R.drawable.aki_winner -> R.drawable.ankilope_celebrate + R.drawable.aki_review, R.drawable.aki_streak, R.drawable.aki_freeze -> R.drawable.ankilope_study + else -> aki + } + } + + fun show( + view: ImageView, + @DrawableRes aki: Int, + ) { + view.isVisible = visible() + if (view.isVisible) view.setImageResource(resource(aki)) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivity.kt index 559a5b7d0b95..05bfa4bf2d7a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivity.kt @@ -10,6 +10,7 @@ import android.graphics.drawable.GradientDrawable import android.os.Bundle import android.view.View import android.view.ViewGroup +import android.widget.ImageView import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.TextView @@ -176,6 +177,20 @@ class AnkiquestHomeActivity : AnkiActivity(R.layout.activity_ankiquest_home) { try { val result = AnkiquestHomeData.profile(current) if (turn == generation && AnkiquestHomeData.account()?.scope == current.scope) profile = result + if (current.token.isNotEmpty()) { + try { + val companion = AnkiquestHomeData.companion(current) + if (turn == generation && AnkiquestHomeData.account()?.scope == current.scope && + AnkiquestCompanion.selected() != companion && AnkiquestCompanion.remember(current, companion) + ) { + AnkiquestWidget.requestUpdate(this@AnkiquestHomeActivity) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "AnkiQuest companion could not be refreshed") + } + } null } catch (e: CancellationException) { throw e @@ -223,6 +238,7 @@ class AnkiquestHomeActivity : AnkiActivity(R.layout.activity_ankiquest_home) { if (!::content.isInitialized) return val position = scroll.scrollY content.removeAllViews() + AnkiquestCompanion.show(findViewById(R.id.aq_home_companion), R.drawable.aki_face) findViewById(R.id.aq_home_loading).isVisible = loading renderToday() scroll.post { scroll.scrollTo(0, position) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeData.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeData.kt index f28ee1faa183..11f5d640b942 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeData.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeData.kt @@ -9,8 +9,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject import java.net.URLEncoder @@ -110,6 +112,38 @@ internal object AnkiquestHomeData { } } + suspend fun companion(account: HomeAccount): String = companionRequest(account, null) + + suspend fun saveCompanion( + account: HomeAccount, + choice: String, + ): String { + require(AnkiquestCompanion.normalize(choice) == choice) + return companionRequest(account, choice) + } + + private suspend fun companionRequest( + account: HomeAccount, + choice: String?, + ): String = + withContext(Dispatchers.IO) { + check(account.token.isNotEmpty()) { "Set your AnkiQuest token to choose a companion." } + val request = + Request + .Builder() + .url(account.url("api/companion/${account.encodedUser}")) + .header("Authorization", "Bearer ${account.token}") + .apply { + if (choice != null) { + post(JSONObject().put("companion", choice).toString().toRequestBody("application/json".toMediaType())) + } + }.build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw Ankiquest.HttpStatusException(response.code) + AnkiquestCompanion.normalize(JSONObject(response.body.string()).getString("companion")) + } + } + suspend fun local(): HomeLocal = CollectionManager.withCol { val now = diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestWidget.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestWidget.kt index 11d86e86aeb3..db3363b12b08 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestWidget.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestWidget.kt @@ -200,6 +200,13 @@ open class AnkiquestWidget : AppWidgetProvider() { period: String = "week", ): RemoteViews { val views = RemoteViews(context.packageName, style.layout) + views.setViewVisibility(R.id.ankiquest_widget_companion, if (AnkiquestCompanion.visible()) View.VISIBLE else View.GONE) + if (AnkiquestCompanion.visible()) { + views.setImageViewResource( + R.id.ankiquest_widget_companion, + AnkiquestCompanion.resource(R.drawable.aki_face), + ) + } views.setTextViewText( R.id.ankiquest_widget_period, AnkiquestLanguage.context(context).getString(PERIODS.first { it.name == period }.label), diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapView.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapView.kt index e84a85e58b04..fd1fd36833f2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapView.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapView.kt @@ -92,8 +92,16 @@ class ReviewHeatmapView if (ReviewHeatmap.searchForDay(currentData, date) != null) onDaySelected?.invoke(currentData, date) } setLoading() - findViewById(R.id.review_heatmap_aki).isVisible = - AnkiDroidApp.sharedPrefsOrNull() != null && AnkiquestNavigation.enabled() + val companion = findViewById(R.id.review_heatmap_aki) + companion.isVisible = AnkiDroidApp.sharedPrefsOrNull() != null && AnkiquestNavigation.enabled() && + com.ichi2.anki.ankiquest.AnkiquestCompanion + .visible() + if (companion.isVisible) { + companion.setImageResource( + com.ichi2.anki.ankiquest.AnkiquestCompanion + .resource(R.drawable.aki_streak), + ) + } } fun setData(data: ReviewHeatmapData) = setData(data, state) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AnkiquestSettingsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AnkiquestSettingsFragment.kt index 9130031dc82f..65d8dfe16ed0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AnkiquestSettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AnkiquestSettingsFragment.kt @@ -24,12 +24,15 @@ import com.ichi2.anki.ankiquest.Ankiquest import com.ichi2.anki.ankiquest.AnkiquestActivity import com.ichi2.anki.ankiquest.AnkiquestAvatarEditor import com.ichi2.anki.ankiquest.AnkiquestAvatars +import com.ichi2.anki.ankiquest.AnkiquestCompanion import com.ichi2.anki.ankiquest.AnkiquestDeckAdapter import com.ichi2.anki.ankiquest.AnkiquestDeckTree +import com.ichi2.anki.ankiquest.AnkiquestHomeData import com.ichi2.anki.ankiquest.AnkiquestNotificationHealth import com.ichi2.anki.ankiquest.AnkiquestNotifier import com.ichi2.anki.ankiquest.AnkiquestPoll import com.ichi2.anki.ankiquest.AnkiquestUpdater +import com.ichi2.anki.ankiquest.AnkiquestWidget import com.ichi2.preferences.VersatileTextPreference import com.ichi2.utils.Permissions.openAppSettingsScreen import kotlinx.coroutines.CancellationException @@ -76,6 +79,7 @@ open class AnkiquestSettingsFragment : SettingsFragment() { override fun initSubscreen() { refreshServerSettings.clear() bindDashboard(R.string.ankiquest_dashboard_key) + updateCompanionIcon() } protected fun bindAccount() { @@ -83,6 +87,7 @@ open class AnkiquestSettingsFragment : SettingsFragment() { VersatileTextPreference.Validator { value -> if (value.isNotEmpty()) value.toHttpUrl() } + bindCompanion() requirePreference(R.string.ankiquest_avatar_key).setOnPreferenceClickListener { pictureAction { val account = checkNotNull(AnkiquestAvatars.account()) { getString(R.string.ankiquest_nudges_unavailable) } @@ -209,6 +214,97 @@ open class AnkiquestSettingsFragment : SettingsFragment() { } } + private fun updateCompanionIcon() { + preferenceScreen.findPreference(getString(R.string.ankiquest_dashboard_key))?.icon = + if (AnkiquestCompanion.visible()) { + ContextCompat.getDrawable(requireContext(), AnkiquestCompanion.resource(R.drawable.aki_face)) + } else { + null + } + } + + private fun bindCompanion() { + val preference = requirePreference(R.string.ankiquest_companion_key) + var generation = 0 + var saving = false + var refreshPending = false + preference.isPersistent = false + preference.isEnabled = false + val refresh = refresh@{ + val request = ++generation + preference.isEnabled = false + val account = AnkiquestHomeData.account()?.takeIf { it.token.isNotEmpty() } + if (account == null) { + preference.summary = getString(R.string.ankiquest_check_unconfigured) + return@refresh + } + preference.summary = getString(R.string.ankiquest_check_running) + if (saving) { + refreshPending = true + return@refresh + } + lifecycleScope.launch { + try { + val choice = AnkiquestHomeData.companion(account) + if (request != generation || AnkiquestHomeData.account()?.scope != account.scope || !isAdded) return@launch + AnkiquestCompanion.remember(account, choice) + preference.value = choice + preference.summary = preference.entry + preference.isEnabled = true + updateCompanionIcon() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (request == generation && + isAdded + ) { + preference.summary = getString(R.string.ankiquest_check_failed, e.message ?: e.javaClass.simpleName) + } + } + } + } + refreshServerSettings.add { refresh() } + preference.setOnPreferenceChangeListener { _, value -> + val account = AnkiquestHomeData.account() ?: return@setOnPreferenceChangeListener false + val request = ++generation + saving = true + preference.isEnabled = false + lifecycleScope.launch { + try { + val choice = AnkiquestHomeData.saveCompanion(account, value.toString()) + if (request != generation || AnkiquestHomeData.account()?.scope != account.scope || !isAdded) return@launch + AnkiquestCompanion.remember(account, choice) + preference.value = choice + preference.summary = preference.entry + preference.isEnabled = true + updateCompanionIcon() + AnkiquestWidget.requestUpdate(requireContext()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (request == generation && isAdded) { + AlertDialog + .Builder(requireContext()) + .setTitle(preference.title) + .setMessage(getString(R.string.ankiquest_check_failed, e.message ?: e.javaClass.simpleName)) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } finally { + saving = false + if (refreshPending && isAdded) { + refreshPending = false + refresh() + } else if (request == generation && isAdded && !preference.isEnabled) { + refresh() + } + } + } + false + } + updateCompanionIcon() + } + private fun bindAlertSettings( key: Int, nudge: Boolean, @@ -578,6 +674,7 @@ open class AnkiquestSettingsFragment : SettingsFragment() { override fun onResume() { super.onResume() + updateCompanionIcon() refreshServerSettings.forEach { it() } // Deliver pending messages promptly after returning from Android alert settings. AnkiquestPoll.refreshNow(requireContext()) diff --git a/AnkiDroid/src/main/res/drawable-nodpi/ankilope_celebrate.png b/AnkiDroid/src/main/res/drawable-nodpi/ankilope_celebrate.png new file mode 100644 index 000000000000..eb609b79215e Binary files /dev/null and b/AnkiDroid/src/main/res/drawable-nodpi/ankilope_celebrate.png differ diff --git a/AnkiDroid/src/main/res/drawable-nodpi/ankilope_face.png b/AnkiDroid/src/main/res/drawable-nodpi/ankilope_face.png new file mode 100644 index 000000000000..d167081a69fa Binary files /dev/null and b/AnkiDroid/src/main/res/drawable-nodpi/ankilope_face.png differ diff --git a/AnkiDroid/src/main/res/drawable-nodpi/ankilope_study.png b/AnkiDroid/src/main/res/drawable-nodpi/ankilope_study.png new file mode 100644 index 000000000000..0ac93708ef8f Binary files /dev/null and b/AnkiDroid/src/main/res/drawable-nodpi/ankilope_study.png differ diff --git a/AnkiDroid/src/main/res/drawable-nodpi/ankilope_welcome.png b/AnkiDroid/src/main/res/drawable-nodpi/ankilope_welcome.png new file mode 100644 index 000000000000..4468f90ebe35 Binary files /dev/null and b/AnkiDroid/src/main/res/drawable-nodpi/ankilope_welcome.png differ diff --git a/AnkiDroid/src/main/res/layout/activity_ankiquest_home.xml b/AnkiDroid/src/main/res/layout/activity_ankiquest_home.xml index fb644e4b3ef0..29d3bdaf39cd 100644 --- a/AnkiDroid/src/main/res/layout/activity_ankiquest_home.xml +++ b/AnkiDroid/src/main/res/layout/activity_ankiquest_home.xml @@ -16,6 +16,7 @@ android:paddingStart="16dp" android:paddingEnd="8dp"> + + + Lernbegleiter + Wähle Aki, Ankilope oder keinen Begleiter. Auf allen Geräten synchronisiert. + Kein Begleiter + diff --git a/AnkiDroid/src/main/res/values-es/ankiquest_companion.xml b/AnkiDroid/src/main/res/values-es/ankiquest_companion.xml new file mode 100644 index 000000000000..9f31f42024d6 --- /dev/null +++ b/AnkiDroid/src/main/res/values-es/ankiquest_companion.xml @@ -0,0 +1,7 @@ + + + + Compañero de estudio + Elige Aki, Ankilope o ninguno. Se sincroniza en todos tus dispositivos. + Sin compañero + diff --git a/AnkiDroid/src/main/res/values-fr/ankiquest_companion.xml b/AnkiDroid/src/main/res/values-fr/ankiquest_companion.xml new file mode 100644 index 000000000000..dede69738beb --- /dev/null +++ b/AnkiDroid/src/main/res/values-fr/ankiquest_companion.xml @@ -0,0 +1,7 @@ + + + + Compagnon d’étude + Choisis Aki, Ankilope ou aucun compagnon. Synchronisé sur tous tes appareils. + Aucun compagnon + diff --git a/AnkiDroid/src/main/res/values-pt/ankiquest_companion.xml b/AnkiDroid/src/main/res/values-pt/ankiquest_companion.xml new file mode 100644 index 000000000000..df0114a33010 --- /dev/null +++ b/AnkiDroid/src/main/res/values-pt/ankiquest_companion.xml @@ -0,0 +1,7 @@ + + + + Companheiro de estudo + Escolhe Aki, Ankilope ou nenhum companheiro. Sincronizado em todos os dispositivos. + Sem companheiro + diff --git a/AnkiDroid/src/main/res/values/ankiquest.xml b/AnkiDroid/src/main/res/values/ankiquest.xml index 4b12f153a197..776354ce2507 100644 --- a/AnkiDroid/src/main/res/values/ankiquest.xml +++ b/AnkiDroid/src/main/res/values/ankiquest.xml @@ -2,22 +2,22 @@ ankiquestAccountScreen - Account & connection + Account & connection Server, player, token and profile picture ankiquestStudyScreen - Study & streaks + Study & streaks Daily study options and streak protection ankiquestNotificationsScreen - Reminders & alerts + Reminders & alerts Reminders, nudges and alert delivery ankiquestSharingScreen - Friends & sharing + Friends & sharing Deck updates and subscriptions ankiquestMaintenanceScreen - App & data + App & data Review uploads and app updates - When to notify - Alert settings + When to notify + Alert settings ankiquestDashboard Leaderboard ankiquestCommunityReminders diff --git a/AnkiDroid/src/main/res/values/ankiquest_companion.xml b/AnkiDroid/src/main/res/values/ankiquest_companion.xml new file mode 100644 index 000000000000..b4e53506b33e --- /dev/null +++ b/AnkiDroid/src/main/res/values/ankiquest_companion.xml @@ -0,0 +1,18 @@ + + + + ankiquestCompanion + Study companion + Choose Aki, Ankilope, or no companion. Synced across devices. + No companion + + Aki + Ankilope + @string/ankiquest_companion_none + + + aki + ankilope + none + + diff --git a/AnkiDroid/src/main/res/xml/preferences_ankiquest_account.xml b/AnkiDroid/src/main/res/xml/preferences_ankiquest_account.xml index b477c45a25a3..101d76baf98d 100644 --- a/AnkiDroid/src/main/res/xml/preferences_ankiquest_account.xml +++ b/AnkiDroid/src/main/res/xml/preferences_ankiquest_account.xml @@ -24,6 +24,13 @@ android:persistent="false" android:title="@string/ankiquest_avatar_title" android:summary="@string/ankiquest_avatar_summary" /> + () - targetContext.resources.getXml(R.xml.preferences_ankiquest).use { xml -> + targetContext.resources.getXml(R.xml.preferences_ankiquest_study).use { xml -> while (xml.next() != XmlPullParser.END_DOCUMENT) { if (xml.eventType != XmlPullParser.START_TAG) continue val key = xml.getAttributeResourceValue("http://schemas.android.com/apk/res/android", "key", 0) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/AnkiquestSettingsMenuTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/AnkiquestSettingsMenuTest.kt index 01897fd598c8..81ff55610f53 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/AnkiquestSettingsMenuTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/AnkiquestSettingsMenuTest.kt @@ -43,6 +43,7 @@ class AnkiquestSettingsMenuTest : RobolectricTest() { R.string.ankiquest_user_key, R.string.ankiquest_token_key, R.string.ankiquest_avatar_key, + R.string.ankiquest_companion_key, R.string.ankiquest_test_key, R.string.ankiquest_open_today_key, R.string.ankiquest_summary_preference_key,