diff --git a/.gitignore b/.gitignore index 868556e..9ad4c06 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,13 @@ /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml +/.idea/deploymentTargetSelector.xml +/.idea/deviceManager.xml +/.idea/appInsightsSettings.xml +/.idea/koin-grouping-settings.xml .DS_Store /build +.claude/settings.local.json /captures .externalNativeBuild .cxx diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d821d62 --- /dev/null +++ b/CLAUDE.md @@ -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() 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`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fbc9665..bb0caf4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,11 @@ plugins { alias(libs.plugins.devtool.ksp) alias(libs.plugins.serialization) alias(libs.plugins.koin.compiler) + alias(libs.plugins.room) +} + +room { + schemaDirectory("$projectDir/schemas") } android { @@ -51,6 +56,7 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.viewmodel.compose) testImplementation(libs.junit) @@ -76,4 +82,11 @@ dependencies { implementation(libs.androidx.room) ksp(libs.androidx.room.compiler) implementation(libs.androidx.room.ktx) + + // Retrofit + implementation(libs.squareup.retrofit2) + implementation(libs.squareup.retrofit2.kotlinx.serialization) + + // Kotlinx + implementation(libs.kotlinx.serialization.json) } \ No newline at end of file diff --git a/app/schemas/com.longnh15.heimdall.data.source.local.HeimdallDatabase/1.json b/app/schemas/com.longnh15.heimdall.data.source.local.HeimdallDatabase/1.json new file mode 100644 index 0000000..5c24a5e --- /dev/null +++ b/app/schemas/com.longnh15.heimdall.data.source.local.HeimdallDatabase/1.json @@ -0,0 +1,54 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "e09aa1c4dfa12dfb105b0ea16d4584ff", + "entities": [ + { + "tableName": "credential", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT, `username` TEXT NOT NULL, `password` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e09aa1c4dfa12dfb105b0ea16d4584ff')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2fde86d..6547032 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ - + + + + android:theme="@style/Theme.Heimdall.Starting"> diff --git a/app/src/main/java/com/longnh15/heimdall/HeimdallApplication.kt b/app/src/main/java/com/longnh15/heimdall/HeimdallApplication.kt index a59eda3..6e70163 100644 --- a/app/src/main/java/com/longnh15/heimdall/HeimdallApplication.kt +++ b/app/src/main/java/com/longnh15/heimdall/HeimdallApplication.kt @@ -9,8 +9,6 @@ import com.longnh15.heimdall.domain.vault.VaultSessionManager import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger -import org.koin.core.annotation.ComponentScan -import org.koin.core.annotation.KoinApplication import org.koin.plugin.module.dsl.startKoin //@KoinApplication(modules = [DatabaseModule::class]) diff --git a/app/src/main/java/com/longnh15/heimdall/MainActivity.kt b/app/src/main/java/com/longnh15/heimdall/MainActivity.kt index 7d6ac3d..4698aeb 100644 --- a/app/src/main/java/com/longnh15/heimdall/MainActivity.kt +++ b/app/src/main/java/com/longnh15/heimdall/MainActivity.kt @@ -8,6 +8,7 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.longnh15.heimdall.navigation.HeimdallNavGraph import com.longnh15.heimdall.navigation.Screen @@ -18,17 +19,28 @@ class MainActivity : ComponentActivity() { private val viewModel: MainViewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) + // Keep the splash screen until the first-launch flag is resolved so the nav graph + // is built once with the correct start destination (no Onboarding -> Login flicker). + splashScreen.setKeepOnScreenCondition { viewModel.isFirstLaunch.value == null && viewModel.token.value == null } enableEdgeToEdge() setContent { HeimdallTheme { val isFirstLaunch by viewModel.isFirstLaunch.collectAsStateWithLifecycle() - HeimdallNavGraph( - modifier = Modifier.fillMaxSize(), - startDestination = if (isFirstLaunch != false) Screen.Onboarding else Screen.Vault, - isAutofillServiceEnabled = ::isAutofillServiceEnabled, - onCompleteOnboarding = viewModel::onCompleteOnboarding, - ) + val token by viewModel.token.collectAsStateWithLifecycle() + if (isFirstLaunch != null && token != null) { + HeimdallNavGraph( + modifier = Modifier.fillMaxSize(), + startDestination = when { + isFirstLaunch!! -> Screen.Onboarding + token!!.isEmpty() -> Screen.Login + else -> Screen.Vault + }, + isAutofillServiceEnabled = ::isAutofillServiceEnabled, + onCompleteOnboarding = viewModel::onCompleteOnboarding, + ) + } } } } diff --git a/app/src/main/java/com/longnh15/heimdall/MainViewModel.kt b/app/src/main/java/com/longnh15/heimdall/MainViewModel.kt index 500ec83..d12bfaa 100644 --- a/app/src/main/java/com/longnh15/heimdall/MainViewModel.kt +++ b/app/src/main/java/com/longnh15/heimdall/MainViewModel.kt @@ -3,6 +3,7 @@ package com.longnh15.heimdall import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.longnh15.heimdall.data.preferences.PreferenceDataSource +import com.longnh15.heimdall.data.source.local.LocalDataSource import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map @@ -12,7 +13,8 @@ import org.koin.core.annotation.KoinViewModel @KoinViewModel class MainViewModel ( - private val preferenceDataSource: PreferenceDataSource + private val preferenceDataSource: PreferenceDataSource, + private val localDataSource: LocalDataSource ) : ViewModel() { val isFirstLaunch: StateFlow = preferenceDataSource.preferences .map { it.isFirstLaunch } @@ -22,6 +24,14 @@ class MainViewModel ( initialValue = null ) + val token: StateFlow = localDataSource.token + .map { it.orEmpty() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = null + ) + fun onCompleteOnboarding() { viewModelScope.launch { preferenceDataSource.setFirstLaunchDone() diff --git a/app/src/main/java/com/longnh15/heimdall/common/GlobalConfig.kt b/app/src/main/java/com/longnh15/heimdall/common/GlobalConfig.kt new file mode 100644 index 0000000..149265b --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/common/GlobalConfig.kt @@ -0,0 +1,10 @@ +package com.longnh15.heimdall.common + +import kotlinx.serialization.json.Json + +object GlobalConfig { + val json = Json { + ignoreUnknownKeys = true + isLenient + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/common/TokenCrypto.kt b/app/src/main/java/com/longnh15/heimdall/common/TokenCrypto.kt new file mode 100644 index 0000000..bcc78d8 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/common/TokenCrypto.kt @@ -0,0 +1,55 @@ +package com.longnh15.heimdall.common + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import kotlin.io.encoding.Base64 + +object TokenCrypto { + private const val PROVIDER = "AndroidKeyStore" + private const val ALIAS = "auth_token_key" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val IV_SIZE = 12 + private const val TAG_BITS = 128 + + private val keystore = KeyStore.getInstance(PROVIDER).apply { load(null) } + + private fun getOrCreateKey(): SecretKey { + (keystore.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { + return it.secretKey + } + val keyGen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, PROVIDER) + keyGen.init( + KeyGenParameterSpec.Builder( + ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + ) + return keyGen.generateKey() + } + + fun encrypt(plainText: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) + val iv = cipher.iv // 12-byte IV, generated by Keystore + val cipherText = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8)) + return Base64.encode(iv + cipherText) // prepend IV + } + + fun decrypt(encoded: String): String { + val combined = Base64.decode(encoded) + val iv = combined.copyOfRange(0, IV_SIZE) + val cipherText = combined.copyOfRange(IV_SIZE, combined.size) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(TAG_BITS, iv)) + return cipher.doFinal(cipherText).toString(Charsets.UTF_8) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/common/ValidationHelper.kt b/app/src/main/java/com/longnh15/heimdall/common/ValidationHelper.kt new file mode 100644 index 0000000..846d823 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/common/ValidationHelper.kt @@ -0,0 +1,9 @@ +package com.longnh15.heimdall.common + +import android.util.Patterns.EMAIL_ADDRESS + +object ValidationHelper { + fun isEmail(input: String): Boolean { + return EMAIL_ADDRESS.matcher(input).matches() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/longnh15/heimdall/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..28ee685 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,45 @@ +package com.longnh15.heimdall.data.repository + +import com.longnh15.heimdall.data.source.local.LocalDataSource +import com.longnh15.heimdall.data.source.remote.ApiService +import com.longnh15.heimdall.data.source.remote.dto.LoginRequest +import com.longnh15.heimdall.data.source.remote.dto.RegisterRequest +import com.longnh15.heimdall.domain.model.ApiResult +import com.longnh15.heimdall.domain.model.AuthInfo +import com.longnh15.heimdall.domain.repository.AuthRepository +import kotlin.time.Instant + +class AuthRepositoryImpl( + private val api: ApiService, + private val localDataSource: LocalDataSource +) : BaseRepository(), AuthRepository { + override suspend fun register( + email: String, + password: String, + name: String + ): ApiResult { + val registerInfo = RegisterRequest(email, password, name) + return safeApiCall( + block = { api.register(registerInfo) }, + transform = { it.userId } + ) + } + + override suspend fun login( + email: String, + password: String + ): ApiResult { + val loginInfo = LoginRequest(email, password) + return safeApiCall( + block = { api.login(loginInfo) }, + transform = { + localDataSource.saveToken(it.token) + AuthInfo( + it.userId, + it.token, + Instant.parse(it.expiresAt) + ) + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/repository/BaseRepository.kt b/app/src/main/java/com/longnh15/heimdall/data/repository/BaseRepository.kt new file mode 100644 index 0000000..4c8f3e4 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/repository/BaseRepository.kt @@ -0,0 +1,36 @@ +package com.longnh15.heimdall.data.repository + +import com.longnh15.heimdall.data.source.remote.toApiError +import com.longnh15.heimdall.domain.model.ApiResult +import retrofit2.Response + +abstract class BaseRepository { + suspend fun safeApiCall( + block: suspend () -> Response + ): ApiResult { + val response = block() + + return if (response.isSuccessful) { + response.body()?.let { + ApiResult.Success(it) + } ?: ApiResult.Error(ApiResult.Error.CODE_UNKNOWN, "Empty response body") + } else { + response.errorBody().toApiError() + } + } + + suspend fun safeApiCall( + block: suspend () -> Response, + transform: suspend (RemoteResponse) -> Expected + ): ApiResult { + val response = block() + + return if (response.isSuccessful) { + response.body()?.let { + ApiResult.Success(transform(it)) + } ?: ApiResult.Error(ApiResult.Error.CODE_UNKNOWN, "Empty response body") + } else { + response.errorBody().toApiError() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/local/LocalDataSource.kt b/app/src/main/java/com/longnh15/heimdall/data/source/local/LocalDataSource.kt index 511a0f0..c967d50 100644 --- a/app/src/main/java/com/longnh15/heimdall/data/source/local/LocalDataSource.kt +++ b/app/src/main/java/com/longnh15/heimdall/data/source/local/LocalDataSource.kt @@ -1,14 +1,30 @@ package com.longnh15.heimdall.data.source.local +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.longnh15.heimdall.common.TokenCrypto import com.longnh15.heimdall.data.source.local.entity.toDomain import com.longnh15.heimdall.data.source.local.entity.toEntity import com.longnh15.heimdall.domain.model.Credential import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +private val Context.dataStore by preferencesDataStore("terces") + class LocalDataSource( - private val credentialDao: CredentialDao + private val credentialDao: CredentialDao, + private val appContext: Context ) { + private object Keys { + val TOKEN = stringPreferencesKey("token") + } + + @Volatile + private var cachedToken: String? = null + fun getAllCredentials(): Flow> { return credentialDao.getAll().map { entities -> entities.map { it.toDomain() } } } @@ -16,4 +32,22 @@ class LocalDataSource( suspend fun save(credential: Credential) { credentialDao.insert(credential.toEntity()) } + + suspend fun saveToken(token: String) { + cachedToken = token + appContext.dataStore.edit { it[Keys.TOKEN] = TokenCrypto.encrypt(token) } // encrypt with a Keystore key + } + + val token: Flow = appContext.dataStore.data + .map { it[Keys.TOKEN]?.let(TokenCrypto::decrypt) } + + suspend fun getToken(): String? { + cachedToken?.let { return it } + return token.first().also { cachedToken = it } + } + + suspend fun clearToken() { + cachedToken = null + appContext.dataStore.edit { it.remove(Keys.TOKEN) } + } } \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/remote/ApiService.kt b/app/src/main/java/com/longnh15/heimdall/data/source/remote/ApiService.kt new file mode 100644 index 0000000..77ec9a7 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/remote/ApiService.kt @@ -0,0 +1,17 @@ +package com.longnh15.heimdall.data.source.remote + +import com.longnh15.heimdall.data.source.remote.dto.LoginRequest +import com.longnh15.heimdall.data.source.remote.dto.LoginResponse +import com.longnh15.heimdall.data.source.remote.dto.RegisterRequest +import com.longnh15.heimdall.data.source.remote.dto.RegisterResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface ApiService { + @POST("/api/auth/register") + suspend fun register(@Body registerInfo: RegisterRequest): Response + + @POST("/api/app/login") + suspend fun login(@Body loginInfo: LoginRequest): Response +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/remote/ErrorBodyParser.kt b/app/src/main/java/com/longnh15/heimdall/data/source/remote/ErrorBodyParser.kt new file mode 100644 index 0000000..627d07d --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/remote/ErrorBodyParser.kt @@ -0,0 +1,18 @@ +package com.longnh15.heimdall.data.source.remote + +import com.longnh15.heimdall.common.GlobalConfig.json +import com.longnh15.heimdall.data.source.remote.dto.ErrorResponse +import com.longnh15.heimdall.domain.model.ApiResult +import okhttp3.ResponseBody + +fun ResponseBody?.toApiError(): ApiResult.Error { + if (this == null) { + return ApiResult.Error(ApiResult.Error.CODE_UNKNOWN, "Unknown error") + } + return try { + val error = json.decodeFromString(string()) + ApiResult.Error(error.error, error.message) + } catch (_: Exception) { + ApiResult.Error(ApiResult.Error.CODE_UNKNOWN, "Unknown error") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/ErrorResponse.kt b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/ErrorResponse.kt new file mode 100644 index 0000000..4183c93 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/ErrorResponse.kt @@ -0,0 +1,9 @@ +package com.longnh15.heimdall.data.source.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ErrorResponse( + val error: Int, + val message: String +) diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/LoginRequest.kt b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/LoginRequest.kt new file mode 100644 index 0000000..9b6486e --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/LoginRequest.kt @@ -0,0 +1,9 @@ +package com.longnh15.heimdall.data.source.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginRequest( + val email: String, + val password: String +) diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/LoginResponse.kt b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/LoginResponse.kt new file mode 100644 index 0000000..4a77730 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/LoginResponse.kt @@ -0,0 +1,10 @@ +package com.longnh15.heimdall.data.source.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginResponse( + val token: String, + val userId: String, + val expiresAt: String, +) diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/RegisterRequest.kt b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/RegisterRequest.kt new file mode 100644 index 0000000..f9cd401 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/RegisterRequest.kt @@ -0,0 +1,10 @@ +package com.longnh15.heimdall.data.source.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class RegisterRequest( + val email: String, + val password: String, + val name: String +) diff --git a/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/RegisterResponse.kt b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/RegisterResponse.kt new file mode 100644 index 0000000..f1e0247 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/data/source/remote/dto/RegisterResponse.kt @@ -0,0 +1,9 @@ +package com.longnh15.heimdall.data.source.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class RegisterResponse( + val ok: Boolean, + val userId: String +) diff --git a/app/src/main/java/com/longnh15/heimdall/di/AppModule.kt b/app/src/main/java/com/longnh15/heimdall/di/AppModule.kt index fae9d2a..593824b 100644 --- a/app/src/main/java/com/longnh15/heimdall/di/AppModule.kt +++ b/app/src/main/java/com/longnh15/heimdall/di/AppModule.kt @@ -2,16 +2,17 @@ package com.longnh15.heimdall.di import com.longnh15.heimdall.MainViewModel import com.longnh15.heimdall.data.preferences.PreferenceDataSource +import com.longnh15.heimdall.data.repository.AuthRepositoryImpl import com.longnh15.heimdall.data.repository.CredentialRepositoryImpl -import com.longnh15.heimdall.data.source.local.LocalDataSource import com.longnh15.heimdall.data.vault.VaultSessionManagerImpl +import com.longnh15.heimdall.domain.repository.AuthRepository import com.longnh15.heimdall.domain.repository.CredentialRepository import com.longnh15.heimdall.domain.vault.VaultSessionManager +import com.longnh15.heimdall.ui.auth.login.LoginViewModel import com.longnh15.heimdall.ui.onboarding.OnboardingViewModel +import com.longnh15.heimdall.ui.auth.register.RegisterViewModel import com.longnh15.heimdall.ui.vault.VaultViewModel import com.longnh15.heimdall.ui.vault.edit.AddEditVaultViewModel -import org.koin.core.annotation.Module -import org.koin.core.annotation.Single import org.koin.dsl.bind import org.koin.dsl.module import org.koin.plugin.module.dsl.single @@ -31,10 +32,13 @@ import org.koin.plugin.module.dsl.viewModel val appModule = module { single() bind VaultSessionManager::class single() bind CredentialRepository::class + single() bind AuthRepository::class single() viewModel() viewModel() viewModel() viewModel() -} \ No newline at end of file + viewModel() + viewModel() +} diff --git a/app/src/main/java/com/longnh15/heimdall/di/DatabaseModule.kt b/app/src/main/java/com/longnh15/heimdall/di/DatabaseModule.kt index 6ee555c..31f629b 100644 --- a/app/src/main/java/com/longnh15/heimdall/di/DatabaseModule.kt +++ b/app/src/main/java/com/longnh15/heimdall/di/DatabaseModule.kt @@ -24,7 +24,7 @@ class DatabaseModule { } @Single - fun provideLocalDataSource(credentialDao: CredentialDao): LocalDataSource { - return LocalDataSource(credentialDao) + fun provideLocalDataSource(credentialDao: CredentialDao, context: Context): LocalDataSource { + return LocalDataSource(credentialDao, context) } } \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/di/NetworkModule.kt b/app/src/main/java/com/longnh15/heimdall/di/NetworkModule.kt new file mode 100644 index 0000000..7dde923 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/di/NetworkModule.kt @@ -0,0 +1,27 @@ +package com.longnh15.heimdall.di + +import com.longnh15.heimdall.common.GlobalConfig.json +import com.longnh15.heimdall.data.source.remote.ApiService +import okhttp3.MediaType.Companion.toMediaType +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import retrofit2.create + +@Module +class NetworkModule { + private companion object { + const val BASE_URL = "https://heimdall.lol" + } + @Single + fun provideRetrofit() = Retrofit.Builder() + .addConverterFactory( + json.asConverterFactory("application/json".toMediaType()) + ) + .baseUrl(BASE_URL) + .build() + + @Single + fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create() +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/domain/model/ApiResult.kt b/app/src/main/java/com/longnh15/heimdall/domain/model/ApiResult.kt new file mode 100644 index 0000000..6efc7db --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/domain/model/ApiResult.kt @@ -0,0 +1,11 @@ +package com.longnh15.heimdall.domain.model + +sealed class ApiResult { + data class Success(val data: T): ApiResult() + data class Error(val code: Int, val message: String): ApiResult() { + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/domain/model/AuthInfo.kt b/app/src/main/java/com/longnh15/heimdall/domain/model/AuthInfo.kt new file mode 100644 index 0000000..e02be53 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/domain/model/AuthInfo.kt @@ -0,0 +1,9 @@ +package com.longnh15.heimdall.domain.model + +import kotlin.time.Instant + +data class AuthInfo( + val token: String, + val userId: String, + val expiresAt: Instant +) diff --git a/app/src/main/java/com/longnh15/heimdall/domain/repository/AuthRepository.kt b/app/src/main/java/com/longnh15/heimdall/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..f306103 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/domain/repository/AuthRepository.kt @@ -0,0 +1,9 @@ +package com.longnh15.heimdall.domain.repository + +import com.longnh15.heimdall.domain.model.ApiResult +import com.longnh15.heimdall.domain.model.AuthInfo + +interface AuthRepository { + suspend fun register(email: String, password: String, name: String): ApiResult + suspend fun login(email: String, password: String): ApiResult +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/domain/vault/VaultSessionManager.kt b/app/src/main/java/com/longnh15/heimdall/domain/vault/VaultSessionManager.kt index 62b54f9..c2c7d64 100644 --- a/app/src/main/java/com/longnh15/heimdall/domain/vault/VaultSessionManager.kt +++ b/app/src/main/java/com/longnh15/heimdall/domain/vault/VaultSessionManager.kt @@ -1,7 +1,6 @@ package com.longnh15.heimdall.domain.vault import kotlinx.coroutines.flow.StateFlow -import org.koin.core.annotation.Singleton interface VaultSessionManager { val sessionState: StateFlow diff --git a/app/src/main/java/com/longnh15/heimdall/navigation/HeimdallNavGraph.kt b/app/src/main/java/com/longnh15/heimdall/navigation/HeimdallNavGraph.kt index ebb5683..15b4dfb 100644 --- a/app/src/main/java/com/longnh15/heimdall/navigation/HeimdallNavGraph.kt +++ b/app/src/main/java/com/longnh15/heimdall/navigation/HeimdallNavGraph.kt @@ -6,8 +6,12 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.longnh15.heimdall.ui.auth.login.LoginScreen +import com.longnh15.heimdall.ui.auth.login.LoginViewModel import com.longnh15.heimdall.ui.onboarding.OnboardingPage import com.longnh15.heimdall.ui.onboarding.OnboardingViewModel +import com.longnh15.heimdall.ui.auth.register.RegisterScreen +import com.longnh15.heimdall.ui.auth.register.RegisterViewModel import com.longnh15.heimdall.ui.vault.VaultScreen import com.longnh15.heimdall.ui.vault.VaultViewModel import com.longnh15.heimdall.ui.vault.edit.AddEditVaultItemScreen @@ -18,7 +22,7 @@ import org.koin.compose.viewmodel.koinViewModel fun HeimdallNavGraph( modifier: Modifier = Modifier, navController: NavHostController = rememberNavController(), - startDestination: Screen = Screen.Onboarding, + startDestination: Screen = Screen.Login, isAutofillServiceEnabled: () -> Boolean, onCompleteOnboarding: () -> Unit, ) { @@ -27,6 +31,27 @@ fun HeimdallNavGraph( startDestination = startDestination, modifier = modifier ) { + composable { + LoginScreen( + viewModel = koinViewModel(), + navigateToVault = { + navController.navigate(Screen.Vault) { + popUpTo(Screen.Login) { inclusive = true } + } + }, + navigateToRegister = { + navController.navigate(Screen.Register) + } + ) + } + composable { + RegisterScreen( + viewModel = koinViewModel(), + navigateToLogin = { + navController.popBackStack() + } + ) + } composable { OnboardingPage( viewModel = koinViewModel(), diff --git a/app/src/main/java/com/longnh15/heimdall/navigation/Screen.kt b/app/src/main/java/com/longnh15/heimdall/navigation/Screen.kt index a47cb80..82219f1 100644 --- a/app/src/main/java/com/longnh15/heimdall/navigation/Screen.kt +++ b/app/src/main/java/com/longnh15/heimdall/navigation/Screen.kt @@ -4,6 +4,10 @@ import kotlinx.serialization.Serializable @Serializable sealed class Screen { + @Serializable + data object Login: Screen() + @Serializable + data object Register: Screen() @Serializable data object Onboarding: Screen() @Serializable diff --git a/app/src/main/java/com/longnh15/heimdall/ui/auth/login/AuthForm.kt b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/AuthForm.kt new file mode 100644 index 0000000..afe412b --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/AuthForm.kt @@ -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 + ) + } + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginEvent.kt b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginEvent.kt new file mode 100644 index 0000000..92e1a09 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginEvent.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginScreen.kt b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginScreen.kt new file mode 100644 index 0000000..792ec78 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginScreen.kt @@ -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 = {} + ) +} diff --git a/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginState.kt b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginState.kt new file mode 100644 index 0000000..df57a11 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginState.kt @@ -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, +) diff --git a/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginViewModel.kt b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginViewModel.kt new file mode 100644 index 0000000..fa6bcb6 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/auth/login/LoginViewModel.kt @@ -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() + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterEvent.kt b/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterEvent.kt new file mode 100644 index 0000000..71d4813 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterEvent.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterScreen.kt b/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterScreen.kt new file mode 100644 index 0000000..157347a --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterScreen.kt @@ -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() +} diff --git a/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterState.kt b/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterState.kt new file mode 100644 index 0000000..069379d --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterState.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterViewModel.kt b/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterViewModel.kt new file mode 100644 index 0000000..ce1f9c1 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/auth/register/RegisterViewModel.kt @@ -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() + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/common/AutofillNotEnabledDialog.kt b/app/src/main/java/com/longnh15/heimdall/ui/common/AutofillNotEnabledDialog.kt index a69f107..335da56 100644 --- a/app/src/main/java/com/longnh15/heimdall/ui/common/AutofillNotEnabledDialog.kt +++ b/app/src/main/java/com/longnh15/heimdall/ui/common/AutofillNotEnabledDialog.kt @@ -7,6 +7,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.longnh15.heimdall.R @Composable fun AutofillNotEnabledDialog( @@ -18,15 +20,15 @@ fun AutofillNotEnabledDialog( icon = { Icon(Icons.Outlined.Warning, contentDescription = null) }, - title = { Text("Autofill not enabled") }, + title = { Text(stringResource(R.string.autofill_dialog_title)) }, 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 = { - TextButton(onClick = onRetry) { Text("Open settings") } + TextButton(onClick = onRetry) { Text(stringResource(R.string.open_settings)) } }, dismissButton = { - TextButton(onClick = onDismiss) { Text("Skip for now") } + TextButton(onClick = onDismiss) { Text(stringResource(R.string.autofill_dialog_skip)) } } ) } \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/common/UiText.kt b/app/src/main/java/com/longnh15/heimdall/ui/common/UiText.kt new file mode 100644 index 0000000..1857c55 --- /dev/null +++ b/app/src/main/java/com/longnh15/heimdall/ui/common/UiText.kt @@ -0,0 +1,39 @@ +package com.longnh15.heimdall.ui.common + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.longnh15.heimdall.R +import com.longnh15.heimdall.domain.model.ApiResult + +/** + * A string that may be defined either as a runtime value (e.g. a message from the + * backend) or as a string resource. Lets layers without a [Context] (ViewModels, + * domain/data) describe user-facing text without resolving it, deferring resolution + * to the UI where a [Context] / composition is available. + */ +sealed interface UiText { + data class Dynamic(val value: String) : UiText + + class Res(@StringRes val id: Int, vararg val args: Any) : UiText + + @Composable + fun asString(): String = when (this) { + is Dynamic -> value + is Res -> stringResource(id, *args) + } + + fun asString(context: Context): String = when (this) { + is Dynamic -> value + is Res -> context.getString(id, *args) + } +} + +/** + * Maps an API error to user-facing text: client-side failures ([ApiResult.Error.CODE_UNKNOWN]) + * become a localized generic message, while real server messages are shown as-is. + */ +fun ApiResult.Error.toUiText(): UiText = + if (code == ApiResult.Error.CODE_UNKNOWN) UiText.Res(R.string.error_generic) + else UiText.Dynamic(message) diff --git a/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingFinalPage.kt b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingFinalPage.kt index f743b92..aa80a99 100644 --- a/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingFinalPage.kt +++ b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingFinalPage.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.longnh15.heimdall.R @@ -33,7 +34,7 @@ fun OnboardingFinalPage( verticalArrangement = Arrangement.spacedBy(32.dp) ) { OnboardingBadge { - Text("You're all set") + Text(stringResource(R.string.onboarding_final_badge)) } Box( modifier = Modifier @@ -43,20 +44,20 @@ fun OnboardingFinalPage( ) { Image( 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 = "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 ) Spacer(Modifier.weight(1f)) Button(onClick = navigateToVault) { - Text("Enter my vault ↗") + Text(stringResource(R.string.onboarding_final_enter_vault)) } } } \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingIntroducePage.kt b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingIntroducePage.kt index 33170af..fb534f3 100644 --- a/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingIntroducePage.kt +++ b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingIntroducePage.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -34,11 +35,11 @@ fun OnboardingIntroducePage(modifier: Modifier = Modifier) { verticalArrangement = Arrangement.spacedBy(32.dp) ) { 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 = "Heimdall keeps your passwords safe and fills them in automatically", + text = stringResource(R.string.onboarding_introduce_body), textAlign = TextAlign.Center ) Column( @@ -47,18 +48,18 @@ fun OnboardingIntroducePage(modifier: Modifier = Modifier) { ) { FeatureItem( iconDrawableRes = R.drawable.lock_icon, - text = "End-to-end encrypted", - sub = "Only you can see your data" + text = stringResource(R.string.onboarding_feature_encrypted_title), + sub = stringResource(R.string.onboarding_feature_encrypted_sub) ) FeatureItem( iconDrawableRes = R.drawable.crosshair_icon, - text = "Autofill anywhere", - sub = "Works across all your apps" + text = stringResource(R.string.onboarding_feature_autofill_title), + sub = stringResource(R.string.onboarding_feature_autofill_sub) ) FeatureItem( iconDrawableRes = R.drawable.person_icon, - text = "Biometric unlock", - sub = "Fingerprint or face to open" + text = stringResource(R.string.onboarding_feature_biometric_title), + sub = stringResource(R.string.onboarding_feature_biometric_sub) ) } } @@ -89,7 +90,7 @@ fun FeatureItem( .background(Color(0xFFEEEDFE), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center ) { - Image(painterResource(iconDrawableRes), "Icon") + Image(painterResource(iconDrawableRes), stringResource(R.string.onboarding_feature_icon_desc)) } Column { Text(text = text, fontSize = 13.sp, fontWeight = FontWeight(500), color = MaterialTheme.colorScheme.onPrimary) diff --git a/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingRequestEnablePage.kt b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingRequestEnablePage.kt index 8e95612..d146781 100644 --- a/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingRequestEnablePage.kt +++ b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingRequestEnablePage.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.longnh15.heimdall.R @@ -34,7 +35,7 @@ fun OnboardingRequestEnablePage( verticalArrangement = Arrangement.spacedBy(32.dp) ) { OnboardingBadge { - Text("Setup · 1 of 2") + Text(stringResource(R.string.onboarding_enable_badge)) } Box( modifier = Modifier @@ -44,20 +45,20 @@ fun OnboardingRequestEnablePage( ) { Image( 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 = "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 ) Spacer(Modifier.weight(1f)) Button(onClick = onOpenAutofillSettings) { - Text("Open settings") + Text(stringResource(R.string.open_settings)) } } } \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingWelcomePage.kt b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingWelcomePage.kt index 16e7100..afee0d2 100644 --- a/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingWelcomePage.kt +++ b/app/src/main/java/com/longnh15/heimdall/ui/onboarding/OnboardingWelcomePage.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -36,12 +37,12 @@ fun OnboardingWelcomePage(modifier: Modifier = Modifier) { ) { Image( 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 = "Your all-seeing guardian for passwords. Secure, private, and always watching over your credentials", + text = stringResource(R.string.onboarding_welcome_body), textAlign = TextAlign.Center ) } diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/LockOverlay.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/LockOverlay.kt index d8f6d60..6628ffa 100644 --- a/app/src/main/java/com/longnh15/heimdall/ui/vault/LockOverlay.kt +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/LockOverlay.kt @@ -19,7 +19,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.longnh15.heimdall.R @Composable fun LockOverlay( @@ -43,12 +45,12 @@ fun LockOverlay( modifier = Modifier.size(48.dp) ) Text( - text = "Vault is locked", + text = stringResource(R.string.vault_locked_title), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground ) Text( - text = "Authenticate to access your credentials", + text = stringResource(R.string.vault_locked_subtitle), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -60,7 +62,7 @@ fun LockOverlay( modifier = Modifier.size(18.dp) ) Spacer(modifier = Modifier.width(8.dp)) - Text("Unlock with biometrics") + Text(stringResource(R.string.vault_unlock_biometrics)) } } } diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultHeader.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultHeader.kt index 769be45..1da50c2 100644 --- a/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultHeader.kt +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultHeader.kt @@ -17,14 +17,16 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.longnh15.heimdall.R @OptIn(ExperimentalMaterial3Api::class) @Composable fun VaultHeader() { TopAppBar( title = { - Text("My vault") + Text(stringResource(R.string.vault_title)) }, colors = TopAppBarDefaults.topAppBarColors().copy( containerColor = MaterialTheme.colorScheme.background diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultScreen.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultScreen.kt index c6826a2..b98c16f 100644 --- a/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultScreen.kt +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/VaultScreen.kt @@ -13,8 +13,10 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.longnh15.heimdall.R @Composable 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( - title = "Unlock Heimdall", + title = unlockTitle, AuthenticationRequest.Biometric.Fallback.DeviceCredential, ) { - setSubtitle("Confirm your identity to access your vault") + setSubtitle(unlockSubtitle) } launcher.launch(request) } } diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditEvent.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditEvent.kt index 9ac9efe..120ec2f 100644 --- a/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditEvent.kt +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditEvent.kt @@ -1,6 +1,8 @@ package com.longnh15.heimdall.ui.vault.edit +import com.longnh15.heimdall.ui.common.UiText + sealed class AddEditEvent { data object SaveSuccess: AddEditEvent() - data class SaveError(val message: String): AddEditEvent() + data class SaveError(val message: UiText): AddEditEvent() } \ No newline at end of file diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditHeader.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditHeader.kt index 30e890c..62cb259 100644 --- a/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditHeader.kt +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditHeader.kt @@ -20,9 +20,11 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.longnh15.heimdall.R @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -34,7 +36,7 @@ fun AddEditHeader( ) { TopAppBar( title = { - Text("New item") + Text(stringResource(R.string.add_edit_title)) }, modifier = Modifier.padding(end = 16.dp), colors = TopAppBarDefaults.topAppBarColors().copy( @@ -64,7 +66,7 @@ fun AddEditHeader( Spacer(Modifier.width(8.dp)) } 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, fontWeight = FontWeight(500), color = MaterialTheme.colorScheme.onTertiaryContainer diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditVaultItemScreen.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditVaultItemScreen.kt index b2139f7..a1fe0fd 100644 --- a/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditVaultItemScreen.kt +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditVaultItemScreen.kt @@ -24,10 +24,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier 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.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 @@ -37,12 +40,13 @@ fun AddEditVaultItemScreen( popScreen: () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current LaunchedEffect(Unit) { viewModel.uiEvent.collect { event -> when (event) { 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), verticalArrangement = Arrangement.spacedBy(10.dp) ) { - AddEditFormHeader("details") + AddEditFormHeader(stringResource(R.string.add_edit_section_details)) AddEditForm { AddEditFormTextField( value = viewModel.state.name, onValueChange = viewModel::onNameChanged, - label = "Name", - placeholder = "e.g. Google" + label = stringResource(R.string.add_edit_name_label), + placeholder = stringResource(R.string.add_edit_name_placeholder) ) AddEditDivider() AddEditFormTextField( value = viewModel.state.url, onValueChange = viewModel::onUrlChanged, - label = "URL", - placeholder = "google.com", + label = stringResource(R.string.add_edit_url_label), + placeholder = stringResource(R.string.add_edit_url_placeholder), actions = { IconButton( onClick = {}, @@ -90,20 +94,20 @@ fun AddEditVaultItemScreen( } ) } - AddEditFormHeader("credentials") + AddEditFormHeader(stringResource(R.string.add_edit_section_credentials)) AddEditForm { AddEditFormTextField( viewModel.state.username, onValueChange = viewModel::onUsernameChanged, - label = "Username", - placeholder = "you@email.com" + label = stringResource(R.string.add_edit_username_label), + placeholder = stringResource(R.string.add_edit_username_placeholder) ) AddEditDivider() AddEditFormTextField( value = viewModel.state.password, onValueChange = viewModel::onPasswordChanged, - label = "Password", - placeholder = "••••••••", + label = stringResource(R.string.field_password_label), + placeholder = stringResource(R.string.field_password_placeholder), isHideContent = viewModel.state.isShowPassword.not(), isLastField = true, autoCorrectEnabled = false, diff --git a/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditVaultViewModel.kt b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditVaultViewModel.kt index 588e489..5697aee 100644 --- a/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditVaultViewModel.kt +++ b/app/src/main/java/com/longnh15/heimdall/ui/vault/edit/AddEditVaultViewModel.kt @@ -5,7 +5,9 @@ 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.domain.repository.CredentialRepository +import com.longnh15.heimdall.ui.common.UiText import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @@ -52,7 +54,8 @@ class AddEditVaultViewModel( _uiEvent.send(AddEditEvent.SaveSuccess) } catch (e: Exception) { 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)) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0b3f407..938e984 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,91 @@ Heimdall - \ No newline at end of file + + + Email is required + Enter a valid email address + Password is required + Password must be at least %1$d characters + Something went wrong. + + + Email + Password + •••••••• + Open settings + + + Welcome back + Access your secure vault credentials + you@email.com + Forgot password? + Login + Don\'t have an account? + Create one + + + Create account + Secure your digital life with enterprise-grade encryption. + Full name + John Doe + name@vault.com + Create account + By creating an account you agree to our Terms of Service and Privacy Policy. + Already have an account? + Sign in + + + Heimdall Logo + Welcome to Heimdall + Your all-seeing guardian for passwords. Secure, private, and always watching over your credentials + + + What you get + Everything in one vault + Heimdall keeps your passwords safe and fills them in automatically + Icon + End-to-end encrypted + Only you can see your data + Autofill anywhere + Works across all your apps + Biometric unlock + Fingerprint or face to open + + + Page icon + Setup · 1 of 2 + Enable autofill + Let Heimdall fill your passwords automatically. You\'ll be taken to Android settings — tap Heimdall and you\'re done. + + + You\'re all set + Heimdall is watching + Your vault is ready. Start adding passwords or import from your browser to get going. + Enter my vault ↗ + + + Autofill not enabled + Heimdall wasn\'t selected as your autofill provider. Open settings and tap Heimdall to enable it. + Skip for now + + + My vault + Unlock Heimdall + Confirm your identity to access your vault + Vault is locked + Authenticate to access your credentials + Unlock with biometrics + + + New item + Saving… + Save + details + credentials + Name + e.g. Google + URL + google.com + Username + you@email.com + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index c3725c6..d344c02 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -2,4 +2,12 @@ \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9a75fc8..246ff39 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,17 +6,20 @@ junitVersion = "1.3.0" espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" -kotlin = "2.3.20" +kotlin = "2.4.0" composeBom = "2026.05.01" kspVersion = "2.3.6" -koin = "4.2.1" -koinKsp = "4.2.1" -koinPlugin = "1.0.0" +koin = "4.2.2" +koinKsp = "4.2.2" +koinPlugin = "1.0.1" navigation = "2.9.8" serialization = "2.4.0" +serializationJson = "1.11.0" datastore = "1.2.1" biometric = "1.4.0-alpha07" room = "2.8.4" +retrofit = "3.0.0" +splashscreen = "1.2.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -43,10 +46,14 @@ koin-compose-viewmodel-navigation = { group = "io.insert-koin", name = "koin-com koin-compose-viewmodel = { group = "io.insert-koin", name = "koin-compose-viewmodel", version.ref = "koin" } koin-annotations = { group = "io.insert-koin", name = "koin-annotations", version.ref = "koinKsp"} navigation-compose = { group = "androidx.navigation" , name = "navigation-compose", version.ref = "navigation" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } biometric-compose = { group = "androidx.biometric", name = "biometric-compose", version.ref = "biometric" } androidx-room = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serializationJson" } +squareup-retrofit2 = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +squareup-retrofit2-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -54,4 +61,5 @@ devtool-ksp = { id = "com.google.devtools.ksp", version.ref = "kspVersion" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" } koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koinPlugin" } +room = { id = "androidx.room", version.ref = "room" }