diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/di/AppModule.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/di/AppModule.kt index 55d31be..15ad07b 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/di/AppModule.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/di/AppModule.kt @@ -1,11 +1,15 @@ package com.github.feelbeatapp.androidclient.di +import androidx.compose.material3.SnackbarHostState import com.github.feelbeatapp.androidclient.BuildConfig import com.github.feelbeatapp.androidclient.auth.AuthManager import com.github.feelbeatapp.androidclient.auth.OauthConfig import com.github.feelbeatapp.androidclient.auth.spotify.SpotifyAuthManager import com.github.feelbeatapp.androidclient.auth.storage.AuthStorage import com.github.feelbeatapp.androidclient.auth.storage.PreferencesAuthStorage +import com.github.feelbeatapp.androidclient.error.ErrorEmitter +import com.github.feelbeatapp.androidclient.error.ErrorHandler +import com.github.feelbeatapp.androidclient.error.ErrorReceiver import com.github.feelbeatapp.androidclient.network.api.FeelBeatApi import com.github.feelbeatapp.androidclient.network.api.KtorFeelBeatApi import com.github.feelbeatapp.androidclient.network.fullduplex.NetworkAgent @@ -37,6 +41,8 @@ abstract class AppModule { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } } + private val errorHandler = ErrorHandler() + @Provides @Singleton fun provideHttpClient(): HttpClient { @@ -68,6 +74,18 @@ abstract class AppModule { fun provideApiUrl(): String { return BuildConfig.API_URL } + + @Provides + @Singleton + fun provideErrorEmitter(): ErrorEmitter { + return errorHandler + } + + @Provides + @Singleton + fun provideErrorReceiver(): ErrorReceiver { + return errorHandler + } } @Singleton @Binds abstract fun bindAuthManager(authManager: SpotifyAuthManager): AuthManager diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorCode.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorCode.kt index a2d4a8e..342d05d 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorCode.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorCode.kt @@ -1,5 +1,7 @@ package com.github.feelbeatapp.androidclient.error +import com.github.feelbeatapp.androidclient.R + enum class ErrorCode { AUTHORIZATION_SERVER_UNREACHABLE, AUTHORIZATION_ACCESS_TOKEN_ERROR, @@ -8,4 +10,15 @@ enum class ErrorCode { FEELBEAT_SERVER_UNREACHABLE, FEELBEAT_SERVER_ERROR, FEELBEAT_SERVER_INCORRECT_RESPONSE_FORMAT, + INCORRECT_PLAYLIST_LINK; + + fun toStringId(): Int { + return when (this) { + AUTHORIZATION_SERVER_UNREACHABLE -> R.string.authorization_server_unreachable + FEELBEAT_SERVER_UNREACHABLE -> R.string.feelbeat_server_unreachable + INCORRECT_PLAYLIST_LINK -> R.string.incorrect_playlist_link + FEELBEAT_SERVER_ERROR -> R.string.feelbeat_server_error + else -> R.string.unexpected_error + } + } } diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorEmitter.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorEmitter.kt new file mode 100644 index 0000000..a9896a2 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorEmitter.kt @@ -0,0 +1,7 @@ +package com.github.feelbeatapp.androidclient.error + +import kotlinx.coroutines.flow.SharedFlow + +interface ErrorEmitter { + val errors: SharedFlow +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorHandler.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorHandler.kt new file mode 100644 index 0000000..34afc61 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorHandler.kt @@ -0,0 +1,14 @@ +package com.github.feelbeatapp.androidclient.error + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject + +class ErrorHandler @Inject constructor() : ErrorEmitter, ErrorReceiver { + private val _errors = MutableSharedFlow() + override val errors = _errors.asSharedFlow() + + override suspend fun submitError(exception: FeelBeatException) { + _errors.emit(exception) + } +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorReceiver.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorReceiver.kt new file mode 100644 index 0000000..613a3f3 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorReceiver.kt @@ -0,0 +1,5 @@ +package com.github.feelbeatapp.androidclient.error + +interface ErrorReceiver { + suspend fun submitError(exception: FeelBeatException) +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/error/FeelBeatException.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/error/FeelBeatException.kt index 4026386..feae720 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/error/FeelBeatException.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/error/FeelBeatException.kt @@ -1,6 +1,6 @@ package com.github.feelbeatapp.androidclient.error -class FeelBeatException(val code: ErrorCode, debugMessage: String?, cause: Throwable?) : +open class FeelBeatException(val code: ErrorCode, debugMessage: String?, cause: Throwable?) : Exception(debugMessage, cause) { constructor(code: ErrorCode, debugMessage: String) : this(code, debugMessage, null) diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/error/FeelBeatServerException.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/error/FeelBeatServerException.kt new file mode 100644 index 0000000..fe6832a --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/error/FeelBeatServerException.kt @@ -0,0 +1,4 @@ +package com.github.feelbeatapp.androidclient.error + +class FeelBeatServerException(val serverMessage: String) : + FeelBeatException(ErrorCode.FEELBEAT_SERVER_ERROR) diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/error/utils.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/error/utils.kt deleted file mode 100644 index 8cd8c7b..0000000 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/error/utils.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.github.feelbeatapp.androidclient.error - -import android.content.Context -import com.github.feelbeatapp.androidclient.R - -fun errorCodeToStringResource(context: Context, code: ErrorCode): String { - return when (code) { - ErrorCode.AUTHORIZATION_SERVER_UNREACHABLE -> - context.getString(R.string.authorization_server_unreachable) - else -> " sfsdf" - } -} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/network/api/KtorFeelBeatApi.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/network/api/KtorFeelBeatApi.kt index 44e59a6..2f3c44d 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/network/api/KtorFeelBeatApi.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/network/api/KtorFeelBeatApi.kt @@ -3,6 +3,7 @@ package com.github.feelbeatapp.androidclient.network.api import com.github.feelbeatapp.androidclient.auth.AuthManager import com.github.feelbeatapp.androidclient.error.ErrorCode import com.github.feelbeatapp.androidclient.error.FeelBeatException +import com.github.feelbeatapp.androidclient.error.FeelBeatServerException import com.github.feelbeatapp.androidclient.network.api.payloads.CreateRoomPayload import com.github.feelbeatapp.androidclient.network.api.responses.CreateRoomResponse import com.github.feelbeatapp.androidclient.ui.app.model.RoomSettings @@ -48,7 +49,7 @@ constructor( } if (res.status != HttpStatusCode.OK) { - throw FeelBeatException(ErrorCode.FEELBEAT_SERVER_ERROR, res.bodyAsText()) + throw FeelBeatServerException(res.bodyAsText()) } val (roomId) = diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/AppScreen.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/AppScreen.kt index 423dcd2..31b3e5b 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/AppScreen.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/AppScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -78,6 +79,7 @@ fun AppScreen( onNavigateBack = onNavigateBack, ) }, + snackbarHost = { SnackbarHost(hostState = appViewModel.snackBarHost) }, bottomBar = { bottomBar() }, modifier = Modifier.fillMaxSize(), ) { padding -> diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/AppViewModel.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/AppViewModel.kt index f59ed24..b67f4e2 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/AppViewModel.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/AppViewModel.kt @@ -1,28 +1,56 @@ package com.github.feelbeatapp.androidclient.ui.app +import android.content.Context +import androidx.compose.material3.SnackbarHostState import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.feelbeatapp.androidclient.auth.AuthManager +import com.github.feelbeatapp.androidclient.error.ErrorEmitter +import com.github.feelbeatapp.androidclient.error.FeelBeatException +import com.github.feelbeatapp.androidclient.error.FeelBeatServerException import com.github.feelbeatapp.androidclient.network.spotify.SpotifyAPI import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import javax.inject.Inject data class PlayerIdentity(val name: String, val imageUrl: String) @HiltViewModel class AppViewModel @Inject -constructor(private val authManager: AuthManager, private val spotifyAPI: SpotifyAPI) : - ViewModel() { +constructor( + @ApplicationContext private val ctx: Context, + private val authManager: AuthManager, + private val spotifyAPI: SpotifyAPI, + private val errorEmitter: ErrorEmitter, +) : ViewModel() { private val _playerIdentity = MutableStateFlow(null) val playerIdentity = _playerIdentity.asStateFlow() + val snackBarHost = SnackbarHostState() + init { loadPlayerIdentity() + handleErrors() + } + + private fun handleErrors() { + viewModelScope.launch(Dispatchers.Default) { + errorEmitter.errors.collect { + snackBarHost.showSnackbar(formatException(it), withDismissAction = true) + } + } + } + + private fun formatException(exception: FeelBeatException): String { + return when (exception) { + is FeelBeatServerException -> ctx.getString(exception.code.toStringId(), exception.serverMessage) + else -> ctx.getString(exception.code.toStringId()) + } } private fun loadPlayerIdentity() { diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/roomsettings/screens/NewRoomSettingsScreen.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/roomsettings/screens/NewRoomSettingsScreen.kt index bcb2729..24763dc 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/roomsettings/screens/NewRoomSettingsScreen.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/roomsettings/screens/NewRoomSettingsScreen.kt @@ -31,8 +31,8 @@ fun NewRoomSettingsScreen( newRoomSettingsViewModel: NewRoomSettingsViewModel = hiltViewModel(), modifier: Modifier = Modifier, ) { - val roomCreationState by newRoomSettingsViewModel.roomCreationState.collectAsState() val createdRoomId by newRoomSettingsViewModel.roomCreated.collectAsState(null) + val loading by newRoomSettingsViewModel.loading.collectAsState() LaunchedEffect(createdRoomId) { val roomId = createdRoomId @@ -47,9 +47,7 @@ fun NewRoomSettingsScreen( ) { SettingsControls(viewModel = newRoomSettingsViewModel) - Text(roomCreationState.errorMessage ?: "") - - if (roomCreationState.loading) { + if (loading) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { CircularProgressIndicator( color = MaterialTheme.colorScheme.secondary, diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/roomsettings/viewmodels/NewRoomSettingsViewModel.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/roomsettings/viewmodels/NewRoomSettingsViewModel.kt index 636e205..23a7b88 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/roomsettings/viewmodels/NewRoomSettingsViewModel.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/roomsettings/viewmodels/NewRoomSettingsViewModel.kt @@ -1,37 +1,49 @@ package com.github.feelbeatapp.androidclient.ui.app.roomsettings.viewmodels import androidx.lifecycle.viewModelScope +import com.github.feelbeatapp.androidclient.error.ErrorCode +import com.github.feelbeatapp.androidclient.error.ErrorReceiver +import com.github.feelbeatapp.androidclient.error.FeelBeatException import com.github.feelbeatapp.androidclient.network.api.FeelBeatApi import dagger.hilt.android.lifecycle.HiltViewModel +import io.ktor.http.Url import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -data class RoomCreationState(val errorMessage: String?, val loading: Boolean = false) +const val SPOTIFY_PLAYLIST_HOST = "open.spotify.com" @HiltViewModel -class NewRoomSettingsViewModel @Inject constructor(private val feelBeatApi: FeelBeatApi) : +class NewRoomSettingsViewModel +@Inject +constructor(private val feelBeatApi: FeelBeatApi, private val errorReceiver: ErrorReceiver) : RoomSettingsViewModel() { - private val _roomCreationState = MutableStateFlow(RoomCreationState(null)) - val roomCreationState = _roomCreationState.asStateFlow() + private val _loading = MutableStateFlow(false) + val loading = _loading.asStateFlow() private val _roomCreated = MutableSharedFlow() val roomCreated = _roomCreated.asSharedFlow() fun createRoom() { viewModelScope.launch(Dispatchers.IO) { - _roomCreationState.update { it.copy(loading = true) } + val url = Url(roomSettings.value.playlistLink) + if (url.host != SPOTIFY_PLAYLIST_HOST || url.segments.isEmpty()) { + errorReceiver.submitError(FeelBeatException(ErrorCode.INCORRECT_PLAYLIST_LINK)) + return@launch + } + + _loading.value = true try { val roomId = feelBeatApi.createRoom(roomSettings.value) _roomCreated.emit(roomId) - _roomCreationState.update { it.copy(loading = false) } - } catch (e: Throwable) { - _roomCreationState.update { it.copy(errorMessage = e.message, loading = false) } + } catch (e: FeelBeatException) { + errorReceiver.submitError(e) + } finally { + _loading.value = false } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c0f30be..be63fe6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,6 +32,10 @@ Ups, that\'s not correct answer time left Authorization server is unreachable + FeelBeat server is unreachable + Server error: %1$s + Unexpected error occurred + Make sure playlist link is valid With how many points you starting to guess How many points you will loose after incorrect guess Get ready