From 8d7ca7821435c2470f4f895649dcd3706782f439 Mon Sep 17 00:00:00 2001
From: DatLag <jeff.retz08@gmail.com>
Date: Mon, 15 Apr 2024 21:02:05 +0200
Subject: [PATCH] change how repeated requests work and added fallback client

---
 .../anilist/AiringTodayStateMachine.kt        | 26 +++---
 .../aniflow/anilist/MediumStateMachine.kt     | 22 +++--
 .../anilist/PopularNextSeasonStateMachine.kt  | 19 +++--
 .../anilist/PopularSeasonStateMachine.kt      | 19 +++--
 .../anilist/TrendingAnimeStateMachine.kt      | 22 ++---
 .../aniflow/anilist/state/SeasonState.kt      |  3 +-
 .../src/androidMain/AndroidManifest.xml       | 14 +++-
 .../datlag/aniflow/common/ExtendCompose.kt    | 27 +++++-
 .../datlag/aniflow/module/NetworkModule.kt    | 10 +++
 .../dev/datlag/aniflow/other/Constants.kt     |  1 +
 .../screen/initial/component/CompactScreen.kt | 28 +++++++
 .../initial/component/ExpandedScreen.kt       | 31 ++++++-
 .../screen/initial/component/MediumScreen.kt  | 36 ++++++--
 .../screen/initial/home/HomeScreen.kt         | 17 +++-
 .../screen/initial/model/FABConfig.kt         | 16 ++++
 .../screen/medium/MediumScreenComponent.kt    |  2 +
 .../dev/datlag/aniflow/model/CatchResult.kt   | 84 ++++++++++++++-----
 17 files changed, 296 insertions(+), 81 deletions(-)
 create mode 100644 composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/model/FABConfig.kt

diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/AiringTodayStateMachine.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/AiringTodayStateMachine.kt
index 5966905..ff7bb23 100644
--- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/AiringTodayStateMachine.kt
+++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/AiringTodayStateMachine.kt
@@ -7,6 +7,8 @@ import dev.datlag.aniflow.anilist.model.Medium
 import dev.datlag.aniflow.anilist.type.AiringSort
 import dev.datlag.aniflow.firebase.FirebaseFactory
 import dev.datlag.aniflow.model.CatchResult
