Compare commits

...

5 Commits

54 changed files with 1592 additions and 75 deletions

5
.gitignore vendored
View File

@ -7,8 +7,13 @@
/.idea/workspace.xml /.idea/workspace.xml
/.idea/navEditor.xml /.idea/navEditor.xml
/.idea/assetWizardSettings.xml /.idea/assetWizardSettings.xml
/.idea/deploymentTargetSelector.xml
/.idea/deviceManager.xml
/.idea/appInsightsSettings.xml
/.idea/koin-grouping-settings.xml
.DS_Store .DS_Store
/build /build
.claude/settings.local.json
/captures /captures
.externalNativeBuild .externalNativeBuild
.cxx .cxx

88
CLAUDE.md Normal file
View File

@ -0,0 +1,88 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Heimdall is an Android password manager (Jetpack Compose, Kotlin) that also acts as a
system **Autofill service**. Users register/login against a remote backend, then store and
retrieve credentials locally in an encrypted vault.
## Build & Test
```bash
./gradlew assembleDebug # build debug APK
./gradlew installDebug # build + install on connected device/emulator
./gradlew test # JVM unit tests (src/test)
./gradlew connectedAndroidTest # instrumented tests on device (src/androidTest)
./gradlew lint # Android lint
# single unit test class / method:
./gradlew test --tests "com.longnh15.heimdall.SomeTest"
./gradlew test --tests "com.longnh15.heimdall.SomeTest.someMethod"
```
- Single-module project (`:app`). Dependencies are managed via the version catalog
`gradle/libs.versions.toml` — add/upgrade libraries there, not inline in `build.gradle.kts`.
- minSdk 26, targetSdk/compileSdk 37, Java 11.
## Architecture
Clean-architecture-style layering under `app/src/main/java/com/longnh15/heimdall/`:
- **`domain/`** — interfaces + models, no Android/framework deps. `repository/` holds repo
interfaces, `model/` holds domain types (`ApiResult`, `AuthInfo`, `Credential`), `vault/`
holds the session abstraction.
- **`data/`** — implementations. `repository/*Impl` implement domain interfaces; `source/remote`
is Retrofit (`ApiService` + `dto/`); `source/local` is Room (`HeimdallDatabase`, `CredentialDao`,
`entity/` + mappers) plus DataStore; `preferences/` and `vault/` hold other impls.
- **`ui/`** — Compose screens grouped by feature (`auth/login`, `auth/register`, `onboarding`,
`vault`, `vault/edit`). Each feature follows MVVM: `*Screen` (Compose) + `*ViewModel` +
`*State` + `*Event` (user intents). State is exposed as `StateFlow` via `stateIn`.
- **`navigation/`** — `Screen` is a `@Serializable sealed class`; routes are type-safe Compose
Navigation destinations wired in `HeimdallNavGraph`.
Key cross-cutting flows:
- **Network**: repositories extend `BaseRepository` and wrap calls in `safeApiCall { ... }`,
which converts a Retrofit `Response` into `ApiResult.Success`/`ApiResult.Error`. Error bodies
are parsed by `ErrorBodyParser.toApiError()`. JSON config lives in `common/GlobalConfig.json`.
Base URL is hardcoded in `di/NetworkModule`.
- **Auth tokens**: on login, the token is encrypted with `common/TokenCrypto` (AES/GCM key in
the Android Keystore, IV prepended) and persisted in the `"terces"` DataStore via
`LocalDataSource`. Never store the token in plaintext.
- **Vault locking**: `VaultSessionManager` (impl in `data/vault`) holds an in-memory
`Locked`/`Unlocked(expiredAt)` `StateFlow`. `HeimdallApplication` observes
`ProcessLifecycleOwner` and calls `refreshSession()` on app foreground, re-locking after the
timeout. ViewModels read `sessionState` to gate access. (Timeout is currently 5 seconds —
`TIMEOUT_DURATION` in `VaultSessionManagerImpl`.)
- **Autofill**: `HeimdallAutofillService` (registered in the manifest, BIND_AUTOFILL_SERVICE)
receives an `AssistStructure`; `StructureParser` classifies username/password fields using a
layered heuristic (autofillHints → inputType → htmlInfo → text/hint keyword matching). The
fill values in `buildFillResponse` are currently **hardcoded placeholders**, not yet wired to
the vault.
- **First launch / onboarding**: `MainViewModel` reads `PreferenceDataSource` (`"settings"`
DataStore) to decide the nav start destination (Onboarding vs Login); the splash screen is held
until that flag resolves to avoid a flicker.
## Dependency Injection (Koin) — note the inconsistency
DI is **mid-migration between two Koin styles**, and only one is actually loaded:
- `HeimdallApplication.startKoin` loads **only** the manual `appModule` (`di/AppModule.kt`),
which uses the DSL (`single<Impl>() bind Interface::class`, `viewModel<...>()`).
- Other files use **Koin annotations** (`@Module`, `@Single` in `NetworkModule`/`DatabaseModule`,
`@KoinViewModel`, `@Singleton`) which require the KSP-generated module to be included to take
effect. These are **not currently passed to `startKoin`**.
When adding a dependency, prefer registering it in `appModule` so it is actually wired, or
explicitly include the annotation-generated modules in `startKoin`. Don't assume an `@Single`
annotation alone makes a class injectable here.
## Conventions
- Room schemas are exported to `app/schemas/`; bump the DB version and commit the new schema JSON
when changing entities.
- DTOs (`data/source/remote/dto`) use `kotlinx.serialization` (`@Serializable`); domain models are
separate types — map between them in the repository.
- Validation lives in `common/ValidationHelper`.

