implement login/register

This commit is contained in:
ShenLong 2026-06-17 16:19:10 +07:00
parent 580bc993cc
commit 7f4949420f
40 changed files with 1290 additions and 21 deletions

5
.gitignore vendored
View File

@ -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

View File

@ -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)
}

View File

@ -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')"
]
}
}

View File

@ -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" />

View File

@ -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])

View File

@ -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,20 +19,31 @@ 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()
val token by viewModel.token.collectAsStateWithLifecycle()
if (isFirstLaunch != null && token != null) {
HeimdallNavGraph(
modifier = Modifier.fillMaxSize(),
startDestination = if (isFirstLaunch != false) Screen.Onboarding else Screen.Vault,
startDestination = when {
isFirstLaunch!! -> Screen.Onboarding
token!!.isEmpty() -> Screen.Login
else -> Screen.Vault
},
isAutofillServiceEnabled = ::isAutofillServiceEnabled,
onCompleteOnboarding = viewModel::onCompleteOnboarding,
)
}
}
}
}
fun isAutofillServiceEnabled(): Boolean {
val afm = getSystemService(AutofillManager::class.java)

View File

@ -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()

View File

@ -0,0 +1,10 @@
package com.longnh15.heimdall.common
import kotlinx.serialization.json.Json
object GlobalConfig {
val json = Json {
ignoreUnknownKeys = true
isLenient
}
}

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

@ -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)
)
}
)
}
}

View File

@ -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()
}
}
}

View File

@ -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) }
}
}

View File

@ -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>
}

View File

@ -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")
}
}

View File

@ -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
)

View File

@ -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
)

View File

@ -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,
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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>()
}

View File

@ -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)
}
}

View 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>()
}

View File

@ -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>()
}

View File

@ -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
)

View File

@ -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>
}

View File

@ -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>

View File

@ -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>(),

View File

@ -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

View File

@ -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
)
}
}
)
}
}
}

View File

@ -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()
}

View File

@ -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 = {}
)
}

View File

@ -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,
)

View File

@ -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
}
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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
)

View File

@ -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
}
}

View File

@ -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>

View File

@ -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" }