From 4f4cc52c10063b5962258a6e46e4bbb9fe8b759b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20St=C4=99pie=C5=84?= Date: Fri, 10 Jan 2025 01:11:48 +0100 Subject: [PATCH] Implement game data streaming and prototype room joining --- app/build.gradle.kts | 2 +- .../androidclient/api/feelbeat/FeelBeatApi.kt | 10 + .../api/feelbeat/KtorFeelBeatApi.kt | 108 ++++++++++ .../feelbeat}/responses/CreateRoomResponse.kt | 2 +- .../feelbeat/responses/FetchRoomsResponse.kt | 5 + .../responses/RoomListViewResponse.kt} | 4 +- .../api/spotify/KtorSpotifyAPI.kt | 29 +++ .../androidclient/api/spotify/SpotifyAPI.kt | 7 + .../spotify/responses/ProfileResponse.kt | 2 +- .../auth/spotify/SpotifyAuthManager.kt | 190 ----------------- .../androidclient/game/datastreaming/Game.kt | 18 ++ .../game/datastreaming/GameDataStreamer.kt | 10 + .../datastreaming/RemoteGameDataStreamer.kt | 80 ++++++++ .../messages/server/InitialMessage.kt | 46 +++++ .../messages/server/NewPlayerMessage.kt | 12 ++ .../messages/server/PlayerLeftMessage.kt | 11 + .../messages/server/ServerMessage.kt | 14 ++ .../androidclient/game/model/GameState.kt | 10 + .../androidclient/game/model/Player.kt | 5 + .../model/RoomSettings.kt} | 6 +- .../androidclient/game/model/Song.kt | 11 + .../{ => infra}/auth/AuthData.kt | 2 +- .../{ => infra}/auth/AuthManager.kt | 2 +- .../{ => infra}/auth/AuthState.kt | 2 +- .../{ => infra}/auth/OauthConfig.kt | 2 +- .../{ => infra}/auth/PKCEUtils.kt | 2 +- .../infra/auth/spotify/SpotifyAuthManager.kt | 194 ++++++++++++++++++ .../{ => infra}/auth/spotify/TokenResponse.kt | 2 +- .../{ => infra}/auth/storage/AuthStorage.kt | 4 +- .../auth/storage/PreferencesAuthStorage.kt | 4 +- .../androidclient/{ => infra}/di/AppModule.kt | 54 ++--- .../{ => infra}/error/ErrorCode.kt | 6 +- .../{ => infra}/error/ErrorEmitter.kt | 2 +- .../{ => infra}/error/ErrorHandler.kt | 2 +- .../{ => infra}/error/ErrorReceiver.kt | 2 +- .../{ => infra}/error/FeelBeatException.kt | 2 +- .../error/FeelBeatServerException.kt | 2 +- .../infra/network/NetworkClient.kt | 23 +++ .../infra/network/WebsocketClient.kt | 80 ++++++++ .../androidclient/network/api/FeelBeatApi.kt | 10 - .../network/api/KtorFeelBeatApi.kt | 104 ---------- .../api/responses/FetchRoomsResponse.kt | 6 - .../network/fullduplex/NetworkAgent.kt | 22 -- .../network/fullduplex/WebsocketClient.kt | 97 --------- .../network/spotify/KtorSpotifyAPI.kt | 26 --- .../network/spotify/SpotifyAPI.kt | 7 - .../androidclient/ui/MainActivity.kt | 2 +- .../androidclient/ui/app/AppScreen.kt | 2 + .../androidclient/ui/app/AppViewModel.kt | 20 +- .../ui/app/components/PlayerCard.kt | 42 ++++ .../ui/app/components/SongCard.kt | 88 ++++++++ .../game/gameresult/GameResultViewModel.kt | 27 +-- .../app/game/guesssong/GuessResultScreen.kt | 12 +- .../ui/app/game/guesssong/GuessSongScreen.kt | 10 +- .../app/game/guesssong/GuessSongViewModel.kt | 25 +-- .../ui/app/game/startgame/StartGameScreen.kt | 30 +-- .../app/game/startgame/StartGameViewModel.kt | 13 +- .../androidclient/ui/app/home/HomeScreen.kt | 9 +- .../ui/app/home/HomeViewModel.kt | 14 +- .../ui/app/lobby/LobbyBottomBar.kt | 17 ++ .../app/lobby/acceptgame/AcceptGameScreen.kt | 114 ---------- .../lobby/acceptgame/AcceptGameViewModel.kt | 50 ----- .../ui/app/lobby/acceptgame/GameState.kt | 16 -- .../ui/app/lobby/lobbyhome/LobbyHomeScreen.kt | 107 ++++++++++ .../app/lobby/lobbyhome/LobbyHomeViewModel.kt | 53 +++++ .../app/lobby/lobbysongs/LobbySongsScreen.kt | 44 ++++ .../lobby/lobbysongs/LobbySongsViewModel.kt | 40 ++++ .../ui/app/navigation/AppGraph.kt | 4 + .../ui/app/navigation/AppRoute.kt | 1 + .../ui/app/navigation/LobbyGraph.kt | 9 +- .../viewmodels/NewRoomSettingsViewModel.kt | 8 +- .../viewmodels/RoomSettingsViewModel.kt | 23 ++- .../androidclient/ui/app/uimodel/Player.kt | 2 - .../ui/app/uimodel/PlayerWithResult.kt | 2 +- .../ui/app/uimodel/RoomSettings.kt | 24 --- .../androidclient/ui/login/AuthActivity.kt | 2 +- .../androidclient/ui/login/LoginViewModel.kt | 2 +- app/src/main/res/values/strings.xml | 4 + .../feelbeatapp/androidclient/PKCETest.kt | 2 +- .../androidclient/utils/FakeNetworkAgent.kt | 13 +- 80 files changed, 1215 insertions(+), 857 deletions(-) create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/api/feelbeat/FeelBeatApi.kt create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/api/feelbeat/KtorFeelBeatApi.kt rename app/src/main/java/com/github/feelbeatapp/androidclient/{network/api => api/feelbeat}/responses/CreateRoomResponse.kt (61%) create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/api/feelbeat/responses/FetchRoomsResponse.kt rename app/src/main/java/com/github/feelbeatapp/androidclient/{model/RoomListView.kt => api/feelbeat/responses/RoomListViewResponse.kt} (63%) create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/api/spotify/KtorSpotifyAPI.kt create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/api/spotify/SpotifyAPI.kt rename app/src/main/java/com/github/feelbeatapp/androidclient/{network => api}/spotify/responses/ProfileResponse.kt (92%) delete mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/auth/spotify/SpotifyAuthManager.kt create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/Game.kt create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/GameDataStreamer.kt create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/RemoteGameDataStreamer.kt create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/messages/server/InitialMessage.kt create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/messages/server/NewPlayerMessage.kt create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/messages/server/PlayerLeftMessage.kt create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/messages/server/ServerMessage.kt create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/game/model/GameState.kt create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/game/model/Player.kt rename app/src/main/java/com/github/feelbeatapp/androidclient/{model/CreateRoomPayload.kt => game/model/RoomSettings.kt} (65%) create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/game/model/Song.kt rename app/src/main/java/com/github/feelbeatapp/androidclient/{ => infra}/auth/AuthData.kt (68%) rename app/src/main/java/com/github/feelbeatapp/androidclient/{ => infra}/auth/AuthManager.kt (86%) rename app/src/main/java/com/github/feelbeatapp/androidclient/{ => infra}/auth/AuthState.kt (61%) rename app/src/main/java/com/github/feelbeatapp/androidclient/{ => infra}/auth/OauthConfig.kt (85%) rename app/src/main/java/com/github/feelbeatapp/androidclient/{ => infra}/auth/PKCEUtils.kt (93%) create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/spotify/SpotifyAuthManager.kt rename app/src/main/java/com/github/feelbeatapp/androidclient/{ => infra}/auth/spotify/TokenResponse.kt (85%) rename app/src/main/java/com/github/feelbeatapp/androidclient/{ => infra}/auth/storage/AuthStorage.kt (50%) rename app/src/main/java/com/github/feelbeatapp/androidclient/{ => infra}/auth/storage/PreferencesAuthStorage.kt (92%) rename app/src/main/java/com/github/feelbeatapp/androidclient/{ => infra}/di/AppModule.kt (60%) rename app/src/main/java/com/github/feelbeatapp/androidclient/{ => infra}/error/ErrorCode.kt (78%) rename app/src/main/java/com/github/feelbeatapp/androidclient/{ => infra}/error/ErrorEmitter.kt (67%) rename app/src/main/java/com/github/feelbeatapp/androidclient/{ => infra}/error/ErrorHandler.kt (89%) rename app/src/main/java/com/github/feelbeatapp/androidclient/{ => infra}/error/ErrorReceiver.kt (60%) rename app/src/main/java/com/github/feelbeatapp/androidclient/{ => infra}/error/FeelBeatException.kt (86%) rename app/src/main/java/com/github/feelbeatapp/androidclient/{ => infra}/error/FeelBeatServerException.kt (66%) create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/infra/network/NetworkClient.kt create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/infra/network/WebsocketClient.kt delete mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/network/api/FeelBeatApi.kt delete mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/network/api/KtorFeelBeatApi.kt delete mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/network/api/responses/FetchRoomsResponse.kt delete mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/network/fullduplex/NetworkAgent.kt delete mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/network/fullduplex/WebsocketClient.kt delete mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/network/spotify/KtorSpotifyAPI.kt delete mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/network/spotify/SpotifyAPI.kt create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/components/PlayerCard.kt create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/components/SongCard.kt delete mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/acceptgame/AcceptGameScreen.kt delete mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/acceptgame/AcceptGameViewModel.kt delete mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/acceptgame/GameState.kt create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/lobbyhome/LobbyHomeScreen.kt create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/lobbyhome/LobbyHomeViewModel.kt create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/lobbysongs/LobbySongsScreen.kt create mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/lobbysongs/LobbySongsViewModel.kt delete mode 100644 app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/uimodel/RoomSettings.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5455b6a..eff3bf6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,7 +24,7 @@ android { buildTypes { debug { - buildConfigField("String", "SOCKET_URI", "\"ws://10.0.2.2\"") + buildConfigField("String", "SOCKET_URI", "\"ws://10.0.2.2:3000\"") buildConfigField("String", "SPOTIFY_CLIENT_ID", "\"0368b2bddb504887b517fc4e8fca9cc5\"") buildConfigField("String", "SPOTIFY_REDIRECT_URI", "\"feelbeat://callback\"") buildConfigField("String", "SPOTIFY_SCOPE", "\"user-read-private user-read-email\"") diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/api/feelbeat/FeelBeatApi.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/api/feelbeat/FeelBeatApi.kt new file mode 100644 index 0000000..06a57be --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/api/feelbeat/FeelBeatApi.kt @@ -0,0 +1,10 @@ +package com.github.feelbeatapp.androidclient.api.feelbeat + +import com.github.feelbeatapp.androidclient.game.model.RoomSettings +import com.github.feelbeatapp.androidclient.api.feelbeat.responses.RoomListViewResponse + +interface FeelBeatApi { + suspend fun createRoom(payload: RoomSettings): String + + suspend fun fetchRooms(): List +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/api/feelbeat/KtorFeelBeatApi.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/api/feelbeat/KtorFeelBeatApi.kt new file mode 100644 index 0000000..bade316 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/api/feelbeat/KtorFeelBeatApi.kt @@ -0,0 +1,108 @@ +package com.github.feelbeatapp.androidclient.api.feelbeat + +import com.github.feelbeatapp.androidclient.api.feelbeat.responses.CreateRoomResponse +import com.github.feelbeatapp.androidclient.api.feelbeat.responses.FetchRoomsResponse +import com.github.feelbeatapp.androidclient.api.feelbeat.responses.RoomListViewResponse +import com.github.feelbeatapp.androidclient.game.model.RoomSettings +import com.github.feelbeatapp.androidclient.infra.auth.AuthManager +import com.github.feelbeatapp.androidclient.infra.error.ErrorCode +import com.github.feelbeatapp.androidclient.infra.error.FeelBeatException +import com.github.feelbeatapp.androidclient.infra.error.FeelBeatServerException +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.util.network.UnresolvedAddressException +import java.io.IOException +import javax.inject.Inject +import javax.inject.Named +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class KtorFeelBeatApi +@Inject +constructor( + @Named("API_URL") private val baseUrl: String, + private val httpClient: HttpClient, + private val authManager: AuthManager, +) : FeelBeatApi { + override suspend fun createRoom(payload: RoomSettings): String = + withContext(Dispatchers.IO) { + val token = authManager.getAccessToken() + + val res = + try { + httpClient.post("$baseUrl/create") { + headers { set("Authorization", "Bearer $token") } + contentType(ContentType.Application.Json) + setBody(payload) + } + } catch (e: Exception) { + when (e) { + is IOException, + is UnresolvedAddressException -> + throw FeelBeatException(ErrorCode.FEELBEAT_SERVER_UNREACHABLE, e) + else -> throw e + } + } + + if (res.status != HttpStatusCode.OK) { + throw FeelBeatServerException(res.bodyAsText().trim()) + } + + val (roomId) = + try { + res.body() + } catch (e: UnsupportedOperationException) { + throw FeelBeatException( + ErrorCode.FEELBEAT_SERVER_INCORRECT_RESPONSE_FORMAT, + "Failed to parse server response", + e, + ) + } + + roomId + } + + override suspend fun fetchRooms(): List = + withContext(Dispatchers.IO) { + val token = authManager.getAccessToken() + + val res = + try { + httpClient.get("$baseUrl/rooms") { + headers { set("Authorization", "Bearer $token") } + } + } catch (e: Exception) { + when (e) { + is IOException, + is UnresolvedAddressException -> + throw FeelBeatException(ErrorCode.FEELBEAT_SERVER_UNREACHABLE, e) + else -> throw e + } + } + + if (res.status != HttpStatusCode.OK) { + throw FeelBeatServerException(res.bodyAsText().trim()) + } + + val (rooms) = + try { + res.body() + } catch (e: UnsupportedOperationException) { + throw FeelBeatException( + ErrorCode.FEELBEAT_SERVER_INCORRECT_RESPONSE_FORMAT, + "Failed to parse server response", + e, + ) + } + + rooms + } +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/network/api/responses/CreateRoomResponse.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/api/feelbeat/responses/CreateRoomResponse.kt similarity index 61% rename from app/src/main/java/com/github/feelbeatapp/androidclient/network/api/responses/CreateRoomResponse.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/api/feelbeat/responses/CreateRoomResponse.kt index 2f5a0b2..97de5d0 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/network/api/responses/CreateRoomResponse.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/api/feelbeat/responses/CreateRoomResponse.kt @@ -1,4 +1,4 @@ -package com.github.feelbeatapp.androidclient.network.api.responses +package com.github.feelbeatapp.androidclient.api.feelbeat.responses import kotlinx.serialization.Serializable diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/api/feelbeat/responses/FetchRoomsResponse.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/api/feelbeat/responses/FetchRoomsResponse.kt new file mode 100644 index 0000000..5e679b6 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/api/feelbeat/responses/FetchRoomsResponse.kt @@ -0,0 +1,5 @@ +package com.github.feelbeatapp.androidclient.api.feelbeat.responses + +import kotlinx.serialization.Serializable + +@Serializable data class FetchRoomsResponse(val rooms: List) diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/model/RoomListView.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/api/feelbeat/responses/RoomListViewResponse.kt similarity index 63% rename from app/src/main/java/com/github/feelbeatapp/androidclient/model/RoomListView.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/api/feelbeat/responses/RoomListViewResponse.kt index 5727638..3dc558e 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/model/RoomListView.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/api/feelbeat/responses/RoomListViewResponse.kt @@ -1,9 +1,9 @@ -package com.github.feelbeatapp.androidclient.model +package com.github.feelbeatapp.androidclient.api.feelbeat.responses import kotlinx.serialization.Serializable @Serializable -data class RoomListView( +data class RoomListViewResponse( val id: String, val name: String, val players: Int, diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/api/spotify/KtorSpotifyAPI.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/api/spotify/KtorSpotifyAPI.kt new file mode 100644 index 0000000..4ce6201 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/api/spotify/KtorSpotifyAPI.kt @@ -0,0 +1,29 @@ +package com.github.feelbeatapp.androidclient.api.spotify + +import com.github.feelbeatapp.androidclient.api.spotify.responses.ProfileResponse +import com.github.feelbeatapp.androidclient.infra.auth.AuthManager +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.headers +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +const val SPOTIFY_API_URL = "https://api.spotify.com/v1" + +class KtorSpotifyAPI +@Inject +constructor(private val httpClient: HttpClient, private val authManager: AuthManager) : SpotifyAPI { + override suspend fun getProfile(): ProfileResponse = + withContext(Dispatchers.IO) { + val accessToken = authManager.getAccessToken() + + val response = + httpClient.get("$SPOTIFY_API_URL/me") { + headers { append("Authorization", "Bearer $accessToken") } + } + + response.body() + } +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/api/spotify/SpotifyAPI.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/api/spotify/SpotifyAPI.kt new file mode 100644 index 0000000..e83392c --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/api/spotify/SpotifyAPI.kt @@ -0,0 +1,7 @@ +package com.github.feelbeatapp.androidclient.api.spotify + +import com.github.feelbeatapp.androidclient.api.spotify.responses.ProfileResponse + +interface SpotifyAPI { + suspend fun getProfile(): ProfileResponse +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/network/spotify/responses/ProfileResponse.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/api/spotify/responses/ProfileResponse.kt similarity index 92% rename from app/src/main/java/com/github/feelbeatapp/androidclient/network/spotify/responses/ProfileResponse.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/api/spotify/responses/ProfileResponse.kt index 9d76cfa..d3c9379 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/network/spotify/responses/ProfileResponse.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/api/spotify/responses/ProfileResponse.kt @@ -1,4 +1,4 @@ -package com.github.feelbeatapp.androidclient.network.spotify.responses +package com.github.feelbeatapp.androidclient.api.spotify.responses import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/auth/spotify/SpotifyAuthManager.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/auth/spotify/SpotifyAuthManager.kt deleted file mode 100644 index 682f22e..0000000 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/auth/spotify/SpotifyAuthManager.kt +++ /dev/null @@ -1,190 +0,0 @@ -package com.github.feelbeatapp.androidclient.auth.spotify - -import android.content.Context -import android.net.Uri -import android.util.Log -import androidx.browser.customtabs.CustomTabsIntent -import com.github.feelbeatapp.androidclient.auth.AuthData -import com.github.feelbeatapp.androidclient.auth.AuthManager -import com.github.feelbeatapp.androidclient.auth.AuthState -import com.github.feelbeatapp.androidclient.auth.OauthConfig -import com.github.feelbeatapp.androidclient.auth.PKCEUtils -import com.github.feelbeatapp.androidclient.auth.storage.AuthStorage -import com.github.feelbeatapp.androidclient.error.ErrorCode -import com.github.feelbeatapp.androidclient.error.FeelBeatException -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.forms.submitForm -import io.ktor.http.parameters -import io.ktor.serialization.JsonConvertException -import io.ktor.util.network.UnresolvedAddressException -import java.io.IOException -import java.time.Instant -import javax.inject.Inject - -class SpotifyAuthManager -@Inject -constructor( - private val oauthConfig: OauthConfig, - private val httpClient: HttpClient, - private val authStorage: AuthStorage, -) : AuthManager { - private val pkce = PKCEUtils() - private var currentCodeVerifier: String? = null - private var authData: AuthData? = null - private var state: AuthState = AuthState.NOT_AUTHENTICATED - - init { - authData = authStorage.retrieveAuthData() - if (authData != null) { - state = AuthState.AUTHENTICATED - } - } - - override fun authState(): AuthState { - return state - } - - override fun startLoginFlow(ctx: Context) { - if (state != AuthState.NOT_AUTHENTICATED) { - return - } - - state = AuthState.AUTHENTICATING - - val loginIntent = CustomTabsIntent.Builder().build() - - val code = pkce.generateCodeVerifier() - - val authorizeRedirect = - Uri.parse(oauthConfig.authorizeUri) - .buildUpon() - .appendQueryParameter("response_type", "code") - .appendQueryParameter("code_challenge_method", "S256") - .appendQueryParameter("client_id", oauthConfig.clientId) - .appendQueryParameter("scope", oauthConfig.scope) - .appendQueryParameter("redirect_uri", oauthConfig.redirectUri) - .appendQueryParameter("code_challenge", pkce.getCodeChallenge(code)) - .build() - - currentCodeVerifier = code - loginIntent.launchUrl(ctx, authorizeRedirect) - } - - override suspend fun fetchAccessToken(code: String) { - val response = - try { - httpClient.submitForm( - oauthConfig.tokenUri, - formParameters = - parameters { - append("grant_type", "authorization_code") - append("code", code) - append("redirect_uri", oauthConfig.redirectUri) - append("client_id", oauthConfig.clientId) - append("code_verifier", checkNotNull(currentCodeVerifier)) - }, - ) {} - } catch (e: Exception) { - when (e) { - is IOException, - is UnresolvedAddressException -> - throw FeelBeatException(ErrorCode.AUTHORIZATION_SERVER_UNREACHABLE, e) - else -> throw e - } - } - - val tokenResponse = - try { - response.body() - } catch (e: JsonConvertException) { - throw FeelBeatException(ErrorCode.AUTHORIZATION_ACCESS_TOKEN_ERROR, e) - } - - val newAuthData = tokenResponseToAuthData(tokenResponse) - authStorage.storeAuthData(newAuthData) - authData = newAuthData - onAuthenticated() - } - - override fun cancelLoginFlow() { - state = AuthState.NOT_AUTHENTICATED - } - - override suspend fun getAccessToken(): String { - var auth = checkNotNull(authData) { "Not authenticated" } - if (hasExpired(auth.expires)) { - refreshAccessToken() - auth = checkNotNull(authData) { "Not authenticated" } - } - - return auth.accessToken - } - - private fun onAuthenticated() { - state = AuthState.AUTHENTICATED - } - - private fun calculateExpiration(expiresIn: Int): Instant { - return Instant.now().plusSeconds(expiresIn.toLong()) - } - - private fun hasExpired(expires: Instant): Boolean { - return expires <= Instant.now() - } - - private suspend fun refreshAccessToken() { - Log.i("SpotifyAuth", "refreshing access token") - val auth = checkNotNull(authData, { "Not authenticated" }) - - val response = - try { - httpClient.submitForm( - oauthConfig.refreshUri, - parameters { - append("grant_type", "refresh_token") - append("refresh_token", auth.refreshToken) - append("client_id", oauthConfig.clientId) - }, - ) {} - } catch (e: Exception) { - when (e) { - is IOException, - is UnresolvedAddressException -> - throw FeelBeatException(ErrorCode.AUTHORIZATION_SERVER_UNREACHABLE, e) - - else -> throw e - } - } - - val tokenResponse = - try { - response.body() - } catch (e: JsonConvertException) { - throw FeelBeatException(ErrorCode.AUTHORIZATION_REFRESH_TOKEN_ERROR, e) - } - val newAuthData = tokenResponseToAuthData(tokenResponse) - authStorage.storeAuthData(newAuthData) - authData = newAuthData - Log.i("SpotifyAuth", "access token refreshed successfully") - } - - private fun tokenResponseToAuthData(tokenResponse: TokenResponse): AuthData { - return AuthData( - accessToken = tokenResponse.accessToken, - refreshToken = tokenResponse.refreshToken, - expires = calculateExpiration(tokenResponse.expiresIn), - ) - } - - override fun logout() { - try { - authStorage.clearAuthData() - authData = null - state = AuthState.NOT_AUTHENTICATED - } catch (e: Exception) { - Log.e("SpotifyAuth", "Error during logout", e) - throw FeelBeatException(ErrorCode.AUTHENTICATION_LOGOUT_ERROR, e) - } - } -} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/Game.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/Game.kt new file mode 100644 index 0000000..de9b652 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/Game.kt @@ -0,0 +1,18 @@ +package com.github.feelbeatapp.androidclient.game.datastreaming + +import com.github.feelbeatapp.androidclient.game.model.GameState +import com.github.feelbeatapp.androidclient.game.model.Player + +class Game(private var gameState: GameState) { + fun gameState(): GameState { + return gameState + } + + fun addPlayer(player: Player) { + gameState = gameState.copy(players = gameState.players.plus(player)) + } + + fun removePlayer(playerId: String) { + gameState = gameState.copy(players = gameState.players.filter { it.id != playerId }) + } +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/GameDataStreamer.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/GameDataStreamer.kt new file mode 100644 index 0000000..5a4cfdb --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/GameDataStreamer.kt @@ -0,0 +1,10 @@ +package com.github.feelbeatapp.androidclient.game.datastreaming + +import com.github.feelbeatapp.androidclient.game.model.GameState +import kotlinx.coroutines.flow.StateFlow + +interface GameDataStreamer { + suspend fun joinRoom(roomId: String) + + fun gameStateFlow(): StateFlow +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/RemoteGameDataStreamer.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/RemoteGameDataStreamer.kt new file mode 100644 index 0000000..743efd8 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/RemoteGameDataStreamer.kt @@ -0,0 +1,80 @@ +package com.github.feelbeatapp.androidclient.game.datastreaming + +import android.util.Log +import com.github.feelbeatapp.androidclient.game.datastreaming.messages.server.InitialGameState +import com.github.feelbeatapp.androidclient.game.datastreaming.messages.server.InitialMessage +import com.github.feelbeatapp.androidclient.game.datastreaming.messages.server.NewPlayerMessage +import com.github.feelbeatapp.androidclient.game.datastreaming.messages.server.PlayerLeftMessage +import com.github.feelbeatapp.androidclient.game.datastreaming.messages.server.ServerMessageType +import com.github.feelbeatapp.androidclient.game.model.GameState +import com.github.feelbeatapp.androidclient.infra.error.ErrorCode +import com.github.feelbeatapp.androidclient.infra.error.FeelBeatException +import com.github.feelbeatapp.androidclient.infra.network.NetworkClient +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive + +class RemoteGameDataStreamer @Inject constructor(private val networkClient: NetworkClient) : + GameDataStreamer { + private var game: Game? = null + private var gameStateFlow = MutableStateFlow(null) + private var scope: CoroutineScope? = null + + override suspend fun joinRoom(roomId: String) { + scope?.cancel() + val newScope = CoroutineScope(Dispatchers.IO) + scope = newScope + newScope.launch { networkClient.connect("/ws/$roomId").collect { processMessage(it) } } + scope = null + gameStateFlow.value = null + } + + override fun gameStateFlow(): StateFlow { + return gameStateFlow.asStateFlow() + } + + private fun processMessage(content: String) { + try { + val type = Json.decodeFromString(content)["type"]?.jsonPrimitive?.content + + when (type) { + ServerMessageType.INITIAL.name -> + loadInitialState(Json.decodeFromString(content).payload) + ServerMessageType.NEW_PLAYER.name -> { + game?.addPlayer(Json.decodeFromString(content).payload) + gameStateFlow.value = game?.gameState() + } + ServerMessageType.PLAYER_LEFT.name -> { + game?.removePlayer(Json.decodeFromString(content).payload) + gameStateFlow.value = game?.gameState() + } + else -> Log.w("RemoteGameDataStreamer", "Received unexpected message: $content") + } + } catch (e: Exception) { + throw FeelBeatException(ErrorCode.FEELBEAT_SERVER_INCORRECT_RESPONSE_FORMAT, e) + } + } + + private fun loadInitialState(initialState: InitialGameState) { + game = + Game( + GameState( + roomId = initialState.id, + playlistName = initialState.playlist.name, + playlistImageUrl = initialState.playlist.imageUrl, + adminId = initialState.admin, + players = initialState.players, + songs = initialState.playlist.songs.map { it.toSongModel() }, + ) + ) + gameStateFlow.value = game?.gameState() + } +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/messages/server/InitialMessage.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/messages/server/InitialMessage.kt new file mode 100644 index 0000000..1203569 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/messages/server/InitialMessage.kt @@ -0,0 +1,46 @@ +package com.github.feelbeatapp.androidclient.game.datastreaming.messages.server + +import com.github.feelbeatapp.androidclient.game.model.Player +import com.github.feelbeatapp.androidclient.game.model.RoomSettings +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.time.Duration.Companion.seconds + +@Serializable +@SerialName("INITIAL") +data class InitialMessage( + override val type: String = ServerMessageType.INITIAL.name, + val payload: InitialGameState, +) : ServerMessage() + +@Serializable +data class InitialGameState( + val id: String, + val me: String, + val admin: String, + val playlist: Playlist, + val players: List, + val settings: RoomSettings, +) + +@Serializable data class Playlist(val name: String, val imageUrl: String, val songs: List) + +@Serializable +data class Song( + val id: String, + val title: String, + val artist: String, + val imageUrl: String, + val durationSec: Int, +) { + fun toSongModel(): com.github.feelbeatapp.androidclient.game.model.Song { + return com.github.feelbeatapp.androidclient.game.model.Song( + id = id, + title = title, + artist = artist, + imageUrl = imageUrl, + duration = durationSec.seconds, + ) + } +} + diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/messages/server/NewPlayerMessage.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/messages/server/NewPlayerMessage.kt new file mode 100644 index 0000000..6bcdb54 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/messages/server/NewPlayerMessage.kt @@ -0,0 +1,12 @@ +package com.github.feelbeatapp.androidclient.game.datastreaming.messages.server + +import com.github.feelbeatapp.androidclient.game.model.Player +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("NEW_PLAYER") +data class NewPlayerMessage( + override val type: String = ServerMessageType.NEW_PLAYER.name, + val payload: Player, +) : ServerMessage() diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/messages/server/PlayerLeftMessage.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/messages/server/PlayerLeftMessage.kt new file mode 100644 index 0000000..fba6702 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/messages/server/PlayerLeftMessage.kt @@ -0,0 +1,11 @@ +package com.github.feelbeatapp.androidclient.game.datastreaming.messages.server + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("PLAYER_LEFT") +data class PlayerLeftMessage( + override val type: String = ServerMessageType.PLAYER_LEFT.name, + val payload: String, +) : ServerMessage() diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/messages/server/ServerMessage.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/messages/server/ServerMessage.kt new file mode 100644 index 0000000..2c96dd3 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/game/datastreaming/messages/server/ServerMessage.kt @@ -0,0 +1,14 @@ +package com.github.feelbeatapp.androidclient.game.datastreaming.messages.server + +import kotlinx.serialization.Serializable + +enum class ServerMessageType { + INITIAL, + NEW_PLAYER, + PLAYER_LEFT, +} + +@Serializable +sealed class ServerMessage { + abstract val type: String +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/game/model/GameState.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/game/model/GameState.kt new file mode 100644 index 0000000..88965a9 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/game/model/GameState.kt @@ -0,0 +1,10 @@ +package com.github.feelbeatapp.androidclient.game.model + +data class GameState( + val roomId: String, + val playlistName: String, + val playlistImageUrl: String, + val adminId: String, + val players: List, + val songs: List, +) diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/game/model/Player.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/game/model/Player.kt new file mode 100644 index 0000000..f3880f0 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/game/model/Player.kt @@ -0,0 +1,5 @@ +package com.github.feelbeatapp.androidclient.game.model + +import kotlinx.serialization.Serializable + +@Serializable data class Player(val id: String, val name: String, val imageUrl: String) diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/model/CreateRoomPayload.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/game/model/RoomSettings.kt similarity index 65% rename from app/src/main/java/com/github/feelbeatapp/androidclient/model/CreateRoomPayload.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/game/model/RoomSettings.kt index ff03f40..6caa8cb 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/model/CreateRoomPayload.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/game/model/RoomSettings.kt @@ -1,13 +1,13 @@ -package com.github.feelbeatapp.androidclient.model +package com.github.feelbeatapp.androidclient.game.model import kotlinx.serialization.Serializable @Serializable -data class CreateRoomPayload( +data class RoomSettings( val maxPlayers: Int, val turnCount: Int, val timePenaltyPerSecond: Int, val basePoints: Int, val incorrectGuessPenalty: Int, - val playListId: String, + val playlistId: String, ) diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/game/model/Song.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/game/model/Song.kt new file mode 100644 index 0000000..a595907 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/game/model/Song.kt @@ -0,0 +1,11 @@ +package com.github.feelbeatapp.androidclient.game.model + +import kotlin.time.Duration + +data class Song( + val id: String, + val title: String, + val artist: String, + val imageUrl: String, + val duration: Duration, +) diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/auth/AuthData.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/AuthData.kt similarity index 68% rename from app/src/main/java/com/github/feelbeatapp/androidclient/auth/AuthData.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/AuthData.kt index 7392573..f2a9b50 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/auth/AuthData.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/AuthData.kt @@ -1,4 +1,4 @@ -package com.github.feelbeatapp.androidclient.auth +package com.github.feelbeatapp.androidclient.infra.auth import java.time.Instant diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/auth/AuthManager.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/AuthManager.kt similarity index 86% rename from app/src/main/java/com/github/feelbeatapp/androidclient/auth/AuthManager.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/AuthManager.kt index 567f534..17d2932 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/auth/AuthManager.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/AuthManager.kt @@ -1,4 +1,4 @@ -package com.github.feelbeatapp.androidclient.auth +package com.github.feelbeatapp.androidclient.infra.auth import android.content.Context diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/auth/AuthState.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/AuthState.kt similarity index 61% rename from app/src/main/java/com/github/feelbeatapp/androidclient/auth/AuthState.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/AuthState.kt index 9ef96b7..b4e0c6b 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/auth/AuthState.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/AuthState.kt @@ -1,4 +1,4 @@ -package com.github.feelbeatapp.androidclient.auth +package com.github.feelbeatapp.androidclient.infra.auth enum class AuthState { NOT_AUTHENTICATED, diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/auth/OauthConfig.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/OauthConfig.kt similarity index 85% rename from app/src/main/java/com/github/feelbeatapp/androidclient/auth/OauthConfig.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/OauthConfig.kt index 2d51815..a3347cb 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/auth/OauthConfig.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/OauthConfig.kt @@ -1,4 +1,4 @@ -package com.github.feelbeatapp.androidclient.auth +package com.github.feelbeatapp.androidclient.infra.auth data class OauthConfig( val clientId: String, diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/auth/PKCEUtils.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/PKCEUtils.kt similarity index 93% rename from app/src/main/java/com/github/feelbeatapp/androidclient/auth/PKCEUtils.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/PKCEUtils.kt index d721174..8c827d6 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/auth/PKCEUtils.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/PKCEUtils.kt @@ -1,4 +1,4 @@ -package com.github.feelbeatapp.androidclient.auth +package com.github.feelbeatapp.androidclient.infra.auth import java.security.MessageDigest import kotlin.io.encoding.Base64 diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/spotify/SpotifyAuthManager.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/spotify/SpotifyAuthManager.kt new file mode 100644 index 0000000..f39d7ef --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/spotify/SpotifyAuthManager.kt @@ -0,0 +1,194 @@ +package com.github.feelbeatapp.androidclient.infra.auth.spotify + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.browser.customtabs.CustomTabsIntent +import com.github.feelbeatapp.androidclient.infra.auth.AuthData +import com.github.feelbeatapp.androidclient.infra.auth.AuthManager +import com.github.feelbeatapp.androidclient.infra.auth.AuthState +import com.github.feelbeatapp.androidclient.infra.auth.OauthConfig +import com.github.feelbeatapp.androidclient.infra.auth.PKCEUtils +import com.github.feelbeatapp.androidclient.infra.auth.storage.AuthStorage +import com.github.feelbeatapp.androidclient.infra.error.ErrorCode +import com.github.feelbeatapp.androidclient.infra.error.FeelBeatException +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.forms.submitForm +import io.ktor.http.parameters +import io.ktor.serialization.JsonConvertException +import io.ktor.util.network.UnresolvedAddressException +import java.io.IOException +import java.time.Instant +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class SpotifyAuthManager +@Inject +constructor( + private val oauthConfig: OauthConfig, + private val httpClient: HttpClient, + private val authStorage: AuthStorage, +) : AuthManager { + private val pkce = PKCEUtils() + private var currentCodeVerifier: String? = null + private var authData: AuthData? = null + private var state: AuthState = AuthState.NOT_AUTHENTICATED + + init { + authData = authStorage.retrieveAuthData() + if (authData != null) { + state = AuthState.AUTHENTICATED + } + } + + override fun authState(): AuthState { + return state + } + + override fun startLoginFlow(ctx: Context) { + if (state != AuthState.NOT_AUTHENTICATED) { + return + } + + state = AuthState.AUTHENTICATING + + val loginIntent = CustomTabsIntent.Builder().build() + + val code = pkce.generateCodeVerifier() + + val authorizeRedirect = + Uri.parse(oauthConfig.authorizeUri) + .buildUpon() + .appendQueryParameter("response_type", "code") + .appendQueryParameter("code_challenge_method", "S256") + .appendQueryParameter("client_id", oauthConfig.clientId) + .appendQueryParameter("scope", oauthConfig.scope) + .appendQueryParameter("redirect_uri", oauthConfig.redirectUri) + .appendQueryParameter("code_challenge", pkce.getCodeChallenge(code)) + .build() + + currentCodeVerifier = code + loginIntent.launchUrl(ctx, authorizeRedirect) + } + + override suspend fun fetchAccessToken(code: String) = + withContext(Dispatchers.IO) { + val response = + try { + httpClient.submitForm( + oauthConfig.tokenUri, + formParameters = + parameters { + append("grant_type", "authorization_code") + append("code", code) + append("redirect_uri", oauthConfig.redirectUri) + append("client_id", oauthConfig.clientId) + append("code_verifier", checkNotNull(currentCodeVerifier)) + }, + ) {} + } catch (e: Exception) { + when (e) { + is IOException, + is UnresolvedAddressException -> + throw FeelBeatException(ErrorCode.AUTHORIZATION_SERVER_UNREACHABLE, e) + else -> throw e + } + } + + val tokenResponse = + try { + response.body() + } catch (e: JsonConvertException) { + throw FeelBeatException(ErrorCode.AUTHORIZATION_ACCESS_TOKEN_ERROR, e) + } + + val newAuthData = tokenResponseToAuthData(tokenResponse) + authStorage.storeAuthData(newAuthData) + authData = newAuthData + onAuthenticated() + } + + override fun cancelLoginFlow() { + state = AuthState.NOT_AUTHENTICATED + } + + override suspend fun getAccessToken(): String { + var auth = checkNotNull(authData) { "Not authenticated" } + if (hasExpired(auth.expires)) { + refreshAccessToken() + auth = checkNotNull(authData) { "Not authenticated" } + } + + return auth.accessToken + } + + private fun onAuthenticated() { + state = AuthState.AUTHENTICATED + } + + private fun calculateExpiration(expiresIn: Int): Instant { + return Instant.now().plusSeconds(expiresIn.toLong()) + } + + private fun hasExpired(expires: Instant): Boolean { + return expires <= Instant.now() + } + + private suspend fun refreshAccessToken() = + withContext(Dispatchers.IO) { + Log.i("SpotifyAuth", "refreshing access token") + val auth = checkNotNull(authData, { "Not authenticated" }) + + val response = + try { + httpClient.submitForm( + oauthConfig.refreshUri, + parameters { + append("grant_type", "refresh_token") + append("refresh_token", auth.refreshToken) + append("client_id", oauthConfig.clientId) + }, + ) {} + } catch (e: Exception) { + when (e) { + is IOException, + is UnresolvedAddressException -> + throw FeelBeatException(ErrorCode.AUTHORIZATION_SERVER_UNREACHABLE, e) + + else -> throw e + } + } + + val tokenResponse = + try { + response.body() + } catch (e: JsonConvertException) { + throw FeelBeatException(ErrorCode.AUTHORIZATION_REFRESH_TOKEN_ERROR, e) + } + val newAuthData = tokenResponseToAuthData(tokenResponse) + authStorage.storeAuthData(newAuthData) + authData = newAuthData + Log.i("SpotifyAuth", "access token refreshed successfully") + } + + private fun tokenResponseToAuthData(tokenResponse: TokenResponse): AuthData { + return AuthData( + accessToken = tokenResponse.accessToken, + refreshToken = tokenResponse.refreshToken, + expires = calculateExpiration(tokenResponse.expiresIn), + ) + } + + override fun logout() { + try { + authStorage.clearAuthData() + authData = null + state = AuthState.NOT_AUTHENTICATED + } catch (e: Exception) { + Log.e("SpotifyAuth", "Error during logout", e) + throw FeelBeatException(ErrorCode.AUTHENTICATION_LOGOUT_ERROR, e) + } + } +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/auth/spotify/TokenResponse.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/spotify/TokenResponse.kt similarity index 85% rename from app/src/main/java/com/github/feelbeatapp/androidclient/auth/spotify/TokenResponse.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/spotify/TokenResponse.kt index 2bca49c..92ecdae 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/auth/spotify/TokenResponse.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/spotify/TokenResponse.kt @@ -1,4 +1,4 @@ -package com.github.feelbeatapp.androidclient.auth.spotify +package com.github.feelbeatapp.androidclient.infra.auth.spotify import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/auth/storage/AuthStorage.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/storage/AuthStorage.kt similarity index 50% rename from app/src/main/java/com/github/feelbeatapp/androidclient/auth/storage/AuthStorage.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/storage/AuthStorage.kt index 758081f..449600a 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/auth/storage/AuthStorage.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/storage/AuthStorage.kt @@ -1,6 +1,6 @@ -package com.github.feelbeatapp.androidclient.auth.storage +package com.github.feelbeatapp.androidclient.infra.auth.storage -import com.github.feelbeatapp.androidclient.auth.AuthData +import com.github.feelbeatapp.androidclient.infra.auth.AuthData interface AuthStorage { fun storeAuthData(data: AuthData) diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/auth/storage/PreferencesAuthStorage.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/storage/PreferencesAuthStorage.kt similarity index 92% rename from app/src/main/java/com/github/feelbeatapp/androidclient/auth/storage/PreferencesAuthStorage.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/storage/PreferencesAuthStorage.kt index f306afd..e0a05e6 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/auth/storage/PreferencesAuthStorage.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/auth/storage/PreferencesAuthStorage.kt @@ -1,9 +1,9 @@ -package com.github.feelbeatapp.androidclient.auth.storage +package com.github.feelbeatapp.androidclient.infra.auth.storage import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKeys -import com.github.feelbeatapp.androidclient.auth.AuthData +import com.github.feelbeatapp.androidclient.infra.auth.AuthData import dagger.hilt.android.qualifiers.ApplicationContext import java.time.Instant import javax.inject.Inject diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/di/AppModule.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/di/AppModule.kt similarity index 60% rename from app/src/main/java/com/github/feelbeatapp/androidclient/di/AppModule.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/infra/di/AppModule.kt index 15ad07b..078a839 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/di/AppModule.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/di/AppModule.kt @@ -1,21 +1,22 @@ -package com.github.feelbeatapp.androidclient.di +package com.github.feelbeatapp.androidclient.infra.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 -import com.github.feelbeatapp.androidclient.network.fullduplex.WebsocketClient -import com.github.feelbeatapp.androidclient.network.spotify.KtorSpotifyAPI -import com.github.feelbeatapp.androidclient.network.spotify.SpotifyAPI +import com.github.feelbeatapp.androidclient.api.feelbeat.FeelBeatApi +import com.github.feelbeatapp.androidclient.api.feelbeat.KtorFeelBeatApi +import com.github.feelbeatapp.androidclient.api.spotify.KtorSpotifyAPI +import com.github.feelbeatapp.androidclient.api.spotify.SpotifyAPI +import com.github.feelbeatapp.androidclient.game.datastreaming.GameDataStreamer +import com.github.feelbeatapp.androidclient.game.datastreaming.RemoteGameDataStreamer +import com.github.feelbeatapp.androidclient.infra.auth.AuthManager +import com.github.feelbeatapp.androidclient.infra.auth.OauthConfig +import com.github.feelbeatapp.androidclient.infra.auth.spotify.SpotifyAuthManager +import com.github.feelbeatapp.androidclient.infra.auth.storage.AuthStorage +import com.github.feelbeatapp.androidclient.infra.auth.storage.PreferencesAuthStorage +import com.github.feelbeatapp.androidclient.infra.error.ErrorEmitter +import com.github.feelbeatapp.androidclient.infra.error.ErrorHandler +import com.github.feelbeatapp.androidclient.infra.error.ErrorReceiver +import com.github.feelbeatapp.androidclient.infra.network.NetworkClient +import com.github.feelbeatapp.androidclient.infra.network.WebsocketClient import dagger.Binds import dagger.Module import dagger.Provides @@ -27,9 +28,9 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.websocket.WebSockets import io.ktor.http.Url import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json import javax.inject.Named import javax.inject.Singleton -import kotlinx.serialization.json.Json @Module @InstallIn(SingletonComponent::class) @@ -49,12 +50,6 @@ abstract class AppModule { return httpClient } - @Provides - @Singleton - fun provideNetworkAgent(): NetworkAgent { - return WebsocketClient(httpClient, Url(BuildConfig.SOCKET_URI)) - } - @Provides @Singleton fun provideOAuthConfig(): OauthConfig { @@ -75,6 +70,13 @@ abstract class AppModule { return BuildConfig.API_URL } + @Provides + @Singleton + @Named("SOCKET_URI") + fun provideSocketUri(): Url { + return Url(BuildConfig.SOCKET_URI) + } + @Provides @Singleton fun provideErrorEmitter(): ErrorEmitter { @@ -95,4 +97,10 @@ abstract class AppModule { @Singleton @Binds abstract fun bindSpotifyAPI(spotifyAPI: KtorSpotifyAPI): SpotifyAPI @Singleton @Binds abstract fun bindFeelBeatAPI(feelBeatApi: KtorFeelBeatApi): FeelBeatApi + + @Singleton @Binds abstract fun bindNetworkClient(networkClient: WebsocketClient): NetworkClient + + @Singleton + @Binds + abstract fun bindGameDataStreamer(gameDataStreamer: RemoteGameDataStreamer): GameDataStreamer } diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorCode.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/error/ErrorCode.kt similarity index 78% rename from app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorCode.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/infra/error/ErrorCode.kt index 342d05d..97c6485 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorCode.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/error/ErrorCode.kt @@ -1,4 +1,4 @@ -package com.github.feelbeatapp.androidclient.error +package com.github.feelbeatapp.androidclient.infra.error import com.github.feelbeatapp.androidclient.R @@ -10,7 +10,8 @@ enum class ErrorCode { FEELBEAT_SERVER_UNREACHABLE, FEELBEAT_SERVER_ERROR, FEELBEAT_SERVER_INCORRECT_RESPONSE_FORMAT, - INCORRECT_PLAYLIST_LINK; + INCORRECT_PLAYLIST_LINK, + FEELBEAT_SERVER_FAILED_TO_JOIN_ROOM; fun toStringId(): Int { return when (this) { @@ -18,6 +19,7 @@ enum class ErrorCode { FEELBEAT_SERVER_UNREACHABLE -> R.string.feelbeat_server_unreachable INCORRECT_PLAYLIST_LINK -> R.string.incorrect_playlist_link FEELBEAT_SERVER_ERROR -> R.string.feelbeat_server_error + FEELBEAT_SERVER_FAILED_TO_JOIN_ROOM -> R.string.failed_to_join_room 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/infra/error/ErrorEmitter.kt similarity index 67% rename from app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorEmitter.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/infra/error/ErrorEmitter.kt index a9896a2..eab6a25 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorEmitter.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/error/ErrorEmitter.kt @@ -1,4 +1,4 @@ -package com.github.feelbeatapp.androidclient.error +package com.github.feelbeatapp.androidclient.infra.error import kotlinx.coroutines.flow.SharedFlow diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorHandler.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/error/ErrorHandler.kt similarity index 89% rename from app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorHandler.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/infra/error/ErrorHandler.kt index 4ea2d90..b6f66a9 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorHandler.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/error/ErrorHandler.kt @@ -1,4 +1,4 @@ -package com.github.feelbeatapp.androidclient.error +package com.github.feelbeatapp.androidclient.infra.error import android.util.Log import javax.inject.Inject diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorReceiver.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/error/ErrorReceiver.kt similarity index 60% rename from app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorReceiver.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/infra/error/ErrorReceiver.kt index 613a3f3..08ceea9 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/error/ErrorReceiver.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/error/ErrorReceiver.kt @@ -1,4 +1,4 @@ -package com.github.feelbeatapp.androidclient.error +package com.github.feelbeatapp.androidclient.infra.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/infra/error/FeelBeatException.kt similarity index 86% rename from app/src/main/java/com/github/feelbeatapp/androidclient/error/FeelBeatException.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/infra/error/FeelBeatException.kt index feae720..97a4252 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/error/FeelBeatException.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/error/FeelBeatException.kt @@ -1,4 +1,4 @@ -package com.github.feelbeatapp.androidclient.error +package com.github.feelbeatapp.androidclient.infra.error open class FeelBeatException(val code: ErrorCode, debugMessage: String?, cause: Throwable?) : Exception(debugMessage, cause) { diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/error/FeelBeatServerException.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/error/FeelBeatServerException.kt similarity index 66% rename from app/src/main/java/com/github/feelbeatapp/androidclient/error/FeelBeatServerException.kt rename to app/src/main/java/com/github/feelbeatapp/androidclient/infra/error/FeelBeatServerException.kt index fe6832a..9324f24 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/error/FeelBeatServerException.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/error/FeelBeatServerException.kt @@ -1,4 +1,4 @@ -package com.github.feelbeatapp.androidclient.error +package com.github.feelbeatapp.androidclient.infra.error class FeelBeatServerException(val serverMessage: String) : FeelBeatException(ErrorCode.FEELBEAT_SERVER_ERROR) diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/infra/network/NetworkClient.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/network/NetworkClient.kt new file mode 100644 index 0000000..2346a03 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/network/NetworkClient.kt @@ -0,0 +1,23 @@ +package com.github.feelbeatapp.androidclient.infra.network + +import kotlinx.coroutines.flow.Flow + +/** Implementation of communication with FeelBeat server */ +interface NetworkClient { + /** + * Connects to FeelBeatServer + * + * @return flow of text messages from server + */ + suspend fun connect(path: String): Flow + + /** Disconnects current connection */ + suspend fun disconnect() + + /** + * Sends message to FeelBeat server + * + * @param text text to send + */ + suspend fun sendMessage(text: String) +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/infra/network/WebsocketClient.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/network/WebsocketClient.kt new file mode 100644 index 0000000..93b78e4 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/infra/network/WebsocketClient.kt @@ -0,0 +1,80 @@ +package com.github.feelbeatapp.androidclient.infra.network + +import android.util.Log +import com.github.feelbeatapp.androidclient.infra.auth.AuthManager +import com.github.feelbeatapp.androidclient.infra.error.ErrorCode +import com.github.feelbeatapp.androidclient.infra.error.FeelBeatException +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.webSocket +import io.ktor.client.request.header +import io.ktor.http.HttpMethod +import io.ktor.http.Url +import io.ktor.http.fullPath +import io.ktor.websocket.Frame +import io.ktor.websocket.WebSocketSession +import io.ktor.websocket.close +import io.ktor.websocket.readText +import io.ktor.websocket.send +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onCompletion +import java.util.ArrayDeque +import java.util.Queue +import javax.inject.Inject +import javax.inject.Named + +/** Websocket implementation of communication with FeelBeat server */ +class WebsocketClient +@Inject +constructor( + private val httpClient: HttpClient, + @Named("SOCKET_URI") private val serverUrl: Url, + private val authManager: AuthManager, +) : NetworkClient { + private var session: WebSocketSession? = null + private val offlineQueue: Queue = ArrayDeque() + + override suspend fun connect(path: String): Flow { + return flow { + val token = authManager.getAccessToken() + + httpClient.webSocket( + method = HttpMethod.Get, + host = serverUrl.host, + port = serverUrl.port, + path = "${serverUrl.fullPath}${path}", + request = { header("Authorization", "Bearer $token") }, + ) { + for (msg in offlineQueue) { + this.send(msg) + } + offlineQueue.clear() + + for (frame in incoming) { + if (frame is Frame.Text) { + emit(frame.readText()) + } else { + Log.w("Ignoring message", frame.data.decodeToString()) + } + } + } + } + .catch { e -> + throw FeelBeatException(ErrorCode.FEELBEAT_SERVER_FAILED_TO_JOIN_ROOM, e) + } + .onCompletion { Log.d("Look", "completed") } + } + + override suspend fun disconnect() { + session?.close() + } + + override suspend fun sendMessage(text: String) { + if (session == null) { + offlineQueue.offer(text) + } else { + session?.send(text) + } + } +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/network/api/FeelBeatApi.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/network/api/FeelBeatApi.kt deleted file mode 100644 index c67fcf3..0000000 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/network/api/FeelBeatApi.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.github.feelbeatapp.androidclient.network.api - -import com.github.feelbeatapp.androidclient.model.CreateRoomPayload -import com.github.feelbeatapp.androidclient.model.RoomListView - -interface FeelBeatApi { - suspend fun createRoom(payload: CreateRoomPayload): String - - suspend fun fetchRooms(): List -} 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 deleted file mode 100644 index a7e84c8..0000000 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/network/api/KtorFeelBeatApi.kt +++ /dev/null @@ -1,104 +0,0 @@ -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.model.CreateRoomPayload -import com.github.feelbeatapp.androidclient.model.RoomListView -import com.github.feelbeatapp.androidclient.network.api.responses.CreateRoomResponse -import com.github.feelbeatapp.androidclient.network.api.responses.FetchRoomsResponse -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.get -import io.ktor.client.request.headers -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.HttpStatusCode -import io.ktor.http.contentType -import io.ktor.util.network.UnresolvedAddressException -import java.io.IOException -import javax.inject.Inject -import javax.inject.Named - -class KtorFeelBeatApi -@Inject -constructor( - @Named("API_URL") private val baseUrl: String, - private val httpClient: HttpClient, - private val authManager: AuthManager, -) : FeelBeatApi { - override suspend fun createRoom(payload: CreateRoomPayload): String { - val token = authManager.getAccessToken() - - val res = - try { - httpClient.post("$baseUrl/create") { - headers { set("Authorization", "Bearer $token") } - contentType(ContentType.Application.Json) - setBody(payload) - } - } catch (e: Exception) { - when (e) { - is IOException, - is UnresolvedAddressException -> - throw FeelBeatException(ErrorCode.FEELBEAT_SERVER_UNREACHABLE, e) - else -> throw e - } - } - - if (res.status != HttpStatusCode.OK) { - throw FeelBeatServerException(res.bodyAsText().trim()) - } - - val (roomId) = - try { - res.body() - } catch (e: UnsupportedOperationException) { - throw FeelBeatException( - ErrorCode.FEELBEAT_SERVER_INCORRECT_RESPONSE_FORMAT, - "Failed to parse server response", - e, - ) - } - - return roomId - } - - override suspend fun fetchRooms(): List { - val token = authManager.getAccessToken() - - val res = - try { - httpClient.get("$baseUrl/rooms") { - headers { set("Authorization", "Bearer $token") } - } - } catch (e: Exception) { - when (e) { - is IOException, - is UnresolvedAddressException -> - throw FeelBeatException(ErrorCode.FEELBEAT_SERVER_UNREACHABLE, e) - else -> throw e - } - } - - if (res.status != HttpStatusCode.OK) { - throw FeelBeatServerException(res.bodyAsText().trim()) - } - - val (rooms) = - try { - res.body() - } catch (e: UnsupportedOperationException) { - throw FeelBeatException( - ErrorCode.FEELBEAT_SERVER_INCORRECT_RESPONSE_FORMAT, - "Failed to parse server response", - e, - ) - } - - return rooms - } -} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/network/api/responses/FetchRoomsResponse.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/network/api/responses/FetchRoomsResponse.kt deleted file mode 100644 index 5db8fda..0000000 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/network/api/responses/FetchRoomsResponse.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.github.feelbeatapp.androidclient.network.api.responses - -import com.github.feelbeatapp.androidclient.model.RoomListView -import kotlinx.serialization.Serializable - -@Serializable data class FetchRoomsResponse(val rooms: List) diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/network/fullduplex/NetworkAgent.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/network/fullduplex/NetworkAgent.kt deleted file mode 100644 index adfdc54..0000000 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/network/fullduplex/NetworkAgent.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.github.feelbeatapp.androidclient.network.fullduplex - -import kotlinx.coroutines.flow.SharedFlow - -/** Implementation of communication with FeelBeat server */ -interface NetworkAgent { - /** Connects to FeelBeatServer */ - fun connect(path: String) - - /** Disconnects current connection */ - suspend fun disconnect() - - /** - * Sends message to FeelBeat server - * - * @param text text to send - */ - suspend fun sendMessage(text: String) - - /** Received messages from FeelBeat server */ - fun receiveFlow(): SharedFlow -} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/network/fullduplex/WebsocketClient.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/network/fullduplex/WebsocketClient.kt deleted file mode 100644 index 44d6a5a..0000000 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/network/fullduplex/WebsocketClient.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.github.feelbeatapp.androidclient.network.fullduplex - -import android.util.Log -import io.ktor.client.HttpClient -import io.ktor.client.plugins.websocket.webSocket -import io.ktor.http.HttpMethod -import io.ktor.http.Url -import io.ktor.http.fullPath -import io.ktor.websocket.Frame -import io.ktor.websocket.WebSocketSession -import io.ktor.websocket.close -import io.ktor.websocket.readText -import io.ktor.websocket.send -import java.net.ConnectException -import java.util.ArrayDeque -import java.util.Queue -import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.consumeAsFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking - -/** Websocket implementation of communication with FeelBeat server */ -class WebsocketClient -@Inject -constructor(private val httpClient: HttpClient, private val serverUrl: Url) : NetworkAgent { - companion object { - private const val RECONNECT_DELAY_MS = 500 - } - - private var session: WebSocketSession? = null - private val receiveFlow = MutableSharedFlow() - private val offlineQueue: Queue = ArrayDeque() - - private suspend fun receiveWorker(incomingChannel: ReceiveChannel) { - incomingChannel.consumeAsFlow().collect { frame -> - when (frame) { - is Frame.Text -> receiveFlow.emit(frame.readText()) - else -> Log.w("unidentified message", frame.data.toString()) - } - } - } - - override fun connect(path: String) { - CoroutineScope(Dispatchers.IO).launch { - while (true) { - try { - httpClient.webSocket( - method = HttpMethod.Get, - host = serverUrl.host, - port = serverUrl.port, - path = "${serverUrl.fullPath}${path}", - ) { - session = this - runBlocking { - launch { receiveWorker(checkNotNull(session).incoming) } - - // Send stacked messages - launch { - for (msg in offlineQueue) { - checkNotNull(session).send(msg) - } - offlineQueue.clear() - } - } - session = null - } - } catch (_: ConnectException) { - Log.e("websocket", "Connection exception occurred") - delay(RECONNECT_DELAY_MS.milliseconds) - } - } - } - } - - override suspend fun disconnect() { - session?.close() - } - - override suspend fun sendMessage(text: String) { - if (session == null) { - offlineQueue.offer(text) - } else { - session?.send(text) - } - } - - override fun receiveFlow(): SharedFlow { - return receiveFlow - } -} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/network/spotify/KtorSpotifyAPI.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/network/spotify/KtorSpotifyAPI.kt deleted file mode 100644 index dd90d5b..0000000 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/network/spotify/KtorSpotifyAPI.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.github.feelbeatapp.androidclient.network.spotify - -import com.github.feelbeatapp.androidclient.auth.AuthManager -import com.github.feelbeatapp.androidclient.network.spotify.responses.ProfileResponse -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.get -import io.ktor.client.request.headers -import javax.inject.Inject - -const val SPOTIFY_API_URL = "https://api.spotify.com/v1" - -class KtorSpotifyAPI -@Inject -constructor(private val httpClient: HttpClient, private val authManager: AuthManager) : SpotifyAPI { - override suspend fun getProfile(): ProfileResponse { - val accessToken = authManager.getAccessToken() - - val response = - httpClient.get("$SPOTIFY_API_URL/me") { - headers { append("Authorization", "Bearer $accessToken") } - } - - return response.body() - } -} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/network/spotify/SpotifyAPI.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/network/spotify/SpotifyAPI.kt deleted file mode 100644 index 850c20d..0000000 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/network/spotify/SpotifyAPI.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.github.feelbeatapp.androidclient.network.spotify - -import com.github.feelbeatapp.androidclient.network.spotify.responses.ProfileResponse - -interface SpotifyAPI { - suspend fun getProfile(): ProfileResponse -} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/MainActivity.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/MainActivity.kt index aa5c224..470d295 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/MainActivity.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/MainActivity.kt @@ -4,7 +4,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import com.github.feelbeatapp.androidclient.auth.AuthManager +import com.github.feelbeatapp.androidclient.infra.auth.AuthManager import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject 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 31b3e5b..9559780 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.ScaffoldDefaults import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults @@ -70,6 +71,7 @@ fun AppScreen( } Scaffold( + contentWindowInsets = ScaffoldDefaults.contentWindowInsets, topBar = { AppBar( title = title, 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 b67f4e2..0507967 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 @@ -4,18 +4,18 @@ 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 com.github.feelbeatapp.androidclient.api.spotify.SpotifyAPI +import com.github.feelbeatapp.androidclient.infra.auth.AuthManager +import com.github.feelbeatapp.androidclient.infra.error.ErrorEmitter +import com.github.feelbeatapp.androidclient.infra.error.FeelBeatException +import com.github.feelbeatapp.androidclient.infra.error.FeelBeatServerException import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject 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) @@ -48,7 +48,8 @@ constructor( private fun formatException(exception: FeelBeatException): String { return when (exception) { - is FeelBeatServerException -> ctx.getString(exception.code.toStringId(), exception.serverMessage) + is FeelBeatServerException -> + ctx.getString(exception.code.toStringId(), exception.serverMessage) else -> ctx.getString(exception.code.toStringId()) } } @@ -57,7 +58,10 @@ constructor( viewModelScope.launch(Dispatchers.IO) { val profile = spotifyAPI.getProfile() _playerIdentity.value = - PlayerIdentity(name = profile.displayName, imageUrl = profile.images.first().url) + PlayerIdentity( + name = profile.displayName, + imageUrl = if (profile.images.isNotEmpty()) profile.images.first().url else "", + ) } } diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/components/PlayerCard.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/components/PlayerCard.kt new file mode 100644 index 0000000..19320ae --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/components/PlayerCard.kt @@ -0,0 +1,42 @@ +package com.github.feelbeatapp.androidclient.ui.app.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.github.feelbeatapp.androidclient.R +import com.github.feelbeatapp.androidclient.game.model.Player + +@Composable +fun PlayerCard(player: Player, size: Dp = 80.dp, modifier: Modifier = Modifier) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) { + AsyncImage( + model = player.imageUrl, + error = painterResource(R.drawable.userimage), + placeholder = painterResource(R.drawable.userimage), + contentDescription = stringResource(R.string.player_image), + modifier = + Modifier.size(size) + .clip(CircleShape) + .border(2.dp, MaterialTheme.colorScheme.primary, CircleShape), + ) + Text( + text = player.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(top = 4.dp), + ) + } +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/components/SongCard.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/components/SongCard.kt new file mode 100644 index 0000000..7f550f3 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/components/SongCard.kt @@ -0,0 +1,88 @@ +package com.github.feelbeatapp.androidclient.ui.app.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.github.feelbeatapp.androidclient.R +import com.github.feelbeatapp.androidclient.ui.theme.FeelBeatTheme +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@Composable +fun SongCard( + title: String, + artist: String, + imageUrl: String, + duration: Duration, + size: Dp, + elevation: Dp = 6.dp, + modifier: Modifier = Modifier, +) { + ElevatedCard( + elevation = CardDefaults.cardElevation(defaultElevation = elevation), + modifier = modifier.height(size).fillMaxWidth(), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Box(modifier = Modifier.height(size).width(size)) { + AsyncImage( + model = imageUrl, + contentDescription = stringResource(R.string.song_cover), + contentScale = ContentScale.Fit, + placeholder = painterResource(R.drawable.account), + error = painterResource(R.drawable.account), + ) + } + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(10.dp, 0.dp), + ) { + Column(verticalArrangement = Arrangement.Center) { + Text(title, style = MaterialTheme.typography.titleMedium) + + Text(artist, style = MaterialTheme.typography.titleSmall) + } + + Text(duration.toString()) + } + } + } +} + +@Preview +@Composable +fun SongCardPreview() { + FeelBeatTheme { + SongCard( + title = "Aerials", + artist = "System of a down", + imageUrl = "https://upload.wikimedia.org/wikipedia/it/2/20/Aerials.png", + duration = 320.seconds, + size = 60.dp, + ) + } +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/gameresult/GameResultViewModel.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/gameresult/GameResultViewModel.kt index e1de7c7..e9cbd75 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/gameresult/GameResultViewModel.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/gameresult/GameResultViewModel.kt @@ -2,9 +2,6 @@ package com.github.feelbeatapp.androidclient.ui.app.game.gameresult import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.feelbeatapp.androidclient.R -import com.github.feelbeatapp.androidclient.ui.app.game.guesssong.ResultStatus -import com.github.feelbeatapp.androidclient.ui.app.uimodel.Player import com.github.feelbeatapp.androidclient.ui.app.uimodel.PlayerWithResult import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -21,28 +18,6 @@ class GameResultViewModel : ViewModel() { @SuppressWarnings("MagicNumber") private fun fetchGameResults() { - viewModelScope.launch { - val results = - listOf( - PlayerWithResult( - Player("User123", R.drawable.userimage), - ResultStatus.CORRECT, - 10, - ), - PlayerWithResult( - Player("User456", R.drawable.userimage), - ResultStatus.WRONG, - 7, - ), - PlayerWithResult( - Player("User789", R.drawable.userimage), - ResultStatus.NORESPONSE, - 1, - ), - ) - .sortedByDescending { it.points } - - _players.value = results - } + viewModelScope.launch {} } } diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/guesssong/GuessResultScreen.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/guesssong/GuessResultScreen.kt index a8acc08..85a575a 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/guesssong/GuessResultScreen.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/guesssong/GuessResultScreen.kt @@ -1,6 +1,5 @@ package com.github.feelbeatapp.androidclient.ui.app.game.guesssong -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,6 +23,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage import com.github.feelbeatapp.androidclient.R import com.github.feelbeatapp.androidclient.ui.app.navigation.AppRoute @@ -50,7 +50,7 @@ fun GuessResultScreen( ) { guessState.players.forEach { playerWithResult -> PlayerStatusIcon( - image = playerWithResult.player.image, + image = playerWithResult.player.imageUrl, isCorrect = (playerWithResult.resultStatus == ResultStatus.CORRECT), ) } @@ -93,10 +93,12 @@ fun GuessResultScreen( } @Composable -fun PlayerStatusIcon(image: Int, isCorrect: Boolean) { +fun PlayerStatusIcon(image: String, isCorrect: Boolean) { Box(contentAlignment = Alignment.TopEnd) { - Image( - painter = painterResource(id = image), + AsyncImage( + model = image, + placeholder = painterResource(R.drawable.userimage), + error = painterResource(R.drawable.userimage), contentDescription = "Player Avatar", modifier = Modifier.size(60.dp).clip(CircleShape), ) diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/guesssong/GuessSongScreen.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/guesssong/GuessSongScreen.kt index a8b4bcd..65107ac 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/guesssong/GuessSongScreen.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/guesssong/GuessSongScreen.kt @@ -1,6 +1,5 @@ package com.github.feelbeatapp.androidclient.ui.app.game.guesssong -import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -37,10 +36,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage import com.github.feelbeatapp.androidclient.R +import com.github.feelbeatapp.androidclient.ui.app.navigation.AppRoute import com.github.feelbeatapp.androidclient.ui.app.uimodel.PlayerWithResult import com.github.feelbeatapp.androidclient.ui.app.uimodel.Song -import com.github.feelbeatapp.androidclient.ui.app.navigation.AppRoute @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -131,8 +131,10 @@ fun PlayerStatusIcon(player: PlayerWithResult) { } Box(modifier = Modifier.size(48.dp)) { - Image( - painter = painterResource(id = player.player.image), + AsyncImage( + model = player.player.imageUrl, + placeholder = painterResource(R.drawable.userimage), + error = painterResource(R.drawable.userimage), contentDescription = stringResource(R.string.player_avatar), modifier = Modifier.size(48.dp).clip(CircleShape), ) diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/guesssong/GuessSongViewModel.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/guesssong/GuessSongViewModel.kt index e16a96d..6cfd0b6 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/guesssong/GuessSongViewModel.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/guesssong/GuessSongViewModel.kt @@ -3,9 +3,6 @@ package com.github.feelbeatapp.androidclient.ui.app.game.guesssong import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.feelbeatapp.androidclient.R -import com.github.feelbeatapp.androidclient.ui.app.uimodel.Player -import com.github.feelbeatapp.androidclient.ui.app.uimodel.PlayerWithResult import com.github.feelbeatapp.androidclient.ui.app.uimodel.Song import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -31,27 +28,7 @@ class GuessSongViewModel : ViewModel() { } private fun loadPlayers() { - viewModelScope.launch { - val examplePlayers = - listOf( - PlayerWithResult( - Player("User123", R.drawable.userimage), - ResultStatus.CORRECT, - 0, - ), - PlayerWithResult( - Player("User456", R.drawable.userimage), - ResultStatus.WRONG, - 0, - ), - PlayerWithResult( - Player("User789", R.drawable.userimage), - ResultStatus.NORESPONSE, - 0, - ), - ) - _guessState.value = _guessState.value.copy(players = examplePlayers) - } + viewModelScope.launch {} } @SuppressWarnings("MagicNumber") diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/startgame/StartGameScreen.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/startgame/StartGameScreen.kt index f8b4d6d..f800d62 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/startgame/StartGameScreen.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/startgame/StartGameScreen.kt @@ -1,16 +1,12 @@ package com.github.feelbeatapp.androidclient.ui.app.game.startgame -import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -22,13 +18,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.github.feelbeatapp.androidclient.R -import com.github.feelbeatapp.androidclient.ui.app.uimodel.Player +import com.github.feelbeatapp.androidclient.ui.app.components.PlayerCard import com.github.feelbeatapp.androidclient.ui.app.navigation.AppRoute @Composable @@ -67,26 +59,6 @@ fun StartGameScreen( } } -@Composable -fun PlayerCard(player: Player) { - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(16.dp)) { - Image( - painter = painterResource(id = player.image), - contentDescription = stringResource(R.string.player_image), - modifier = - Modifier.size(80.dp) - .clip(CircleShape) - .border(2.dp, MaterialTheme.colorScheme.primary, CircleShape), - ) - Text( - text = player.name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(top = 10.dp), - ) - } -} - @Preview @Composable fun StartGamePreview() { diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/startgame/StartGameViewModel.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/startgame/StartGameViewModel.kt index 1af68cd..84d5bca 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/startgame/StartGameViewModel.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/game/startgame/StartGameViewModel.kt @@ -2,8 +2,7 @@ package com.github.feelbeatapp.androidclient.ui.app.game.startgame import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.feelbeatapp.androidclient.R -import com.github.feelbeatapp.androidclient.ui.app.uimodel.Player +import com.github.feelbeatapp.androidclient.game.model.Player import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -18,14 +17,6 @@ class StartGameViewModel : ViewModel() { } private fun loadPlayers() { - viewModelScope.launch { - val examplePlayers = - listOf( - Player("User123", R.drawable.userimage), - Player("User456", R.drawable.userimage), - Player("User789", R.drawable.userimage), - ) - _players.value = examplePlayers - } + viewModelScope.launch {} } } diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/home/HomeScreen.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/home/HomeScreen.kt index 7de5b31..390c605 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/home/HomeScreen.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/home/HomeScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -37,7 +36,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.github.feelbeatapp.androidclient.R -import com.github.feelbeatapp.androidclient.model.RoomListView +import com.github.feelbeatapp.androidclient.api.feelbeat.responses.RoomListViewResponse @Composable fun HomeScreen( @@ -101,21 +100,21 @@ fun HomeScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable fun RoomList( - items: List, + items: List, isRefreshing: Boolean, onRefresh: () -> Unit, onRoomSelect: (String) -> Unit, modifier: Modifier = Modifier, ) { PullToRefreshBox(isRefreshing = isRefreshing, onRefresh = onRefresh, modifier = modifier) { - LazyColumn(Modifier.fillMaxSize()) { + LazyColumn(Modifier) { items(items) { ListItem({ RoomItem(room = it, onClick = { onRoomSelect(it.id) }) }) } } } } @Composable -fun RoomItem(room: RoomListView, onClick: () -> Unit) { +fun RoomItem(room: RoomListViewResponse, onClick: () -> Unit) { Card( onClick = onClick, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/home/HomeViewModel.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/home/HomeViewModel.kt index 5a11a97..d4bc401 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/home/HomeViewModel.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/home/HomeViewModel.kt @@ -2,24 +2,24 @@ package com.github.feelbeatapp.androidclient.ui.app.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.feelbeatapp.androidclient.error.ErrorReceiver -import com.github.feelbeatapp.androidclient.error.FeelBeatException -import com.github.feelbeatapp.androidclient.model.RoomListView -import com.github.feelbeatapp.androidclient.network.api.FeelBeatApi +import com.github.feelbeatapp.androidclient.infra.error.ErrorReceiver +import com.github.feelbeatapp.androidclient.infra.error.FeelBeatException +import com.github.feelbeatapp.androidclient.api.feelbeat.responses.RoomListViewResponse +import com.github.feelbeatapp.androidclient.api.feelbeat.FeelBeatApi import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor(private val feelBeatApi: FeelBeatApi, private val errorReceiver: ErrorReceiver) : ViewModel() { - private val _rooms = MutableStateFlow>(listOf()) - val rooms: StateFlow> = _rooms.asStateFlow() + private val _rooms = MutableStateFlow>(listOf()) + val rooms: StateFlow> = _rooms.asStateFlow() private val _loading = MutableStateFlow(false) val loading = _loading.asStateFlow() diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/LobbyBottomBar.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/LobbyBottomBar.kt index 846f00f..df20084 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/LobbyBottomBar.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/LobbyBottomBar.kt @@ -2,6 +2,7 @@ package com.github.feelbeatapp.androidclient.ui.app.lobby import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -11,8 +12,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import com.github.feelbeatapp.androidclient.R import com.github.feelbeatapp.androidclient.ui.app.navigation.AppRoute +import com.github.feelbeatapp.androidclient.ui.theme.FeelBeatTheme @Composable fun LobbyBottomBar( @@ -33,6 +36,14 @@ fun LobbyBottomBar( selected = activeRoute == AppRoute.ROOM_LOBBY, onClick = { onNavigate(AppRoute.ROOM_LOBBY) }, ) + + NavigationBarItem( + icon = { Icon(Icons.Filled.PlayArrow, contentDescription = "List icon") }, + label = { Text("Songs") }, + selected = activeRoute == AppRoute.ROOM_SONGS, + onClick = { onNavigate(AppRoute.ROOM_SONGS) }, + ) + NavigationBarItem( icon = { Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings)) @@ -43,3 +54,9 @@ fun LobbyBottomBar( ) } } + +@Composable +@Preview +fun LobbyBottomBarPreview() { + FeelBeatTheme { LobbyBottomBar(AppRoute.ROOM_LOBBY, {}) } +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/acceptgame/AcceptGameScreen.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/acceptgame/AcceptGameScreen.kt deleted file mode 100644 index 46a2ffe..0000000 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/acceptgame/AcceptGameScreen.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.github.feelbeatapp.androidclient.ui.app.lobby.acceptgame - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.github.feelbeatapp.androidclient.R -import com.github.feelbeatapp.androidclient.ui.app.game.startgame.PlayerCard -import com.github.feelbeatapp.androidclient.ui.app.uimodel.Song - -@Composable -fun AcceptGameScreen( - roomId: String, - onPlay: () -> Unit, - viewModel: AcceptGameViewModel = AcceptGameViewModel(), -) { - val gameState = viewModel.gameState.collectAsState().value - - Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { - Column( - modifier = - Modifier.fillMaxSize() - .padding(bottom = 56.dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(roomId) - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - gameState.players.forEach { player -> PlayerCard(player = player) } - } - - Spacer(modifier = Modifier.height(32.dp)) - - Column( - horizontalAlignment = Alignment.Start, - modifier = Modifier.padding(horizontal = 16.dp), - ) { - Text( - text = - stringResource( - id = R.string.snippet_duration_val, - gameState.snippetDuration, - ), - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = stringResource(id = R.string.points_to_win_val, gameState.pointsToWin), - style = MaterialTheme.typography.bodyMedium, - ) - } - - Spacer(modifier = Modifier.height(32.dp)) - - Text( - text = stringResource(id = R.string.playlist_name, gameState.playlist.name), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(horizontal = 16.dp), - ) - - gameState.playlist.songs.forEach { song -> SongItem(song = song) } - - Spacer(modifier = Modifier.height(32.dp)) - - Button( - onClick = onPlay, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - ) { - Text(stringResource(R.string.play), style = MaterialTheme.typography.headlineMedium) - } - } - } -} - -@Composable -fun SongItem(song: Song) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = song.title, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f), - ) - } -} - -@Preview -@Composable -fun PreviewAcceptScreen() { - AcceptGameScreen("Room id", {}) -} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/acceptgame/AcceptGameViewModel.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/acceptgame/AcceptGameViewModel.kt deleted file mode 100644 index 7c34015..0000000 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/acceptgame/AcceptGameViewModel.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.github.feelbeatapp.androidclient.ui.app.lobby.acceptgame - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.github.feelbeatapp.androidclient.R -import com.github.feelbeatapp.androidclient.ui.app.uimodel.Player -import com.github.feelbeatapp.androidclient.ui.app.uimodel.Song -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -class AcceptGameViewModel : ViewModel() { - private val _gameState = MutableStateFlow(GameState()) - val gameState: StateFlow = _gameState.asStateFlow() - - init { - loadPlayers() - loadSongs() - } - - private fun loadPlayers() { - viewModelScope.launch { - val examplePlayers = - listOf( - Player("User123", R.drawable.userimage), - Player("User456", R.drawable.userimage), - Player("User789", R.drawable.userimage), - ) - - _gameState.update { it.copy(players = examplePlayers) } - } - } - - @SuppressWarnings("MagicNumber") - private fun loadSongs() { - viewModelScope.launch { - val exampleSongs = - listOf( - Song(1, "Song 1"), - Song(2, "Song 2"), - Song(3, "Song 3"), - Song(4, "Song 4"), - Song(5, "Song 5"), - ) - _gameState.update { it.copy(songs = exampleSongs) } - } - } -} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/acceptgame/GameState.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/acceptgame/GameState.kt deleted file mode 100644 index 15ea3d7..0000000 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/acceptgame/GameState.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.github.feelbeatapp.androidclient.ui.app.lobby.acceptgame - -import com.github.feelbeatapp.androidclient.ui.app.uimodel.Player -import com.github.feelbeatapp.androidclient.ui.app.uimodel.Playlist -import com.github.feelbeatapp.androidclient.ui.app.uimodel.Room -import com.github.feelbeatapp.androidclient.ui.app.uimodel.Song - - -data class GameState( - val players: List = emptyList(), - val songs: List = emptyList(), - val selectedRoom: Room? = null, - val playlist: Playlist = Playlist(name = "Playlist #1", songs = emptyList()), - val snippetDuration: Int = 30, - val pointsToWin: Int = 10, -) diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/lobbyhome/LobbyHomeScreen.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/lobbyhome/LobbyHomeScreen.kt new file mode 100644 index 0000000..1b9c3d0 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/lobbyhome/LobbyHomeScreen.kt @@ -0,0 +1,107 @@ +package com.github.feelbeatapp.androidclient.ui.app.lobby.lobbyhome + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import coil3.compose.AsyncImage +import com.github.feelbeatapp.androidclient.R +import com.github.feelbeatapp.androidclient.ui.app.components.PlayerCard + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun LobbyHomeScreen( + roomId: String, + onPlay: () -> Unit, + viewModel: LobbyHomeViewModel = hiltViewModel(), +) { + val lobbyState = viewModel.lobbyHomeState.collectAsState().value + + LaunchedEffect(roomId) { + if (roomId != lobbyState.currentRoomId) { + viewModel.joinRoom(roomId) + } + } + + if (lobbyState.currentRoomId == null) { + Text("Loading ") + return + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = 16.dp), + ) { + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier.height(200.dp).width(150.dp), + ) { + AsyncImage( + model = lobbyState.playlistImageUrl, + contentDescription = stringResource(R.string.playlist_image), + contentScale = ContentScale.Fit, + placeholder = painterResource(R.drawable.userimage), + error = painterResource(R.drawable.userimage), + modifier = Modifier.fillMaxSize(), + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = lobbyState.playlistName, style = MaterialTheme.typography.displayMedium) + + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Icon(Icons.Default.Star, contentDescription = "star icon") + Text(text = lobbyState.adminName, style = MaterialTheme.typography.titleLarge) + } + } + + FlowRow( + horizontalArrangement = Arrangement.SpaceEvenly, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) { + lobbyState.players.forEach { player -> PlayerCard(player = player) } + } + + Spacer(modifier = Modifier.height(32.dp)) + + Button(onClick = onPlay, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + Text(stringResource(R.string.play), style = MaterialTheme.typography.headlineMedium) + } + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewAcceptScreen() { + LobbyHomeScreen("Room id", {}) +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/lobbyhome/LobbyHomeViewModel.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/lobbyhome/LobbyHomeViewModel.kt new file mode 100644 index 0000000..5eaf87e --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/lobbyhome/LobbyHomeViewModel.kt @@ -0,0 +1,53 @@ +package com.github.feelbeatapp.androidclient.ui.app.lobby.lobbyhome + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.feelbeatapp.androidclient.game.datastreaming.GameDataStreamer +import com.github.feelbeatapp.androidclient.game.model.GameState +import com.github.feelbeatapp.androidclient.game.model.Player +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class LobbyHomeState( + val currentRoomId: String? = null, + val playlistName: String = "Playlist", + val playlistImageUrl: String = "", + val adminName: String = "Admin", + val players: List = listOf(), +) + +@HiltViewModel +class LobbyHomeViewModel @Inject constructor(private val gameDataStreamer: GameDataStreamer) : + ViewModel() { + private val _lobbyHomeState = MutableStateFlow(LobbyHomeState()) + val lobbyHomeState = _lobbyHomeState.asStateFlow() + + init { + updateState(gameDataStreamer.gameStateFlow().value) + viewModelScope.launch { gameDataStreamer.gameStateFlow().collect { updateState(it) } } + } + + private fun updateState(gameState: GameState?) { + if (gameState != null) { + _lobbyHomeState.value = + LobbyHomeState( + currentRoomId = gameState.roomId, + playlistName = gameState.playlistName, + playlistImageUrl = gameState.playlistImageUrl, + adminName = gameState.players.find { it.id == gameState.adminId }?.name ?: "", + players = gameState.players, + ) + } else { + _lobbyHomeState.update { it.copy(currentRoomId = null) } + } + } + + suspend fun joinRoom(roomId: String) { + _lobbyHomeState.update { it.copy(currentRoomId = null) } + gameDataStreamer.joinRoom(roomId) + } +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/lobbysongs/LobbySongsScreen.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/lobbysongs/LobbySongsScreen.kt new file mode 100644 index 0000000..627cf4e --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/lobbysongs/LobbySongsScreen.kt @@ -0,0 +1,44 @@ +package com.github.feelbeatapp.androidclient.ui.app.lobby.lobbysongs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.github.feelbeatapp.androidclient.ui.app.components.SongCard +import com.github.feelbeatapp.androidclient.ui.theme.FeelBeatTheme + +@Composable +fun LobbySongsScreen(viewModel: LobbySongsViewModel = hiltViewModel()) { + val songs by viewModel.songs.collectAsStateWithLifecycle() + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize().padding(16.dp), + ) { + items(songs) { song -> + SongCard( + title = song.title, + artist = song.artist, + imageUrl = song.imageUrl, + duration = song.duration, + size = 80.dp, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun LobbySongsScreenPreview() { + FeelBeatTheme { LobbySongsScreen() } +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/lobbysongs/LobbySongsViewModel.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/lobbysongs/LobbySongsViewModel.kt new file mode 100644 index 0000000..206f178 --- /dev/null +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/lobby/lobbysongs/LobbySongsViewModel.kt @@ -0,0 +1,40 @@ +package com.github.feelbeatapp.androidclient.ui.app.lobby.lobbysongs + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.feelbeatapp.androidclient.game.datastreaming.GameDataStreamer +import com.github.feelbeatapp.androidclient.game.model.Song +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class LobbySongsViewModel @Inject constructor(private val gameDataStreamer: GameDataStreamer) : + ViewModel() { + private val _songs = + MutableStateFlow>( + listOf( + Song( + "asdf", + "Aerials", + "System of a down", + "https://upload.wikimedia.org/wikipedia/it/2/20/Aerials.png", + 320.seconds, + ) + ) + ) + val songs = _songs.asStateFlow() + + init { + viewModelScope.launch { + gameDataStreamer.gameStateFlow().collect { gameState -> + _songs.value = gameState?.songs ?: listOf() + Log.d("yup", "updating") + } + } + } +} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/navigation/AppGraph.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/navigation/AppGraph.kt index 4f33c04..446fd25 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/navigation/AppGraph.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/navigation/AppGraph.kt @@ -87,6 +87,7 @@ fun getRouteTitle(route: String?): String { return stringResource( when (route) { AppRoute.ROOM_LOBBY.route -> R.string.get_ready + AppRoute.ROOM_SONGS.route -> R.string.playlist_content AppRoute.ROOM_EDIT.route -> R.string.edit_room AppRoute.NEW_ROOM.route -> R.string.create_room else -> R.string.app_name @@ -106,6 +107,9 @@ fun getBottomBar(route: String?, onNavigate: (AppRoute) -> Unit): @Composable () AppRoute.ROOM_LOBBY.route -> ({ LobbyBottomBar(AppRoute.ROOM_LOBBY, onNavigate = onNavigate) }) + AppRoute.ROOM_SONGS.route -> ({ + LobbyBottomBar(AppRoute.ROOM_SONGS, onNavigate = onNavigate) + }) AppRoute.ROOM_EDIT.route -> ({ LobbyBottomBar(AppRoute.ROOM_EDIT, onNavigate = onNavigate) }) diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/navigation/AppRoute.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/navigation/AppRoute.kt index a55029a..57ad9b4 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/navigation/AppRoute.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/navigation/AppRoute.kt @@ -5,6 +5,7 @@ enum class AppRoute(val route: String) { NEW_ROOM("new_room"), ROOM("room"), ROOM_LOBBY("room/{roomId}"), + ROOM_SONGS("room/{roomId}/songs"), ROOM_EDIT("room/{roomId}/edit"), GAME("game"), START_GAME("game/{roomId}"), diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/navigation/LobbyGraph.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/navigation/LobbyGraph.kt index 2e2c412..2e6116a 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/navigation/LobbyGraph.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/navigation/LobbyGraph.kt @@ -2,13 +2,16 @@ package com.github.feelbeatapp.androidclient.ui.app.navigation import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable -import com.github.feelbeatapp.androidclient.ui.app.lobby.acceptgame.AcceptGameScreen +import com.github.feelbeatapp.androidclient.ui.app.lobby.lobbyhome.LobbyHomeScreen +import com.github.feelbeatapp.androidclient.ui.app.lobby.lobbysongs.LobbySongsScreen import com.github.feelbeatapp.androidclient.ui.app.roomsettings.screens.EditRoomSettingsScreen - fun NavGraphBuilder.lobbyGraph(onPlay: () -> Unit) { composable(route = AppRoute.ROOM_LOBBY.route) { - AcceptGameScreen(it.getRoomId(), onPlay = onPlay) + LobbyHomeScreen(it.getRoomId(), onPlay = onPlay) } + + composable(route = AppRoute.ROOM_SONGS.route) { LobbySongsScreen() } + composable(route = AppRoute.ROOM_EDIT.route) { EditRoomSettingsScreen() } } 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 cc48d63..f453bdc 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,10 +1,10 @@ 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 com.github.feelbeatapp.androidclient.infra.error.ErrorCode +import com.github.feelbeatapp.androidclient.infra.error.ErrorReceiver +import com.github.feelbeatapp.androidclient.infra.error.FeelBeatException +import com.github.feelbeatapp.androidclient.api.feelbeat.FeelBeatApi import dagger.hilt.android.lifecycle.HiltViewModel import io.ktor.http.Url import javax.inject.Inject diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/roomsettings/viewmodels/RoomSettingsViewModel.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/roomsettings/viewmodels/RoomSettingsViewModel.kt index 2315a0a..2bc98ce 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/roomsettings/viewmodels/RoomSettingsViewModel.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/roomsettings/viewmodels/RoomSettingsViewModel.kt @@ -1,11 +1,32 @@ package com.github.feelbeatapp.androidclient.ui.app.roomsettings.viewmodels import androidx.lifecycle.ViewModel -import com.github.feelbeatapp.androidclient.ui.app.uimodel.RoomSettings +import com.github.feelbeatapp.androidclient.game.model.RoomSettings +import io.ktor.http.Url import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +data class RoomSettings( + val maxPlayers: Int, + val turnCount: Int, + val timePenaltyPerSecond: Int, + val basePoints: Int, + val incorrectGuessPenalty: Int, + val playlistLink: String, +) { + fun toCreateRoomPayload(): RoomSettings { + return RoomSettings( + maxPlayers = maxPlayers, + turnCount = turnCount, + timePenaltyPerSecond = timePenaltyPerSecond, + basePoints = basePoints, + incorrectGuessPenalty = incorrectGuessPenalty, + playlistId = Url(playlistLink).segments.last(), + ) + } +} + abstract class RoomSettingsViewModel : ViewModel() { private val _roomSettings = MutableStateFlow( diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/uimodel/Player.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/uimodel/Player.kt index 3bd9c3f..0cc65b2 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/uimodel/Player.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/uimodel/Player.kt @@ -1,3 +1 @@ package com.github.feelbeatapp.androidclient.ui.app.uimodel - -data class Player(val name: String, val image: Int) diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/uimodel/PlayerWithResult.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/uimodel/PlayerWithResult.kt index 8100ba5..7edb125 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/uimodel/PlayerWithResult.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/uimodel/PlayerWithResult.kt @@ -1,6 +1,6 @@ package com.github.feelbeatapp.androidclient.ui.app.uimodel +import com.github.feelbeatapp.androidclient.game.model.Player import com.github.feelbeatapp.androidclient.ui.app.game.guesssong.ResultStatus - data class PlayerWithResult(val player: Player, val resultStatus: ResultStatus, val points: Int) diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/uimodel/RoomSettings.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/uimodel/RoomSettings.kt deleted file mode 100644 index 3719d01..0000000 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/app/uimodel/RoomSettings.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.github.feelbeatapp.androidclient.ui.app.uimodel - -import com.github.feelbeatapp.androidclient.model.CreateRoomPayload -import io.ktor.http.Url - -data class RoomSettings( - val maxPlayers: Int, - val turnCount: Int, - val timePenaltyPerSecond: Int, - val basePoints: Int, - val incorrectGuessPenalty: Int, - val playlistLink: String, -) { - fun toCreateRoomPayload(): CreateRoomPayload { - return CreateRoomPayload( - maxPlayers = maxPlayers, - turnCount = turnCount, - timePenaltyPerSecond = timePenaltyPerSecond, - basePoints = basePoints, - incorrectGuessPenalty = incorrectGuessPenalty, - playListId = Url(playlistLink).segments.last(), - ) - } -} diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/login/AuthActivity.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/login/AuthActivity.kt index becc1a4..6693d49 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/login/AuthActivity.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/login/AuthActivity.kt @@ -6,7 +6,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import com.github.feelbeatapp.androidclient.auth.AuthManager +import com.github.feelbeatapp.androidclient.infra.auth.AuthManager import com.github.feelbeatapp.androidclient.ui.MainActivity import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject diff --git a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/login/LoginViewModel.kt b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/login/LoginViewModel.kt index fdb97cf..f96c0e3 100644 --- a/app/src/main/java/com/github/feelbeatapp/androidclient/ui/login/LoginViewModel.kt +++ b/app/src/main/java/com/github/feelbeatapp/androidclient/ui/login/LoginViewModel.kt @@ -2,7 +2,7 @@ package com.github.feelbeatapp.androidclient.ui.login import android.content.Context import androidx.lifecycle.ViewModel -import com.github.feelbeatapp.androidclient.auth.AuthManager +import com.github.feelbeatapp.androidclient.infra.auth.AuthManager import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index be63fe6..13a78f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -39,4 +39,8 @@ With how many points you starting to guess How many points you will loose after incorrect guess Get ready + Playlist image + Song cover + Playlist content + Couldn\'t join the room \ No newline at end of file diff --git a/app/src/test/java/com/github/feelbeatapp/androidclient/PKCETest.kt b/app/src/test/java/com/github/feelbeatapp/androidclient/PKCETest.kt index 2721991..50f5186 100644 --- a/app/src/test/java/com/github/feelbeatapp/androidclient/PKCETest.kt +++ b/app/src/test/java/com/github/feelbeatapp/androidclient/PKCETest.kt @@ -1,6 +1,6 @@ package com.github.feelbeatapp.androidclient -import com.github.feelbeatapp.androidclient.auth.PKCEUtils +import com.github.feelbeatapp.androidclient.infra.auth.PKCEUtils import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/java/com/github/feelbeatapp/androidclient/utils/FakeNetworkAgent.kt b/app/src/test/java/com/github/feelbeatapp/androidclient/utils/FakeNetworkAgent.kt index ca40766..92aa5c8 100644 --- a/app/src/test/java/com/github/feelbeatapp/androidclient/utils/FakeNetworkAgent.kt +++ b/app/src/test/java/com/github/feelbeatapp/androidclient/utils/FakeNetworkAgent.kt @@ -1,11 +1,10 @@ package com.github.feelbeatapp.androidclient.utils -import com.github.feelbeatapp.androidclient.network.fullduplex.NetworkAgent +import com.github.feelbeatapp.androidclient.infra.network.NetworkClient +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -class FakeNetworkAgent : NetworkAgent { +class FakeNetworkAgent : NetworkClient { private val _sentMessages: MutableList = ArrayList() val sentMessages: List get() = _sentMessages.toList() @@ -16,7 +15,7 @@ class FakeNetworkAgent : NetworkAgent { incoming.emit(text) } - override fun connect(path: String) { + override suspend fun connect(path: String): Flow { TODO("Not yet implemented") } @@ -27,8 +26,4 @@ class FakeNetworkAgent : NetworkAgent { override suspend fun sendMessage(text: String) { _sentMessages.add(text) } - - override fun receiveFlow(): SharedFlow { - return incoming.asSharedFlow() - } }