From 7f4949420ff1b6a8fc1b8e8746d3420bd13615df Mon Sep 17 00:00:00 2001 From: ShenLong Date: Wed, 17 Jun 2026 16:19:10 +0700 Subject: [PATCH] implement login/register --- .gitignore | 5 + app/build.gradle.kts | 13 + .../1.json | 54 +++++ app/src/main/AndroidManifest.xml | 7 +- .../longnh15/heimdall/HeimdallApplication.kt | 2 - .../com/longnh15/heimdall/MainActivity.kt | 24 +- .../com/longnh15/heimdall/MainViewModel.kt | 12 +- .../longnh15/heimdall/common/GlobalConfig.kt | 10 + .../longnh15/heimdall/common/TokenCrypto.kt | 55 +++++ .../heimdall/common/ValidationHelper.kt | 9 + .../data/repository/AuthRepositoryImpl.kt | 45 ++++ .../data/repository/BaseRepository.kt | 36 +++ .../data/source/local/LocalDataSource.kt | 36 ++- .../heimdall/data/source/remote/ApiService.kt | 17 ++ .../data/source/remote/ErrorBodyParser.kt | 18 ++ .../data/source/remote/dto/ErrorResponse.kt | 9 + .../data/source/remote/dto/LoginRequest.kt | 9 + .../data/source/remote/dto/LoginResponse.kt | 10 + .../data/source/remote/dto/RegisterRequest.kt | 10 + .../source/remote/dto/RegisterResponse.kt | 9 + .../com/longnh15/heimdall/di/AppModule.kt | 12 +- .../longnh15/heimdall/di/DatabaseModule.kt | 4 +- .../com/longnh15/heimdall/di/NetworkModule.kt | 27 +++ .../heimdall/domain/model/ApiResult.kt | 6 + .../heimdall/domain/model/AuthInfo.kt | 9 + .../domain/repository/AuthRepository.kt | 9 + .../domain/vault/VaultSessionManager.kt | 1 - .../heimdall/navigation/HeimdallNavGraph.kt | 27 ++- .../longnh15/heimdall/navigation/Screen.kt | 4 + .../heimdall/ui/auth/login/AuthForm.kt | 210 ++++++++++++++++ .../heimdall/ui/auth/login/LoginEvent.kt | 6 + .../heimdall/ui/auth/login/LoginScreen.kt | 226 ++++++++++++++++++ .../heimdall/ui/auth/login/LoginState.kt | 11 + .../heimdall/ui/auth/login/LoginViewModel.kt | 57 +++++ .../ui/auth/register/RegisterEvent.kt | 6 + .../ui/auth/register/RegisterScreen.kt | 212 ++++++++++++++++ .../ui/auth/register/RegisterState.kt | 12 + .../ui/auth/register/RegisterViewModel.kt | 66 +++++ app/src/main/res/values/themes.xml | 8 + gradle/libs.versions.toml | 8 + 40 files changed, 1290 insertions(+), 21 deletions(-) create mode 100644 app/schemas/com.longnh15.heimdall.data.source.local.HeimdallDatabase/1.json create mode 100644 app/src/main/java/com/longnh15/heimdall/common/GlobalConfig.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/common/TokenCrypto.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/common/ValidationHelper.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/data/repository/AuthRepositoryImpl.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/data/repository/BaseRepository.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/data/source/remote/ApiService.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/data/source/remote/ErrorBodyParser.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/ErrorResponse.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/LoginRequest.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/LoginResponse.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/RegisterRequest.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/RegisterResponse.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/di/NetworkModule.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/domain/model/ApiResult.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/domain/model/AuthInfo.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/domain/repository/AuthRepository.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/ui/auth/login/AuthForm.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginEvent.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginScreen.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginState.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginViewModel.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterEvent.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterScreen.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterState.kt create mode 100644 app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterViewModel.kt diff --git a/.gitignore b/.gitignore index 868556e..9ad4c06 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,13 @@ /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml +/.idea/deploymentTargetSelector.xml +/.idea/deviceManager.xml +/.idea/appInsightsSettings.xml +/.idea/koin-grouping-settings.xml .DS_Store /build +.claude/settings.local.json /captures .externalNativeBuild .cxx diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fbc9665..bb0caf4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,11 @@ plugins { alias(libs.plugins.devtool.ksp) alias(libs.plugins.serialization) alias(libs.plugins.koin.compiler) + alias(libs.plugins.room) +} + +room { + schemaDirectory("$projectDir/schemas") } android { @@ -51,6 +56,7 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.viewmodel.compose) testImplementation(libs.junit) @@ -76,4 +82,11 @@ dependencies { implementation(libs.androidx.room) ksp(libs.androidx.room.compiler) implementation(libs.androidx.room.ktx) + + // Retrofit + implementation(libs.squareup.retrofit2) + implementation(libs.squareup.retrofit2.kotlinx.serialization) + + // Kotlinx + implementation(libs.kotlinx.serialization.json) } \ No newline at end of file diff --git a/app/schemas/com.longnh15.heimdall.data.source.local.HeimdallDatabase/1.json b/app/schemas/com.longnh15.heimdall.data.source.local.HeimdallDatabase/1.json new file mode 100644 index 0000000..5c24a5e --- /dev/null +++ b/app/schemas/com.longnh15.heimdall.data.source.local.HeimdallDatabase/1.json @@ -0,0 +1,54 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "e09aa1c4dfa12dfb105b0ea16d4584ff", + "entities": [ + { + "tableName": "credential", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT, `username` TEXT NOT NULL, `password` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e09aa1c4dfa12dfb105b0ea16d4584ff')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2fde86d..6547032 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ - + + + + android:theme="@style/Theme.Heimdall.Starting"> diff --git a/app/src/main/java/com/longnh15/heimdall/HeimdallApplication.kt b/app/src/main/java/com/longnh15/heimdall/HeimdallApplication.kt index a59eda3..6e70163 100644 --- a/app/src/main/java/com/longnh15/heimdall/HeimdallApplication.kt +++ b/app/src/main/java/com/longnh15/heimdall/HeimdallApplication.kt @@ -9,8 +9,6 @@ import com.longnh15.heimdall.domain.vault.VaultSessionManager import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger -import org.koin.core.annotation.ComponentScan -import org.koin.core.annotation.KoinApplication import org.koin.plugin.module.dsl.startKoin //@KoinApplication(modules = [DatabaseModule::class]) diff --git a/app/src/main/java/com/longnh15/heimdall/MainActivity.kt b/app/src/main/java/com/longnh15/heimdall/MainActivity.kt index 7d6ac3d..4698aeb 100644 --- a/app/src/main/java/com/longnh15/heimdall/MainActivity.kt +++ b/app/src/main/java/com/longnh15/heimdall/MainActivity.kt @@ -8,6 +8,7 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.longnh15.heimdall.navigation.HeimdallNavGraph import com.longnh15.heimdall.navigation.Screen @@ -18,17 +19,28 @@ class MainActivity : ComponentActivity() { private val viewModel: MainViewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) + // Keep the splash screen until the first-launch flag is resolved so the nav graph + // is built once with the correct start destination (no Onboarding -> Login flicker). + splashScreen.setKeepOnScreenCondition { viewModel.isFirstLaunch.value == null && viewModel.token.value == null } enableEdgeToEdge() setContent { HeimdallTheme { val isFirstLaunch by viewModel.isFirstLaunch.collectAsStateWithLifecycle() - HeimdallNavGraph( - modifier = Modifier.fillMaxSize(), - startDestination = if (isFirstLaunch != false) Screen.Onboarding else Screen.Vault, - isAutofillServiceEnabled = ::isAutofillServiceEnabled, - onCompleteOnboarding = viewModel::onCompleteOnboarding, - ) + val token by viewModel.token.collectAsStateWithLifecycle() + if (isFirstLaunch != null && token != null) { + HeimdallNavGraph( + modifier = Modifier.fillMaxSize(), + startDestination = when { + isFirstLaunch!! -> Screen.Onboarding + token!!.isEmpty() -> Screen.Login + else -> Screen.Vault + }, + isAutofillServiceEnabled = ::isAutofillServiceEnabled, + onCompleteOnboarding = viewModel::onCompleteOnboarding, + ) + } } } } diff --git a/app/src/main/java/com/longnh15/heimdall/MainViewModel.kt b/app/src/main/java/com/longnh15/heimdall/MainViewModel.kt index 500ec83..d12bfaa 100644 --- a/app/src/main/java/com/longnh15/heimdall/MainViewModel.kt +++ b/app/src/main/java/com/longnh15/heimdall/MainViewModel.kt @@ -3,6 +3,7 @@ package com.longnh15.heimdall import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.longnh15.heimdall.data.preferences.PreferenceDataSource +import com.longnh15.heimdall.data.source.local.LocalDataSource import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map @@ -12,7 +13,8 @@ import org.koin.core.annotation.KoinViewModel @KoinViewModel class MainViewModel ( - private val preferenceDataSource: PreferenceDataSource + private val preferenceDataSource: PreferenceDataSource, + private val localDataSource: LocalDataSource ) : ViewModel() { val isFirstLaunch: StateFlow = preferenceDataSource.preferences .map { it.isFirstLaunch } @@ -22,6 +24,14 @@ class MainViewModel ( initialValue = null ) + val token: StateFlow = localDataSource.token + .map { it.orEmpty() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = null + ) + fun onCompleteOnboarding() { viewModelScope.launch { preferenceDataSource.setFirstLaunchDone() diff --git a/app/src/main/java/com/longnh15/heimdall/common/GlobalConfig.kt b/app/src/main/java/com/longnh15/heimdall/common/GlobalConfig.kt new file mode 100644 index 0000000..149265b --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/common/GlobalConfig.kt @@ -0,0 +1,10 @@ +package com.longnh15.heimdall.common + +import kotlinx.serialization.json.Json + +object GlobalConfig { + val json = Json { + ignoreUnknownKeys = true + isLenient + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/common/TokenCrypto.kt b/app/src/main/java/com/longnh15/heimdall/common/TokenCrypto.kt new file mode 100644 index 0000000..bcc78d8 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/common/TokenCrypto.kt @@ -0,0 +1,55 @@ +package com.longnh15.heimdall.common + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import kotlin.io.encoding.Base64 + +object TokenCrypto { + private const val PROVIDER = "AndroidKeyStore" + private const val ALIAS = "auth_token_key" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val IV_SIZE = 12 + private const val TAG_BITS = 128 + + private val keystore = KeyStore.getInstance(PROVIDER).apply { load(null) } + + private fun getOrCreateKey(): SecretKey { + (keystore.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { + return it.secretKey + } + val keyGen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, PROVIDER) + keyGen.init( + KeyGenParameterSpec.Builder( + ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + ) + return keyGen.generateKey() + } + + fun encrypt(plainText: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) + val iv = cipher.iv // 12-byte IV, generated by Keystore + val cipherText = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8)) + return Base64.encode(iv + cipherText) // prepend IV + } + + fun decrypt(encoded: String): String { + val combined = Base64.decode(encoded) + val iv = combined.copyOfRange(0, IV_SIZE) + val cipherText = combined.copyOfRange(IV_SIZE, combined.size) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(TAG_BITS, iv)) + return cipher.doFinal(cipherText).toString(Charsets.UTF_8) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/common/ValidationHelper.kt b/app/src/main/java/com/longnh15/heimdall/common/ValidationHelper.kt new file mode 100644 index 0000000..846d823 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/common/ValidationHelper.kt @@ -0,0 +1,9 @@ +package com.longnh15.heimdall.common + +import android.util.Patterns.EMAIL_ADDRESS + +object ValidationHelper { + fun isEmail(input: String): Boolean { + return EMAIL_ADDRESS.matcher(input).matches() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/longnh15/heimdall/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..28ee685 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,45 @@ +package com.longnh15.heimdall.data.repository + +import com.longnh15.heimdall.data.source.local.LocalDataSource +import com.longnh15.heimdall.data.source.remote.ApiService +import com.longnh15.heimdall.data.source.remote.dto.LoginRequest +import com.longnh15.heimdall.data.source.remote.dto.RegisterRequest +import com.longnh15.heimdall.domain.model.ApiResult +import com.longnh15.heimdall.domain.model.AuthInfo +import com.longnh15.heimdall.domain.repository.AuthRepository +import kotlin.time.Instant + +class AuthRepositoryImpl( + private val api: ApiService, + private val localDataSource: LocalDataSource +) : BaseRepository(), AuthRepository { + override suspend fun register( + email: String, + password: String, + name: String + ): ApiResult { + val registerInfo = RegisterRequest(email, password, name) + return safeApiCall( + block = { api.register(registerInfo) }, + transform = { it.userId } + ) + } + + override suspend fun login( + email: String, + password: String + ): ApiResult { + val loginInfo = LoginRequest(email, password) + return safeApiCall( + block = { api.login(loginInfo) }, + transform = { + localDataSource.saveToken(it.token) + AuthInfo( + it.userId, + it.token, + Instant.parse(it.expiresAt) + ) + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/repository/BaseRepository.kt b/app/src/main/java/com/longnh15/heimdall/data/repository/BaseRepository.kt new file mode 100644 index 0000000..42e0f08 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/repository/BaseRepository.kt @@ -0,0 +1,36 @@ +package com.longnh15.heimdall.data.repository + +import com.longnh15.heimdall.data.source.remote.toApiError +import com.longnh15.heimdall.domain.model.ApiResult +import retrofit2.Response + +abstract class BaseRepository { + suspend fun safeApiCall( + block: suspend () -> Response + ): ApiResult { + val response = block() + + return if (response.isSuccessful) { + response.body()?.let { + ApiResult.Success(it) + } ?: ApiResult.Error(-999, "Empty response body") + } else { + response.errorBody().toApiError() + } + } + + suspend fun safeApiCall( + block: suspend () -> Response, + transform: suspend (RemoteResponse) -> Expected + ): ApiResult { + val response = block() + + return if (response.isSuccessful) { + response.body()?.let { + ApiResult.Success(transform(it)) + } ?: ApiResult.Error(-999, "Empty response body") + } else { + response.errorBody().toApiError() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/local/LocalDataSource.kt b/app/src/main/java/com/longnh15/heimdall/data/source/local/LocalDataSource.kt index 511a0f0..c967d50 100644 --- a/app/src/main/java/com/longnh15/heimdall/data/source/local/LocalDataSource.kt +++ b/app/src/main/java/com/longnh15/heimdall/data/source/local/LocalDataSource.kt @@ -1,14 +1,30 @@ package com.longnh15.heimdall.data.source.local +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.longnh15.heimdall.common.TokenCrypto import com.longnh15.heimdall.data.source.local.entity.toDomain import com.longnh15.heimdall.data.source.local.entity.toEntity import com.longnh15.heimdall.domain.model.Credential import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +private val Context.dataStore by preferencesDataStore("terces") + class LocalDataSource( - private val credentialDao: CredentialDao + private val credentialDao: CredentialDao, + private val appContext: Context ) { + private object Keys { + val TOKEN = stringPreferencesKey("token") + } + + @Volatile + private var cachedToken: String? = null + fun getAllCredentials(): Flow> { return credentialDao.getAll().map { entities -> entities.map { it.toDomain() } } } @@ -16,4 +32,22 @@ class LocalDataSource( suspend fun save(credential: Credential) { credentialDao.insert(credential.toEntity()) } + + suspend fun saveToken(token: String) { + cachedToken = token + appContext.dataStore.edit { it[Keys.TOKEN] = TokenCrypto.encrypt(token) } // encrypt with a Keystore key + } + + val token: Flow = appContext.dataStore.data + .map { it[Keys.TOKEN]?.let(TokenCrypto::decrypt) } + + suspend fun getToken(): String? { + cachedToken?.let { return it } + return token.first().also { cachedToken = it } + } + + suspend fun clearToken() { + cachedToken = null + appContext.dataStore.edit { it.remove(Keys.TOKEN) } + } } \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/remote/ApiService.kt b/app/src/main/java/com/longnh15/heimdall/data/source/remote/ApiService.kt new file mode 100644 index 0000000..77ec9a7 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/remote/ApiService.kt @@ -0,0 +1,17 @@ +package com.longnh15.heimdall.data.source.remote + +import com.longnh15.heimdall.data.source.remote.dto.LoginRequest +import com.longnh15.heimdall.data.source.remote.dto.LoginResponse +import com.longnh15.heimdall.data.source.remote.dto.RegisterRequest +import com.longnh15.heimdall.data.source.remote.dto.RegisterResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface ApiService { + @POST("/api/auth/register") + suspend fun register(@Body registerInfo: RegisterRequest): Response + + @POST("/api/app/login") + suspend fun login(@Body loginInfo: LoginRequest): Response +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/remote/ErrorBodyParser.kt b/app/src/main/java/com/longnh15/heimdall/data/source/remote/ErrorBodyParser.kt new file mode 100644 index 0000000..4b1d8d6 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/remote/ErrorBodyParser.kt @@ -0,0 +1,18 @@ +package com.longnh15.heimdall.data.source.remote + +import com.longnh15.heimdall.common.GlobalConfig.json +import com.longnh15.heimdall.data.source.remote.dto.ErrorResponse +import com.longnh15.heimdall.domain.model.ApiResult +import okhttp3.ResponseBody + +fun ResponseBody?.toApiError(): ApiResult.Error { + if (this == null) { + return ApiResult.Error(-999, "Unknown error") + } + return try { + val error = json.decodeFromString(string()) + ApiResult.Error(error.error, error.message) + } catch (_: Exception) { + ApiResult.Error(-999, "Unknown error") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/ErrorResponse.kt b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/ErrorResponse.kt new file mode 100644 index 0000000..4183c93 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/ErrorResponse.kt @@ -0,0 +1,9 @@ +package com.longnh15.heimdall.data.source.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ErrorResponse( + val error: Int, + val message: String +) diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/LoginRequest.kt b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/LoginRequest.kt new file mode 100644 index 0000000..9b6486e --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/LoginRequest.kt @@ -0,0 +1,9 @@ +package com.longnh15.heimdall.data.source.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginRequest( + val email: String, + val password: String +) diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/LoginResponse.kt b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/LoginResponse.kt new file mode 100644 index 0000000..4a77730 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/LoginResponse.kt @@ -0,0 +1,10 @@ +package com.longnh15.heimdall.data.source.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginResponse( + val token: String, + val userId: String, + val expiresAt: String, +) diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/RegisterRequest.kt b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/RegisterRequest.kt new file mode 100644 index 0000000..f9cd401 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/RegisterRequest.kt @@ -0,0 +1,10 @@ +package com.longnh15.heimdall.data.source.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class RegisterRequest( + val email: String, + val password: String, + val name: String +) diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/RegisterResponse.kt b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/RegisterResponse.kt new file mode 100644 index 0000000..f1e0247 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/RegisterResponse.kt @@ -0,0 +1,9 @@ +package com.longnh15.heimdall.data.source.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class RegisterResponse( + val ok: Boolean, + val userId: String +) diff --git a/app/src/main/java/com/longnh15/heimdall/di/AppModule.kt b/app/src/main/java/com/longnh15/heimdall/di/AppModule.kt index fae9d2a..593824b 100644 --- a/app/src/main/java/com/longnh15/heimdall/di/AppModule.kt +++ b/app/src/main/java/com/longnh15/heimdall/di/AppModule.kt @@ -2,16 +2,17 @@ package com.longnh15.heimdall.di import com.longnh15.heimdall.MainViewModel import com.longnh15.heimdall.data.preferences.PreferenceDataSource +import com.longnh15.heimdall.data.repository.AuthRepositoryImpl import com.longnh15.heimdall.data.repository.CredentialRepositoryImpl -import com.longnh15.heimdall.data.source.local.LocalDataSource import com.longnh15.heimdall.data.vault.VaultSessionManagerImpl +import com.longnh15.heimdall.domain.repository.AuthRepository import com.longnh15.heimdall.domain.repository.CredentialRepository import com.longnh15.heimdall.domain.vault.VaultSessionManager +import com.longnh15.heimdall.ui.auth.login.LoginViewModel import com.longnh15.heimdall.ui.onboarding.OnboardingViewModel +import com.longnh15.heimdall.ui.auth.register.RegisterViewModel import com.longnh15.heimdall.ui.vault.VaultViewModel import com.longnh15.heimdall.ui.vault.edit.AddEditVaultViewModel -import org.koin.core.annotation.Module -import org.koin.core.annotation.Single import org.koin.dsl.bind import org.koin.dsl.module import org.koin.plugin.module.dsl.single @@ -31,10 +32,13 @@ import org.koin.plugin.module.dsl.viewModel val appModule = module { single() bind VaultSessionManager::class single() bind CredentialRepository::class + single() bind AuthRepository::class single() viewModel() viewModel() viewModel() viewModel() -} \ No newline at end of file + viewModel() + viewModel() +} diff --git a/app/src/main/java/com/longnh15/heimdall/di/DatabaseModule.kt b/app/src/main/java/com/longnh15/heimdall/di/DatabaseModule.kt index 6ee555c..31f629b 100644 --- a/app/src/main/java/com/longnh15/heimdall/di/DatabaseModule.kt +++ b/app/src/main/java/com/longnh15/heimdall/di/DatabaseModule.kt @@ -24,7 +24,7 @@ class DatabaseModule { } @Single - fun provideLocalDataSource(credentialDao: CredentialDao): LocalDataSource { - return LocalDataSource(credentialDao) + fun provideLocalDataSource(credentialDao: CredentialDao, context: Context): LocalDataSource { + return LocalDataSource(credentialDao, context) } } \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/di/NetworkModule.kt b/app/src/main/java/com/longnh15/heimdall/di/NetworkModule.kt new file mode 100644 index 0000000..7dde923 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/di/NetworkModule.kt @@ -0,0 +1,27 @@ +package com.longnh15.heimdall.di + +import com.longnh15.heimdall.common.GlobalConfig.json +import com.longnh15.heimdall.data.source.remote.ApiService +import okhttp3.MediaType.Companion.toMediaType +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import retrofit2.create + +@Module +class NetworkModule { + private companion object { + const val BASE_URL = "https://heimdall.lol" + } + @Single + fun provideRetrofit() = Retrofit.Builder() + .addConverterFactory( + json.asConverterFactory("application/json".toMediaType()) + ) + .baseUrl(BASE_URL) + .build() + + @Single + fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create() +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/domain/model/ApiResult.kt b/app/src/main/java/com/longnh15/heimdall/domain/model/ApiResult.kt new file mode 100644 index 0000000..f482e91 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/domain/model/ApiResult.kt @@ -0,0 +1,6 @@ +package com.longnh15.heimdall.domain.model + +sealed class ApiResult { + data class Success(val data: T): ApiResult() + data class Error(val code: Int, val message: String): ApiResult() +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/domain/model/AuthInfo.kt b/app/src/main/java/com/longnh15/heimdall/domain/model/AuthInfo.kt new file mode 100644 index 0000000..e02be53 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/domain/model/AuthInfo.kt @@ -0,0 +1,9 @@ +package com.longnh15.heimdall.domain.model + +import kotlin.time.Instant + +data class AuthInfo( + val token: String, + val userId: String, + val expiresAt: Instant +) diff --git a/app/src/main/java/com/longnh15/heimdall/domain/repository/AuthRepository.kt b/app/src/main/java/com/longnh15/heimdall/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..f306103 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/domain/repository/AuthRepository.kt @@ -0,0 +1,9 @@ +package com.longnh15.heimdall.domain.repository + +import com.longnh15.heimdall.domain.model.ApiResult +import com.longnh15.heimdall.domain.model.AuthInfo + +interface AuthRepository { + suspend fun register(email: String, password: String, name: String): ApiResult + suspend fun login(email: String, password: String): ApiResult +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/domain/vault/VaultSessionManager.kt b/app/src/main/java/com/longnh15/heimdall/domain/vault/VaultSessionManager.kt index 62b54f9..c2c7d64 100644 --- a/app/src/main/java/com/longnh15/heimdall/domain/vault/VaultSessionManager.kt +++ b/app/src/main/java/com/longnh15/heimdall/domain/vault/VaultSessionManager.kt @@ -1,7 +1,6 @@ package com.longnh15.heimdall.domain.vault import kotlinx.coroutines.flow.StateFlow -import org.koin.core.annotation.Singleton interface VaultSessionManager { val sessionState: StateFlow diff --git a/app/src/main/java/com/longnh15/heimdall/navigation/HeimdallNavGraph.kt b/app/src/main/java/com/longnh15/heimdall/navigation/HeimdallNavGraph.kt index ebb5683..15b4dfb 100644 --- a/app/src/main/java/com/longnh15/heimdall/navigation/HeimdallNavGraph.kt +++ b/app/src/main/java/com/longnh15/heimdall/navigation/HeimdallNavGraph.kt @@ -6,8 +6,12 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.longnh15.heimdall.ui.auth.login.LoginScreen +import com.longnh15.heimdall.ui.auth.login.LoginViewModel import com.longnh15.heimdall.ui.onboarding.OnboardingPage import com.longnh15.heimdall.ui.onboarding.OnboardingViewModel +import com.longnh15.heimdall.ui.auth.register.RegisterScreen +import com.longnh15.heimdall.ui.auth.register.RegisterViewModel import com.longnh15.heimdall.ui.vault.VaultScreen import com.longnh15.heimdall.ui.vault.VaultViewModel import com.longnh15.heimdall.ui.vault.edit.AddEditVaultItemScreen @@ -18,7 +22,7 @@ import org.koin.compose.viewmodel.koinViewModel fun HeimdallNavGraph( modifier: Modifier = Modifier, navController: NavHostController = rememberNavController(), - startDestination: Screen = Screen.Onboarding, + startDestination: Screen = Screen.Login, isAutofillServiceEnabled: () -> Boolean, onCompleteOnboarding: () -> Unit, ) { @@ -27,6 +31,27 @@ fun HeimdallNavGraph( startDestination = startDestination, modifier = modifier ) { + composable { + LoginScreen( + viewModel = koinViewModel(), + navigateToVault = { + navController.navigate(Screen.Vault) { + popUpTo(Screen.Login) { inclusive = true } + } + }, + navigateToRegister = { + navController.navigate(Screen.Register) + } + ) + } + composable { + RegisterScreen( + viewModel = koinViewModel(), + navigateToLogin = { + navController.popBackStack() + } + ) + } composable { OnboardingPage( viewModel = koinViewModel(), diff --git a/app/src/main/java/com/longnh15/heimdall/navigation/Screen.kt b/app/src/main/java/com/longnh15/heimdall/navigation/Screen.kt index a47cb80..82219f1 100644 --- a/app/src/main/java/com/longnh15/heimdall/navigation/Screen.kt +++ b/app/src/main/java/com/longnh15/heimdall/navigation/Screen.kt @@ -4,6 +4,10 @@ import kotlinx.serialization.Serializable @Serializable sealed class Screen { + @Serializable + data object Login: Screen() + @Serializable + data object Register: Screen() @Serializable data object Onboarding: Screen() @Serializable diff --git a/app/src/main/java/com/longnh15/heimdall/ui/auth/login/AuthForm.kt b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/AuthForm.kt new file mode 100644 index 0000000..cea6708 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/AuthForm.kt @@ -0,0 +1,210 @@ +package com.longnh15.heimdall.ui.auth.login + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_NO +import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.longnh15.heimdall.ui.theme.HeimdallTheme + +@Composable +fun AuthForm( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.onPrimary) + .border(0.5.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(12.dp)), + content = content + ) +} + +@Composable +fun AuthFormTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + placeholder: String, + error: String? = null, + isHideContent: Boolean = false, + isLastField: Boolean = false, + autoCorrectEnabled: Boolean = true, + keyboardType: KeyboardType = KeyboardType.Unspecified, + actions: @Composable RowScope.() -> Unit = {}, +) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label.uppercase(), + modifier = Modifier.width(100.dp).padding(vertical = 12.dp), + color = MaterialTheme.colorScheme.primary, + fontSize = 13.sp, + fontWeight = FontWeight.Bold + ) + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.weight(1f), + textStyle = TextStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp + ), + keyboardOptions = if (isLastField) + KeyboardOptions.Default.copy( + autoCorrectEnabled = autoCorrectEnabled, + keyboardType = keyboardType, + imeAction = ImeAction.Done + ) + else + KeyboardOptions.Default.copy( + autoCorrectEnabled = autoCorrectEnabled, + keyboardType = keyboardType, + imeAction = ImeAction.Next + ), + singleLine = true, + visualTransformation = if (isHideContent) PasswordVisualTransformation() else VisualTransformation.None, + decorationBox = { innerTextField -> + if (value.isEmpty()) { + Text( + text = placeholder, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.surfaceVariant + ) + } + innerTextField() + } + ) + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) { + actions() + } + } + if (error != null) { + Text( + text = error, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.error, + fontSize = 12.sp + ) + } + } +} + +@Composable +fun AuthDivider() { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outline + ) +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Composable +fun AuthFormPreviewLight() { + HeimdallTheme { + AuthForm { + AuthFormTextField( + value = "", + onValueChange = {}, + label = "Name", + placeholder = "e.g. Google" + ) + AuthDivider() + AuthFormTextField( + value = "abc", + onValueChange = {}, + label = "Password", + placeholder = "••••••••", + isHideContent = true, + autoCorrectEnabled = false, + actions = { + IconButton( + onClick = {}, + modifier = Modifier.focusProperties { canFocus = false } + ) { + Icon( + imageVector = Icons.Outlined.Visibility, + null + ) + } + } + ) + } + } +} + +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +fun AuthFormPreviewDark() { + HeimdallTheme { + AuthForm { + AuthFormTextField( + value = "", + onValueChange = {}, + label = "Name", + placeholder = "e.g. Google" + ) + AuthDivider() + AuthFormTextField( + value = "abc", + onValueChange = {}, + label = "Password", + placeholder = "••••••••", + isHideContent = true, + autoCorrectEnabled = false, + actions = { + IconButton( + onClick = {}, + modifier = Modifier.focusProperties { canFocus = false } + ) { + Icon( + imageVector = Icons.Outlined.VisibilityOff, + null + ) + } + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginEvent.kt b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginEvent.kt new file mode 100644 index 0000000..450fb24 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginEvent.kt @@ -0,0 +1,6 @@ +package com.longnh15.heimdall.ui.auth.login + +sealed class LoginEvent { + data object LoginSuccess: LoginEvent() + data class LoginError(val message: String): LoginEvent() +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginScreen.kt b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginScreen.kt new file mode 100644 index 0000000..449ce3a --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginScreen.kt @@ -0,0 +1,226 @@ +package com.longnh15.heimdall.ui.auth.login + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.longnh15.heimdall.ui.theme.HeimdallTheme + +@Composable +fun LoginScreen( + modifier: Modifier = Modifier, + viewModel: LoginViewModel = viewModel(), + navigateToVault: () -> Unit, + navigateToRegister: () -> Unit, +) { + val snackbarHostState = remember { SnackbarHostState() } + val state = viewModel.state + + LaunchedEffect(Unit) { + viewModel.uiEvent.collect { event -> + when (event) { + is LoginEvent.LoginSuccess -> navigateToVault() + is LoginEvent.LoginError -> snackbarHostState.showSnackbar(event.message) + } + } + } + + HeimdallTheme { + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = MaterialTheme.colorScheme.background, + snackbarHost = { + SnackbarHost(snackbarHostState) + } + ) { paddingValues -> + Column( + modifier = modifier + .padding(paddingValues) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(horizontal = 24.dp, vertical = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + BrandBadge() + + Spacer(Modifier.height(24.dp)) + + Text( + text = "Welcome back", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Access your secure vault credentials", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(36.dp)) + + AuthForm(modifier = Modifier.widthIn(max = 480.dp)) { + AuthFormTextField( + value = state.email, + onValueChange = viewModel::onEmailChanged, + label = "Email", + placeholder = "you@email.com", + error = state.emailError, + keyboardType = KeyboardType.Email + ) + AuthDivider() + AuthFormTextField( + value = state.password, + onValueChange = viewModel::onPasswordChanged, + label = "Password", + placeholder = "••••••••", + error = state.passwordError, + isHideContent = !state.isShowPassword, + autoCorrectEnabled = false, + isLastField = true, + keyboardType = KeyboardType.Password, + actions = { + IconButton( + onClick = viewModel::toggleShowPassword, + modifier = Modifier.focusProperties { canFocus = false } + ) { + Icon( + imageVector = if (state.isShowPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + null + ) + } + } + ) + } + + TextButton( + onClick = {}, + modifier = Modifier + .align(Alignment.End) + .widthIn(max = 480.dp) + ) { + Text("Forgot password?") + } + + Spacer(Modifier.height(8.dp)) + + Button( + onClick = viewModel::onLoginPressed, + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 480.dp) + .height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Text( + text = "Login", + style = MaterialTheme.typography.titleMedium + ) + } + + Spacer(Modifier.height(28.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = "Don't have an account?", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + TextButton(onClick = navigateToRegister) { + Text( + text = "Create one", + fontWeight = FontWeight.SemiBold + ) + } + } + } + } + } +} + +@Composable +private fun BrandBadge() { + Box( + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(22.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(36.dp) + ) + } +} + +@Preview +@Composable +fun LoginScreenPreviewLight() { + LoginScreen( + navigateToVault = {}, + navigateToRegister = {} + ) +} + +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +fun LoginScreenPreviewDark() { + LoginScreen( + navigateToVault = {}, + navigateToRegister = {} + ) +} diff --git a/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginState.kt b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginState.kt new file mode 100644 index 0000000..8278f8a --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginState.kt @@ -0,0 +1,11 @@ +package com.longnh15.heimdall.ui.auth.login + +data class LoginState( + val email: String = "", + val password: String = "", + + val emailError: String? = null, + val passwordError: String? = null, + + val isShowPassword: Boolean = false, +) diff --git a/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginViewModel.kt b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginViewModel.kt new file mode 100644 index 0000000..b4ad292 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginViewModel.kt @@ -0,0 +1,57 @@ +package com.longnh15.heimdall.ui.auth.login + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.longnh15.heimdall.common.ValidationHelper +import com.longnh15.heimdall.domain.model.ApiResult +import com.longnh15.heimdall.domain.repository.AuthRepository +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +class LoginViewModel( + private val authRepository: AuthRepository +) : ViewModel() { + var state by mutableStateOf(LoginState()) + private set + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() + + fun onEmailChanged(value: String) { + state = state.copy(email = value, emailError = null) + } + + fun onPasswordChanged(value: String) { + state = state.copy(password = value, passwordError = null) + } + + fun toggleShowPassword() { + state = state.copy(isShowPassword = !state.isShowPassword) + } + + fun onLoginPressed() { + if (!validate()) return + + viewModelScope.launch { + when (val result = authRepository.login(state.email, state.password)) { + is ApiResult.Success -> _uiEvent.send(LoginEvent.LoginSuccess) + is ApiResult.Error -> _uiEvent.send(LoginEvent.LoginError(result.message)) + } + } + } + + private fun validate(): Boolean { + val emailError = when { + state.email.isBlank() -> "Email is required" + !ValidationHelper.isEmail(state.email) -> "Enter a valid email address" + else -> null + } + val passwordError = if (state.password.isBlank()) "Password is required" else null + + state = state.copy(emailError = emailError, passwordError = passwordError) + return emailError == null && passwordError == null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterEvent.kt b/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterEvent.kt new file mode 100644 index 0000000..6d8dde4 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterEvent.kt @@ -0,0 +1,6 @@ +package com.longnh15.heimdall.ui.auth.register + +sealed class RegisterEvent { + data object RegisterSuccess: RegisterEvent() + data class RegisterError(val message: String): RegisterEvent() +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterScreen.kt b/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterScreen.kt new file mode 100644 index 0000000..dd694dc --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterScreen.kt @@ -0,0 +1,212 @@ +package com.longnh15.heimdall.ui.auth.register + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.PersonAddAlt1 +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.longnh15.heimdall.ui.auth.login.AuthDivider +import com.longnh15.heimdall.ui.auth.login.AuthForm +import com.longnh15.heimdall.ui.auth.login.AuthFormTextField +import com.longnh15.heimdall.ui.theme.HeimdallTheme + +@Composable +fun RegisterScreen( + modifier: Modifier = Modifier, + viewModel: RegisterViewModel = viewModel(), + navigateToLogin: () -> Unit = {}, +) { + val state = viewModel.state + + HeimdallTheme { + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + Column( + modifier = modifier + .padding(paddingValues) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(horizontal = 24.dp, vertical = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + BrandBadge() + + Spacer(Modifier.height(24.dp)) + + Text( + text = "Create account", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Secure your digital life with enterprise-grade encryption.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(36.dp)) + + AuthForm(modifier = Modifier.widthIn(max = 480.dp)) { + AuthFormTextField( + value = state.name, + onValueChange = viewModel::onNameChanged, + label = "Full name", + placeholder = "John Doe", + keyboardType = KeyboardType.Text + ) + AuthDivider() + AuthFormTextField( + value = state.email, + onValueChange = viewModel::onEmailChanged, + label = "Email", + placeholder = "name@vault.com", + keyboardType = KeyboardType.Email, + ) + AuthDivider() + AuthFormTextField( + value = state.password, + onValueChange = viewModel::onPasswordChanged, + label = "Password", + placeholder = "••••••••", + isHideContent = !state.isShowPassword, + autoCorrectEnabled = false, + isLastField = true, + keyboardType = KeyboardType.Password, + actions = { + IconButton( + onClick = viewModel::toggleShowPassword, + modifier = Modifier.focusProperties { canFocus = false } + ) { + Icon( + imageVector = if (state.isShowPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + null + ) + } + } + ) + } + + Spacer(Modifier.height(24.dp)) + + Button( + onClick = viewModel::onRegisterPressed, + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 480.dp) + .height(52.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Text( + text = "Create account", + style = MaterialTheme.typography.titleMedium + ) + } + + Spacer(Modifier.height(16.dp)) + + Text( + text = "By creating an account you agree to our Terms of Service and Privacy Policy.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = 480.dp) + ) + + Spacer(Modifier.height(28.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = "Already have an account?", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + TextButton(onClick = navigateToLogin) { + Text( + text = "Sign in", + fontWeight = FontWeight.SemiBold + ) + } + } + } + } + } +} + +@Composable +private fun BrandBadge() { + Box( + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(22.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.PersonAddAlt1, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(36.dp) + ) + } +} + +@Preview +@Composable +fun RegisterScreenLightPreview() { + RegisterScreen() +} + +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +fun RegisterScreenDarkPreview() { + RegisterScreen() +} diff --git a/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterState.kt b/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterState.kt new file mode 100644 index 0000000..0840181 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterState.kt @@ -0,0 +1,12 @@ +package com.longnh15.heimdall.ui.auth.register + +data class RegisterState( + val name: String = "", + val email: String = "", + val password: String = "", + + val emailError: String? = null, + val passwordError: String? = null, + + val isShowPassword: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterViewModel.kt b/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterViewModel.kt new file mode 100644 index 0000000..63cef89 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterViewModel.kt @@ -0,0 +1,66 @@ +package com.longnh15.heimdall.ui.auth.register + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.longnh15.heimdall.common.ValidationHelper +import com.longnh15.heimdall.domain.model.ApiResult +import com.longnh15.heimdall.domain.repository.AuthRepository +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +class RegisterViewModel( + private val authRepository: AuthRepository +) : ViewModel() { + var state by mutableStateOf(RegisterState()) + private set + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() + + fun onNameChanged(value: String) { + state = state.copy(name = value) + } + + fun onEmailChanged(value: String) { + state = state.copy(email = value) + } + + fun onPasswordChanged(value: String) { + state = state.copy(password = value) + } + + fun toggleShowPassword() { + state = state.copy(isShowPassword = !state.isShowPassword) + } + + fun onRegisterPressed() { + if (!validate()) return + + viewModelScope.launch { + when (val result = authRepository.register(state.email, state.password, state.name)) { + is ApiResult.Success -> _uiEvent.send(RegisterEvent.RegisterSuccess) + is ApiResult.Error -> _uiEvent.send(RegisterEvent.RegisterError(result.message)) + } + } + } + + private fun validate(): Boolean { + val emailError = when { + state.email.isBlank() -> "Email is required" + !ValidationHelper.isEmail(state.email) -> "Enter a valid email address" + else -> null + } + val passwordError = when { + state.password.isBlank() -> "Password is required" + state.password.length < 12 -> "Password must be at least 12 characters" + else -> null + } + + state = state.copy(emailError = emailError, passwordError = passwordError) + return emailError == null && passwordError == null + } + +} \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index c3725c6..d344c02 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -2,4 +2,12 @@ \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9a75fc8..9d0c301 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,9 +14,12 @@ koinKsp = "4.2.1" koinPlugin = "1.0.0" navigation = "2.9.8" serialization = "2.4.0" +serializationJson = "1.11.0" datastore = "1.2.1" biometric = "1.4.0-alpha07" room = "2.8.4" +retrofit = "3.0.0" +splashscreen = "1.2.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -43,10 +46,14 @@ koin-compose-viewmodel-navigation = { group = "io.insert-koin", name = "koin-com koin-compose-viewmodel = { group = "io.insert-koin", name = "koin-compose-viewmodel", version.ref = "koin" } koin-annotations = { group = "io.insert-koin", name = "koin-annotations", version.ref = "koinKsp"} navigation-compose = { group = "androidx.navigation" , name = "navigation-compose", version.ref = "navigation" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } biometric-compose = { group = "androidx.biometric", name = "biometric-compose", version.ref = "biometric" } androidx-room = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serializationJson" } +squareup-retrofit2 = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +squareup-retrofit2-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -54,4 +61,5 @@ devtool-ksp = { id = "com.google.devtools.ksp", version.ref = "kspVersion" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" } koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koinPlugin" } +room = { id = "androidx.room", version.ref = "room" }