From 463e490827e7e14b248fd5e674a998b4ae2084b4 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sun, 27 Apr 2025 18:30:01 +0530 Subject: [PATCH 01/16] Add LoginButton composable and integrate it into MainActivity --- .../java/com/notifier/app/MainActivity.kt | 26 +++---------------- .../app/auth/presentation/LoginButton.kt | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/LoginButton.kt diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index 5f178ef..bac8266 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -7,10 +7,8 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import com.notifier.app.auth.presentation.LoginButton import com.notifier.app.ui.theme.GitHubNotifierTheme import dagger.hilt.android.AndroidEntryPoint @@ -22,28 +20,12 @@ class MainActivity : ComponentActivity() { setContent { GitHubNotifierTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) + LoginButton( + modifier = Modifier.padding(innerPadding), + onClick = {} ) } } } } } - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - GitHubNotifierTheme { - Greeting("Android") - } -} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/LoginButton.kt b/app/src/main/java/com/notifier/app/auth/presentation/LoginButton.kt new file mode 100644 index 0000000..2b8bd6b --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/LoginButton.kt @@ -0,0 +1,26 @@ +package com.notifier.app.auth.presentation + +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.notifier.app.ui.theme.GitHubNotifierTheme + +@Composable +fun LoginButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + Button( + modifier = modifier, + onClick = onClick, + ) { + Text("Login with GitHub") + } +} + +@Preview +@Composable +private fun LoginButtonPreview() { + GitHubNotifierTheme { + LoginButton(onClick = {}) + } +} From d084a29f37e4bc2dbb8cd03e31d41424c73b0a64 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sun, 27 Apr 2025 18:39:42 +0530 Subject: [PATCH 02/16] Add CLIENT_ID and CLIENT_SECRET to buildConfig in debug and release builds --- app/build.gradle.kts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f652653..79b735f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) @@ -22,12 +24,20 @@ android { } buildTypes { + val properties = Properties().apply { + load(project.rootProject.file("local.properties").inputStream()) + } + debug { buildConfigField("String", "BASE_URL", "\"https://api.github.com/\"") + buildConfigField("String", "CLIENT_ID", "\"${properties.getProperty("CLIENT_ID")}\"") + buildConfigField("String", "CLIENT_SECRET", "\"${properties.getProperty("CLIENT_SECRET")}\"") } release { isMinifyEnabled = false buildConfigField("String", "BASE_URL", "\"https://api.github.com/\"") + buildConfigField("String", "CLIENT_ID", "\"${properties.getProperty("CLIENT_ID")}\"") + buildConfigField("String", "CLIENT_SECRET", "\"${properties.getProperty("CLIENT_SECRET")}\"") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" From ed82b5a4438e5ef0d5e311f5d340d02e04e02848 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sun, 27 Apr 2025 19:33:08 +0530 Subject: [PATCH 03/16] Add GitHub OAuth integration with LoginButton and createGitHubAuthIntent --- .../java/com/notifier/app/MainActivity.kt | 7 ++---- .../{ => components}/LoginButton.kt | 13 +++++++---- .../util/createGitHubAuthIntent.kt | 22 +++++++++++++++++++ 3 files changed, 33 insertions(+), 9 deletions(-) rename app/src/main/java/com/notifier/app/auth/presentation/{ => components}/LoginButton.kt (55%) create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/util/createGitHubAuthIntent.kt diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index bac8266..9dacf97 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.ui.Modifier -import com.notifier.app.auth.presentation.LoginButton +import com.notifier.app.auth.presentation.components.LoginButton import com.notifier.app.ui.theme.GitHubNotifierTheme import dagger.hilt.android.AndroidEntryPoint @@ -20,10 +20,7 @@ class MainActivity : ComponentActivity() { setContent { GitHubNotifierTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - LoginButton( - modifier = Modifier.padding(innerPadding), - onClick = {} - ) + LoginButton(modifier = Modifier.padding(innerPadding)) } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/LoginButton.kt b/app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt similarity index 55% rename from app/src/main/java/com/notifier/app/auth/presentation/LoginButton.kt rename to app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt index 2b8bd6b..392b534 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/LoginButton.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt @@ -1,17 +1,22 @@ -package com.notifier.app.auth.presentation +package com.notifier.app.auth.presentation.components import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview +import com.notifier.app.auth.presentation.util.createGitHubAuthIntent import com.notifier.app.ui.theme.GitHubNotifierTheme @Composable -fun LoginButton(onClick: () -> Unit, modifier: Modifier = Modifier) { +fun LoginButton(modifier: Modifier = Modifier) { + val context = LocalContext.current Button( modifier = modifier, - onClick = onClick, + onClick = { + context.startActivity(createGitHubAuthIntent()) + }, ) { Text("Login with GitHub") } @@ -21,6 +26,6 @@ fun LoginButton(onClick: () -> Unit, modifier: Modifier = Modifier) { @Composable private fun LoginButtonPreview() { GitHubNotifierTheme { - LoginButton(onClick = {}) + LoginButton() } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/util/createGitHubAuthIntent.kt b/app/src/main/java/com/notifier/app/auth/presentation/util/createGitHubAuthIntent.kt new file mode 100644 index 0000000..8205579 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/util/createGitHubAuthIntent.kt @@ -0,0 +1,22 @@ +package com.notifier.app.auth.presentation.util + +import android.content.Intent +import androidx.core.net.toUri +import com.notifier.app.BuildConfig +import java.util.UUID + +private const val GITHUB_AUTH_BASE_URL = "https://github.com/login/oauth/authorize" + +/** + * Creates an [Intent] to launch the GitHub OAuth authorization page in a browser. + * + * @return [Intent] configured with the GitHub OAuth URL and required parameters. + */ +fun createGitHubAuthIntent(): Intent { + val clientId = BuildConfig.CLIENT_ID + val state = UUID.randomUUID().toString() + + val authUrl = "$GITHUB_AUTH_BASE_URL?client_id=$clientId&state=$state" + + return Intent(Intent.ACTION_VIEW, authUrl.toUri()) +} From 7058ed3d8d3a2a7acc3885d02bb1b466eaad0797 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sun, 27 Apr 2025 20:24:54 +0530 Subject: [PATCH 04/16] Add LoginScreen composable and update MainActivity to use it --- app/src/main/AndroidManifest.xml | 11 +++++- .../java/com/notifier/app/MainActivity.kt | 4 +- .../app/auth/presentation/LoginScreen.kt | 38 +++++++++++++++++++ .../presentation/components/LoginButton.kt | 6 ++- 4 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 20fcb03..7334fe1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,9 +14,18 @@ android:exported="true"> - + + + + + + + + diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index 9dacf97..c8b9bbd 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.ui.Modifier -import com.notifier.app.auth.presentation.components.LoginButton +import com.notifier.app.auth.presentation.LoginScreen import com.notifier.app.ui.theme.GitHubNotifierTheme import dagger.hilt.android.AndroidEntryPoint @@ -20,7 +20,7 @@ class MainActivity : ComponentActivity() { setContent { GitHubNotifierTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - LoginButton(modifier = Modifier.padding(innerPadding)) + LoginScreen(modifier = Modifier.padding(innerPadding)) } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt new file mode 100644 index 0000000..bff0113 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt @@ -0,0 +1,38 @@ +package com.notifier.app.auth.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewDynamicColors +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.notifier.app.auth.presentation.components.LoginButton +import com.notifier.app.ui.theme.GitHubNotifierTheme + +@Composable +fun LoginScreen( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + LoginButton() + } +} + +@PreviewLightDark +@PreviewDynamicColors +@Composable +private fun LoginScreenPreview() { + GitHubNotifierTheme { + Scaffold { innerPadding -> + LoginScreen(modifier = Modifier.padding(innerPadding)) + } + } +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt b/app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt index 392b534..664896a 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt @@ -5,7 +5,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewDynamicColors +import androidx.compose.ui.tooling.preview.PreviewLightDark import com.notifier.app.auth.presentation.util.createGitHubAuthIntent import com.notifier.app.ui.theme.GitHubNotifierTheme @@ -22,7 +23,8 @@ fun LoginButton(modifier: Modifier = Modifier) { } } -@Preview +@PreviewLightDark +@PreviewDynamicColors @Composable private fun LoginButtonPreview() { GitHubNotifierTheme { From bbedd44b26fd2aec8c74ceb85e7828168cf4fedb Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Mon, 28 Apr 2025 18:44:20 +0530 Subject: [PATCH 05/16] Add authentication feature with ViewModel, data sources, and error handling --- app/build.gradle.kts | 1 + .../java/com/notifier/app/MainActivity.kt | 31 +++++++++++++++- .../app/auth/data/mappers/AuthTokenMapper.kt | 10 ++++++ .../networking/RemoteAuthTokenDataSource.kt | 36 +++++++++++++++++++ .../networking/dto/AuthTokenResponseDto.kt | 14 ++++++++ .../com/notifier/app/auth/domain/AuthToken.kt | 7 ++++ .../app/auth/domain/AuthTokenDataSource.kt | 12 +++++++ .../app/auth/presentation/AuthAction.kt | 5 +++ .../app/auth/presentation/AuthEvent.kt | 7 ++++ .../app/auth/presentation/AuthState.kt | 10 ++++++ .../app/auth/presentation/AuthViewModel.kt | 34 ++++++++++++++++++ .../app/auth/presentation/LoginScreen.kt | 8 ++++- .../notifier/app/core/presentation/.gitkeep | 0 .../presentation/util/NetworkErrorToString.kt | 17 +++++++++ .../core/presentation/util/ObserveAsEvents.kt | 27 ++++++++++++++ .../java/com/notifier/app/di/ApiModule.kt | 12 +++++++ app/src/main/res/values/strings.xml | 5 +++ gradle/libs.versions.toml | 10 +++--- 18 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/notifier/app/auth/data/mappers/AuthTokenMapper.kt create mode 100644 app/src/main/java/com/notifier/app/auth/data/networking/RemoteAuthTokenDataSource.kt create mode 100644 app/src/main/java/com/notifier/app/auth/data/networking/dto/AuthTokenResponseDto.kt create mode 100644 app/src/main/java/com/notifier/app/auth/domain/AuthToken.kt create mode 100644 app/src/main/java/com/notifier/app/auth/domain/AuthTokenDataSource.kt create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/AuthAction.kt create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/AuthEvent.kt create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/AuthState.kt create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/AuthViewModel.kt delete mode 100644 app/src/main/java/com/notifier/app/core/presentation/.gitkeep create mode 100644 app/src/main/java/com/notifier/app/core/presentation/util/NetworkErrorToString.kt create mode 100644 app/src/main/java/com/notifier/app/core/presentation/util/ObserveAsEvents.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 79b735f..a0b3061 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -69,6 +69,7 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.androidx.navigation.compose) implementation(libs.bundles.ktor) implementation(libs.dagger.hilt) ksp(libs.dagger.hilt.compiler) diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index c8b9bbd..62dafa0 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -1,14 +1,23 @@ package com.notifier.app import android.os.Bundle +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.notifier.app.auth.presentation.AuthEvent +import com.notifier.app.auth.presentation.AuthViewModel import com.notifier.app.auth.presentation.LoginScreen +import com.notifier.app.core.presentation.util.ObserveAsEvents +import com.notifier.app.core.presentation.util.toString import com.notifier.app.ui.theme.GitHubNotifierTheme import dagger.hilt.android.AndroidEntryPoint @@ -20,7 +29,27 @@ class MainActivity : ComponentActivity() { setContent { GitHubNotifierTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - LoginScreen(modifier = Modifier.padding(innerPadding)) + val viewModel: AuthViewModel by viewModels() + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + ObserveAsEvents(events = viewModel.events) { event -> + when (event) { + is AuthEvent.Error -> { + Toast.makeText( + context, + event.error.toString(context), + Toast.LENGTH_LONG + ).show() + } + } + } + LoginScreen( + state = state, + onAction = { action -> + viewModel.onAction(action) + }, + modifier = Modifier.padding(innerPadding) + ) } } } diff --git a/app/src/main/java/com/notifier/app/auth/data/mappers/AuthTokenMapper.kt b/app/src/main/java/com/notifier/app/auth/data/mappers/AuthTokenMapper.kt new file mode 100644 index 0000000..9dbb167 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/data/mappers/AuthTokenMapper.kt @@ -0,0 +1,10 @@ +package com.notifier.app.auth.data.mappers + +import com.notifier.app.auth.data.networking.dto.AuthTokenResponseDto +import com.notifier.app.auth.domain.AuthToken + +fun AuthTokenResponseDto.toAuthToken() = AuthToken( + accessToken = accessToken, + scope = scope, + tokenType = tokenType, +) diff --git a/app/src/main/java/com/notifier/app/auth/data/networking/RemoteAuthTokenDataSource.kt b/app/src/main/java/com/notifier/app/auth/data/networking/RemoteAuthTokenDataSource.kt new file mode 100644 index 0000000..d55c47e --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/data/networking/RemoteAuthTokenDataSource.kt @@ -0,0 +1,36 @@ +package com.notifier.app.auth.data.networking + +import com.notifier.app.auth.data.mappers.toAuthToken +import com.notifier.app.auth.data.networking.dto.AuthTokenResponseDto +import com.notifier.app.auth.domain.AuthToken +import com.notifier.app.auth.domain.AuthTokenDataSource +import com.notifier.app.core.data.networking.safeCall +import com.notifier.app.core.domain.util.NetworkError +import com.notifier.app.core.domain.util.Result +import com.notifier.app.core.domain.util.map +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import javax.inject.Inject + +class RemoteAuthTokenDataSource @Inject constructor( + private val httpClient: HttpClient, +) : AuthTokenDataSource { + override suspend fun getAuthToken( + clientId: String, + clientSecret: String, + code: String, + ): Result { + return safeCall { + httpClient.get( + urlString = "https://github.com/login/oauth/access_token" + ) { + parameter("client_id", clientId) + parameter("client_secret", clientSecret) + parameter("code", code) + } + }.map { response -> + response.toAuthToken() + } + } +} diff --git a/app/src/main/java/com/notifier/app/auth/data/networking/dto/AuthTokenResponseDto.kt b/app/src/main/java/com/notifier/app/auth/data/networking/dto/AuthTokenResponseDto.kt new file mode 100644 index 0000000..e0ff727 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/data/networking/dto/AuthTokenResponseDto.kt @@ -0,0 +1,14 @@ +package com.notifier.app.auth.data.networking.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AuthTokenResponseDto( + @SerialName("access_token") + val accessToken: String, + @SerialName("scope") + val scope: String, + @SerialName("token_type") + val tokenType: String, +) diff --git a/app/src/main/java/com/notifier/app/auth/domain/AuthToken.kt b/app/src/main/java/com/notifier/app/auth/domain/AuthToken.kt new file mode 100644 index 0000000..0c8f750 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/domain/AuthToken.kt @@ -0,0 +1,7 @@ +package com.notifier.app.auth.domain + +data class AuthToken( + val accessToken: String, + val scope: String, + val tokenType: String, +) diff --git a/app/src/main/java/com/notifier/app/auth/domain/AuthTokenDataSource.kt b/app/src/main/java/com/notifier/app/auth/domain/AuthTokenDataSource.kt new file mode 100644 index 0000000..eb69669 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/domain/AuthTokenDataSource.kt @@ -0,0 +1,12 @@ +package com.notifier.app.auth.domain + +import com.notifier.app.core.domain.util.NetworkError +import com.notifier.app.core.domain.util.Result + +interface AuthTokenDataSource { + suspend fun getAuthToken( + clientId: String, + clientSecret: String, + code: String, + ): Result +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/AuthAction.kt b/app/src/main/java/com/notifier/app/auth/presentation/AuthAction.kt new file mode 100644 index 0000000..57ed033 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/AuthAction.kt @@ -0,0 +1,5 @@ +package com.notifier.app.auth.presentation + +sealed interface AuthAction { + data object OnLoginButtonClick : AuthAction +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/AuthEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/AuthEvent.kt new file mode 100644 index 0000000..6c21299 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/AuthEvent.kt @@ -0,0 +1,7 @@ +package com.notifier.app.auth.presentation + +import com.notifier.app.core.domain.util.NetworkError + +sealed interface AuthEvent { + data class Error(val error: NetworkError) : AuthEvent +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/AuthState.kt b/app/src/main/java/com/notifier/app/auth/presentation/AuthState.kt new file mode 100644 index 0000000..1ee1a5f --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/AuthState.kt @@ -0,0 +1,10 @@ +package com.notifier.app.auth.presentation + +import androidx.compose.runtime.Immutable +import com.notifier.app.auth.domain.AuthToken + +@Immutable +data class AuthState( + val isLoading: Boolean = false, + val authToken: AuthToken? = null, +) diff --git a/app/src/main/java/com/notifier/app/auth/presentation/AuthViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/AuthViewModel.kt new file mode 100644 index 0000000..2b8909e --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/AuthViewModel.kt @@ -0,0 +1,34 @@ +package com.notifier.app.auth.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.notifier.app.auth.domain.AuthTokenDataSource +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class AuthViewModel @Inject constructor( + private val authTokenDataSource: AuthTokenDataSource, +) : ViewModel() { + private val _state = MutableStateFlow(AuthState()) + val state = _state + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), + AuthState() + ) + + private val _events = Channel() + val events = _events.receiveAsFlow() + + fun onAction(action: AuthAction) { + when (action) { + is AuthAction.OnLoginButtonClick -> {} + } + } +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt index bff0113..3e6b03d 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt @@ -15,6 +15,8 @@ import com.notifier.app.ui.theme.GitHubNotifierTheme @Composable fun LoginScreen( + state: AuthState, + onAction: (AuthAction) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -32,7 +34,11 @@ fun LoginScreen( private fun LoginScreenPreview() { GitHubNotifierTheme { Scaffold { innerPadding -> - LoginScreen(modifier = Modifier.padding(innerPadding)) + LoginScreen( + state = AuthState(), + onAction = {}, + modifier = Modifier.padding(innerPadding) + ) } } } diff --git a/app/src/main/java/com/notifier/app/core/presentation/.gitkeep b/app/src/main/java/com/notifier/app/core/presentation/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/main/java/com/notifier/app/core/presentation/util/NetworkErrorToString.kt b/app/src/main/java/com/notifier/app/core/presentation/util/NetworkErrorToString.kt new file mode 100644 index 0000000..df4152b --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/util/NetworkErrorToString.kt @@ -0,0 +1,17 @@ +package com.notifier.app.core.presentation.util + +import android.content.Context +import com.notifier.app.R +import com.notifier.app.core.domain.util.NetworkError + +fun NetworkError.toString(context: Context): String { + val resId = when (this) { + NetworkError.REQUEST_TIMEOUT -> R.string.error_request_timeout + NetworkError.TOO_MANY_REQUESTS -> R.string.error_too_many_requests + NetworkError.NO_INTERNET -> R.string.error_no_internet + NetworkError.SERVER_ERROR -> R.string.error_unknown + NetworkError.SERIALIZATION -> R.string.error_serialization + NetworkError.UNKNOWN -> R.string.error_unknown + } + return context.getString(resId) +} diff --git a/app/src/main/java/com/notifier/app/core/presentation/util/ObserveAsEvents.kt b/app/src/main/java/com/notifier/app/core/presentation/util/ObserveAsEvents.kt new file mode 100644 index 0000000..37ae42f --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/util/ObserveAsEvents.kt @@ -0,0 +1,27 @@ +package com.notifier.app.core.presentation.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +@Composable +fun ObserveAsEvents( + events: Flow, + key1: Any? = null, + key2: Any? = null, + onEvent: (T) -> Unit +) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(lifecycleOwner.lifecycle, key1, key2) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + withContext(Dispatchers.Main.immediate) { + events.collect(onEvent) + } + } + } +} diff --git a/app/src/main/java/com/notifier/app/di/ApiModule.kt b/app/src/main/java/com/notifier/app/di/ApiModule.kt index e4dd221..ba92f5b 100644 --- a/app/src/main/java/com/notifier/app/di/ApiModule.kt +++ b/app/src/main/java/com/notifier/app/di/ApiModule.kt @@ -1,5 +1,7 @@ package com.notifier.app.di +import com.notifier.app.auth.data.networking.RemoteAuthTokenDataSource +import com.notifier.app.auth.domain.AuthTokenDataSource import com.notifier.app.core.data.networking.HttpClientFactory import dagger.Module import dagger.Provides @@ -7,12 +9,22 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object ApiModule { @Provides + @Singleton fun provideHttpClient(): HttpClient { return HttpClientFactory.create(CIO.create()) } + + @Provides + @Singleton + fun provideRemoteAuthTokenDataSource( + httpClient: HttpClient, + ): AuthTokenDataSource { + return RemoteAuthTokenDataSource(httpClient) + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d9719f0..bccf8e3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,8 @@ GitHub Notifier + The request timed out. + Oops, it seems like your quota is exceeded. + Couldn\'t connect to server, please check your internet connection. + Something went wrong. Please try again later. + Couldn\'t serialize or deserialize data. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c35a32c..9519b1b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,8 @@ truth = "1.1.3" kotlinxCoroutinesTest = "1.10.1" mockk = "1.13.8" ksp = "2.0.21-1.0.27" -dagger-hilt = "2.56.1" +daggerHilt = "2.56.1" +navigation = "2.8.9" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -30,6 +31,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } @@ -39,8 +41,8 @@ ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } -dagger-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "dagger-hilt" } -dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "dagger-hilt" } +dagger-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "daggerHilt" } +dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "daggerHilt" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -48,7 +50,7 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } -dagger-hilt = { id = "com.google.dagger.hilt.android", version.ref = "dagger-hilt" } +dagger-hilt = { id = "com.google.dagger.hilt.android", version.ref = "daggerHilt" } [bundles] ktor = [ From 1b7b3b5acc07dacbf0aa1d3c21c586b5016e0e91 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Mon, 28 Apr 2025 20:08:07 +0530 Subject: [PATCH 06/16] Add ConnectingToGitHubScreen and navigation setup in MainActivity --- .../java/com/notifier/app/MainActivity.kt | 66 ++++++++++++++----- .../connecting/ConnectingToGitHubScreen.kt | 42 ++++++++++++ .../routes/ConnectingToGitHubScreen.kt | 9 +++ .../navigation/routes/LoginScreen.kt | 6 ++ 4 files changed, 105 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt create mode 100644 app/src/main/java/com/notifier/app/core/presentation/navigation/routes/ConnectingToGitHubScreen.kt create mode 100644 app/src/main/java/com/notifier/app/core/presentation/navigation/routes/LoginScreen.kt diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index 62dafa0..8b1b6e4 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -1,6 +1,7 @@ package com.notifier.app import android.os.Bundle +import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -13,9 +14,17 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navDeepLink +import androidx.navigation.toRoute import com.notifier.app.auth.presentation.AuthEvent import com.notifier.app.auth.presentation.AuthViewModel +import com.notifier.app.auth.presentation.connecting.ConnectingToGitHubScreen import com.notifier.app.auth.presentation.LoginScreen +import com.notifier.app.core.presentation.navigation.routes.ConnectingToGitHubScreen +import com.notifier.app.core.presentation.navigation.routes.LoginScreen import com.notifier.app.core.presentation.util.ObserveAsEvents import com.notifier.app.core.presentation.util.toString import com.notifier.app.ui.theme.GitHubNotifierTheme @@ -29,27 +38,48 @@ class MainActivity : ComponentActivity() { setContent { GitHubNotifierTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - val viewModel: AuthViewModel by viewModels() - val state by viewModel.state.collectAsStateWithLifecycle() - val context = LocalContext.current - ObserveAsEvents(events = viewModel.events) { event -> - when (event) { - is AuthEvent.Error -> { - Toast.makeText( - context, - event.error.toString(context), - Toast.LENGTH_LONG - ).show() + val navController = rememberNavController() + NavHost( + navController = navController, + startDestination = LoginScreen, + modifier = Modifier.padding(innerPadding) + ) { + composable { + val viewModel: AuthViewModel by viewModels() + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + ObserveAsEvents(events = viewModel.events) { event -> + when (event) { + is AuthEvent.Error -> { + Toast.makeText( + context, + event.error.toString(context), + Toast.LENGTH_LONG + ).show() + } + } } + LoginScreen( + state = state, + onAction = { action -> + viewModel.onAction(action) + } + ) + } + composable( + deepLinks = listOf( + navDeepLink( + basePath = "github-notifier://auth-callback" + ) + ) + ) { + val code = it.toRoute().code + val state = it.toRoute().state + Log.d("MainActivity", "Code: $code") + Log.d("MainActivity", "State: $state") + ConnectingToGitHubScreen() } } - LoginScreen( - state = state, - onAction = { action -> - viewModel.onAction(action) - }, - modifier = Modifier.padding(innerPadding) - ) } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt new file mode 100644 index 0000000..1301ccb --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt @@ -0,0 +1,42 @@ +package com.notifier.app.auth.presentation.connecting + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewDynamicColors +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.notifier.app.ui.theme.GitHubNotifierTheme + +@Composable +fun ConnectingToGitHubScreen( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Connecting to GitHub...", + ) + } +} + +@PreviewLightDark +@PreviewDynamicColors +@Composable +private fun ConnectingToGitHubScreenPreview() { + GitHubNotifierTheme { + Scaffold { innerPadding -> + ConnectingToGitHubScreen( + modifier = Modifier.padding(innerPadding) + ) + } + } +} diff --git a/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/ConnectingToGitHubScreen.kt b/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/ConnectingToGitHubScreen.kt new file mode 100644 index 0000000..3921109 --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/ConnectingToGitHubScreen.kt @@ -0,0 +1,9 @@ +package com.notifier.app.core.presentation.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class ConnectingToGitHubScreen( + val code: String? = null, + val state: String? = null, +) diff --git a/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/LoginScreen.kt b/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/LoginScreen.kt new file mode 100644 index 0000000..9898070 --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/LoginScreen.kt @@ -0,0 +1,6 @@ +package com.notifier.app.core.presentation.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data object LoginScreen From 9c6c7acebc2107aff0c30fbf5c890ba4bf54926a Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Mon, 28 Apr 2025 22:18:37 +0530 Subject: [PATCH 07/16] Update build.gradle.kts to handle missing local.properties gracefully and provide default values for CLIENT_ID and CLIENT_SECRET --- app/build.gradle.kts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a0b3061..a204b51 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,19 +25,38 @@ android { buildTypes { val properties = Properties().apply { - load(project.rootProject.file("local.properties").inputStream()) + val localPropertiesFile = project.rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + load(localPropertiesFile.inputStream()) + } } debug { buildConfigField("String", "BASE_URL", "\"https://api.github.com/\"") - buildConfigField("String", "CLIENT_ID", "\"${properties.getProperty("CLIENT_ID")}\"") - buildConfigField("String", "CLIENT_SECRET", "\"${properties.getProperty("CLIENT_SECRET")}\"") + buildConfigField( + "String", + "CLIENT_ID", + "\"${properties.getProperty("CLIENT_ID", "dummy_client_id")}\"" + ) + buildConfigField( + "String", + "CLIENT_SECRET", + "\"${properties.getProperty("CLIENT_SECRET", "dummy_client_secret")}\"" + ) } release { isMinifyEnabled = false buildConfigField("String", "BASE_URL", "\"https://api.github.com/\"") - buildConfigField("String", "CLIENT_ID", "\"${properties.getProperty("CLIENT_ID")}\"") - buildConfigField("String", "CLIENT_SECRET", "\"${properties.getProperty("CLIENT_SECRET")}\"") + buildConfigField( + "String", + "CLIENT_ID", + "\"${properties.getProperty("CLIENT_ID", "dummy_client_id")}\"" + ) + buildConfigField( + "String", + "CLIENT_SECRET", + "\"${properties.getProperty("CLIENT_SECRET", "dummy_client_secret")}\"" + ) proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" From 2cda0cc8e19ba9e408735d7f7c57f5bf37dd29ae Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 29 Apr 2025 13:38:52 +0530 Subject: [PATCH 08/16] Add GitHub connection handling with state management and navigation setup --- app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 2 + .../java/com/notifier/app/MainActivity.kt | 90 +++++++------------ .../networking/RemoteAuthTokenDataSource.kt | 4 +- .../connecting/ConnectingToGitHubEvent.kt | 7 ++ .../connecting/ConnectingToGitHubRoute.kt | 45 ++++++++++ .../connecting/ConnectingToGitHubScreen.kt | 25 +++++- .../connecting/ConnectingToGitHubState.kt | 17 ++++ .../connecting/ConnectingToGitHubViewModel.kt | 67 ++++++++++++++ .../presentation/{ => login}/AuthAction.kt | 2 +- .../presentation/{ => login}/AuthEvent.kt | 2 +- .../presentation/{ => login}/AuthState.kt | 2 +- .../presentation/{ => login}/AuthViewModel.kt | 2 +- .../app/auth/presentation/login/LoginRoute.kt | 40 +++++++++ .../presentation/{ => login}/LoginScreen.kt | 4 +- .../{ => login}/components/LoginButton.kt | 2 +- .../routes/ConnectingToGitHubScreen.kt | 9 -- .../navigation/routes/LoginScreen.kt | 6 -- gradle/libs.versions.toml | 2 + 19 files changed, 246 insertions(+), 84 deletions(-) create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubEvent.kt create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubRoute.kt create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubState.kt create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubViewModel.kt rename app/src/main/java/com/notifier/app/auth/presentation/{ => login}/AuthAction.kt (62%) rename app/src/main/java/com/notifier/app/auth/presentation/{ => login}/AuthEvent.kt (74%) rename app/src/main/java/com/notifier/app/auth/presentation/{ => login}/AuthState.kt (80%) rename app/src/main/java/com/notifier/app/auth/presentation/{ => login}/AuthViewModel.kt (95%) create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt rename app/src/main/java/com/notifier/app/auth/presentation/{ => login}/LoginScreen.kt (90%) rename app/src/main/java/com/notifier/app/auth/presentation/{ => login}/components/LoginButton.kt (93%) delete mode 100644 app/src/main/java/com/notifier/app/core/presentation/navigation/routes/ConnectingToGitHubScreen.kt delete mode 100644 app/src/main/java/com/notifier/app/core/presentation/navigation/routes/LoginScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a204b51..c438ecd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -91,6 +91,8 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.bundles.ktor) implementation(libs.dagger.hilt) + implementation(libs.hilt.navigation.compose) + ksp(libs.dagger.hilt.compiler) testImplementation(libs.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7334fe1..7f1665b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + - val navController = rememberNavController() - NavHost( - navController = navController, - startDestination = LoginScreen, - modifier = Modifier.padding(innerPadding) - ) { - composable { - val viewModel: AuthViewModel by viewModels() - val state by viewModel.state.collectAsStateWithLifecycle() - val context = LocalContext.current - ObserveAsEvents(events = viewModel.events) { event -> - when (event) { - is AuthEvent.Error -> { - Toast.makeText( - context, - event.error.toString(context), - Toast.LENGTH_LONG - ).show() - } - } - } - LoginScreen( - state = state, - onAction = { action -> - viewModel.onAction(action) - } - ) - } - composable( - deepLinks = listOf( - navDeepLink( - basePath = "github-notifier://auth-callback" - ) - ) - ) { - val code = it.toRoute().code - val state = it.toRoute().state - Log.d("MainActivity", "Code: $code") - Log.d("MainActivity", "State: $state") - ConnectingToGitHubScreen() - } - } - } + MainAppContent() + } + } + } +} + +@Composable +private fun MainAppContent() { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + val navController = rememberNavController() + + NavHost( + navController = navController, + startDestination = LoginScreen, + modifier = Modifier.padding(innerPadding) + ) { + composable { + LoginRoute() + } + + composable( + deepLinks = listOf( + navDeepLink( + basePath = "github-notifier://auth-callback" + ) + ) + ) { + val args = it.toRoute() + ConnectingToGitHubRoute(code = args.code) } } } diff --git a/app/src/main/java/com/notifier/app/auth/data/networking/RemoteAuthTokenDataSource.kt b/app/src/main/java/com/notifier/app/auth/data/networking/RemoteAuthTokenDataSource.kt index d55c47e..98aaa4a 100644 --- a/app/src/main/java/com/notifier/app/auth/data/networking/RemoteAuthTokenDataSource.kt +++ b/app/src/main/java/com/notifier/app/auth/data/networking/RemoteAuthTokenDataSource.kt @@ -9,8 +9,8 @@ import com.notifier.app.core.domain.util.NetworkError import com.notifier.app.core.domain.util.Result import com.notifier.app.core.domain.util.map import io.ktor.client.HttpClient -import io.ktor.client.request.get import io.ktor.client.request.parameter +import io.ktor.client.request.post import javax.inject.Inject class RemoteAuthTokenDataSource @Inject constructor( @@ -22,7 +22,7 @@ class RemoteAuthTokenDataSource @Inject constructor( code: String, ): Result { return safeCall { - httpClient.get( + httpClient.post( urlString = "https://github.com/login/oauth/access_token" ) { parameter("client_id", clientId) diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubEvent.kt new file mode 100644 index 0000000..b32eb37 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubEvent.kt @@ -0,0 +1,7 @@ +package com.notifier.app.auth.presentation.connecting + +import com.notifier.app.core.domain.util.NetworkError + +sealed interface ConnectingToGitHubEvent { + data class Error(val error: NetworkError) : ConnectingToGitHubEvent +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubRoute.kt new file mode 100644 index 0000000..696574d --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubRoute.kt @@ -0,0 +1,45 @@ +package com.notifier.app.auth.presentation.connecting + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.notifier.app.core.presentation.util.ObserveAsEvents +import com.notifier.app.core.presentation.util.toString +import kotlinx.serialization.Serializable + +@Serializable +data class ConnectingToGitHubScreen( + val code: String? = null, + val state: String? = null, +) + +@Composable +fun ConnectingToGitHubRoute( + code: String?, + viewModel: ConnectingToGitHubViewModel = hiltViewModel() +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(code) { + viewModel.getAuthToken(code) + } + + ObserveAsEvents(events = viewModel.events) { event -> + when (event) { + is ConnectingToGitHubEvent.Error -> { + Toast.makeText( + context, + event.error.toString(context), + Toast.LENGTH_LONG + ).show() + } + } + } + + ConnectingToGitHubScreen(state = state) +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt index 1301ccb..821f97a 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -15,6 +16,7 @@ import com.notifier.app.ui.theme.GitHubNotifierTheme @Composable fun ConnectingToGitHubScreen( + state: ConnectingToGitHubState, modifier: Modifier = Modifier, ) { Column( @@ -22,9 +24,25 @@ fun ConnectingToGitHubScreen( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = "Connecting to GitHub...", - ) + when (state.connectionState) { + ConnectionState.FETCHING_TOKEN -> { + Text(text = "Connecting to GitHub...") + } + ConnectionState.SAVING_TOKEN -> { + Text(text = "Saving user information...") + } + ConnectionState.SUCCESS -> { + Text(text = "Connected successfully!") + Button( + onClick = { /* TODO: Navigate to the next screen */ }, + ) { + Text(text = "Continue") + } + } + ConnectionState.FAILED -> { + Text(text = "Connection Failed. Please try again.") + } + } } } @@ -35,6 +53,7 @@ private fun ConnectingToGitHubScreenPreview() { GitHubNotifierTheme { Scaffold { innerPadding -> ConnectingToGitHubScreen( + state = ConnectingToGitHubState(), modifier = Modifier.padding(innerPadding) ) } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubState.kt b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubState.kt new file mode 100644 index 0000000..2fe756e --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubState.kt @@ -0,0 +1,17 @@ +package com.notifier.app.auth.presentation.connecting + +import androidx.compose.runtime.Immutable +import com.notifier.app.auth.domain.AuthToken + +@Immutable +data class ConnectingToGitHubState( + val connectionState: ConnectionState = ConnectionState.FETCHING_TOKEN, + val authToken: AuthToken? = null, +) + +enum class ConnectionState { + FETCHING_TOKEN, + SAVING_TOKEN, + SUCCESS, + FAILED, +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubViewModel.kt new file mode 100644 index 0000000..2b0a74e --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubViewModel.kt @@ -0,0 +1,67 @@ +package com.notifier.app.auth.presentation.connecting + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.notifier.app.BuildConfig +import com.notifier.app.auth.domain.AuthTokenDataSource +import com.notifier.app.core.domain.util.onError +import com.notifier.app.core.domain.util.onSuccess +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ConnectingToGitHubViewModel @Inject constructor( + private val authTokenDataSource: AuthTokenDataSource, +) : ViewModel() { + private val _state = MutableStateFlow(ConnectingToGitHubState()) + val state = _state + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), + ConnectingToGitHubState() + ) + + private val _events = Channel() + val events = _events.receiveAsFlow() + + fun getAuthToken(code: String?) { + if (code.isNullOrBlank()) { + _state.update { + it.copy( + connectionState = ConnectionState.FAILED + ) + } + return + } + + _state.update { + it.copy( + connectionState = ConnectionState.FETCHING_TOKEN + ) + } + + viewModelScope.launch { + authTokenDataSource.getAuthToken( + clientId = BuildConfig.CLIENT_ID, + clientSecret = BuildConfig.CLIENT_SECRET, + code = code + ).onSuccess { authToken -> + _state.update { + it.copy( + connectionState = ConnectionState.SAVING_TOKEN, + authToken = authToken + ) + } + }.onError { error -> + _events.send(ConnectingToGitHubEvent.Error(error)) + } + } + } +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/AuthAction.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/AuthAction.kt similarity index 62% rename from app/src/main/java/com/notifier/app/auth/presentation/AuthAction.kt rename to app/src/main/java/com/notifier/app/auth/presentation/login/AuthAction.kt index 57ed033..a762982 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/AuthAction.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/AuthAction.kt @@ -1,4 +1,4 @@ -package com.notifier.app.auth.presentation +package com.notifier.app.auth.presentation.login sealed interface AuthAction { data object OnLoginButtonClick : AuthAction diff --git a/app/src/main/java/com/notifier/app/auth/presentation/AuthEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/AuthEvent.kt similarity index 74% rename from app/src/main/java/com/notifier/app/auth/presentation/AuthEvent.kt rename to app/src/main/java/com/notifier/app/auth/presentation/login/AuthEvent.kt index 6c21299..886ea8c 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/AuthEvent.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/AuthEvent.kt @@ -1,4 +1,4 @@ -package com.notifier.app.auth.presentation +package com.notifier.app.auth.presentation.login import com.notifier.app.core.domain.util.NetworkError diff --git a/app/src/main/java/com/notifier/app/auth/presentation/AuthState.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/AuthState.kt similarity index 80% rename from app/src/main/java/com/notifier/app/auth/presentation/AuthState.kt rename to app/src/main/java/com/notifier/app/auth/presentation/login/AuthState.kt index 1ee1a5f..c487c4a 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/AuthState.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/AuthState.kt @@ -1,4 +1,4 @@ -package com.notifier.app.auth.presentation +package com.notifier.app.auth.presentation.login import androidx.compose.runtime.Immutable import com.notifier.app.auth.domain.AuthToken diff --git a/app/src/main/java/com/notifier/app/auth/presentation/AuthViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/AuthViewModel.kt similarity index 95% rename from app/src/main/java/com/notifier/app/auth/presentation/AuthViewModel.kt rename to app/src/main/java/com/notifier/app/auth/presentation/login/AuthViewModel.kt index 2b8909e..a716eb5 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/AuthViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/AuthViewModel.kt @@ -1,4 +1,4 @@ -package com.notifier.app.auth.presentation +package com.notifier.app.auth.presentation.login import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt new file mode 100644 index 0000000..a9f73eb --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt @@ -0,0 +1,40 @@ +package com.notifier.app.auth.presentation.login + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.notifier.app.core.presentation.util.ObserveAsEvents +import com.notifier.app.core.presentation.util.toString +import kotlinx.serialization.Serializable + +@Serializable +data object LoginScreen + +@Composable +fun LoginRoute( + viewModel : AuthViewModel = hiltViewModel() +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + + ObserveAsEvents(events = viewModel.events) { event -> + when (event) { + is AuthEvent.Error -> { + Toast.makeText( + context, + event.error.toString(context), + Toast.LENGTH_LONG + ).show() + } + } + } + + LoginScreen( + state = state, + onAction = { action -> viewModel.onAction(action) } + ) +} + diff --git a/app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt similarity index 90% rename from app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt rename to app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt index 3e6b03d..be55cd3 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt @@ -1,4 +1,4 @@ -package com.notifier.app.auth.presentation +package com.notifier.app.auth.presentation.login import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -10,7 +10,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewDynamicColors import androidx.compose.ui.tooling.preview.PreviewLightDark -import com.notifier.app.auth.presentation.components.LoginButton +import com.notifier.app.auth.presentation.login.components.LoginButton import com.notifier.app.ui.theme.GitHubNotifierTheme @Composable diff --git a/app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt similarity index 93% rename from app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt rename to app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt index 664896a..05f213f 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt @@ -1,4 +1,4 @@ -package com.notifier.app.auth.presentation.components +package com.notifier.app.auth.presentation.login.components import androidx.compose.material3.Button import androidx.compose.material3.Text diff --git a/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/ConnectingToGitHubScreen.kt b/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/ConnectingToGitHubScreen.kt deleted file mode 100644 index 3921109..0000000 --- a/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/ConnectingToGitHubScreen.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.notifier.app.core.presentation.navigation.routes - -import kotlinx.serialization.Serializable - -@Serializable -data class ConnectingToGitHubScreen( - val code: String? = null, - val state: String? = null, -) diff --git a/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/LoginScreen.kt b/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/LoginScreen.kt deleted file mode 100644 index 9898070..0000000 --- a/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/LoginScreen.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.notifier.app.core.presentation.navigation.routes - -import kotlinx.serialization.Serializable - -@Serializable -data object LoginScreen diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9519b1b..7606708 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ mockk = "1.13.8" ksp = "2.0.21-1.0.27" daggerHilt = "2.56.1" navigation = "2.8.9" +hiltNavigationCompose = "1.2.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -43,6 +44,7 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } dagger-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "daggerHilt" } dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "daggerHilt" } +hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 2bb8b02d917f8dbd6a189637b5b6193d82f8656d Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 29 Apr 2025 13:47:52 +0530 Subject: [PATCH 09/16] Rename authentication-related classes and update references to improve clarity and consistency --- .../java/com/notifier/app/MainActivity.kt | 12 +++++----- .../connecting/ConnectingToGitHubEvent.kt | 7 ------ .../connecting/ConnectingToGitHubState.kt | 17 ------------- .../app/auth/presentation/login/AuthAction.kt | 5 ---- .../auth/presentation/login/LoginAction.kt | 5 ++++ .../login/{AuthEvent.kt => LoginEvent.kt} | 4 ++-- .../app/auth/presentation/login/LoginRoute.kt | 4 ++-- .../auth/presentation/login/LoginScreen.kt | 6 ++--- .../login/{AuthState.kt => LoginState.kt} | 2 +- .../{AuthViewModel.kt => LoginViewModel.kt} | 14 +++++------ .../app/auth/presentation/setup/SetupEvent.kt | 7 ++++++ .../SetupRoute.kt} | 12 +++++----- .../SetupScreen.kt} | 22 ++++++++--------- .../app/auth/presentation/setup/SetupState.kt | 24 +++++++++++++++++++ .../SetupViewModel.kt} | 18 +++++++------- 15 files changed, 82 insertions(+), 77 deletions(-) delete mode 100644 app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubEvent.kt delete mode 100644 app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubState.kt delete mode 100644 app/src/main/java/com/notifier/app/auth/presentation/login/AuthAction.kt create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/login/LoginAction.kt rename app/src/main/java/com/notifier/app/auth/presentation/login/{AuthEvent.kt => LoginEvent.kt} (54%) rename app/src/main/java/com/notifier/app/auth/presentation/login/{AuthState.kt => LoginState.kt} (90%) rename app/src/main/java/com/notifier/app/auth/presentation/login/{AuthViewModel.kt => LoginViewModel.kt} (68%) create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt rename app/src/main/java/com/notifier/app/auth/presentation/{connecting/ConnectingToGitHubRoute.kt => setup/SetupRoute.kt} (79%) rename app/src/main/java/com/notifier/app/auth/presentation/{connecting/ConnectingToGitHubScreen.kt => setup/SetupScreen.kt} (76%) create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt rename app/src/main/java/com/notifier/app/auth/presentation/{connecting/ConnectingToGitHubViewModel.kt => setup/SetupViewModel.kt} (75%) diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index 03302c9..df4c200 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -14,8 +14,8 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navDeepLink import androidx.navigation.toRoute -import com.notifier.app.auth.presentation.connecting.ConnectingToGitHubRoute -import com.notifier.app.auth.presentation.connecting.ConnectingToGitHubScreen +import com.notifier.app.auth.presentation.setup.SetupRoute +import com.notifier.app.auth.presentation.setup.SetupScreen import com.notifier.app.auth.presentation.login.LoginRoute import com.notifier.app.auth.presentation.login.LoginScreen import com.notifier.app.ui.theme.GitHubNotifierTheme @@ -48,15 +48,15 @@ private fun MainAppContent() { LoginRoute() } - composable( + composable( deepLinks = listOf( - navDeepLink( + navDeepLink( basePath = "github-notifier://auth-callback" ) ) ) { - val args = it.toRoute() - ConnectingToGitHubRoute(code = args.code) + val args = it.toRoute() + SetupRoute(code = args.code) } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubEvent.kt deleted file mode 100644 index b32eb37..0000000 --- a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubEvent.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.notifier.app.auth.presentation.connecting - -import com.notifier.app.core.domain.util.NetworkError - -sealed interface ConnectingToGitHubEvent { - data class Error(val error: NetworkError) : ConnectingToGitHubEvent -} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubState.kt b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubState.kt deleted file mode 100644 index 2fe756e..0000000 --- a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubState.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.notifier.app.auth.presentation.connecting - -import androidx.compose.runtime.Immutable -import com.notifier.app.auth.domain.AuthToken - -@Immutable -data class ConnectingToGitHubState( - val connectionState: ConnectionState = ConnectionState.FETCHING_TOKEN, - val authToken: AuthToken? = null, -) - -enum class ConnectionState { - FETCHING_TOKEN, - SAVING_TOKEN, - SUCCESS, - FAILED, -} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/AuthAction.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/AuthAction.kt deleted file mode 100644 index a762982..0000000 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/AuthAction.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.notifier.app.auth.presentation.login - -sealed interface AuthAction { - data object OnLoginButtonClick : AuthAction -} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginAction.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginAction.kt new file mode 100644 index 0000000..32ac54b --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginAction.kt @@ -0,0 +1,5 @@ +package com.notifier.app.auth.presentation.login + +sealed interface LoginAction { + data object OnLoginButtonClick : LoginAction +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/AuthEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt similarity index 54% rename from app/src/main/java/com/notifier/app/auth/presentation/login/AuthEvent.kt rename to app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt index 886ea8c..50af57b 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/AuthEvent.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt @@ -2,6 +2,6 @@ package com.notifier.app.auth.presentation.login import com.notifier.app.core.domain.util.NetworkError -sealed interface AuthEvent { - data class Error(val error: NetworkError) : AuthEvent +sealed interface LoginEvent { + data class Error(val error: NetworkError) : LoginEvent } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt index a9f73eb..ab64ee3 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt @@ -15,14 +15,14 @@ data object LoginScreen @Composable fun LoginRoute( - viewModel : AuthViewModel = hiltViewModel() + viewModel : LoginViewModel = hiltViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current ObserveAsEvents(events = viewModel.events) { event -> when (event) { - is AuthEvent.Error -> { + is LoginEvent.Error -> { Toast.makeText( context, event.error.toString(context), diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt index be55cd3..93727a8 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt @@ -15,8 +15,8 @@ import com.notifier.app.ui.theme.GitHubNotifierTheme @Composable fun LoginScreen( - state: AuthState, - onAction: (AuthAction) -> Unit, + state: LoginState, + onAction: (LoginAction) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -35,7 +35,7 @@ private fun LoginScreenPreview() { GitHubNotifierTheme { Scaffold { innerPadding -> LoginScreen( - state = AuthState(), + state = LoginState(), onAction = {}, modifier = Modifier.padding(innerPadding) ) diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/AuthState.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginState.kt similarity index 90% rename from app/src/main/java/com/notifier/app/auth/presentation/login/AuthState.kt rename to app/src/main/java/com/notifier/app/auth/presentation/login/LoginState.kt index c487c4a..0fc2e8e 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/AuthState.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginState.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Immutable import com.notifier.app.auth.domain.AuthToken @Immutable -data class AuthState( +data class LoginState( val isLoading: Boolean = false, val authToken: AuthToken? = null, ) diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/AuthViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt similarity index 68% rename from app/src/main/java/com/notifier/app/auth/presentation/login/AuthViewModel.kt rename to app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt index a716eb5..6ea57d6 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/AuthViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt @@ -12,23 +12,21 @@ import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel -class AuthViewModel @Inject constructor( - private val authTokenDataSource: AuthTokenDataSource, -) : ViewModel() { - private val _state = MutableStateFlow(AuthState()) +class LoginViewModel @Inject constructor() : ViewModel() { + private val _state = MutableStateFlow(LoginState()) val state = _state .stateIn( viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), - AuthState() + LoginState() ) - private val _events = Channel() + private val _events = Channel() val events = _events.receiveAsFlow() - fun onAction(action: AuthAction) { + fun onAction(action: LoginAction) { when (action) { - is AuthAction.OnLoginButtonClick -> {} + is LoginAction.OnLoginButtonClick -> {} } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt new file mode 100644 index 0000000..b2327ce --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt @@ -0,0 +1,7 @@ +package com.notifier.app.auth.presentation.setup + +import com.notifier.app.core.domain.util.NetworkError + +sealed interface SetupEvent { + data class Error(val error: NetworkError) : SetupEvent +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt similarity index 79% rename from app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubRoute.kt rename to app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt index 696574d..1897434 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubRoute.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt @@ -1,4 +1,4 @@ -package com.notifier.app.auth.presentation.connecting +package com.notifier.app.auth.presentation.setup import android.widget.Toast import androidx.compose.runtime.Composable @@ -12,15 +12,15 @@ import com.notifier.app.core.presentation.util.toString import kotlinx.serialization.Serializable @Serializable -data class ConnectingToGitHubScreen( +data class SetupScreen( val code: String? = null, val state: String? = null, ) @Composable -fun ConnectingToGitHubRoute( +fun SetupRoute( code: String?, - viewModel: ConnectingToGitHubViewModel = hiltViewModel() + viewModel: SetupViewModel = hiltViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current @@ -31,7 +31,7 @@ fun ConnectingToGitHubRoute( ObserveAsEvents(events = viewModel.events) { event -> when (event) { - is ConnectingToGitHubEvent.Error -> { + is SetupEvent.Error -> { Toast.makeText( context, event.error.toString(context), @@ -41,5 +41,5 @@ fun ConnectingToGitHubRoute( } } - ConnectingToGitHubScreen(state = state) + SetupScreen(state = state) } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt similarity index 76% rename from app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt rename to app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt index 821f97a..3a068b3 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt @@ -1,4 +1,4 @@ -package com.notifier.app.auth.presentation.connecting +package com.notifier.app.auth.presentation.setup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -15,8 +15,8 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark import com.notifier.app.ui.theme.GitHubNotifierTheme @Composable -fun ConnectingToGitHubScreen( - state: ConnectingToGitHubState, +fun SetupScreen( + state: SetupState, modifier: Modifier = Modifier, ) { Column( @@ -24,14 +24,14 @@ fun ConnectingToGitHubScreen( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - when (state.connectionState) { - ConnectionState.FETCHING_TOKEN -> { + when (state.setupStep) { + SetupStep.FETCHING_TOKEN -> { Text(text = "Connecting to GitHub...") } - ConnectionState.SAVING_TOKEN -> { + SetupStep.SAVING_TOKEN -> { Text(text = "Saving user information...") } - ConnectionState.SUCCESS -> { + SetupStep.SUCCESS -> { Text(text = "Connected successfully!") Button( onClick = { /* TODO: Navigate to the next screen */ }, @@ -39,7 +39,7 @@ fun ConnectingToGitHubScreen( Text(text = "Continue") } } - ConnectionState.FAILED -> { + SetupStep.FAILED -> { Text(text = "Connection Failed. Please try again.") } } @@ -49,11 +49,11 @@ fun ConnectingToGitHubScreen( @PreviewLightDark @PreviewDynamicColors @Composable -private fun ConnectingToGitHubScreenPreview() { +private fun SetupScreenPreview() { GitHubNotifierTheme { Scaffold { innerPadding -> - ConnectingToGitHubScreen( - state = ConnectingToGitHubState(), + SetupScreen( + state = SetupState(), modifier = Modifier.padding(innerPadding) ) } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt new file mode 100644 index 0000000..6b12d93 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt @@ -0,0 +1,24 @@ +package com.notifier.app.auth.presentation.setup + +import androidx.compose.runtime.Immutable +import com.notifier.app.auth.domain.AuthToken + +@Immutable +data class SetupState( + val setupStep: SetupStep = SetupStep.FETCHING_TOKEN, + val authToken: AuthToken? = null, +) + +enum class SetupStep { + /** Currently retrieving the OAuth token from the backend. */ + FETCHING_TOKEN, + + /** OAuth token has been retrieved, now saving locally. */ + SAVING_TOKEN, + + /** Token saved successfully and setup is complete. */ + SUCCESS, + + /** An error occurred during setup. */ + FAILED +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt similarity index 75% rename from app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubViewModel.kt rename to app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt index 2b0a74e..fde0011 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt @@ -1,4 +1,4 @@ -package com.notifier.app.auth.presentation.connecting +package com.notifier.app.auth.presentation.setup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -17,25 +17,25 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class ConnectingToGitHubViewModel @Inject constructor( +class SetupViewModel @Inject constructor( private val authTokenDataSource: AuthTokenDataSource, ) : ViewModel() { - private val _state = MutableStateFlow(ConnectingToGitHubState()) + private val _state = MutableStateFlow(SetupState()) val state = _state .stateIn( viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), - ConnectingToGitHubState() + SetupState() ) - private val _events = Channel() + private val _events = Channel() val events = _events.receiveAsFlow() fun getAuthToken(code: String?) { if (code.isNullOrBlank()) { _state.update { it.copy( - connectionState = ConnectionState.FAILED + setupStep = SetupStep.FAILED ) } return @@ -43,7 +43,7 @@ class ConnectingToGitHubViewModel @Inject constructor( _state.update { it.copy( - connectionState = ConnectionState.FETCHING_TOKEN + setupStep = SetupStep.FETCHING_TOKEN ) } @@ -55,12 +55,12 @@ class ConnectingToGitHubViewModel @Inject constructor( ).onSuccess { authToken -> _state.update { it.copy( - connectionState = ConnectionState.SAVING_TOKEN, + setupStep = SetupStep.SAVING_TOKEN, authToken = authToken ) } }.onError { error -> - _events.send(ConnectingToGitHubEvent.Error(error)) + _events.send(SetupEvent.Error(error)) } } } From b710a5e63b748a20c874f3d7675617504e6f7af5 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 29 Apr 2025 13:49:56 +0530 Subject: [PATCH 10/16] Update SetupStep enum documentation to clarify token retrieval process from GitHub --- .../com/notifier/app/auth/presentation/setup/SetupState.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt index 6b12d93..a4faf00 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt @@ -10,10 +10,10 @@ data class SetupState( ) enum class SetupStep { - /** Currently retrieving the OAuth token from the backend. */ + /** Currently retrieving the access token from GitHub. */ FETCHING_TOKEN, - /** OAuth token has been retrieved, now saving locally. */ + /** Access token has been retrieved, now saving locally. */ SAVING_TOKEN, /** Token saved successfully and setup is complete. */ From 6e396b232d20c60452d7587e9b2f31a668e36de9 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 1 May 2025 11:36:22 +0530 Subject: [PATCH 11/16] Fix daggerHilt variable name --- gradle/libs.versions.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4b6fb8e..f4d99a1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,9 +44,9 @@ truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } -dagger-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "dagger-hilt" } -dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "dagger-hilt" } -dagger-hilt-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "dagger-hilt" } +dagger-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "daggerHilt" } +dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "daggerHilt" } +dagger-hilt-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "daggerHilt" } hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } [plugins] From bbb01509d2fe48353cf3f7891e118610e4650b2a Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 1 May 2025 12:38:28 +0530 Subject: [PATCH 12/16] Refactor error handling in DataStore operations and update related classes for improved clarity --- .../java/com/notifier/app/MainActivity.kt | 4 +- .../app/auth/domain/AuthTokenDataSource.kt | 4 +- .../app/auth/presentation/login/LoginRoute.kt | 2 +- .../auth/presentation/login/LoginViewModel.kt | 1 - .../app/auth/presentation/setup/SetupEvent.kt | 4 +- .../app/auth/presentation/setup/SetupRoute.kt | 16 +++---- .../auth/presentation/setup/SetupScreen.kt | 3 ++ .../auth/presentation/setup/SetupViewModel.kt | 46 +++++++++++++------ .../core/data/persistence/DataStoreManager.kt | 23 ++++------ .../data/persistence/runDataStoreCatching.kt | 37 +++++++++++++++ .../app/core/domain/util/PersistenceError.kt | 7 +++ .../core/presentation/util/ObserveAsEvents.kt | 2 +- .../util/PersistenceErrorToString.kt | 14 ++++++ .../app/core/presentation/util/showToast.kt | 15 ++++++ .../domain/NotificationDataSource.kt | 8 ++-- app/src/main/res/values/strings.xml | 5 +- 16 files changed, 142 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/com/notifier/app/core/data/persistence/runDataStoreCatching.kt create mode 100644 app/src/main/java/com/notifier/app/core/domain/util/PersistenceError.kt create mode 100644 app/src/main/java/com/notifier/app/core/presentation/util/PersistenceErrorToString.kt create mode 100644 app/src/main/java/com/notifier/app/core/presentation/util/showToast.kt diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index df4c200..dd9830f 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -14,10 +14,10 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navDeepLink import androidx.navigation.toRoute -import com.notifier.app.auth.presentation.setup.SetupRoute -import com.notifier.app.auth.presentation.setup.SetupScreen import com.notifier.app.auth.presentation.login.LoginRoute import com.notifier.app.auth.presentation.login.LoginScreen +import com.notifier.app.auth.presentation.setup.SetupRoute +import com.notifier.app.auth.presentation.setup.SetupScreen import com.notifier.app.ui.theme.GitHubNotifierTheme import dagger.hilt.android.AndroidEntryPoint diff --git a/app/src/main/java/com/notifier/app/auth/domain/AuthTokenDataSource.kt b/app/src/main/java/com/notifier/app/auth/domain/AuthTokenDataSource.kt index eb69669..f1800c3 100644 --- a/app/src/main/java/com/notifier/app/auth/domain/AuthTokenDataSource.kt +++ b/app/src/main/java/com/notifier/app/auth/domain/AuthTokenDataSource.kt @@ -1,6 +1,6 @@ package com.notifier.app.auth.domain -import com.notifier.app.core.domain.util.NetworkError +import com.notifier.app.core.domain.util.Error import com.notifier.app.core.domain.util.Result interface AuthTokenDataSource { @@ -8,5 +8,5 @@ interface AuthTokenDataSource { clientId: String, clientSecret: String, code: String, - ): Result + ): Result } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt index ab64ee3..5afc90f 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt @@ -15,7 +15,7 @@ data object LoginScreen @Composable fun LoginRoute( - viewModel : LoginViewModel = hiltViewModel() + viewModel: LoginViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt index 6ea57d6..4ce1169 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt @@ -2,7 +2,6 @@ package com.notifier.app.auth.presentation.login import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.notifier.app.auth.domain.AuthTokenDataSource import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt index b2327ce..564b867 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt @@ -1,7 +1,9 @@ package com.notifier.app.auth.presentation.setup import com.notifier.app.core.domain.util.NetworkError +import com.notifier.app.core.domain.util.PersistenceError sealed interface SetupEvent { - data class Error(val error: NetworkError) : SetupEvent + data class NetworkErrorEvent(val error: NetworkError) : SetupEvent + data class PersistenceErrorEvent(val error: PersistenceError) : SetupEvent } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt index 1897434..26f44c3 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt @@ -1,6 +1,5 @@ package com.notifier.app.auth.presentation.setup -import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -8,6 +7,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.notifier.app.core.presentation.util.ObserveAsEvents +import com.notifier.app.core.presentation.util.showToast import com.notifier.app.core.presentation.util.toString import kotlinx.serialization.Serializable @@ -20,7 +20,7 @@ data class SetupScreen( @Composable fun SetupRoute( code: String?, - viewModel: SetupViewModel = hiltViewModel() + viewModel: SetupViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current @@ -31,12 +31,12 @@ fun SetupRoute( ObserveAsEvents(events = viewModel.events) { event -> when (event) { - is SetupEvent.Error -> { - Toast.makeText( - context, - event.error.toString(context), - Toast.LENGTH_LONG - ).show() + is SetupEvent.PersistenceErrorEvent -> { + showToast(context, event.error.toString(context)) + } + + is SetupEvent.NetworkErrorEvent -> { + showToast(context, event.error.toString(context)) } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt index 3a068b3..0549313 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt @@ -28,9 +28,11 @@ fun SetupScreen( SetupStep.FETCHING_TOKEN -> { Text(text = "Connecting to GitHub...") } + SetupStep.SAVING_TOKEN -> { Text(text = "Saving user information...") } + SetupStep.SUCCESS -> { Text(text = "Connected successfully!") Button( @@ -39,6 +41,7 @@ fun SetupScreen( Text(text = "Continue") } } + SetupStep.FAILED -> { Text(text = "Connection Failed. Please try again.") } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt index fde0011..00f6c41 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt @@ -4,6 +4,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.notifier.app.BuildConfig import com.notifier.app.auth.domain.AuthTokenDataSource +import com.notifier.app.core.data.persistence.DataStoreManager +import com.notifier.app.core.domain.util.NetworkError +import com.notifier.app.core.domain.util.PersistenceError import com.notifier.app.core.domain.util.onError import com.notifier.app.core.domain.util.onSuccess import dagger.hilt.android.lifecycle.HiltViewModel @@ -19,33 +22,31 @@ import javax.inject.Inject @HiltViewModel class SetupViewModel @Inject constructor( private val authTokenDataSource: AuthTokenDataSource, + private val dataStoreManager: DataStoreManager, ) : ViewModel() { + private val _state = MutableStateFlow(SetupState()) val state = _state .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), - SetupState() + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), + initialValue = SetupState() ) private val _events = Channel() val events = _events.receiveAsFlow() + /** + * Initiates the process of exchanging the authorization code for an access token. + * Updates state accordingly and emits error events when necessary. + */ fun getAuthToken(code: String?) { if (code.isNullOrBlank()) { - _state.update { - it.copy( - setupStep = SetupStep.FAILED - ) - } + _state.update { it.copy(setupStep = SetupStep.FAILED) } return } - _state.update { - it.copy( - setupStep = SetupStep.FETCHING_TOKEN - ) - } + _state.update { it.copy(setupStep = SetupStep.FETCHING_TOKEN) } viewModelScope.launch { authTokenDataSource.getAuthToken( @@ -59,8 +60,25 @@ class SetupViewModel @Inject constructor( authToken = authToken ) } + saveAuthToken(authToken.accessToken) + }.onError { error -> + _state.update { it.copy(setupStep = SetupStep.FAILED) } + _events.send(SetupEvent.NetworkErrorEvent(error as NetworkError)) + } + } + } + + /** + * Saves the access token to DataStore and updates setup state. + * Emits an error event if saving fails. + */ + private fun saveAuthToken(token: String) { + viewModelScope.launch { + dataStoreManager.setAccessToken(token).onSuccess { + _state.update { it.copy(setupStep = SetupStep.SUCCESS) } }.onError { error -> - _events.send(SetupEvent.Error(error)) + _state.update { it.copy(setupStep = SetupStep.FAILED) } + _events.send(SetupEvent.PersistenceErrorEvent(error as PersistenceError)) } } } diff --git a/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt b/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt index 224a47b..77749d2 100644 --- a/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt +++ b/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt @@ -3,6 +3,9 @@ package com.notifier.app.core.data.persistence import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit +import com.notifier.app.core.domain.util.Error +import com.notifier.app.core.domain.util.PersistenceError +import com.notifier.app.core.domain.util.Result import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -10,8 +13,6 @@ import javax.inject.Singleton /** * Manages access to DataStore for reading and writing app preferences. - * - * @property dataStore the injected instance of DataStore for preferences. */ @Singleton class DataStoreManager @Inject constructor( @@ -21,25 +22,21 @@ class DataStoreManager @Inject constructor( /** * Retrieves the stored access token from DataStore. * - * @return the access token if found, or an empty string if not set or error occurs. + * @return [Result.Success] with the token, or [Result.Error] with a [PersistenceError]. */ - suspend fun getAccessToken(): String { - return try { - dataStore.data - .map { preferences -> preferences[PreferenceKeys.ACCESS_TOKEN] ?: "" } - .first() - } catch (e: Exception) { - e.printStackTrace() - "" - } + suspend fun getAccessToken(): Result = runDataStoreCatching { + dataStore.data + .map { preferences -> preferences[PreferenceKeys.ACCESS_TOKEN] ?: "" } + .first() } /** * Saves the given access token to DataStore. * * @param accessToken the token to store. + * @return [Result.Success] if stored successfully, or [Result.Error] with a [PersistenceError]. */ - suspend fun setAccessToken(accessToken: String) { + suspend fun setAccessToken(accessToken: String): Result = runDataStoreCatching { dataStore.edit { preferences -> preferences[PreferenceKeys.ACCESS_TOKEN] = accessToken } diff --git a/app/src/main/java/com/notifier/app/core/data/persistence/runDataStoreCatching.kt b/app/src/main/java/com/notifier/app/core/data/persistence/runDataStoreCatching.kt new file mode 100644 index 0000000..f06cc74 --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/data/persistence/runDataStoreCatching.kt @@ -0,0 +1,37 @@ +package com.notifier.app.core.data.persistence + +import com.notifier.app.core.domain.util.PersistenceError +import com.notifier.app.core.domain.util.Result +import kotlinx.coroutines.ensureActive +import kotlinx.io.IOException +import kotlinx.serialization.SerializationException +import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.coroutineContext + +/** + * Executes a suspend block safely, returning a [Result] that wraps either the success value + * or a [PersistenceError] in case of exception. + * + * Use this to wrap DataStore operations that might fail (e.g., IO, serialization). + * + * @param block the suspend lambda to execute. + * @return a [Result.Success] if the block executes without exception, + * or a [Result.Error] with appropriate [PersistenceError]. + */ +suspend inline fun runDataStoreCatching( + crossinline block: suspend () -> T, +): Result { + return try { + val result = block() + Result.Success(result) + } catch (e: CancellationException) { + throw e // Let coroutine cancellation propagate + } catch (e: IOException) { + Result.Error(PersistenceError.IO) + } catch (e: SerializationException) { + Result.Error(PersistenceError.SERIALIZATION) + } catch (e: Exception) { + coroutineContext.ensureActive() + Result.Error(PersistenceError.UNKNOWN) + } +} diff --git a/app/src/main/java/com/notifier/app/core/domain/util/PersistenceError.kt b/app/src/main/java/com/notifier/app/core/domain/util/PersistenceError.kt new file mode 100644 index 0000000..23ffd9b --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/domain/util/PersistenceError.kt @@ -0,0 +1,7 @@ +package com.notifier.app.core.domain.util + +enum class PersistenceError : Error { + IO, + SERIALIZATION, + UNKNOWN, +} diff --git a/app/src/main/java/com/notifier/app/core/presentation/util/ObserveAsEvents.kt b/app/src/main/java/com/notifier/app/core/presentation/util/ObserveAsEvents.kt index 37ae42f..5053e1d 100644 --- a/app/src/main/java/com/notifier/app/core/presentation/util/ObserveAsEvents.kt +++ b/app/src/main/java/com/notifier/app/core/presentation/util/ObserveAsEvents.kt @@ -14,7 +14,7 @@ fun ObserveAsEvents( events: Flow, key1: Any? = null, key2: Any? = null, - onEvent: (T) -> Unit + onEvent: (T) -> Unit, ) { val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(lifecycleOwner.lifecycle, key1, key2) { diff --git a/app/src/main/java/com/notifier/app/core/presentation/util/PersistenceErrorToString.kt b/app/src/main/java/com/notifier/app/core/presentation/util/PersistenceErrorToString.kt new file mode 100644 index 0000000..bc3e509 --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/util/PersistenceErrorToString.kt @@ -0,0 +1,14 @@ +package com.notifier.app.core.presentation.util + +import android.content.Context +import com.notifier.app.R +import com.notifier.app.core.domain.util.PersistenceError + +fun PersistenceError.toString(context: Context): String { + val resId = when (this) { + PersistenceError.IO -> R.string.error_io + PersistenceError.SERIALIZATION -> R.string.error_serialization + PersistenceError.UNKNOWN -> R.string.error_unknown + } + return context.getString(resId) +} diff --git a/app/src/main/java/com/notifier/app/core/presentation/util/showToast.kt b/app/src/main/java/com/notifier/app/core/presentation/util/showToast.kt new file mode 100644 index 0000000..114d53e --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/util/showToast.kt @@ -0,0 +1,15 @@ +package com.notifier.app.core.presentation.util + +import android.content.Context +import android.widget.Toast + +/** + * Extension function to show a Toast message in the given context. + * + * @param context the context in which the Toast should be shown. + * @param message the message to be shown in the Toast. + * @param duration the duration for which the Toast should be shown. Defaults to Toast.LENGTH_LONG. + */ +fun showToast(context: Context, message: String, duration: Int = Toast.LENGTH_LONG) { + Toast.makeText(context, message, duration).show() +} diff --git a/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt b/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt index d1b7f12..d66a59d 100644 --- a/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt +++ b/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt @@ -1,6 +1,6 @@ package com.notifier.app.notification.domain -import com.notifier.app.core.domain.util.NetworkError +import com.notifier.app.core.domain.util.Error import com.notifier.app.core.domain.util.Result /** @@ -17,9 +17,9 @@ interface NotificationDataSource { * This function is a suspend function, meaning it should be called from a coroutine * or another suspending function. It returns a [Result] that either contains: * - A **successful** list of [Notification] objects. - * - A **failure** with an appropriate [NetworkError]. + * - A **failure** with an appropriate [Error]. * - * @return A [Result] containing either a list of notifications or a network error. + * @return A [Result] containing either a list of notifications or an error. */ - suspend fun getNotifications(): Result, NetworkError> + suspend fun getNotifications(): Result, Error> } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bccf8e3..f57a1d9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,6 +3,7 @@ The request timed out. Oops, it seems like your quota is exceeded. Couldn\'t connect to server, please check your internet connection. - Something went wrong. Please try again later. - Couldn\'t serialize or deserialize data. + An unexpected error occurred. Please try again later. + There was an issue with the data format. Please try again later. + Something went wrong while accessing data. Please try again later. From 0d604e8bf8ca6169961165a1f7f44a5acd21425b Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 1 May 2025 13:12:00 +0530 Subject: [PATCH 13/16] Update test script to include verbose output for connectedAndroidTest --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b004171..0ec6afd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -76,4 +76,5 @@ jobs: force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: ./gradlew connectedAndroidTest --daemon && killall -INT crashpad_handler || true + script: ./gradlew connectedAndroidTest --info --daemon && killall -INT crashpad_handler + From de4f8624e1f6de0911ffcfd35e71922dbe817d05 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 1 May 2025 13:24:33 +0530 Subject: [PATCH 14/16] Enhance test script to handle emulator termination on test failure --- .github/workflows/test.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0ec6afd..4baa6fe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -76,5 +76,11 @@ jobs: force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: ./gradlew connectedAndroidTest --info --daemon && killall -INT crashpad_handler + script: | + ./gradlew connectedAndroidTest --daemon + if [ $? -ne 0 ]; then + echo "Tests failed. Terminating emulator." + killall -INT emulator || true + exit 1 + fi From a34fa01498a5e935dd87bffcecbe8664886e5e60 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 1 May 2025 13:30:37 +0530 Subject: [PATCH 15/16] Revert changes to test.yml --- .github/workflows/test.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4baa6fe..b004171 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -76,11 +76,4 @@ jobs: force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: | - ./gradlew connectedAndroidTest --daemon - if [ $? -ne 0 ]; then - echo "Tests failed. Terminating emulator." - killall -INT emulator || true - exit 1 - fi - + script: ./gradlew connectedAndroidTest --daemon && killall -INT crashpad_handler || true From fb5a0cdfd1cc3fb193c6f324b9e509872a638f2c Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 1 May 2025 13:31:04 +0530 Subject: [PATCH 16/16] Refactor DataStoreManager tests to assert Result type and improve clarity --- .../data/persistence/DataStoreManagerTest.kt | 34 ++++++++++++++----- .../core/data/persistence/DataStoreManager.kt | 1 - 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/app/src/androidTest/java/com/notifier/app/core/data/persistence/DataStoreManagerTest.kt b/app/src/androidTest/java/com/notifier/app/core/data/persistence/DataStoreManagerTest.kt index f9b22c2..7ed5369 100644 --- a/app/src/androidTest/java/com/notifier/app/core/data/persistence/DataStoreManagerTest.kt +++ b/app/src/androidTest/java/com/notifier/app/core/data/persistence/DataStoreManagerTest.kt @@ -1,6 +1,7 @@ package com.notifier.app.core.data.persistence import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.notifier.app.core.domain.util.Result import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest @@ -28,23 +29,31 @@ class DataStoreManagerTest { @Test fun testGetAccessToken_whenTokenExists_returnsToken() = runTest { val token = "persisted_token" - dataStoreManager.setAccessToken(token) + // Save the token + val result = dataStoreManager.setAccessToken(token) + assert(result is Result.Success) + // Retrieve the token val retrieved = dataStoreManager.getAccessToken() - assertEquals(token, retrieved) + assert(retrieved is Result.Success) + assertEquals(token, (retrieved as Result.Success).data) } @Test fun testGetAccessToken_whenTokenNotSet_returnsEmptyString() = runTest { - val token = dataStoreManager.getAccessToken() - assertEquals("", token) + val result = dataStoreManager.getAccessToken() + assert(result is Result.Success) + assertEquals("", (result as Result.Success).data) } @Test fun testSetAccessToken_withEmptyString_returnsEmptyString() = runTest { - dataStoreManager.setAccessToken("") + val result = dataStoreManager.setAccessToken("") + assert(result is Result.Success) + val token = dataStoreManager.getAccessToken() - assertEquals("", token) + assert(token is Result.Success) + assertEquals("", (token as Result.Success).data) } @Test @@ -52,10 +61,17 @@ class DataStoreManagerTest { val initialToken = "initial_token" val updatedToken = "updated_token" - dataStoreManager.setAccessToken(initialToken) - dataStoreManager.setAccessToken(updatedToken) + // Set initial token + var result = dataStoreManager.setAccessToken(initialToken) + assert(result is Result.Success) + + // Set updated token + result = dataStoreManager.setAccessToken(updatedToken) + assert(result is Result.Success) + // Retrieve the updated token val retrievedToken = dataStoreManager.getAccessToken() - assertEquals(updatedToken, retrievedToken) + assert(retrievedToken is Result.Success) + assertEquals(updatedToken, (retrievedToken as Result.Success).data) } } diff --git a/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt b/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt index 77749d2..7a34207 100644 --- a/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt +++ b/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt @@ -18,7 +18,6 @@ import javax.inject.Singleton class DataStoreManager @Inject constructor( private val dataStore: DataStore, ) { - /** * Retrieves the stored access token from DataStore. *