+import dev.datlag.aniflow.model.mapError
+import dev.datlag.aniflow.model.saveFirstOrNull
 import dev.datlag.tooling.async.suspendCatching
 import dev.datlag.tooling.safeSubList
 import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -19,6 +21,7 @@ import kotlin.time.Duration.Companion.hours
 @OptIn(ExperimentalCoroutinesApi::class)
 class AiringTodayStateMachine(
     private val client: ApolloClient,
+    private val fallbackClient: ApolloClient,
     private val crashlytics: FirebaseFactory.Crashlytics?
 ) : FlowReduxStateMachine<AiringTodayStateMachine.State, AiringTodayStateMachine.Action>(
     initialState = currentState
@@ -35,8 +38,14 @@ class AiringTodayStateMachine(
                         return@onEnter state.override { State.Success(query, it) }
                     }
 
-                    val response = CatchResult.result {
-                        client.query(state.snapshot.query).execute().dataOrThrow()
+                    val response = CatchResult.repeat(times = 2) {
+                        val query = client.query(state.snapshot.query)
+
+                        query.execute().data ?: query.toFlow().saveFirstOrNull()?.dataOrThrow()
+                    }.mapError {
+                        val query = fallbackClient.query(state.snapshot.query)
+
+                        query.execute().data ?: query.toFlow().saveFirstOrNull()?.dataOrThrow()
                     }.mapSuccess<State> {
                         val wantedContent = if (!state.snapshot.adultContent) {
                             val content = it.Page?.airingSchedulesFilterNotNull() ?: emptyList()
@@ -68,15 +77,7 @@ class AiringTodayStateMachine(
                         response.asSuccess {
                             crashlytics?.log(it)
 
-                            if (retry <= 3) {
-                                State.Loading(
-                                    query,
-                                    adultContent,
-                                    retry + 1
-                                )
-                            } else {
-                                State.Error(query, adultContent)
-                            }
+                            State.Error(query, adultContent)
                         }
                     }
                 }
@@ -104,8 +105,7 @@ class AiringTodayStateMachine(
     sealed interface State {
         data class Loading(
             internal val query: AiringQuery,
-            val adultContent: Boolean = false,
-            internal val retry: Int = 0
+            val adultContent: Boolean = false
         ) : State {
             constructor(
                 page: Int,
diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/MediumStateMachine.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/MediumStateMachine.kt
index dda4ece..639bef4 100644
--- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/MediumStateMachine.kt
+++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/MediumStateMachine.kt
@@ -6,12 +6,15 @@ import com.freeletics.flowredux.dsl.FlowReduxStateMachine
 import dev.datlag.aniflow.anilist.model.Medium
 import dev.datlag.aniflow.firebase.FirebaseFactory
 import dev.datlag.aniflow.model.CatchResult
+import dev.datlag.aniflow.model.mapError
+import dev.datlag.aniflow.model.saveFirstOrNull
 import dev.datlag.tooling.async.suspendCatching
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 
 @OptIn(ExperimentalCoroutinesApi::class)
 class MediumStateMachine(
     private val client: ApolloClient,
+    private val fallbackClient: ApolloClient,
     private val crashlytics: FirebaseFactory.Crashlytics?,
     private val id: Int
 ) : FlowReduxStateMachine<MediumStateMachine.State, MediumStateMachine.Action>(
@@ -28,8 +31,14 @@ class MediumStateMachine(
                     currentState = it
                 }
                 onEnter { state ->
-                    val response = CatchResult.result {
-                        client.query(state.snapshot.query).execute().dataOrThrow()
+                    val response = CatchResult.repeat(times = 2) {
+                        val query = client.query(state.snapshot.query)
+
+                        query.execute().data ?: query.toFlow().saveFirstOrNull()?.dataOrThrow()
+                    }.mapError {
+                        val query = fallbackClient.query(state.snapshot.query)
+
+                        query.execute().data ?: query.toFlow().saveFirstOrNull()?.dataOrThrow()
                     }.mapSuccess<State> {
                         it.Media?.let { data ->
                             State.Success(state.snapshot.query, Medium.Full(data))
@@ -45,11 +54,7 @@ class MediumStateMachine(
                             if (cached != null) {
                                 State.Success(query, cached)
                             } else {
-                                if (retry <= 3) {
-                                    State.Loading(query, retry + 1)
-                                } else {
-                                    State.Error(query)
-                                }
+                                State.Error(query)
                             }
                         }
                     }
@@ -76,8 +81,7 @@ class MediumStateMachine(
 
     sealed interface State {
         data class Loading(
-            internal val query: MediumQuery,
-            internal val retry: Int = 0
+            internal val query: MediumQuery
         ) : State {
             constructor(id: Int) : this(
                 MediumQuery(
diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/PopularNextSeasonStateMachine.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/PopularNextSeasonStateMachine.kt
index 0f3f1b5..fc260f5 100644
--- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/PopularNextSeasonStateMachine.kt
+++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/PopularNextSeasonStateMachine.kt
@@ -6,12 +6,15 @@ import dev.datlag.aniflow.anilist.state.SeasonAction
 import dev.datlag.aniflow.anilist.state.SeasonState
 import dev.datlag.aniflow.firebase.FirebaseFactory
 import dev.datlag.aniflow.model.CatchResult
+import dev.datlag.aniflow.model.mapError
+import dev.datlag.aniflow.model.saveFirstOrNull
 import dev.datlag.tooling.async.suspendCatching
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 
 @OptIn(ExperimentalCoroutinesApi::class)
 class PopularNextSeasonStateMachine(
     private val client: ApolloClient,
+    private val fallbackClient: ApolloClient,
     private val crashlytics: FirebaseFactory.Crashlytics?
 ) : FlowReduxStateMachine<SeasonState, SeasonAction>(
     initialState = currentState
@@ -28,8 +31,14 @@ class PopularNextSeasonStateMachine(
                         return@onEnter state.override { SeasonState.Success(query, it) }
                     }
 
-                    val response = CatchResult.result {
-                        client.query(state.snapshot.query).execute().dataOrThrow()
+                    val response = CatchResult.repeat(times = 2) {
+                        val query = client.query(state.snapshot.query)
+
+                        query.execute().data ?: query.toFlow().saveFirstOrNull()?.dataOrThrow()
+                    }.mapError {
+                        val query = fallbackClient.query(state.snapshot.query)
+
+                        query.execute().data ?: query.toFlow().saveFirstOrNull()?.dataOrThrow()
                     }.mapSuccess<SeasonState> {
                         SeasonState.Success(state.snapshot.query, it)
                     }
@@ -38,11 +47,7 @@ class PopularNextSeasonStateMachine(
                         response.asSuccess {
                             crashlytics?.log(it)
 
-                            if (state.snapshot.retry <= 3) {
-                                SeasonState.Loading(state.snapshot.query, state.snapshot.retry + 1)
-                            } else {
-                                SeasonState.Error(query)
-                            }
+                            SeasonState.Error(query)
                         }
                     }
                 }
diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/PopularSeasonStateMachine.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/PopularSeasonStateMachine.kt
index 79535d5..3ef0e4d 100644
--- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/PopularSeasonStateMachine.kt
+++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/PopularSeasonStateMachine.kt
@@ -13,6 +13,8 @@ import dev.datlag.aniflow.anilist.type.MediaSort
 import dev.datlag.aniflow.anilist.type.MediaType
 import dev.datlag.aniflow.firebase.FirebaseFactory
 import dev.datlag.aniflow.model.CatchResult
+import dev.datlag.aniflow.model.mapError
+import dev.datlag.aniflow.model.saveFirstOrNull
 import dev.datlag.tooling.async.suspendCatching
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.datetime.Clock
@@ -23,6 +25,7 @@ import kotlinx.datetime.toLocalDateTime
 @OptIn(ExperimentalCoroutinesApi::class)
 class PopularSeasonStateMachine(
     private val client: ApolloClient,
+    private val fallbackClient: ApolloClient,
     private val crashlytics: FirebaseFactory.Crashlytics?
 ) : FlowReduxStateMachine<SeasonState, SeasonAction>(
     initialState = currentState
@@ -39,8 +42,14 @@ class PopularSeasonStateMachine(
                         return@onEnter state.override { SeasonState.Success(query, it) }
                     }
 
-                    val response = CatchResult.result {
-                        client.query(state.snapshot.query).execute().dataOrThrow()
+                    val response = CatchResult.repeat(times = 2) {
+                        val query = client.query(state.snapshot.query)
+
+                        query.execute().data ?: query.toFlow().saveFirstOrNull()?.dataOrThrow()
+                    }.mapError {
+                        val query = fallbackClient.query(state.snapshot.query)
+
+                        query.execute().data ?: query.toFlow().saveFirstOrNull()?.dataOrThrow()
                     }.mapSuccess<SeasonState> {
                         SeasonState.Success(state.snapshot.query, it)
                     }
@@ -49,11 +58,7 @@ class PopularSeasonStateMachine(
                         response.asSuccess {
                             crashlytics?.log(it)
 
-                            if (retry <= 3) {
-                                SeasonState.Loading(query, retry + 1)
-                            } else {
-                                SeasonState.Error(query)
-                            }
+                            SeasonState.Error(query)
                         }
                     }
                 }
diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/TrendingAnimeStateMachine.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/TrendingAnimeStateMachine.kt
index 2139840..b60f7cc 100644
--- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/TrendingAnimeStateMachine.kt
+++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/TrendingAnimeStateMachine.kt
@@ -8,13 +8,14 @@ import com.freeletics.flowredux.dsl.FlowReduxStateMachine
 import dev.datlag.aniflow.anilist.type.MediaSort
 import dev.datlag.aniflow.anilist.type.MediaType
 import dev.datlag.aniflow.firebase.FirebaseFactory
-import dev.datlag.aniflow.model.CatchResult
+import dev.datlag.aniflow.model.*
 import dev.datlag.tooling.async.suspendCatching
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 
 @OptIn(ExperimentalCoroutinesApi::class, ApolloExperimental::class)
 class TrendingAnimeStateMachine(
     private val client: ApolloClient,
+    private val fallbackClient: ApolloClient,
     private val crashlytics: FirebaseFactory.Crashlytics?
 ) : FlowReduxStateMachine<TrendingAnimeStateMachine.State, TrendingAnimeStateMachine.Action>(
     initialState = currentState
@@ -31,8 +32,14 @@ class TrendingAnimeStateMachine(
                         return@onEnter state.override { State.Success(query, it) }
                     }
 
-                    val response = CatchResult.result {
-                        client.query(state.snapshot.query).execute().dataOrThrow()
+                    val response = CatchResult.repeat(2) {
+                        val query = client.query(state.snapshot.query)
+
+                         query.execute().data ?: query.toFlow().saveFirstOrNull()?.dataOrThrow()
+                    }.mapError {
+                        val query = fallbackClient.query(state.snapshot.query)
+                        
+                        query.execute().data ?: query.toFlow().saveFirstOrNull()?.dataOrThrow()
                     }.mapSuccess<State> {
                         State.Success(state.snapshot.query, it)
                     }
@@ -41,11 +48,7 @@ class TrendingAnimeStateMachine(
                         response.asSuccess {
                             crashlytics?.log(it)
 
-                            if (retry <= 3) {
-                                State.Loading(query, retry + 1)
-                            } else {
-                                State.Error(query)
-                            }
+                            State.Error(query)
                         }
                     }
                 }
@@ -73,8 +76,7 @@ class TrendingAnimeStateMachine(
 
     sealed interface State {
         data class Loading(
-            internal val query: TrendingQuery,
-            internal val retry: Int = 0
+            internal val query: TrendingQuery
         ) : State {
             constructor(
                 page: Int,
diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/state/SeasonState.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/state/SeasonState.kt
index 9fe77df..25b7a8b 100644
--- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/state/SeasonState.kt
+++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/state/SeasonState.kt
@@ -13,8 +13,7 @@ import kotlinx.datetime.Instant
 
 sealed interface SeasonState {
     data class Loading(
-        internal val query: SeasonQuery,
-        internal val retry: Int = 0
+        internal val query: SeasonQuery
     ) : SeasonState {
         constructor(
             page: Int,
diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index eca5156..261d17b 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -1,5 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
-<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
 
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
     <uses-permission android:name="android.permission.INTERNET"/>
@@ -46,6 +47,17 @@
             </intent-filter>
         </activity>
 
+        <!-- Trigger Google Play services to install the backported photo picker module. -->
+        <service android:name="com.google.android.gms.metadata.ModuleDependencies"
+            android:enabled="false"
+            android:exported="false"
+            tools:ignore="MissingClass">
+            <intent-filter>
+                <action android:name="com.google.android.gms.metadata.MODULE_DEPENDENCIES" />
+            </intent-filter>
+            <meta-data android:name="photopicker_activity:0:required" android:value="" />
+        </service>
+
         <meta-data
             android:name="instantapps.clients.allowed"
             android:value="true"/>
diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/common/ExtendCompose.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/common/ExtendCompose.kt
index 2dc08be..cde107f 100644
--- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/common/ExtendCompose.kt
+++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/common/ExtendCompose.kt
@@ -6,8 +6,8 @@ import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.calculateEndPadding
 import androidx.compose.foundation.layout.calculateStartPadding
 import androidx.compose.foundation.layout.padding
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.*
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.composed
 import androidx.compose.ui.geometry.Offset
@@ -146,3 +146,26 @@ fun Modifier.shimmer(shape: Shape = RectangleShape): Modifier = composed {
 fun shimmerPainter(): BrushPainter {
     return BrushPainter(shimmerBrush())
 }
+
+
+@Composable
+fun LazyListState.isScrollingUp(): Boolean {
+    var previousIndex by remember(this) {
+        mutableStateOf(firstVisibleItemIndex)
+    }
+    var previousScrollOffset by remember(this) {
+        mutableStateOf(firstVisibleItemScrollOffset)
+    }
+    return remember(this) {
+        derivedStateOf {
+            if (previousIndex != firstVisibleItemIndex) {
+                previousIndex > firstVisibleItemIndex
+            } else {
+                previousScrollOffset >= firstVisibleItemScrollOffset
+            }.also {
+                previousIndex = firstVisibleItemIndex
+                previousScrollOffset = firstVisibleItemScrollOffset
+            }
+        }
+    }.value
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/module/NetworkModule.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/module/NetworkModule.kt
index b734125..4e650d2 100644
--- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/module/NetworkModule.kt
+++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/module/NetworkModule.kt
@@ -85,27 +85,37 @@ data object NetworkModule {
                 })
                 .build()
         }
+        bindSingleton<ApolloClient>(Constants.AniList.FALLBACK_APOLLO_CLIENT) {
+            ApolloClient.Builder()
+                .dispatcher(ioDispatcher())
+                .serverUrl(Constants.AniList.SERVER_URL)
+                .build()
+        }
         bindProvider<TrendingAnimeStateMachine> {
             TrendingAnimeStateMachine(
                 client = instance(Constants.AniList.APOLLO_CLIENT),
+                fallbackClient = instance(Constants.AniList.FALLBACK_APOLLO_CLIENT),
                 crashlytics = nullableFirebaseInstance()?.crashlytics
             )
         }
         bindProvider<AiringTodayStateMachine> {
             AiringTodayStateMachine(
                 client = instance(Constants.AniList.APOLLO_CLIENT),
+                fallbackClient = instance(Constants.AniList.FALLBACK_APOLLO_CLIENT),
                 crashlytics = nullableFirebaseInstance()?.crashlytics
             )
         }
         bindProvider<PopularSeasonStateMachine> {
             PopularSeasonStateMachine(
                 client = instance(Constants.AniList.APOLLO_CLIENT),
+                fallbackClient = instance(Constants.AniList.FALLBACK_APOLLO_CLIENT),
                 crashlytics = nullableFirebaseInstance()?.crashlytics
             )
         }
         bindProvider<PopularNextSeasonStateMachine> {
             PopularNextSeasonStateMachine(
                 client = instance(Constants.AniList.APOLLO_CLIENT),
+                fallbackClient = instance(Constants.AniList.FALLBACK_APOLLO_CLIENT),
                 crashlytics = nullableFirebaseInstance()?.crashlytics
             )
         }
diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/other/Constants.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/other/Constants.kt
index feb8f1b..370f748 100644
--- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/other/Constants.kt
+++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/other/Constants.kt
@@ -5,6 +5,7 @@ data object Constants {
     data object AniList {
         const val SERVER_URL = "https://graphql.anilist.co/"
         const val APOLLO_CLIENT = "AniListApolloClient"
+        const val FALLBACK_APOLLO_CLIENT = "FallbackAniListApolloClient"
 
         data object Auth {
             const val BASE_URL = "https://anilist.co/api/v2/oauth/"
diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/component/CompactScreen.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/component/CompactScreen.kt
index ea873c9..a3c25fd 100644
--- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/component/CompactScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/component/CompactScreen.kt
@@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CameraEnhance
 import androidx.compose.material3.*
 import androidx.compose.runtime.*
 import androidx.compose.ui.Modifier
@@ -17,7 +19,9 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
 import dev.chrisbanes.haze.materials.HazeMaterials
 import dev.datlag.aniflow.LocalHaze
 import dev.datlag.aniflow.LocalPaddingValues
+import dev.datlag.aniflow.common.isScrollingUp
 import dev.datlag.aniflow.ui.navigation.screen.initial.InitialComponent
+import dev.datlag.aniflow.ui.navigation.screen.initial.model.FABConfig
 import dev.icerock.moko.resources.compose.stringResource
 
 @OptIn(ExperimentalFoundationApi::class, ExperimentalDecomposeApi::class, ExperimentalHazeMaterialsApi::class)
@@ -51,6 +55,30 @@ fun CompactScreen(component: InitialComponent) {
                     )
                 }
             }
+        },
+        floatingActionButton = {
+            val state by FABConfig.state
+
+            when (val current = state) {
+                is FABConfig.Scan -> {
+                    ExtendedFloatingActionButton(
+                        onClick = current.onClick,
+                        icon = {
+                            Icon(
+                                imageVector = Icons.Filled.CameraEnhance,
+                                contentDescription = null
+                            )
+                        },
+                        text = {
+                            Text(
+                                text = "Scan"
+                            )
+                        },
+                        expanded = current.listState.isScrollingUp()
+                    )
+                }
+                else -> { }
+            }
         }
     ) {
         CompositionLocalProvider(
diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/component/ExpandedScreen.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/component/ExpandedScreen.kt
index 7c6c7a3..4960d32 100644
--- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/component/ExpandedScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/component/ExpandedScreen.kt
@@ -2,6 +2,8 @@ package dev.datlag.aniflow.ui.navigation.screen.initial.component
 
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CameraEnhance
 import androidx.compose.material3.*
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
@@ -9,15 +11,42 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.unit.dp
 import com.arkivanov.decompose.ExperimentalDecomposeApi
 import com.arkivanov.decompose.extensions.compose.subscribeAsState
+import dev.datlag.aniflow.common.isScrollingUp
 import dev.datlag.aniflow.ui.custom.ExpandedPages
 import dev.datlag.aniflow.ui.navigation.screen.initial.InitialComponent
+import dev.datlag.aniflow.ui.navigation.screen.initial.model.FABConfig
 import dev.datlag.tooling.compose.EndCornerShape
 import dev.icerock.moko.resources.compose.stringResource
 
 @OptIn(ExperimentalDecomposeApi::class)
 @Composable
 fun ExpandedScreen(component: InitialComponent) {
-    Scaffold {
+    Scaffold(
+        floatingActionButton = {
+            val state by FABConfig.state
+
+            when (val current = state) {
+                is FABConfig.Scan -> {
+                    ExtendedFloatingActionButton(
+                        onClick = current.onClick,
+                        icon = {
+                            Icon(
+                                imageVector = Icons.Filled.CameraEnhance,
+                                contentDescription = null
+                            )
+                        },
+                        text = {
+                            Text(
+                                text = "Scan"
+                            )
+                        },
+                        expanded = current.listState.isScrollingUp()
+                    )
+                }
+                else -> { }
+            }
+        }
+    ) {
         PermanentNavigationDrawer(
             modifier = Modifier.padding(it),
             drawerContent = {
diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/component/MediumScreen.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/component/MediumScreen.kt
index 049cca6..e10514c 100644
--- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/component/MediumScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/component/MediumScreen.kt
@@ -3,23 +3,49 @@ package dev.datlag.aniflow.ui.navigation.screen.initial.component
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.NavigationRail
-import androidx.compose.material3.NavigationRailItem
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CameraEnhance
+import androidx.compose.material3.*
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.Modifier
 import com.arkivanov.decompose.ExperimentalDecomposeApi
 import com.arkivanov.decompose.extensions.compose.subscribeAsState
+import dev.datlag.aniflow.common.isScrollingUp
 import dev.datlag.aniflow.ui.custom.ExpandedPages
 import dev.datlag.aniflow.ui.navigation.screen.initial.InitialComponent
+import dev.datlag.aniflow.ui.navigation.screen.initial.model.FABConfig
 import dev.icerock.moko.resources.compose.stringResource
 
 @OptIn(ExperimentalDecomposeApi::class)
 @Composable
 fun MediumScreen(component: InitialComponent) {
-    Scaffold {
+    Scaffold(
+        floatingActionButton = {
+            val state by FABConfig.state
+
+            when (val current = state) {
+                is FABConfig.Scan -> {
+                    ExtendedFloatingActionButton(
+                        onClick = current.onClick,
+                        icon = {
+                            Icon(
+                                imageVector = Icons.Filled.CameraEnhance,
+                                contentDescription = null
+                            )
+                        },
+                        text = {
+                            Text(
+                                text = "Scan"
+                            )
+                        },
+                        expanded = current.listState.isScrollingUp()
+                    )
+                }
+                else -> { }
+            }
+        }
+    ) {
         Row(
             modifier = Modifier.padding(it)
         ) {
diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/home/HomeScreen.kt
index 421612f..9b89acd 100644
--- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/home/HomeScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/home/HomeScreen.kt
@@ -6,13 +6,16 @@ import androidx.compose.animation.fadeOut
 import androidx.compose.foundation.layout.*
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Camera
+import androidx.compose.material.icons.filled.CameraEnhance
+import androidx.compose.material3.*
 import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
 import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
 import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.text.font.FontWeight
@@ -21,11 +24,13 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState
 import dev.chrisbanes.haze.haze
 import dev.datlag.aniflow.LocalHaze
 import dev.datlag.aniflow.LocalPaddingValues
+import dev.datlag.aniflow.common.isScrollingUp
 import dev.datlag.aniflow.common.plus
 import dev.datlag.aniflow.other.StateSaver
 import dev.datlag.aniflow.ui.navigation.screen.initial.home.component.AiringOverview
 import dev.datlag.aniflow.ui.navigation.screen.initial.home.component.PopularSeasonOverview
 import dev.datlag.aniflow.ui.navigation.screen.initial.home.component.TrendingOverview
+import dev.datlag.aniflow.ui.navigation.screen.initial.model.FABConfig
 
 @Composable
 fun HomeScreen(component: HomeComponent) {
@@ -40,6 +45,13 @@ private fun MainView(component: HomeComponent, modifier: Modifier = Modifier) {
         initialFirstVisibleItemScrollOffset = StateSaver.List.homeOverviewOffset
     )
 
+    LaunchedEffect(listState) {
+        FABConfig.state.value = FABConfig.Scan(
+            listState = listState,
+            onClick = { }
+        )
+    }
+
     LazyColumn(
         state = listState,
         modifier = modifier.haze(state = LocalHaze.current),
@@ -110,6 +122,7 @@ private fun MainView(component: HomeComponent, modifier: Modifier = Modifier) {
         onDispose {
             StateSaver.List.homeOverview = listState.firstVisibleItemIndex
             StateSaver.List.homeOverviewOffset = listState.firstVisibleItemScrollOffset
+            FABConfig.state.value = null
         }
     }
 }
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/model/FABConfig.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/model/FABConfig.kt
new file mode 100644
index 0000000..edd35cc
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/initial/model/FABConfig.kt
@@ -0,0 +1,16 @@
+package dev.datlag.aniflow.ui.navigation.screen.initial.model
+
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.mutableStateOf
+
+sealed interface FABConfig {
+
+    data class Scan(
+        val listState: LazyListState,
+        val onClick: () -> Unit
+    ) : FABConfig
+
+    companion object {
+        val state = mutableStateOf<FABConfig?>(null)
+    }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumScreenComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumScreenComponent.kt
index f9eed48..875bfab 100644
--- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumScreenComponent.kt
+++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/medium/MediumScreenComponent.kt
@@ -46,8 +46,10 @@ class MediumScreenComponent(
 ) : MediumComponent, ComponentContext by componentContext {
 
     private val aniListClient by di.instance<ApolloClient>(Constants.AniList.APOLLO_CLIENT)
+    private val aniListFallbackClient by di.instance<ApolloClient>(Constants.AniList.FALLBACK_APOLLO_CLIENT)
     private val mediumStateMachine = MediumStateMachine(
         client = aniListClient,
+        fallbackClient = aniListFallbackClient,
         crashlytics = di.nullableFirebaseInstance()?.crashlytics,
         id = initialMedium.id
     )
diff --git a/model/src/commonMain/kotlin/dev/datlag/aniflow/model/CatchResult.kt b/model/src/commonMain/kotlin/dev/datlag/aniflow/model/CatchResult.kt
index e193848..b599adf 100644
--- a/model/src/commonMain/kotlin/dev/datlag/aniflow/model/CatchResult.kt
+++ b/model/src/commonMain/kotlin/dev/datlag/aniflow/model/CatchResult.kt
@@ -1,8 +1,13 @@
 package dev.datlag.aniflow.model
 
+import dev.datlag.aniflow.model.CatchResult.Companion.result
+import dev.datlag.aniflow.model.CatchResult.Success
 import dev.datlag.tooling.async.suspendCatching
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
 
 sealed interface CatchResult<T> {
 
@@ -12,12 +17,6 @@ sealed interface CatchResult<T> {
     val isError: Boolean
         get() = this is Error
 
-    fun onError(callback: (Throwable?) -> Unit) = apply {
-        if (this is Error) {
-            callback(this.throwable)
-        }
-    }
-
     fun onSuccess(callback: (T & Any) -> Unit) = apply {
         if (this is Success) {
             callback(this.data)
@@ -48,14 +47,6 @@ sealed interface CatchResult<T> {
         }
     }
 
-    fun asError(onSuccess: () -> Throwable? = { null }): Throwable? {
-        return if (this is Error) {
-            this.throwable
-        } else {
-            onSuccess()
-        }
-    }
-
     fun validate(predicate: (CatchResult<T>) -> Boolean): CatchResult<T> {
         return if (predicate(this)) {
             this
@@ -77,19 +68,14 @@ sealed interface CatchResult<T> {
         }
     }
 
-    suspend fun resultOnError(block: suspend CoroutineScope.() -> T): CatchResult<out T> {
-        return when (this) {
-            is Error -> result(block)
-            else -> this
-        }
-    }
-
     suspend fun <M : Any> mapSuccess(block: suspend (T & Any) -> M?): CatchResult<M> {
         return when (this) {
             is Success -> {
                 block(this.data)?.let(::Success) ?: Error(null)
             }
-            else -> Error(null)
+            is Error -> {
+                Error(this.throwable)
+            }
         }
     }
 
@@ -110,5 +96,59 @@ sealed interface CatchResult<T> {
                 } ?: Error(result.exceptionOrNull())
             }
         }
+
+        suspend fun <T> repeat(
+            times: Int,
+            delayDuration: Duration = 0.seconds,
+            block: suspend CoroutineScope.() -> T
+        ): CatchResult<T & Any> = coroutineScope {
+            var result = suspendCatching(block)
+            var request = 1
+
+            while (result.isFailure && request < times) {
+                delay(delayDuration)
+                result = suspendCatching(block)
+                request++
+            }
+            return@coroutineScope if (result.isFailure) {
+                Error(result.exceptionOrNull())
+            } else {
+                result.getOrNull()?.let {
+                    Success(it)
+                } ?: Error(result.exceptionOrNull())
+            }
+        }
+    }
+}
+
+suspend inline fun <T : Any> CatchResult<T>.resultOnError(noinline block: CoroutineScope.() -> T): CatchResult<out T> {
+    return when (this) {
+        is CatchResult.Error -> result(block)
+        else -> this
+    }
+}
+
+suspend inline fun <reified M : Any> CatchResult<*>.mapError(block: () -> M?): CatchResult<M> {
+    return when (this) {
+        is CatchResult.Error -> {
+            block()?.let(::Success) ?: CatchResult.Error(null)
+        }
+        is Success -> {
+            (this.data as? M)?.let(::Success) ?: block()?.let(::Success) ?: CatchResult.Error(null)
+        }
+    }
+}
+
+inline fun <T> CatchResult<T>.asError(onSuccess: () -> Throwable? = { null }): Throwable? {
+    return if (this is CatchResult.Error) {
+        this.throwable
+    } else {
+        onSuccess()
+    }
+}
+
+inline fun <T> CatchResult<T>.onError(callback: (Throwable?) -> Unit) = apply {
+    if (this is CatchResult.Error) {
+        callback(this.throwable)
     }
 }
\ No newline at end of file