diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c2a7e59..98454b6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + () - configurationViewModel.getConfigurations() - TmdbUtils.setup(configurationViewModel) + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) { + val configurationViewModel = getViewModel() + configurationViewModel.getConfigurations() + TmdbUtils.setup(configurationViewModel) - if (preferences.firstLaunchTesting || preferences.firstLaunch) { - launchActivity(OnboardingActivity::class.java) - } else { - launchActivity(MainActivity::class.java) + if (preferences.firstLaunchTesting || preferences.firstLaunch) { + launchActivity(OnboardingActivity::class.java) + } else { + launchActivity(MainActivity::class.java) + } } } } diff --git a/app/src/main/java/com/owenlejeune/tvtime/MainActivity.kt b/app/src/main/java/com/owenlejeune/tvtime/MainActivity.kt index 650ae9c..2983740 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/MainActivity.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/MainActivity.kt @@ -1,20 +1,35 @@ package com.owenlejeune.tvtime +import android.annotation.SuppressLint import android.os.Bundle +import android.widget.Toast import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.rememberNavController import com.kieronquinn.monetcompat.app.MonetCompatActivity import com.owenlejeune.tvtime.extensions.rememberWindowSizeClass import com.owenlejeune.tvtime.ui.navigation.AppNavigationHost import com.owenlejeune.tvtime.ui.navigation.HomeScreenNavItem import com.owenlejeune.tvtime.ui.theme.TVTimeTheme +import com.owenlejeune.tvtime.ui.viewmodel.ConfigurationViewModel import com.owenlejeune.tvtime.utils.KeyboardManager +import com.owenlejeune.tvtime.utils.NetworkStatus import com.owenlejeune.tvtime.utils.SessionManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -22,6 +37,7 @@ import kotlinx.coroutines.launch class MainActivity : MonetCompatActivity() { + @SuppressLint("AutoboxingStateValueProperty") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -31,19 +47,53 @@ class MainActivity : MonetCompatActivity() { val mainNavStartRoute = HomeScreenNavItem.SortedItems[0].route - lifecycleScope.launchWhenCreated { - monet.awaitMonetReady() - setContent { - AppKeyboardFocusManager() - TVTimeTheme(monetCompat = monet) { - val windowSize = rememberWindowSizeClass() - val appNavController = rememberNavController() - Box { - AppNavigationHost( - appNavController = appNavController, - mainNavStartRoute = mainNavStartRoute, - windowSize = windowSize - ) + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) { + monet.awaitMonetReady() + setContent { + AppKeyboardFocusManager() + TVTimeTheme(monetCompat = monet) { + val snackbarHostState = SnackbarHostState() + + val configViewModel = viewModel() + val networkStatus = configViewModel.networkStatus.collectAsState() + if (networkStatus.value == NetworkStatus.Disconnected) { + LaunchedEffect(networkStatus) { + snackbarHostState.showSnackbar(getString(R.string.network_disconnected_message)) + } + } + + val lastResponseCode = remember { configViewModel.lastResponseCode } + val code = lastResponseCode.value + LaunchedEffect(code) { + when (code) { + 429 -> Toast.makeText( + this@MainActivity, + getString(R.string.network_api_rate_limit_reached), + Toast.LENGTH_SHORT + ).show() + + in 400..599 -> Toast.makeText( + this@MainActivity, + getString(R.string.network_error_occurred, code), + Toast.LENGTH_SHORT + ).show() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) } + ) { + val windowSize = rememberWindowSizeClass() + val appNavController = rememberNavController() + Box(modifier = Modifier.padding(it)) { + AppNavigationHost( + appNavController = appNavController, + mainNavStartRoute = mainNavStartRoute, + windowSize = windowSize + ) + } + } } } } diff --git a/app/src/main/java/com/owenlejeune/tvtime/OnboardingActivity.kt b/app/src/main/java/com/owenlejeune/tvtime/OnboardingActivity.kt index 674a890..2875801 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/OnboardingActivity.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/OnboardingActivity.kt @@ -27,7 +27,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.os.BuildCompat +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.google.accompanist.pager.* import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.kieronquinn.monetcompat.app.MonetCompatActivity @@ -58,11 +60,13 @@ class OnboardingActivity: MonetCompatActivity() { @SuppressLint("UnsafeOptInUsageError") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - lifecycleScope.launchWhenCreated { - monet.awaitMonetReady() - setContent { - TVTimeTheme(monetCompat = monet) { - OnboardingUi() + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) { + monet.awaitMonetReady() + setContent { + TVTimeTheme(monetCompat = monet) { + OnboardingUi() + } } } } diff --git a/app/src/main/java/com/owenlejeune/tvtime/TvTimeApplication.kt b/app/src/main/java/com/owenlejeune/tvtime/TvTimeApplication.kt index 581cdb2..ebd9133 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/TvTimeApplication.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/TvTimeApplication.kt @@ -8,7 +8,6 @@ import com.owenlejeune.tvtime.di.modules.networkModule import com.owenlejeune.tvtime.di.modules.preferencesModule import com.owenlejeune.tvtime.di.modules.viewModelModule import com.owenlejeune.tvtime.preferences.AppPreferences -import dev.kdrag0n.monet.factory.ColorSchemeFactory import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger diff --git a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/TmdbClient.kt b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/TmdbClient.kt index bfabab5..8b83bee 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/TmdbClient.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/api/tmdb/TmdbClient.kt @@ -1,6 +1,8 @@ package com.owenlejeune.tvtime.api.tmdb +import android.annotation.SuppressLint import androidx.compose.ui.text.intl.Locale +import androidx.lifecycle.viewmodel.viewModelFactory import com.owenlejeune.tvtime.BuildConfig import com.owenlejeune.tvtime.api.Client import com.owenlejeune.tvtime.api.QueryParam @@ -17,6 +19,7 @@ import com.owenlejeune.tvtime.api.tmdb.api.v4.AuthenticationV4Api import com.owenlejeune.tvtime.api.tmdb.api.v4.ListV4Api import com.owenlejeune.tvtime.extensions.addQueryParams import com.owenlejeune.tvtime.preferences.AppPreferences +import com.owenlejeune.tvtime.ui.viewmodel.ConfigurationViewModel import com.owenlejeune.tvtime.utils.SessionManager import okhttp3.Interceptor import okhttp3.Response @@ -33,7 +36,6 @@ class TmdbClient: KoinComponent { private val client: Client by inject { parametersOf(V_3_BASE_URL) } private val clientV4: Client by inject { parametersOf(V_4_BASE_URL) } - private val preferences: AppPreferences by inject() init { client.addInterceptor(TmdbInterceptor()) @@ -84,6 +86,21 @@ class TmdbClient: KoinComponent { return clientV4.create(ListV4Api::class.java) } + @SuppressLint("AutoboxingStateValueProperty") + private fun handleResponseCode(response: Response) { + when (response.code) { + 429 -> { + ConfigurationViewModel.lastResponseCode.value = 429 + } + in 400..499 -> { + ConfigurationViewModel.lastResponseCode.value = response.code + } + in 500..599 -> { + ConfigurationViewModel.lastResponseCode.value = response.code + } + } + } + private inner class TmdbInterceptor: Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val apiParam = QueryParam("api_key", BuildConfig.TMDB_ApiKey) @@ -106,7 +123,11 @@ class TmdbClient: KoinComponent { val requestBuilder = chain.request().newBuilder().url(builder.build()) val request = requestBuilder.build() - return chain.proceed(request) + val response = chain.proceed(request) + + handleResponseCode(response) + + return response } private fun sessionIdParam(urlSegments: List): QueryParam? { @@ -154,7 +175,11 @@ class TmdbClient: KoinComponent { } } - return chain.proceed(builder.build()) + val response = chain.proceed(builder.build()) + + handleResponseCode(response) + + return response } private fun shouldIncludeLanguageParam(urlSegments: List): Boolean { diff --git a/app/src/main/java/com/owenlejeune/tvtime/di/modules/modules.kt b/app/src/main/java/com/owenlejeune/tvtime/di/modules/modules.kt index 80217c9..5555041 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/di/modules/modules.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/di/modules/modules.kt @@ -34,6 +34,8 @@ import com.owenlejeune.tvtime.api.tmdb.api.v4.model.ListItem import com.owenlejeune.tvtime.preferences.AppPreferences import com.owenlejeune.tvtime.ui.viewmodel.ConfigurationViewModel import com.owenlejeune.tvtime.ui.viewmodel.SettingsViewModel +import com.owenlejeune.tvtime.utils.NetworkConnectivityService +import com.owenlejeune.tvtime.utils.NetworkConnectivityServiceImpl import com.owenlejeune.tvtime.utils.ResourceUtils import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module @@ -97,9 +99,10 @@ val preferencesModule = module { val appModule = module { factory { ResourceUtils(get()) } + + single { NetworkConnectivityServiceImpl() } } val viewModelModule = module { viewModel { ConfigurationViewModel() } - viewModel { SettingsViewModel() } } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/extensions/ComposeExtensions.kt b/app/src/main/java/com/owenlejeune/tvtime/extensions/ComposeExtensions.kt index cb77860..bf29325 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/extensions/ComposeExtensions.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/extensions/ComposeExtensions.kt @@ -20,16 +20,6 @@ fun LazyGridScope.lazyPagingItems( } } -fun LazyGridScope.listItems( - items: List, - key: (T?) -> Any, - itemContent: @Composable (value: T) -> Unit -) { - items(items.size) { index -> - itemContent(items[index]) - } -} - fun LazyGridScope.header( content: @Composable LazyGridItemScope.() -> Unit ) { @@ -42,21 +32,11 @@ fun LazyGridScope.header( } fun LazyListScope.listItems( - items: Collection, - key: (T?) -> Any, + items: List, + key: ((T) -> Any)? = null, itemContent: @Composable (value: T) -> Unit ) { - items(items.size) { index -> - itemContent(items.elementAt(index)) - } -} - -fun LazyListScope.listItems( - items: List, - key: (T?) -> Any, - itemContent: @Composable (value: T?) -> Unit -) { - items(items.size, key = { key(items[it]) }) { index -> + items(items.size, key = key?.let { { key(items[it]) } }) { index -> itemContent(items[index]) } } @@ -66,7 +46,7 @@ fun LazyListScope.lazyPagingItems( key: ((index: Int) -> Any)? = null, itemContent: @Composable LazyItemScope.(value: T?) -> Unit ) { - items(lazyPagingItems.itemCount) { index -> + items(lazyPagingItems.itemCount, key = key) { index -> itemContent(lazyPagingItems[index]) } } diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/viewmodel/ConfigurationViewModel.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/viewmodel/ConfigurationViewModel.kt index 3790dff..4cc8374 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/viewmodel/ConfigurationViewModel.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/viewmodel/ConfigurationViewModel.kt @@ -1,13 +1,25 @@ package com.owenlejeune.tvtime.ui.viewmodel +import androidx.compose.runtime.mutableIntStateOf import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.owenlejeune.tvtime.api.tmdb.api.v3.ConfigurationService +import com.owenlejeune.tvtime.utils.NetworkConnectivityService +import com.owenlejeune.tvtime.utils.NetworkStatus +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn import org.koin.core.component.KoinComponent import org.koin.core.component.inject class ConfigurationViewModel: ViewModel(), KoinComponent { private val service: ConfigurationService by inject() + private val networkConnectivityService: NetworkConnectivityService by inject() + + companion object Storage { + val lastResponseCode = mutableIntStateOf(0) + } val detailsConfiguration = service.detailsConfiguration val countriesConfiguration = service.countriesConfiguration @@ -16,6 +28,14 @@ class ConfigurationViewModel: ViewModel(), KoinComponent { val primaryTranslationsConfiguration = service.primaryTranslationsConfiguration val timezonesConfiguration = service.timezonesConfiguration + val lastResponseCode = Storage.lastResponseCode + + val networkStatus: StateFlow = networkConnectivityService.networkStatus.stateIn( + initialValue = NetworkStatus.Unknown, + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000) + ) + suspend fun getConfigurations() { service.getDetailsConfiguration() service.getCountriesConfiguration() diff --git a/app/src/main/java/com/owenlejeune/tvtime/utils/NetworkManager.kt b/app/src/main/java/com/owenlejeune/tvtime/utils/NetworkManager.kt new file mode 100644 index 0000000..5511577 --- /dev/null +++ b/app/src/main/java/com/owenlejeune/tvtime/utils/NetworkManager.kt @@ -0,0 +1,62 @@ +package com.owenlejeune.tvtime.utils + +import android.content.Context +import android.net.ConnectivityManager +import android.net.ConnectivityManager.NetworkCallback +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +sealed class NetworkStatus { + object Unknown: NetworkStatus() + object Connected: NetworkStatus() + object Disconnected: NetworkStatus() +} + +interface NetworkConnectivityService { + val networkStatus: Flow +} + +class NetworkConnectivityServiceImpl: NetworkConnectivityService, KoinComponent { + + private val context: Context by inject() + + private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + override val networkStatus: Flow = callbackFlow { + val connectivityCallback = object : NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(NetworkStatus.Connected) + } + override fun onUnavailable() { + trySend(NetworkStatus.Disconnected) + } + override fun onLost(network: Network) { + trySend(NetworkStatus.Disconnected) + } + } + + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) + .build() + + connectivityManager.registerNetworkCallback(request, connectivityCallback) + + awaitClose { + connectivityManager.unregisterNetworkCallback(connectivityCallback) + } + } + .distinctUntilChanged() + .flowOn(Dispatchers.IO) + +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e90249e..fac1343 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -241,4 +241,7 @@ Latest Season %1$d | %2$d Episodes See all seasons + Network Disconnected + API Rate limit reached + %1$d: An error occurred \ No newline at end of file