add network monitor and error message popups

This commit is contained in:
Owen LeJeune
2023-06-29 12:45:11 -04:00
parent 257d60d404
commit c2b65ffd2f
11 changed files with 207 additions and 55 deletions

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<application

View File

@@ -2,11 +2,14 @@ package com.owenlejeune.tvtime
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.owenlejeune.tvtime.extensions.launchActivity
import com.owenlejeune.tvtime.preferences.AppPreferences
import com.owenlejeune.tvtime.ui.viewmodel.ConfigurationViewModel
import com.owenlejeune.tvtime.utils.TmdbUtils
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.getViewModel
@@ -17,15 +20,17 @@ class AppRoutingActivity: AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launchWhenCreated {
val configurationViewModel = getViewModel<ConfigurationViewModel>()
configurationViewModel.getConfigurations()
TmdbUtils.setup(configurationViewModel)
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
val configurationViewModel = getViewModel<ConfigurationViewModel>()
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)
}
}
}
}

View File

@@ -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<ConfigurationViewModel>()
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
)
}
}
}
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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

View File

@@ -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<String>): 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<String>): Boolean {

View File

@@ -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<NetworkConnectivityService> { NetworkConnectivityServiceImpl() }
}
val viewModelModule = module {
viewModel { ConfigurationViewModel() }
viewModel { SettingsViewModel() }
}

View File

@@ -20,16 +20,6 @@ fun <T: Any> LazyGridScope.lazyPagingItems(
}
}
fun <T: Any> LazyGridScope.listItems(
items: List<T>,
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 <T: Any> LazyListScope.listItems(
items: Collection<T>,
key: (T?) -> Any,
items: List<T>,
key: ((T) -> Any)? = null,
itemContent: @Composable (value: T) -> Unit
) {
items(items.size) { index ->
itemContent(items.elementAt(index))
}
}
fun <T: Any?> LazyListScope.listItems(
items: List<T?>,
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 <T: Any> 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])
}
}

View File

@@ -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<NetworkStatus> = networkConnectivityService.networkStatus.stateIn(
initialValue = NetworkStatus.Unknown,
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000)
)
suspend fun getConfigurations() {
service.getDetailsConfiguration()
service.getCountriesConfiguration()

View File

@@ -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<NetworkStatus>
}
class NetworkConnectivityServiceImpl: NetworkConnectivityService, KoinComponent {
private val context: Context by inject()
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
override val networkStatus: Flow<NetworkStatus> = 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)
}

View File

@@ -241,4 +241,7 @@
<string name="latest_season_title">Latest Season</string>
<string name="season_ep_count">%1$d | %2$d Episodes</string>
<string name="see_all_seasons_text">See all seasons</string>
<string name="network_disconnected_message">Network Disconnected</string>
<string name="network_api_rate_limit_reached">API Rate limit reached</string>
<string name="network_error_occurred">%1$d: An error occurred</string>
</resources>