Compare commits
4 Commits
749427f4d8
...
dfd8414be6
| Author | SHA1 | Date | |
|---|---|---|---|
| dfd8414be6 | |||
| e6d05e5d6d | |||
| 7f4949420f | |||
| 580bc993cc |
5
.gitignore
vendored
5
.gitignore
vendored
@ -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
88
CLAUDE.md
Normal 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`.
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 1,
|
||||||
|
"identityHash": "e09aa1c4dfa12dfb105b0ea16d4584ff",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "credential",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT, `username` TEXT NOT NULL, `password` TEXT NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "username",
|
||||||
|
"columnName": "username",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "password",
|
||||||
|
"columnName": "password",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e09aa1c4dfa12dfb105b0ea16d4584ff')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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" />
|
||||||
|
|
||||||
|
|||||||
@ -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])
|
||||||
|
|||||||
@ -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,17 +19,28 @@ 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()
|
||||||
HeimdallNavGraph(
|
val token by viewModel.token.collectAsStateWithLifecycle()
|
||||||
modifier = Modifier.fillMaxSize(),
|
if (isFirstLaunch != null && token != null) {
|
||||||
startDestination = if (isFirstLaunch != false) Screen.Onboarding else Screen.Vault,
|
HeimdallNavGraph(
|
||||||
isAutofillServiceEnabled = ::isAutofillServiceEnabled,
|
modifier = Modifier.fillMaxSize(),
|
||||||
onCompleteOnboarding = viewModel::onCompleteOnboarding,
|
startDestination = when {
|
||||||
)
|
isFirstLaunch!! -> Screen.Onboarding
|
||||||
|
token!!.isEmpty() -> Screen.Login
|
||||||
|
else -> Screen.Vault
|
||||||
|
},
|
||||||
|
isAutofillServiceEnabled = ::isAutofillServiceEnabled,
|
||||||
|
onCompleteOnboarding = viewModel::onCompleteOnboarding,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.longnh15.heimdall
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.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()
|
||||||
|
|||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.longnh15.heimdall.common
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
object GlobalConfig {
|
||||||
|
val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
package com.longnh15.heimdall.common
|
||||||
|
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
|
||||||
|
object TokenCrypto {
|
||||||
|
private const val PROVIDER = "AndroidKeyStore"
|
||||||
|
private const val ALIAS = "auth_token_key"
|
||||||
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
private const val IV_SIZE = 12
|
||||||
|
private const val TAG_BITS = 128
|
||||||
|
|
||||||
|
private val keystore = KeyStore.getInstance(PROVIDER).apply { load(null) }
|
||||||
|
|
||||||
|
private fun getOrCreateKey(): SecretKey {
|
||||||
|
(keystore.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let {
|
||||||
|
return it.secretKey
|
||||||
|
}
|
||||||
|
val keyGen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, PROVIDER)
|
||||||
|
keyGen.init(
|
||||||
|
KeyGenParameterSpec.Builder(
|
||||||
|
ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setKeySize(256)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
return keyGen.generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encrypt(plainText: String): String {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||||
|
val iv = cipher.iv // 12-byte IV, generated by Keystore
|
||||||
|
val cipherText = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))
|
||||||
|
return Base64.encode(iv + cipherText) // prepend IV
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decrypt(encoded: String): String {
|
||||||
|
val combined = Base64.decode(encoded)
|
||||||
|
val iv = combined.copyOfRange(0, IV_SIZE)
|
||||||
|
val cipherText = combined.copyOfRange(IV_SIZE, combined.size)
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(TAG_BITS, iv))
|
||||||
|
return cipher.doFinal(cipherText).toString(Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.longnh15.heimdall.common
|
||||||
|
|
||||||
|
import android.util.Patterns.EMAIL_ADDRESS
|
||||||
|
|
||||||
|
object ValidationHelper {
|
||||||
|
fun isEmail(input: String): Boolean {
|
||||||
|
return EMAIL_ADDRESS.matcher(input).matches()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package com.longnh15.heimdall.data.repository
|
||||||
|
|
||||||
|
import com.longnh15.heimdall.data.source.local.LocalDataSource
|
||||||
|
import com.longnh15.heimdall.data.source.remote.ApiService
|
||||||
|
import com.longnh15.heimdall.data.source.remote.dto.LoginRequest
|
||||||
|
import com.longnh15.heimdall.data.source.remote.dto.RegisterRequest
|
||||||
|
import com.longnh15.heimdall.domain.model.ApiResult
|
||||||
|
import com.longnh15.heimdall.domain.model.AuthInfo
|
||||||
|
import com.longnh15.heimdall.domain.repository.AuthRepository
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class AuthRepositoryImpl(
|
||||||
|
private val api: ApiService,
|
||||||
|
private val localDataSource: LocalDataSource
|
||||||
|
) : BaseRepository(), AuthRepository {
|
||||||
|
override suspend fun register(
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
name: String
|
||||||
|
): ApiResult<String> {
|
||||||
|
val registerInfo = RegisterRequest(email, password, name)
|
||||||
|
return safeApiCall(
|
||||||
|
block = { api.register(registerInfo) },
|
||||||
|
transform = { it.userId }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun login(
|
||||||
|
email: String,
|
||||||
|
password: String
|
||||||
|
): ApiResult<AuthInfo> {
|
||||||
|
val loginInfo = LoginRequest(email, password)
|
||||||
|
return safeApiCall(
|
||||||
|
block = { api.login(loginInfo) },
|
||||||
|
transform = {
|
||||||
|
localDataSource.saveToken(it.token)
|
||||||
|
AuthInfo(
|
||||||
|
it.userId,
|
||||||
|
it.token,
|
||||||
|
Instant.parse(it.expiresAt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package com.longnh15.heimdall.data.repository
|
||||||
|
|
||||||
|
import com.longnh15.heimdall.data.source.remote.toApiError
|
||||||
|
import com.longnh15.heimdall.domain.model.ApiResult
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
|
abstract class BaseRepository {
|
||||||
|
suspend fun <RemoteResponse> safeApiCall(
|
||||||
|
block: suspend () -> Response<RemoteResponse>
|
||||||
|
): ApiResult<RemoteResponse> {
|
||||||
|
val response = block()
|
||||||
|
|
||||||
|
return if (response.isSuccessful) {
|
||||||
|
response.body()?.let {
|
||||||
|
ApiResult.Success(it)
|
||||||
|
} ?: ApiResult.Error(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package com.longnh15.heimdall.data.source.remote
|
||||||
|
|
||||||
|
import com.longnh15.heimdall.data.source.remote.dto.LoginRequest
|
||||||
|
import com.longnh15.heimdall.data.source.remote.dto.LoginResponse
|
||||||
|
import com.longnh15.heimdall.data.source.remote.dto.RegisterRequest
|
||||||
|
import com.longnh15.heimdall.data.source.remote.dto.RegisterResponse
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.POST
|
||||||
|
|
||||||
|
interface ApiService {
|
||||||
|
@POST("/api/auth/register")
|
||||||
|
suspend fun register(@Body registerInfo: RegisterRequest): Response<RegisterResponse>
|
||||||
|
|
||||||
|
@POST("/api/app/login")
|
||||||
|
suspend fun login(@Body loginInfo: LoginRequest): Response<LoginResponse>
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package com.longnh15.heimdall.data.source.remote
|
||||||
|
|
||||||
|
import com.longnh15.heimdall.common.GlobalConfig.json
|
||||||
|
import com.longnh15.heimdall.data.source.remote.dto.ErrorResponse
|
||||||
|
import com.longnh15.heimdall.domain.model.ApiResult
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
|
||||||
|
fun ResponseBody?.toApiError(): ApiResult.Error {
|
||||||
|
if (this == null) {
|
||||||
|
return ApiResult.Error(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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.longnh15.heimdall.data.source.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ErrorResponse(
|
||||||
|
val error: Int,
|
||||||
|
val message: String
|
||||||
|
)
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.longnh15.heimdall.data.source.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginRequest(
|
||||||
|
val email: String,
|
||||||
|
val password: String
|
||||||
|
)
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.longnh15.heimdall.data.source.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginResponse(
|
||||||
|
val token: String,
|
||||||
|
val userId: String,
|
||||||
|
val expiresAt: String,
|
||||||
|
)
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.longnh15.heimdall.data.source.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RegisterRequest(
|
||||||
|
val email: String,
|
||||||
|
val password: String,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.longnh15.heimdall.data.source.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RegisterResponse(
|
||||||
|
val ok: Boolean,
|
||||||
|
val userId: String
|
||||||
|
)
|
||||||
@ -2,16 +2,17 @@ package com.longnh15.heimdall.di
|
|||||||
|
|
||||||
import com.longnh15.heimdall.MainViewModel
|
import com.longnh15.heimdall.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>()
|
||||||
}
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
27
app/src/main/java/com/longnh15/heimdall/di/NetworkModule.kt
Normal file
27
app/src/main/java/com/longnh15/heimdall/di/NetworkModule.kt
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package com.longnh15.heimdall.di
|
||||||
|
|
||||||
|
import com.longnh15.heimdall.common.GlobalConfig.json
|
||||||
|
import com.longnh15.heimdall.data.source.remote.ApiService
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import org.koin.core.annotation.Module
|
||||||
|
import org.koin.core.annotation.Single
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
|
import retrofit2.create
|
||||||
|
|
||||||
|
@Module
|
||||||
|
class NetworkModule {
|
||||||
|
private companion object {
|
||||||
|
const val BASE_URL = "https://heimdall.lol"
|
||||||
|
}
|
||||||
|
@Single
|
||||||
|
fun provideRetrofit() = Retrofit.Builder()
|
||||||
|
.addConverterFactory(
|
||||||
|
json.asConverterFactory("application/json".toMediaType())
|
||||||
|
)
|
||||||
|
.baseUrl(BASE_URL)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Single
|
||||||
|
fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create<ApiService>()
|
||||||
|
}
|
||||||
@ -0,0 +1,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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.longnh15.heimdall.domain.model
|
||||||
|
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
data class AuthInfo(
|
||||||
|
val token: String,
|
||||||
|
val userId: String,
|
||||||
|
val expiresAt: Instant
|
||||||
|
)
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.longnh15.heimdall.domain.repository
|
||||||
|
|
||||||
|
import com.longnh15.heimdall.domain.model.ApiResult
|
||||||
|
import com.longnh15.heimdall.domain.model.AuthInfo
|
||||||
|
|
||||||
|
interface AuthRepository {
|
||||||
|
suspend fun register(email: String, password: String, name: String): ApiResult<String>
|
||||||
|
suspend fun login(email: String, password: String): ApiResult<AuthInfo>
|
||||||
|
}
|
||||||
@ -1,7 +1,6 @@
|
|||||||
package com.longnh15.heimdall.domain.vault
|
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>
|
||||||
|
|||||||
@ -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>(),
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -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 = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
} }
|
} }
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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" }
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user