Login to dashboard worked

This commit is contained in:
2025-08-09 10:36:45 +03:00
parent d9fc689185
commit 07a8d82a4d
28 changed files with 796 additions and 251 deletions

View File

@@ -82,9 +82,6 @@ dependencies {
// [DEPENDENCY] Logging
implementation(Libs.timber)
// [DEPENDENCY] Security
implementation(Libs.securityCrypto)
// [DEPENDENCY] Testing
testImplementation(Libs.junit)
androidTestImplementation(Libs.extJunit)

View File

@@ -1,25 +1,32 @@
// [PACKAGE] com.homebox.lens.ui.screen.dashboard
// [FILE] app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardViewModel.kt
// [SEMANTICS] ui, viewmodel, dashboard, hilt
// [IMPORTS]
// [FILE] DashboardViewModel.kt
// [SEMANTICS] ui_logic, dashboard, state_management, sealed_state, parallel_data_loading, timber_logging
package com.homebox.lens.ui.screen.dashboard
// [IMPORTS]
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.homebox.lens.domain.usecase.GetAllLabelsUseCase
import com.homebox.lens.domain.usecase.GetAllLocationsUseCase
import com.homebox.lens.domain.usecase.GetStatisticsUseCase
import com.homebox.lens.ui.screen.dashboard.DashboardUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber // [FIX] Логирование происходит здесь
import timber.log.Timber
import javax.inject.Inject
// [CORE-LOGIC]
// [VIEWMODEL]
// [ENTITY: ViewModel('DashboardViewModel')]
/**
* [CONTRACT]
* @summary ViewModel для главного экрана (Dashboard).
* @description Оркестрирует загрузку данных для Dashboard, используя строгую модель состояний
* (`DashboardUiState`), и обрабатывает параллельные запросы без состояний гонки.
* @invariant `uiState` всегда является одним из состояний, определенных в `DashboardUiState`.
*/
@HiltViewModel
class DashboardViewModel @Inject constructor(
private val getStatisticsUseCase: GetStatisticsUseCase,
@@ -27,51 +34,72 @@ class DashboardViewModel @Inject constructor(
private val getAllLabelsUseCase: GetAllLabelsUseCase
) : ViewModel() {
// [STATE]
private val _uiState = MutableStateFlow<DashboardUiState>(DashboardUiState.Loading)
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
// [FIX] Добавлен получатель (receiver) `_uiState` для вызова asStateFlow().
// [REASON] `asStateFlow()` является функцией-расширением для `MutableStateFlow` и
// должна вызываться на его экземпляре, чтобы создать публичную, неизменяемую версию потока.
val uiState = _uiState.asStateFlow()
// [LIFECYCLE_HANDLER]
init {
loadDashboardData()
}
private fun loadDashboardData() {
Timber.i("[ACTION] Starting dashboard data load.")
_uiState.value = DashboardUiState.Loading
/**
* [CONTRACT]
* @summary Загружает все необходимые данные для экрана Dashboard.
* @description Выполняет UseCase'ы параллельно и обновляет UI, переключая его
* между состояниями `Loading`, `Success` и `Error` из `DashboardUiState`.
* @sideeffect Асинхронно обновляет `_uiState` одним из состояний `DashboardUiState`.
*/
fun loadDashboardData() {
// [ENTRYPOINT]
viewModelScope.launch {
try {
// Параллельно запрашиваем все данные
val statsDeferred = async { getStatisticsUseCase() }
val locationsDeferred = async { getAllLocationsUseCase() }
val labelsDeferred = async { getAllLabelsUseCase() }
_uiState.value = DashboardUiState.Loading
// [FIX] Используем Timber для логирования.
Timber.i("[ACTION] Starting parallel dashboard data load. State -> Loading.")
val stats = statsDeferred.await()
val locations = locationsDeferred.await()
val labels = labelsDeferred.await()
// [CORE-LOGIC: PARALLEL_FETCH]
val result = runCatching {
coroutineScope {
val statsDeferred = async { getStatisticsUseCase() }
val locationsDeferred = async { getAllLocationsUseCase() }
val labelsDeferred = async { getAllLabelsUseCase() }
// [ACTION] Логируем результат здесь, во ViewModel
if (stats != null && locations != null && labels != null) {
val stats = statsDeferred.await()
val locations = locationsDeferred.await()
val labels = labelsDeferred.await()
// [POSTCONDITION_CHECK]
check(stats != null && locations != null && labels != null) {
"[POSTCONDITION_FAILED] One or more dashboard data sources returned null."
}
Triple(stats, locations, labels)
}
}
// [RESULT_HANDLER]
result.fold(
onSuccess = { (stats, locations, labels) ->
// [FIX] Используем Timber для логирования.
Timber.i("[SUCCESS] Dashboard data loaded successfully. State -> Success.")
_uiState.value = DashboardUiState.Success(
statistics = stats,
locations = locations,
labels = labels
)
Timber.i("[COHERENCE_CHECK_PASSED] Dashboard data loaded successfully.")
} else {
// Одна из операций вернула null
val errorMessage = "Failed to load dashboard data: " +
"stats is ${if(stats==null) "null" else "ok"}, " +
"locations is ${if(locations==null) "null" else "ok"}, " +
"labels is ${if(labels==null) "null" else "ok"}"
Timber.e(errorMessage)
_uiState.value = DashboardUiState.Error("Could not load all dashboard data.")
},
onFailure = { exception ->
// [FIX] Используем Timber для логирования ошибок с передачей исключения.
Timber.e(exception, "[ERROR] Failed to load dashboard data. State -> Error.")
_uiState.value = DashboardUiState.Error(
message = exception.message ?: "Could not load dashboard data."
)
}
} catch (e: Exception) {
// [ERROR_HANDLER] Эта ошибка будет отловлена, если сама корутина `launch` упадет
Timber.e(e, "[ERROR] Critical failure in loadDashboardData coroutine.")
_uiState.value = DashboardUiState.Error(e.message ?: "An unknown critical error occurred")
}
)
}
}
// [END_CLASS_DashboardViewModel]
}
// [END_FILE_DashboardViewModel.kt]

View File

@@ -3,6 +3,7 @@
package com.homebox.lens.ui.screen.setup
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
@@ -15,6 +16,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
// [FIX] Opt-in for experimental Material 3 APIs
@OptIn(ExperimentalMaterial3Api::class)
// [ENTRYPOINT]
@Composable
fun SetupScreen(
@@ -36,6 +39,8 @@ fun SetupScreen(
)
}
// [FIX] Opt-in for experimental Material 3 APIs
@OptIn(ExperimentalMaterial3Api::class)
// [CONTENT]
@Composable
private fun SetupScreenContent(
@@ -99,6 +104,8 @@ private fun SetupScreenContent(
}
}
// [FIX] Opt-in for experimental Material 3 APIs
@OptIn(ExperimentalMaterial3Api::class)
@Preview(showBackground = true)
@Composable
fun SetupScreenPreview() {
@@ -110,4 +117,4 @@ fun SetupScreenPreview() {
onConnectClick = {}
)
}
// [END_FILE_SetupScreen.kt]
// [END_FILE_SetupScreen.kt]

View File

@@ -1,9 +1,21 @@
// [PACKAGE] com.homebox.lens.ui.screen.setup
// [FILE] SetupUiState.kt
// [SEMANTICS] ui_state, data_model, immutable
package com.homebox.lens.ui.screen.setup
// [STATE]
/**
* [ENTITY: DataClass('SetupUiState')]
* [CONTRACT]
* Неизменяемая модель данных, представляющая полное состояние экрана настройки (Setup Screen).
* Использование `data class` предоставляет метод `copy()` для легкого создания новых состояний.
* @property serverUrl URL-адрес сервера Homebox.
* @property username Имя пользователя для входа.
* @property password Пароль пользователя.
* @property isLoading Флаг, указывающий, выполняется ли в данный момент сетевой запрос.
* @property error Сообщение об ошибке для отображения пользователю, или `null` при отсутствии ошибки.
* @property isSetupComplete Флаг, указывающий на успешное завершение настройки и входа.
*/
data class SetupUiState(
val serverUrl: String = "",
val username: String = "",
@@ -12,4 +24,4 @@ data class SetupUiState(
val error: String? = null,
val isSetupComplete: Boolean = false
)
// [END_FILE_SetupUiState.kt]
// [END_FILE_SetupUiState.kt]

View File

@@ -1,14 +1,14 @@
// [PACKAGE] com.homebox.lens.ui.screen.setup
// [FILE] SetupViewModel.kt
// [SEMANTICS] ui_logic, viewmodel, state_management, user_setup, authentication_flow
package com.homebox.lens.ui.screen.setup
// [IMPORTS]
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.homebox.lens.domain.model.Credentials
import com.homebox.lens.domain.model.Result
import com.homebox.lens.domain.repository.CredentialsRepository
import com.homebox.lens.domain.usecase.LoginUseCase
import com.homebox.lens.ui.screen.setup.SetupUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -17,6 +17,18 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
// [VIEWMODEL]
// [ENTITY: ViewModel('SetupViewModel')]
/**
* [CONTRACT]
* ViewModel для экрана первоначальной настройки (Setup).
* Отвечает за:
* 1. Загрузку и сохранение учетных данных (URL сервера, логин, пароль).
* 2. Управление состоянием UI экрана (`SetupUiState`).
* 3. Инициацию процесса входа в систему через `LoginUseCase`.
* @property credentialsRepository Репозиторий для операций с учетными данными.
* @property loginUseCase Use case для выполнения логики входа.
* @invariant Состояние `uiState` всегда является единственным источником истины для UI.
*/
@HiltViewModel
class SetupViewModel @Inject constructor(
private val credentialsRepository: CredentialsRepository,
@@ -27,13 +39,23 @@ class SetupViewModel @Inject constructor(
private val _uiState = MutableStateFlow(SetupUiState())
val uiState = _uiState.asStateFlow()
// [LIFECYCLE_HANDLER]
init {
// [ACTION] Загружаем учетные данные при создании ViewModel.
loadCredentials()
}
/**
* [CONTRACT]
* [HELPER] Загружает учетные данные из репозитория при инициализации.
* @sideeffect Асинхронно обновляет `_uiState` полученными учетными данными.
*/
private fun loadCredentials() {
// [ENTRYPOINT]
viewModelScope.launch {
// [CORE-LOGIC] Подписываемся на поток учетных данных.
credentialsRepository.getCredentials().collect { credentials ->
// [ACTION] Обновляем состояние, если учетные данные существуют.
if (credentials != null) {
_uiState.update {
it.copy(
@@ -47,42 +69,75 @@ class SetupViewModel @Inject constructor(
}
}
// [ACTION]
/**
* [CONTRACT]
* [ACTION] Обновляет URL сервера в состоянии UI в ответ на ввод пользователя.
* @param newUrl Новое значение URL.
* @sideeffect Обновляет поле `serverUrl` в `_uiState`.
*/
fun onServerUrlChange(newUrl: String) {
_uiState.update { it.copy(serverUrl = newUrl) }
}
// [ACTION]
/**
* [CONTRACT]
* [ACTION] Обновляет имя пользователя в состоянии UI в ответ на ввод пользователя.
* @param newUsername Новое значение имени пользователя.
* @sideeffect Обновляет поле `username` в `_uiState`.
*/
fun onUsernameChange(newUsername: String) {
_uiState.update { it.copy(username = newUsername) }
}
// [ACTION]
/**
* [CONTRACT]
* [ACTION] Обновляет пароль в состоянии UI в ответ на ввод пользователя.
* @param newPassword Новое значение пароля.
* @sideeffect Обновляет поле `password` в `_uiState`.
*/
fun onPasswordChange(newPassword: String) {
_uiState.update { it.copy(password = newPassword) }
}
// [ACTION]
/**
* [CONTRACT]
* [ACTION] Запускает процесс подключения и входа в систему по действию пользователя.
* Выполняет две основные операции:
* 1. Сохраняет введенные учетные данные для последующих сессий.
* 2. Выполняет вход в систему с использованием этих данных.
* @sideeffect Обновляет `_uiState`, управляя флагами `isLoading`, `error` и `isSetupComplete`.
* @sideeffect Вызывает `credentialsRepository.saveCredentials`, изменяя сохраненные данные.
* @sideeffect Вызывает `loginUseCase`, который, в свою очередь, сохраняет токен.
*/
fun connect() {
// [ENTRYPOINT]
viewModelScope.launch {
// [ACTION] Устанавливаем состояние загрузки и сбрасываем предыдущую ошибку.
_uiState.update { it.copy(isLoading = true, error = null) }
// [PREPARATION] Готовим данные для операций, очищая их от лишних пробелов.
val credentials = Credentials(
serverUrl = _uiState.value.serverUrl.trim(),
username = _uiState.value.username.trim(),
password = _uiState.value.password
)
// [ACTION] Сохраняем учетные данные для будущего использования.
credentialsRepository.saveCredentials(credentials)
when (val result = loginUseCase(credentials)) {
is Result.Success -> {
// [CORE-LOGIC] Выполняем UseCase и обрабатываем результат.
loginUseCase(credentials).fold(
onSuccess = {
// [ACTION] Обработка успеха: обновляем UI, отмечая завершение настройки.
_uiState.update { it.copy(isLoading = false, isSetupComplete = true) }
},
onFailure = { exception ->
// [ERROR_HANDLER] Обработка ошибки: обновляем UI с сообщением об ошибке.
_uiState.update { it.copy(isLoading = false, error = exception.message ?: "Login failed") }
}
is Result.Error -> {
_uiState.update { it.copy(isLoading = false, error = result.exception.message ?: "Login failed") }
}
}
)
}
}
// [END_CLASS_SetupViewModel]
}
// [END_FILE_SetupViewModel.kt]
// [END_FILE_SetupViewModel.kt]