Implement login and register feature #1
5
.gitignore
vendored
5
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".HeimdallApplication"
|
||||
@ -28,7 +29,7 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Heimdall">
|
||||
android:theme="@style/Theme.Heimdall.Starting">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Boolean?> = preferenceDataSource.preferences
|
||||
.map { it.isFirstLaunch }
|
||||
@ -22,6 +24,14 @@ class MainViewModel (
|
||||
initialValue = null
|
||||
)
|
||||
|
||||
val token: StateFlow<String?> = localDataSource.token
|
||||
.map { it.orEmpty() }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = null
|
||||
)
|
||||
|
||||
fun onCompleteOnboarding() {
|
||||
viewModelScope.launch {
|
||||
preferenceDataSource.setFirstLaunchDone()
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
package com.longnh15.heimdall.common
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object GlobalConfig {
|
||||
val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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<String> {
|
||||
val registerInfo = RegisterRequest(email, password, name)
|
||||
return safeApiCall(
|
||||
block = { api.register(registerInfo) },
|
||||
transform = { it.userId }
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun login(
|
||||
email: String,
|
||||
password: String
|
||||
): ApiResult<AuthInfo> {
|
||||
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)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 <RemoteResponse> safeApiCall(
|
||||
block: suspend () -> Response<RemoteResponse>
|
||||
): ApiResult<RemoteResponse> {
|
||||
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 <RemoteResponse, Expected> safeApiCall(
|
||||
block: suspend () -> Response<RemoteResponse>,
|
||||
transform: suspend (RemoteResponse) -> Expected
|
||||
): ApiResult<Expected> {
|
||||
val response = block()
|
||||
|
||||
return if (response.isSuccessful) {
|
||||
response.body()?.let {
|
||||
ApiResult.Success(transform(it))
|
||||
} ?: ApiResult.Error(-999, "Empty response body")
|
||||
} else {
|
||||
response.errorBody().toApiError()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<List<Credential>> {
|
||||
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<String?> = 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) }
|
||||
}
|
||||
}
|
||||
@ -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<RegisterResponse>
|
||||
|
||||
@POST("/api/app/login")
|
||||
suspend fun login(@Body loginInfo: LoginRequest): Response<LoginResponse>
|
||||
}
|
||||
@ -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<ErrorResponse>(string())
|
||||
ApiResult.Error(error.error, error.message)
|
||||
} catch (_: Exception) {
|
||||
ApiResult.Error(-999, "Unknown error")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
@ -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
|
||||
)
|
||||
@ -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,
|
||||
)
|
||||
@ -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
|
||||
)
|
||||
@ -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
|
||||
)
|
||||
@ -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<VaultSessionManagerImpl>() bind VaultSessionManager::class
|
||||
single<CredentialRepositoryImpl>() bind CredentialRepository::class
|
||||
single<AuthRepositoryImpl>() bind AuthRepository::class
|
||||
single<PreferenceDataSource>()
|
||||
|
||||
viewModel<MainViewModel>()
|
||||
viewModel<OnboardingViewModel>()
|
||||
viewModel<VaultViewModel>()
|
||||
viewModel<AddEditVaultViewModel>()
|
||||
viewModel<LoginViewModel>()
|
||||
viewModel<RegisterViewModel>()
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
27
app/src/main/java/com/longnh15/heimdall/di/NetworkModule.kt
Normal file
27
app/src/main/java/com/longnh15/heimdall/di/NetworkModule.kt
Normal file
@ -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<ApiService>()
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package com.longnh15.heimdall.domain.model
|
||||
|
||||
sealed class ApiResult<out T> {
|
||||
data class Success<T>(val data: T): ApiResult<T>()
|
||||
data class Error(val code: Int, val message: String): ApiResult<Nothing>()
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
@ -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<String>
|
||||
suspend fun login(email: String, password: String): ApiResult<AuthInfo>
|
||||
}
|
||||
@ -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<VaultSessionState>
|
||||
|
||||
@ -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<Screen.Login> {
|
||||
LoginScreen(
|
||||
viewModel = koinViewModel<LoginViewModel>(),
|
||||
navigateToVault = {
|
||||
navController.navigate(Screen.Vault) {
|
||||
popUpTo(Screen.Login) { inclusive = true }
|
||||
}
|
||||
},
|
||||
navigateToRegister = {
|
||||
navController.navigate(Screen.Register)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<Screen.Register> {
|
||||
RegisterScreen(
|
||||
viewModel = koinViewModel<RegisterViewModel>(),
|
||||
navigateToLogin = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<Screen.Onboarding> {
|
||||
OnboardingPage(
|
||||
viewModel = koinViewModel<OnboardingViewModel>(),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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 = {}
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
@ -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<LoginEvent>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
@ -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<RegisterEvent>()
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
@ -2,4 +2,12 @@
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Heimdall" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
|
||||
<!-- Splash screen shown during cold start, kept on screen until the first-launch
|
||||
flag is resolved so the nav graph builds with the correct start destination. -->
|
||||
<style name="Theme.Heimdall.Starting" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/white</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.Heimdall</item>
|
||||
</style>
|
||||
</resources>
|
||||
@ -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" }
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user