View File

@ -4,6 +4,11 @@ plugins {
alias(libs.plugins.devtool.ksp) alias(libs.plugins.devtool.ksp)
alias(libs.plugins.serialization) alias(libs.plugins.serialization)
alias(libs.plugins.koin.compiler) alias(libs.plugins.koin.compiler)
alias(libs.plugins.room)
}
room {
schemaDirectory("$projectDir/schemas")
} }
android { android {
@ -51,6 +56,7 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
testImplementation(libs.junit) testImplementation(libs.junit)
@ -76,4 +82,11 @@ dependencies {
implementation(libs.androidx.room) implementation(libs.androidx.room)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.ktx) 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"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:name=".HeimdallApplication" android:name=".HeimdallApplication"
@ -28,7 +29,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.Heimdall"> android:theme="@style/Theme.Heimdall.Starting">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <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.android.inject
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger 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 import org.koin.plugin.module.dsl.startKoin
//@KoinApplication(modules = [DatabaseModule::class]) //@KoinApplication(modules = [DatabaseModule::class])

View File

@ -8,6 +8,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.longnh15.heimdall.navigation.HeimdallNavGraph import com.longnh15.heimdall.navigation.HeimdallNavGraph
import com.longnh15.heimdall.navigation.Screen import com.longnh15.heimdall.navigation.Screen
@ -18,20 +19,31 @@ class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModel() private val viewModel: MainViewModel by viewModel()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState) 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() enableEdgeToEdge()
setContent { setContent {
HeimdallTheme { HeimdallTheme {
val isFirstLaunch by viewModel.isFirstLaunch.collectAsStateWithLifecycle() val isFirstLaunch by viewModel.isFirstLaunch.collectAsStateWithLifecycle()
val token by viewModel.token.collectAsStateWithLifecycle()
if (isFirstLaunch != null && token != null) {
HeimdallNavGraph( HeimdallNavGraph(
modifier = Modifier.fillMaxSize(), 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, isAutofillServiceEnabled = ::isAutofillServiceEnabled,
onCompleteOnboarding = viewModel::onCompleteOnboarding, onCompleteOnboarding = viewModel::onCompleteOnboarding,
) )
} }
} }
} }
}
fun isAutofillServiceEnabled(): Boolean { fun isAutofillServiceEnabled(): Boolean {
val afm = getSystemService(AutofillManager::class.java) val afm = getSystemService(AutofillManager::class.java)

View File

@ -3,6 +3,7 @@ package com.longnh15.heimdall
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.longnh15.heimdall.data.preferences.PreferenceDataSource import com.longnh15.heimdall.data.preferences.PreferenceDataSource
import com.longnh15.heimdall.data.source.local.LocalDataSource
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -12,7 +13,8 @@ import org.koin.core.annotation.KoinViewModel
@KoinViewModel @KoinViewModel
class MainViewModel ( class MainViewModel (
private val preferenceDataSource: PreferenceDataSource private val preferenceDataSource: PreferenceDataSource,
private val localDataSource: LocalDataSource
) : ViewModel() { ) : ViewModel() {
val isFirstLaunch: StateFlow<Boolean?> = preferenceDataSource.preferences val isFirstLaunch: StateFlow<Boolean?> = preferenceDataSource.preferences
.map { it.isFirstLaunch } .map { it.isFirstLaunch }
@ -22,6 +24,14 @@ class MainViewModel (
initialValue = null initialValue = null
) )
val token: StateFlow<String?> = localDataSource.token
.map { it.orEmpty() }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = null
)
fun onCompleteOnboarding() { fun onCompleteOnboarding() {
viewModelScope.launch { viewModelScope.launch {
preferenceDataSource.setFirstLaunchDone() 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(ApiResult.Error.CODE_UNKNOWN, "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(ApiResult.Error.CODE_UNKNOWN, "Empty response body")
} else {
response.errorBody().toApiError()
}
}
}

View File

@ -1,14 +1,30 @@
package com.longnh15.heimdall.data.source.local 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.toDomain
import com.longnh15.heimdall.data.source.local.entity.toEntity import com.longnh15.heimdall.data.source.local.entity.toEntity
import com.longnh15.heimdall.domain.model.Credential import com.longnh15.heimdall.domain.model.Credential
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
private val Context.dataStore by preferencesDataStore("terces")
class LocalDataSource( 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>> { fun getAllCredentials(): Flow<List<Credential>> {
return credentialDao.getAll().map { entities -> entities.map { it.toDomain() } } return credentialDao.getAll().map { entities -> entities.map { it.toDomain() } }
} }
@ -16,4 +32,22 @@ class LocalDataSource(
suspend fun save(credential: Credential) { suspend fun save(credential: Credential) {
credentialDao.insert(credential.toEntity()) 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(ApiResult.Error.CODE_UNKNOWN, "Unknown error")
}
return try {
val error = json.decodeFromString<ErrorResponse>(string())
ApiResult.Error(error.error, error.message)
} catch (_: Exception) {
ApiResult.Error(ApiResult.Error.CODE_UNKNOWN, "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.MainViewModel
import com.longnh15.heimdall.data.preferences.PreferenceDataSource 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.repository.CredentialRepositoryImpl
import com.longnh15.heimdall.data.source.local.LocalDataSource
import com.longnh15.heimdall.data.vault.VaultSessionManagerImpl 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.repository.CredentialRepository
import com.longnh15.heimdall.domain.vault.VaultSessionManager 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.onboarding.OnboardingViewModel
import com.longnh15.heimdall.ui.auth.register.RegisterViewModel
import com.longnh15.heimdall.ui.vault.VaultViewModel import com.longnh15.heimdall.ui.vault.VaultViewModel
import com.longnh15.heimdall.ui.vault.edit.AddEditVaultViewModel 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.bind
import org.koin.dsl.module import org.koin.dsl.module
import org.koin.plugin.module.dsl.single import org.koin.plugin.module.dsl.single
@ -31,10 +32,13 @@ import org.koin.plugin.module.dsl.viewModel
val appModule = module { val appModule = module {
single<VaultSessionManagerImpl>() bind VaultSessionManager::class single<VaultSessionManagerImpl>() bind VaultSessionManager::class
single<CredentialRepositoryImpl>() bind CredentialRepository::class single<CredentialRepositoryImpl>() bind CredentialRepository::class
single<AuthRepositoryImpl>() bind AuthRepository::class
single<PreferenceDataSource>() single<PreferenceDataSource>()
viewModel<MainViewModel>() viewModel<MainViewModel>()
viewModel<OnboardingViewModel>() viewModel<OnboardingViewModel>()
viewModel<VaultViewModel>() viewModel<VaultViewModel>()
viewModel<AddEditVaultViewModel>() viewModel<AddEditVaultViewModel>()
viewModel<LoginViewModel>()
viewModel<RegisterViewModel>()
} }

View File

@ -24,7 +24,7 @@ class DatabaseModule {
} }
@Single @Single
fun provideLocalDataSource(credentialDao: CredentialDao): LocalDataSource { fun provideLocalDataSource(credentialDao: CredentialDao, context: Context): LocalDataSource {
return LocalDataSource(credentialDao) 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,11 @@
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>() {
companion object {
/** Sentinel code for client-side failures (no/garbled response). [message] is an internal default, not shown to users. */
const val CODE_UNKNOWN = -999
}
}
}

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 package com.longnh15.heimdall.domain.vault
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.koin.core.annotation.Singleton
interface VaultSessionManager { interface VaultSessionManager {
val sessionState: StateFlow<VaultSessionState> val sessionState: StateFlow<VaultSessionState>

View File

@ -6,8 +6,12 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController 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.OnboardingPage
import com.longnh15.heimdall.ui.onboarding.OnboardingViewModel 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.VaultScreen
import com.longnh15.heimdall.ui.vault.VaultViewModel import com.longnh15.heimdall.ui.vault.VaultViewModel
import com.longnh15.heimdall.ui.vault.edit.AddEditVaultItemScreen import com.longnh15.heimdall.ui.vault.edit.AddEditVaultItemScreen
@ -18,7 +22,7 @@ import org.koin.compose.viewmodel.koinViewModel
fun HeimdallNavGraph( fun HeimdallNavGraph(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController(), navController: NavHostController = rememberNavController(),
startDestination: Screen = Screen.Onboarding, startDestination: Screen = Screen.Login,
isAutofillServiceEnabled: () -> Boolean, isAutofillServiceEnabled: () -> Boolean,
onCompleteOnboarding: () -> Unit, onCompleteOnboarding: () -> Unit,
) { ) {
@ -27,6 +31,27 @@ fun HeimdallNavGraph(
startDestination = startDestination, startDestination = startDestination,
modifier = modifier 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> { composable<Screen.Onboarding> {
OnboardingPage( OnboardingPage(
viewModel = koinViewModel<OnboardingViewModel>(), viewModel = koinViewModel<OnboardingViewModel>(),

View File

@ -4,6 +4,10 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
sealed class Screen { sealed class Screen {
@Serializable
data object Login: Screen()
@Serializable
data object Register: Screen()
@Serializable @Serializable
data object Onboarding: Screen() data object Onboarding: Screen()
@Serializable @Serializable

View File

@ -0,0 +1,211 @@
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.common.UiText
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: UiText? = 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.asString(),
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,8 @@
package com.longnh15.heimdall.ui.auth.login
import com.longnh15.heimdall.ui.common.UiText
sealed class LoginEvent {
data object LoginSuccess: LoginEvent()
data class LoginError(val message: UiText): LoginEvent()
}

View File

@ -0,0 +1,230 @@
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
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.R
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
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.uiEvent.collect { event ->
when (event) {
is LoginEvent.LoginSuccess -> navigateToVault()
is LoginEvent.LoginError -> snackbarHostState.showSnackbar(event.message.asString(context))
}
}
}
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 = stringResource(R.string.login_title),
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.login_subtitle),
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 = stringResource(R.string.field_email_label),
placeholder = stringResource(R.string.login_email_placeholder),
error = state.emailError,
keyboardType = KeyboardType.Email
)
AuthDivider()
AuthFormTextField(
value = state.password,
onValueChange = viewModel::onPasswordChanged,
label = stringResource(R.string.field_password_label),
placeholder = stringResource(R.string.field_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(stringResource(R.string.login_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 = stringResource(R.string.login_button),
style = MaterialTheme.typography.titleMedium
)
}
Spacer(Modifier.height(28.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.login_no_account),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(onClick = navigateToRegister) {
Text(
text = stringResource(R.string.login_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,13 @@
package com.longnh15.heimdall.ui.auth.login
import com.longnh15.heimdall.ui.common.UiText
data class LoginState(
val email: String = "",
val password: String = "",
val emailError: UiText? = null,
val passwordError: UiText? = null,
val isShowPassword: Boolean = false,
)

View File

@ -0,0 +1,60 @@
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.R
import com.longnh15.heimdall.common.ValidationHelper
import com.longnh15.heimdall.domain.model.ApiResult
import com.longnh15.heimdall.domain.repository.AuthRepository
import com.longnh15.heimdall.ui.common.UiText
import com.longnh15.heimdall.ui.common.toUiText
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.toUiText()))
}
}
}
private fun validate(): Boolean {
val emailError = when {
state.email.isBlank() -> UiText.Res(R.string.error_email_required)
!ValidationHelper.isEmail(state.email) -> UiText.Res(R.string.error_email_invalid)
else -> null
}
val passwordError = if (state.password.isBlank()) UiText.Res(R.string.error_password_required) else null
state = state.copy(emailError = emailError, passwordError = passwordError)
return emailError == null && passwordError == null
}
}

View File

@ -0,0 +1,8 @@
package com.longnh15.heimdall.ui.auth.register
import com.longnh15.heimdall.ui.common.UiText
sealed class RegisterEvent {
data object RegisterSuccess: RegisterEvent()
data class RegisterError(val message: UiText): RegisterEvent()
}

View File

@ -0,0 +1,231 @@
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.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.platform.LocalContext
import androidx.compose.ui.res.stringResource
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.R
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 snackbarHostState = remember { SnackbarHostState() }
val state = viewModel.state
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.uiEvent.collect { event ->
when (event) {
is RegisterEvent.RegisterSuccess -> navigateToLogin()
is RegisterEvent.RegisterError -> snackbarHostState.showSnackbar(event.message.asString(context))
}
}
}
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 = stringResource(R.string.register_title),
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.register_subtitle),
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 = stringResource(R.string.register_name_label),
placeholder = stringResource(R.string.register_name_placeholder),
keyboardType = KeyboardType.Text
)
AuthDivider()
AuthFormTextField(
value = state.email,
onValueChange = viewModel::onEmailChanged,
label = stringResource(R.string.field_email_label),
placeholder = stringResource(R.string.register_email_placeholder),
error = state.emailError,
keyboardType = KeyboardType.Email,
)
AuthDivider()
AuthFormTextField(
value = state.password,
onValueChange = viewModel::onPasswordChanged,
label = stringResource(R.string.field_password_label),
placeholder = stringResource(R.string.field_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
)
}
}
)
}
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 = stringResource(R.string.register_button),
style = MaterialTheme.typography.titleMedium
)
}
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(R.string.register_terms),
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 = stringResource(R.string.register_have_account),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(onClick = navigateToLogin) {
Text(
text = stringResource(R.string.register_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,14 @@
package com.longnh15.heimdall.ui.auth.register
import com.longnh15.heimdall.ui.common.UiText
data class RegisterState(
val name: String = "",
val email: String = "",
val password: String = "",
val emailError: UiText? = null,
val passwordError: UiText? = null,
val isShowPassword: Boolean = false
)

View File

@ -0,0 +1,73 @@
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.R
import com.longnh15.heimdall.common.ValidationHelper
import com.longnh15.heimdall.domain.model.ApiResult
import com.longnh15.heimdall.domain.repository.AuthRepository
import com.longnh15.heimdall.ui.common.UiText
import com.longnh15.heimdall.ui.common.toUiText
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.toUiText()))
}
}
}
private fun validate(): Boolean {
val emailError = when {
state.email.isBlank() -> UiText.Res(R.string.error_email_required)
!ValidationHelper.isEmail(state.email) -> UiText.Res(R.string.error_email_invalid)
else -> null
}
val passwordError = when {
state.password.isBlank() -> UiText.Res(R.string.error_password_required)
state.password.length < MIN_PASSWORD_LENGTH ->
UiText.Res(R.string.error_password_too_short, MIN_PASSWORD_LENGTH)
else -> null
}
state = state.copy(emailError = emailError, passwordError = passwordError)
return emailError == null && passwordError == null
}
private companion object {
const val MIN_PASSWORD_LENGTH = 12
}
}

View File

@ -7,6 +7,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.longnh15.heimdall.R
@Composable @Composable
fun AutofillNotEnabledDialog( fun AutofillNotEnabledDialog(
@ -18,15 +20,15 @@ fun AutofillNotEnabledDialog(
icon = { icon = {
Icon(Icons.Outlined.Warning, contentDescription = null) Icon(Icons.Outlined.Warning, contentDescription = null)
}, },
title = { Text("Autofill not enabled") }, title = { Text(stringResource(R.string.autofill_dialog_title)) },
text = { text = {
Text("Heimdall wasn't selected as your autofill provider. Open settings and tap Heimdall to enable it.") Text(stringResource(R.string.autofill_dialog_body))
}, },
confirmButton = { confirmButton = {
TextButton(onClick = onRetry) { Text("Open settings") } TextButton(onClick = onRetry) { Text(stringResource(R.string.open_settings)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { Text("Skip for now") } TextButton(onClick = onDismiss) { Text(stringResource(R.string.autofill_dialog_skip)) }
} }
) )
} }

View File

@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.longnh15.heimdall.R import com.longnh15.heimdall.R
@ -33,7 +34,7 @@ fun OnboardingFinalPage(
verticalArrangement = Arrangement.spacedBy(32.dp) verticalArrangement = Arrangement.spacedBy(32.dp)
) { ) {
OnboardingBadge { OnboardingBadge {
Text("You're all set") Text(stringResource(R.string.onboarding_final_badge))
} }
Box( Box(
modifier = Modifier modifier = Modifier
@ -43,20 +44,20 @@ fun OnboardingFinalPage(
) { ) {
Image( Image(
painterResource(R.drawable.tick_icon), painterResource(R.drawable.tick_icon),
"Page icon", stringResource(R.string.onboarding_page_icon_desc),
) )
} }
Text("Heimdall is watching", style = MaterialTheme.typography.headlineMedium) Text(stringResource(R.string.onboarding_final_title), style = MaterialTheme.typography.headlineMedium)
Text( Text(
text = "Your vault is ready. Start adding passwords or import from your browser to get going.", text = stringResource(R.string.onboarding_final_body),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
Button(onClick = navigateToVault) { Button(onClick = navigateToVault) {
Text("Enter my vault ↗") Text(stringResource(R.string.onboarding_final_enter_vault))
} }
} }
} }

View File

@ -20,6 +20,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -34,11 +35,11 @@ fun OnboardingIntroducePage(modifier: Modifier = Modifier) {
verticalArrangement = Arrangement.spacedBy(32.dp) verticalArrangement = Arrangement.spacedBy(32.dp)
) { ) {
OnboardingBadge { OnboardingBadge {
Text("What you get") Text(stringResource(R.string.onboarding_introduce_badge))
} }
Text("Everything in one vault", style = MaterialTheme.typography.headlineMedium) Text(stringResource(R.string.onboarding_introduce_title), style = MaterialTheme.typography.headlineMedium)
Text( Text(
text = "Heimdall keeps your passwords safe and fills them in automatically", text = stringResource(R.string.onboarding_introduce_body),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Column( Column(
@ -47,18 +48,18 @@ fun OnboardingIntroducePage(modifier: Modifier = Modifier) {
) { ) {
FeatureItem( FeatureItem(
iconDrawableRes = R.drawable.lock_icon, iconDrawableRes = R.drawable.lock_icon,
text = "End-to-end encrypted", text = stringResource(R.string.onboarding_feature_encrypted_title),
sub = "Only you can see your data" sub = stringResource(R.string.onboarding_feature_encrypted_sub)
) )
FeatureItem( FeatureItem(
iconDrawableRes = R.drawable.crosshair_icon, iconDrawableRes = R.drawable.crosshair_icon,
text = "Autofill anywhere", text = stringResource(R.string.onboarding_feature_autofill_title),
sub = "Works across all your apps" sub = stringResource(R.string.onboarding_feature_autofill_sub)
) )
FeatureItem( FeatureItem(
iconDrawableRes = R.drawable.person_icon, iconDrawableRes = R.drawable.person_icon,
text = "Biometric unlock", text = stringResource(R.string.onboarding_feature_biometric_title),
sub = "Fingerprint or face to open" sub = stringResource(R.string.onboarding_feature_biometric_sub)
) )
} }
} }
@ -89,7 +90,7 @@ fun FeatureItem(
.background(Color(0xFFEEEDFE), RoundedCornerShape(8.dp)), .background(Color(0xFFEEEDFE), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Image(painterResource(iconDrawableRes), "Icon") Image(painterResource(iconDrawableRes), stringResource(R.string.onboarding_feature_icon_desc))
} }
Column { Column {
Text(text = text, fontSize = 13.sp, fontWeight = FontWeight(500), color = MaterialTheme.colorScheme.onPrimary) Text(text = text, fontSize = 13.sp, fontWeight = FontWeight(500), color = MaterialTheme.colorScheme.onPrimary)

View File

@ -19,6 +19,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.longnh15.heimdall.R import com.longnh15.heimdall.R
@ -34,7 +35,7 @@ fun OnboardingRequestEnablePage(
verticalArrangement = Arrangement.spacedBy(32.dp) verticalArrangement = Arrangement.spacedBy(32.dp)
) { ) {
OnboardingBadge { OnboardingBadge {
Text("Setup · 1 of 2") Text(stringResource(R.string.onboarding_enable_badge))
} }
Box( Box(
modifier = Modifier modifier = Modifier
@ -44,20 +45,20 @@ fun OnboardingRequestEnablePage(
) { ) {
Image( Image(
painterResource(R.drawable.page_icon), painterResource(R.drawable.page_icon),
"Page icon", stringResource(R.string.onboarding_page_icon_desc),
) )
} }
Text("Enable autofill", style = MaterialTheme.typography.headlineMedium) Text(stringResource(R.string.onboarding_enable_title), style = MaterialTheme.typography.headlineMedium)
Text( Text(
text = "Let Heimdall fill your passwords automatically. You'll be taken to Android settings — tap Heimdall and you're done.", text = stringResource(R.string.onboarding_enable_body),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
Button(onClick = onOpenAutofillSettings) { Button(onClick = onOpenAutofillSettings) {
Text("Open settings") Text(stringResource(R.string.open_settings))
} }
} }
} }

View File

@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -36,12 +37,12 @@ fun OnboardingWelcomePage(modifier: Modifier = Modifier) {
) { ) {
Image( Image(
painterResource(R.drawable.shield_icon), painterResource(R.drawable.shield_icon),
"Heimdall Logo", stringResource(R.string.onboarding_welcome_logo_desc),
) )
} }
Text("Welcome to Heimdall", style = MaterialTheme.typography.headlineMedium) Text(stringResource(R.string.onboarding_welcome_title), style = MaterialTheme.typography.headlineMedium)
Text( Text(
text = "Your all-seeing guardian for passwords. Secure, private, and always watching over your credentials", text = stringResource(R.string.onboarding_welcome_body),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} }

View File

@ -19,7 +19,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.longnh15.heimdall.R
@Composable @Composable
fun LockOverlay( fun LockOverlay(
@ -43,12 +45,12 @@ fun LockOverlay(
modifier = Modifier.size(48.dp) modifier = Modifier.size(48.dp)
) )
Text( Text(
text = "Vault is locked", text = stringResource(R.string.vault_locked_title),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground color = MaterialTheme.colorScheme.onBackground
) )
Text( Text(
text = "Authenticate to access your credentials", text = stringResource(R.string.vault_locked_subtitle),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@ -60,7 +62,7 @@ fun LockOverlay(
modifier = Modifier.size(18.dp) modifier = Modifier.size(18.dp)
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text("Unlock with biometrics") Text(stringResource(R.string.vault_unlock_biometrics))
} }
} }
} }

View File

@ -17,14 +17,16 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.longnh15.heimdall.R
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun VaultHeader() { fun VaultHeader() {
TopAppBar( TopAppBar(
title = { title = {
Text("My vault") Text(stringResource(R.string.vault_title))
}, },
colors = TopAppBarDefaults.topAppBarColors().copy( colors = TopAppBarDefaults.topAppBarColors().copy(
containerColor = MaterialTheme.colorScheme.background containerColor = MaterialTheme.colorScheme.background

View File

@ -13,8 +13,10 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.longnh15.heimdall.R
@Composable @Composable
fun VaultScreen( fun VaultScreen(
@ -33,12 +35,14 @@ fun VaultScreen(
} }
} }
) )
val requestBiometric = remember { { val unlockTitle = stringResource(R.string.vault_unlock_title)
val unlockSubtitle = stringResource(R.string.vault_unlock_subtitle)
val requestBiometric = remember(unlockTitle, unlockSubtitle) { {
val request = biometricRequest( val request = biometricRequest(
title = "Unlock Heimdall", title = unlockTitle,
AuthenticationRequest.Biometric.Fallback.DeviceCredential, AuthenticationRequest.Biometric.Fallback.DeviceCredential,
) { ) {
setSubtitle("Confirm your identity to access your vault") setSubtitle(unlockSubtitle)
} }
launcher.launch(request) launcher.launch(request)
} } } }

View File

@ -1,6 +1,8 @@
package com.longnh15.heimdall.ui.vault.edit package com.longnh15.heimdall.ui.vault.edit
import com.longnh15.heimdall.ui.common.UiText
sealed class AddEditEvent { sealed class AddEditEvent {
data object SaveSuccess: AddEditEvent() data object SaveSuccess: AddEditEvent()
data class SaveError(val message: String): AddEditEvent() data class SaveError(val message: UiText): AddEditEvent()
} }

View File

@ -20,9 +20,11 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.longnh15.heimdall.R
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -34,7 +36,7 @@ fun AddEditHeader(
) { ) {
TopAppBar( TopAppBar(
title = { title = {
Text("New item") Text(stringResource(R.string.add_edit_title))
}, },
modifier = Modifier.padding(end = 16.dp), modifier = Modifier.padding(end = 16.dp),
colors = TopAppBarDefaults.topAppBarColors().copy( colors = TopAppBarDefaults.topAppBarColors().copy(
@ -64,7 +66,7 @@ fun AddEditHeader(
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
} }
Text( Text(
text = if (isSaving) "Saving…" else "Save", text = if (isSaving) stringResource(R.string.add_edit_saving) else stringResource(R.string.add_edit_save),
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight(500), fontWeight = FontWeight(500),
color = MaterialTheme.colorScheme.onTertiaryContainer color = MaterialTheme.colorScheme.onTertiaryContainer

View File

@ -24,10 +24,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.longnh15.heimdall.R
import com.longnh15.heimdall.ui.theme.HeimdallTheme import com.longnh15.heimdall.ui.theme.HeimdallTheme
@Composable @Composable
@ -37,12 +40,13 @@ fun AddEditVaultItemScreen(
popScreen: () -> Unit, popScreen: () -> Unit,
) { ) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.uiEvent.collect { event -> viewModel.uiEvent.collect { event ->
when (event) { when (event) {
AddEditEvent.SaveSuccess -> popScreen() AddEditEvent.SaveSuccess -> popScreen()
is AddEditEvent.SaveError -> snackbarHostState.showSnackbar(event.message) is AddEditEvent.SaveError -> snackbarHostState.showSnackbar(event.message.asString(context))
} }
} }
} }
@ -66,20 +70,20 @@ fun AddEditVaultItemScreen(
modifier.padding(paddingValues).padding(horizontal = 16.dp), modifier.padding(paddingValues).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
AddEditFormHeader("details") AddEditFormHeader(stringResource(R.string.add_edit_section_details))
AddEditForm { AddEditForm {
AddEditFormTextField( AddEditFormTextField(
value = viewModel.state.name, value = viewModel.state.name,
onValueChange = viewModel::onNameChanged, onValueChange = viewModel::onNameChanged,
label = "Name", label = stringResource(R.string.add_edit_name_label),
placeholder = "e.g. Google" placeholder = stringResource(R.string.add_edit_name_placeholder)
) )
AddEditDivider() AddEditDivider()
AddEditFormTextField( AddEditFormTextField(
value = viewModel.state.url, value = viewModel.state.url,
onValueChange = viewModel::onUrlChanged, onValueChange = viewModel::onUrlChanged,
label = "URL", label = stringResource(R.string.add_edit_url_label),
placeholder = "google.com", placeholder = stringResource(R.string.add_edit_url_placeholder),
actions = { actions = {
IconButton( IconButton(
onClick = {}, onClick = {},
@ -90,20 +94,20 @@ fun AddEditVaultItemScreen(
} }
) )
} }
AddEditFormHeader("credentials") AddEditFormHeader(stringResource(R.string.add_edit_section_credentials))
AddEditForm { AddEditForm {
AddEditFormTextField( AddEditFormTextField(
viewModel.state.username, viewModel.state.username,
onValueChange = viewModel::onUsernameChanged, onValueChange = viewModel::onUsernameChanged,
label = "Username", label = stringResource(R.string.add_edit_username_label),
placeholder = "you@email.com" placeholder = stringResource(R.string.add_edit_username_placeholder)
) )
AddEditDivider() AddEditDivider()
AddEditFormTextField( AddEditFormTextField(
value = viewModel.state.password, value = viewModel.state.password,
onValueChange = viewModel::onPasswordChanged, onValueChange = viewModel::onPasswordChanged,
label = "Password", label = stringResource(R.string.field_password_label),
placeholder = "••••••••", placeholder = stringResource(R.string.field_password_placeholder),
isHideContent = viewModel.state.isShowPassword.not(), isHideContent = viewModel.state.isShowPassword.not(),
isLastField = true, isLastField = true,
autoCorrectEnabled = false, autoCorrectEnabled = false,

View File

@ -5,7 +5,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.longnh15.heimdall.R
import com.longnh15.heimdall.domain.repository.CredentialRepository import com.longnh15.heimdall.domain.repository.CredentialRepository
import com.longnh15.heimdall.ui.common.UiText
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -52,7 +54,8 @@ class AddEditVaultViewModel(
_uiEvent.send(AddEditEvent.SaveSuccess) _uiEvent.send(AddEditEvent.SaveSuccess)
} catch (e: Exception) { } catch (e: Exception) {
state = state.copy(isSaving = false) state = state.copy(isSaving = false)
_uiEvent.send(AddEditEvent.SaveError(e.message ?: "Something went wrong.")) val message = e.message?.let { UiText.Dynamic(it) } ?: UiText.Res(R.string.error_generic)
_uiEvent.send(AddEditEvent.SaveError(message))
} }
} }
} }

View File

@ -1,3 +1,91 @@
<resources> <resources>
<string name="app_name">Heimdall</string> <string name="app_name">Heimdall</string>
<!-- Errors / validation -->
<string name="error_email_required">Email is required</string>
<string name="error_email_invalid">Enter a valid email address</string>
<string name="error_password_required">Password is required</string>
<string name="error_password_too_short">Password must be at least %1$d characters</string>
<string name="error_generic">Something went wrong.</string>
<!-- Shared form fields -->
<string name="field_email_label">Email</string>
<string name="field_password_label">Password</string>
<string name="field_password_placeholder">••••••••</string>
<string name="open_settings">Open settings</string>
<!-- Login -->
<string name="login_title">Welcome back</string>
<string name="login_subtitle">Access your secure vault credentials</string>
<string name="login_email_placeholder">you@email.com</string>
<string name="login_forgot_password">Forgot password?</string>
<string name="login_button">Login</string>
<string name="login_no_account">Don\'t have an account?</string>
<string name="login_create_one">Create one</string>
<!-- Register -->
<string name="register_title">Create account</string>
<string name="register_subtitle">Secure your digital life with enterprise-grade encryption.</string>
<string name="register_name_label">Full name</string>
<string name="register_name_placeholder">John Doe</string>
<string name="register_email_placeholder">name@vault.com</string>
<string name="register_button">Create account</string>
<string name="register_terms">By creating an account you agree to our Terms of Service and Privacy Policy.</string>
<string name="register_have_account">Already have an account?</string>
<string name="register_sign_in">Sign in</string>
<!-- Onboarding: welcome -->
<string name="onboarding_welcome_logo_desc">Heimdall Logo</string>
<string name="onboarding_welcome_title">Welcome to Heimdall</string>
<string name="onboarding_welcome_body">Your all-seeing guardian for passwords. Secure, private, and always watching over your credentials</string>
<!-- Onboarding: introduce -->
<string name="onboarding_introduce_badge">What you get</string>
<string name="onboarding_introduce_title">Everything in one vault</string>
<string name="onboarding_introduce_body">Heimdall keeps your passwords safe and fills them in automatically</string>
<string name="onboarding_feature_icon_desc">Icon</string>
<string name="onboarding_feature_encrypted_title">End-to-end encrypted</string>
<string name="onboarding_feature_encrypted_sub">Only you can see your data</string>
<string name="onboarding_feature_autofill_title">Autofill anywhere</string>
<string name="onboarding_feature_autofill_sub">Works across all your apps</string>
<string name="onboarding_feature_biometric_title">Biometric unlock</string>
<string name="onboarding_feature_biometric_sub">Fingerprint or face to open</string>
<!-- Onboarding: request enable -->
<string name="onboarding_page_icon_desc">Page icon</string>
<string name="onboarding_enable_badge">Setup · 1 of 2</string>
<string name="onboarding_enable_title">Enable autofill</string>
<string name="onboarding_enable_body">Let Heimdall fill your passwords automatically. You\'ll be taken to Android settings — tap Heimdall and you\'re done.</string>
<!-- Onboarding: final -->
<string name="onboarding_final_badge">You\'re all set</string>
<string name="onboarding_final_title">Heimdall is watching</string>
<string name="onboarding_final_body">Your vault is ready. Start adding passwords or import from your browser to get going.</string>
<string name="onboarding_final_enter_vault">Enter my vault ↗</string>
<!-- Autofill not enabled dialog -->
<string name="autofill_dialog_title">Autofill not enabled</string>
<string name="autofill_dialog_body">Heimdall wasn\'t selected as your autofill provider. Open settings and tap Heimdall to enable it.</string>
<string name="autofill_dialog_skip">Skip for now</string>
<!-- Vault -->
<string name="vault_title">My vault</string>
<string name="vault_unlock_title">Unlock Heimdall</string>
<string name="vault_unlock_subtitle">Confirm your identity to access your vault</string>
<string name="vault_locked_title">Vault is locked</string>
<string name="vault_locked_subtitle">Authenticate to access your credentials</string>
<string name="vault_unlock_biometrics">Unlock with biometrics</string>
<!-- Add / edit vault item -->
<string name="add_edit_title">New item</string>
<string name="add_edit_saving">Saving…</string>
<string name="add_edit_save">Save</string>
<string name="add_edit_section_details">details</string>
<string name="add_edit_section_credentials">credentials</string>
<string name="add_edit_name_label">Name</string>
<string name="add_edit_name_placeholder">e.g. Google</string>
<string name="add_edit_url_label">URL</string>
<string name="add_edit_url_placeholder">google.com</string>
<string name="add_edit_username_label">Username</string>
<string name="add_edit_username_placeholder">you@email.com</string>
</resources> </resources>

View File

@ -2,4 +2,12 @@
<resources> <resources>
<style name="Theme.Heimdall" parent="android:Theme.Material.Light.NoActionBar" /> <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> </resources>

View File

@ -6,17 +6,20 @@ junitVersion = "1.3.0"
espressoCore = "3.7.0" espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.13.0" activityCompose = "1.13.0"
kotlin = "2.3.20" kotlin = "2.4.0"
composeBom = "2026.05.01" composeBom = "2026.05.01"
kspVersion = "2.3.6" kspVersion = "2.3.6"
koin = "4.2.1" koin = "4.2.2"
koinKsp = "4.2.1" koinKsp = "4.2.2"
koinPlugin = "1.0.0" koinPlugin = "1.0.1"
navigation = "2.9.8" navigation = "2.9.8"
serialization = "2.4.0" serialization = "2.4.0"
serializationJson = "1.11.0"
datastore = "1.2.1" datastore = "1.2.1"
biometric = "1.4.0-alpha07" biometric = "1.4.0-alpha07"
room = "2.8.4" room = "2.8.4"
retrofit = "3.0.0"
splashscreen = "1.2.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-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"} koin-annotations = { group = "io.insert-koin", name = "koin-annotations", version.ref = "koinKsp"}
navigation-compose = { group = "androidx.navigation" , name = "navigation-compose", version.ref = "navigation" } 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" } biometric-compose = { group = "androidx.biometric", name = "biometric-compose", version.ref = "biometric" }
androidx-room = { group = "androidx.room", name = "room-runtime", version.ref = "room" } 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-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } 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" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" } serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" }
koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koinPlugin" } koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koinPlugin" }
room = { id = "androidx.room", version.ref = "room" }