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"?> <?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">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<application <application

View File

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

View File

@@ -1,20 +1,35 @@
package com.owenlejeune.tvtime package com.owenlejeune.tvtime
import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box 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.Composable
import androidx.compose.runtime.DisposableEffect 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.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.kieronquinn.monetcompat.app.MonetCompatActivity import com.kieronquinn.monetcompat.app.MonetCompatActivity
import com.owenlejeune.tvtime.extensions.rememberWindowSizeClass import com.owenlejeune.tvtime.extensions.rememberWindowSizeClass
import com.owenlejeune.tvtime.ui.navigation.AppNavigationHost import com.owenlejeune.tvtime.ui.navigation.AppNavigationHost
import com.owenlejeune.tvtime.ui.navigation.HomeScreenNavItem import com.owenlejeune.tvtime.ui.navigation.HomeScreenNavItem
import com.owenlejeune.tvtime.ui.theme.TVTimeTheme 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.KeyboardManager
import com.owenlejeune.tvtime.utils.NetworkStatus
import com.owenlejeune.tvtime.utils.SessionManager import com.owenlejeune.tvtime.utils.SessionManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -22,6 +37,7 @@ import kotlinx.coroutines.launch
class MainActivity : MonetCompatActivity() { class MainActivity : MonetCompatActivity() {
@SuppressLint("AutoboxingStateValueProperty")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -31,19 +47,53 @@ class MainActivity : MonetCompatActivity() {
val mainNavStartRoute = HomeScreenNavItem.SortedItems[0].route val mainNavStartRoute = HomeScreenNavItem.SortedItems[0].route
lifecycleScope.launchWhenCreated { lifecycleScope.launch {
monet.awaitMonetReady() lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
setContent { monet.awaitMonetReady()
AppKeyboardFocusManager() setContent {
TVTimeTheme(monetCompat = monet) { AppKeyboardFocusManager()
val windowSize = rememberWindowSizeClass() TVTimeTheme(monetCompat = monet) {
val appNavController = rememberNavController() val snackbarHostState = SnackbarHostState()
Box {
AppNavigationHost( val configViewModel = viewModel<ConfigurationViewModel>()
appNavController = appNavController, val networkStatus = configViewModel.networkStatus.collectAsState()
mainNavStartRoute = mainNavStartRoute, if (networkStatus.value == NetworkStatus.Disconnected) {
windowSize = windowSize 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.os.BuildCompat import androidx.core.os.BuildCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.accompanist.pager.* import com.google.accompanist.pager.*
import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.kieronquinn.monetcompat.app.MonetCompatActivity import com.kieronquinn.monetcompat.app.MonetCompatActivity
@@ -58,11 +60,13 @@ class OnboardingActivity: MonetCompatActivity() {
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
lifecycleScope.launchWhenCreated { lifecycleScope.launch {
monet.awaitMonetReady() lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
setContent { monet.awaitMonetReady()
TVTimeTheme(monetCompat = monet) { setContent {
OnboardingUi() 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.preferencesModule
import com.owenlejeune.tvtime.di.modules.viewModelModule import com.owenlejeune.tvtime.di.modules.viewModelModule
import com.owenlejeune.tvtime.preferences.AppPreferences import com.owenlejeune.tvtime.preferences.AppPreferences
import dev.kdrag0n.monet.factory.ColorSchemeFactory
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger import org.koin.android.ext.koin.androidLogger

View File

@@ -1,6 +1,8 @@
package com.owenlejeune.tvtime.api.tmdb package com.owenlejeune.tvtime.api.tmdb
import android.annotation.SuppressLint
import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.intl.Locale
import androidx.lifecycle.viewmodel.viewModelFactory
import com.owenlejeune.tvtime.BuildConfig import com.owenlejeune.tvtime.BuildConfig
import com.owenlejeune.tvtime.api.Client import com.owenlejeune.tvtime.api.Client
import com.owenlejeune.tvtime.api.QueryParam 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.api.tmdb.api.v4.ListV4Api
import com.owenlejeune.tvtime.extensions.addQueryParams import com.owenlejeune.tvtime.extensions.addQueryParams
import com.owenlejeune.tvtime.preferences.AppPreferences import com.owenlejeune.tvtime.preferences.AppPreferences
import com.owenlejeune.tvtime.ui.viewmodel.ConfigurationViewModel
import com.owenlejeune.tvtime.utils.SessionManager import com.owenlejeune.tvtime.utils.SessionManager
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
@@ -33,7 +36,6 @@ class TmdbClient: KoinComponent {
private val client: Client by inject { parametersOf(V_3_BASE_URL) } private val client: Client by inject { parametersOf(V_3_BASE_URL) }
private val clientV4: Client by inject { parametersOf(V_4_BASE_URL) } private val clientV4: Client by inject { parametersOf(V_4_BASE_URL) }
private val preferences: AppPreferences by inject()
init { init {
client.addInterceptor(TmdbInterceptor()) client.addInterceptor(TmdbInterceptor())
@@ -84,6 +86,21 @@ class TmdbClient: KoinComponent {
return clientV4.create(ListV4Api::class.java) 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 { private inner class TmdbInterceptor: Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val apiParam = QueryParam("api_key", BuildConfig.TMDB_ApiKey) val apiParam = QueryParam("api_key", BuildConfig.TMDB_ApiKey)
@@ -106,7 +123,11 @@ class TmdbClient: KoinComponent {
val requestBuilder = chain.request().newBuilder().url(builder.build()) val requestBuilder = chain.request().newBuilder().url(builder.build())
val request = requestBuilder.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? { 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 { 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.preferences.AppPreferences
import com.owenlejeune.tvtime.ui.viewmodel.ConfigurationViewModel import com.owenlejeune.tvtime.ui.viewmodel.ConfigurationViewModel
import com.owenlejeune.tvtime.ui.viewmodel.SettingsViewModel 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 com.owenlejeune.tvtime.utils.ResourceUtils
import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
@@ -97,9 +99,10 @@ val preferencesModule = module {
val appModule = module { val appModule = module {
factory { ResourceUtils(get()) } factory { ResourceUtils(get()) }
single<NetworkConnectivityService> { NetworkConnectivityServiceImpl() }
} }
val viewModelModule = module { val viewModelModule = module {
viewModel { ConfigurationViewModel() } 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( fun LazyGridScope.header(
content: @Composable LazyGridItemScope.() -> Unit content: @Composable LazyGridItemScope.() -> Unit
) { ) {
@@ -42,21 +32,11 @@ fun LazyGridScope.header(
} }
fun <T: Any> LazyListScope.listItems( fun <T: Any> LazyListScope.listItems(
items: Collection<T>, items: List<T>,
key: (T?) -> Any, key: ((T) -> Any)? = null,
itemContent: @Composable (value: T) -> Unit itemContent: @Composable (value: T) -> Unit
) { ) {
items(items.size) { index -> items(items.size, key = key?.let { { key(items[it]) } }) { 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 ->
itemContent(items[index]) itemContent(items[index])
} }
} }
@@ -66,7 +46,7 @@ fun <T: Any> LazyListScope.lazyPagingItems(
key: ((index: Int) -> Any)? = null, key: ((index: Int) -> Any)? = null,
itemContent: @Composable LazyItemScope.(value: T?) -> Unit itemContent: @Composable LazyItemScope.(value: T?) -> Unit
) { ) {
items(lazyPagingItems.itemCount) { index -> items(lazyPagingItems.itemCount, key = key) { index ->
itemContent(lazyPagingItems[index]) itemContent(lazyPagingItems[index])
} }
} }

View File

@@ -1,13 +1,25 @@
package com.owenlejeune.tvtime.ui.viewmodel package com.owenlejeune.tvtime.ui.viewmodel
import androidx.compose.runtime.mutableIntStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.owenlejeune.tvtime.api.tmdb.api.v3.ConfigurationService 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.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
class ConfigurationViewModel: ViewModel(), KoinComponent { class ConfigurationViewModel: ViewModel(), KoinComponent {
private val service: ConfigurationService by inject() private val service: ConfigurationService by inject()
private val networkConnectivityService: NetworkConnectivityService by inject()
companion object Storage {
val lastResponseCode = mutableIntStateOf(0)
}
val detailsConfiguration = service.detailsConfiguration val detailsConfiguration = service.detailsConfiguration
val countriesConfiguration = service.countriesConfiguration val countriesConfiguration = service.countriesConfiguration
@@ -16,6 +28,14 @@ class ConfigurationViewModel: ViewModel(), KoinComponent {
val primaryTranslationsConfiguration = service.primaryTranslationsConfiguration val primaryTranslationsConfiguration = service.primaryTranslationsConfiguration
val timezonesConfiguration = service.timezonesConfiguration 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() { suspend fun getConfigurations() {
service.getDetailsConfiguration() service.getDetailsConfiguration()
service.getCountriesConfiguration() 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="latest_season_title">Latest Season</string>
<string name="season_ep_count">%1$d | %2$d Episodes</string> <string name="season_ep_count">%1$d | %2$d Episodes</string>
<string name="see_all_seasons_text">See all seasons</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> </resources>