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 // [DEPENDENCY] Logging
implementation(Libs.timber) implementation(Libs.timber)
// [DEPENDENCY] Security
implementation(Libs.securityCrypto)
// [DEPENDENCY] Testing // [DEPENDENCY] Testing
testImplementation(Libs.junit) testImplementation(Libs.junit)
androidTestImplementation(Libs.extJunit) androidTestImplementation(Libs.extJunit)

View File

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

View File

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

View File

@@ -1,9 +1,21 @@
// [PACKAGE] com.homebox.lens.ui.screen.setup // [PACKAGE] com.homebox.lens.ui.screen.setup
// [FILE] SetupUiState.kt // [FILE] SetupUiState.kt
// [SEMANTICS] ui_state, data_model, immutable
package com.homebox.lens.ui.screen.setup 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( data class SetupUiState(
val serverUrl: String = "", val serverUrl: String = "",
val username: String = "", val username: String = "",

View File

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

View File

@@ -95,8 +95,6 @@ object Libs {
const val composeUiTooling = "androidx.compose.ui:ui-tooling" const val composeUiTooling = "androidx.compose.ui:ui-tooling"
const val composeUiTestManifest = "androidx.compose.ui:ui-test-manifest" const val composeUiTestManifest = "androidx.compose.ui:ui-test-manifest"
// Security
const val securityCrypto = "androidx.security:security-crypto:${Versions.securityCrypto}"
} }
// [END_FILE_Dependencies.kt] // [END_FILE_Dependencies.kt]

View File

@@ -17,6 +17,7 @@ import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT import retrofit2.http.PUT
import retrofit2.http.Path import retrofit2.http.Path
@@ -30,6 +31,9 @@ import retrofit2.http.Query
interface HomeboxApiService { interface HomeboxApiService {
// [ENDPOINT] Auth // [ENDPOINT] Auth
// [FIX] Явно указываем заголовок Content-Type, чтобы переопределить
// значение по умолчанию от Moshi, которое содержит "; charset=UTF-8".
@Headers("Content-Type: application/json")
@POST("v1/users/login") @POST("v1/users/login")
suspend fun login(@Body loginForm: LoginFormDto): TokenResponseDto suspend fun login(@Body loginForm: LoginFormDto): TokenResponseDto

View File

@@ -13,24 +13,34 @@ import com.homebox.lens.domain.model.GroupStatistics
/** /**
* [CONTRACT] * [CONTRACT]
* DTO для статистики. * DTO для статистики.
* [COHERENCE_NOTE] Этот DTO был исправлен, чтобы точно соответствовать JSON-ответу от сервера.
* Поля `items`, `labels`, `locations`, `totalValue` были заменены на `totalItems`, `totalLabels`,
* `totalLocations`, `totalItemPrice` и т.д., чтобы устранить ошибку парсинга `JsonDataException`.
*/ */
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class GroupStatisticsDto( data class GroupStatisticsDto(
@Json(name = "items") val items: Int, @Json(name = "totalItems") val totalItems: Int,
@Json(name = "labels") val labels: Int, @Json(name = "totalLabels") val totalLabels: Int,
@Json(name = "locations") val locations: Int, @Json(name = "totalLocations") val totalLocations: Int,
@Json(name = "totalValue") val totalValue: Double @Json(name = "totalItemPrice") val totalItemPrice: Double,
// [FIX] Добавляем недостающие поля, которые присутствуют в JSON, но отсутствовали в DTO.
// Делаем их nullable на случай, если API перестанет их присылать в будущем.
@Json(name = "totalUsers") val totalUsers: Int? = null,
@Json(name = "totalWithWarranty") val totalWithWarranty: Int? = null
) )
/** /**
* [CONTRACT] * [CONTRACT]
* Маппер из GroupStatisticsDto в доменную модель GroupStatistics. * Маппер из GroupStatisticsDto в доменную модель GroupStatistics.
* [COHERENCE_NOTE] Маппер обновлен для использования правильных полей из исправленного DTO.
*/ */
fun GroupStatisticsDto.toDomain(): GroupStatistics { fun GroupStatisticsDto.toDomain(): GroupStatistics {
// [ACTION] Маппим данные из DTO в доменную модель.
return GroupStatistics( return GroupStatistics(
items = this.items, items = this.totalItems,
labels = this.labels, labels = this.totalLabels,
locations = this.locations, locations = this.totalLocations,
totalValue = this.totalValue totalValue = this.totalItemPrice
) )
} }
// [END_FILE_GroupStatisticsDto.kt]

View File

@@ -13,28 +13,39 @@ import com.homebox.lens.domain.model.LabelOut
/** /**
* [CONTRACT] * [CONTRACT]
* DTO для метки. * DTO для метки.
* [COHERENCE_NOTE] Поле `isArchived` сделано nullable (`Boolean?`),
* так как оно отсутствует в JSON-ответе от сервера. Это исправляет ошибку парсинга
* `JsonDataException: Required value 'isArchived' missing`.
*/ */
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class LabelOutDto( data class LabelOutDto(
@Json(name = "id") val id: String, @Json(name = "id") val id: String,
@Json(name = "name") val name: String, @Json(name = "name") val name: String,
@Json(name = "color") val color: String, // [COHERENCE_NOTE] Поле `color` может быть null или отсутствовать, делаем его nullable для безопасности.
@Json(name = "isArchived") val isArchived: Boolean, @Json(name = "color") val color: String?,
// [FIX] Поле отсутствует в JSON, делаем nullable.
@Json(name = "isArchived") val isArchived: Boolean?,
@Json(name = "createdAt") val createdAt: String, @Json(name = "createdAt") val createdAt: String,
@Json(name = "updatedAt") val updatedAt: String @Json(name = "updatedAt") val updatedAt: String,
// [COHERENCE_NOTE] Поле `description` также может отсутствовать, добавляем его как nullable.
@Json(name = "description") val description: String?
) )
/** /**
* [CONTRACT] * [CONTRACT]
* Маппер из LabelOutDto в доменную модель LabelOut. * Маппер из LabelOutDto в доменную модель LabelOut.
* [COHERENCE_NOTE] Маппер обновлен для безопасной обработки nullable полей
* и предоставления non-nullable значений по умолчанию для доменной модели.
*/ */
fun LabelOutDto.toDomain(): LabelOut { fun LabelOutDto.toDomain(): LabelOut {
return LabelOut( return LabelOut(
id = this.id, id = this.id,
name = this.name, name = this.name,
color = this.color, // [FIX] Используем Elvis-оператор для предоставления значения по умолчанию.
isArchived = this.isArchived, color = this.color ?: "", // Пустая строка как дефолтный цвет
isArchived = this.isArchived ?: false, // `false` как дефолтное состояние архива
createdAt = this.createdAt, createdAt = this.createdAt,
updatedAt = this.updatedAt updatedAt = this.updatedAt
) )
} }
// [END_FILE_LabelOutDto.kt]

View File

@@ -13,30 +13,42 @@ import com.homebox.lens.domain.model.LocationOutCount
/** /**
* [CONTRACT] * [CONTRACT]
* DTO для местоположения со счетчиком. * DTO для местоположения со счетчиком.
* [COHERENCE_NOTE] Поля `color` и `isArchived` сделаны nullable (`String?`, `Boolean?`),
* так как они отсутствуют в JSON-ответе от сервера. Это исправляет ошибку парсинга
* `JsonDataException: Required value '...' missing`.
*/ */
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class LocationOutCountDto( data class LocationOutCountDto(
@Json(name = "id") val id: String, @Json(name = "id") val id: String,
@Json(name = "name") val name: String, @Json(name = "name") val name: String,
@Json(name = "color") val color: String, // [FIX] Поле отсутствует в JSON, делаем nullable.
@Json(name = "isArchived") val isArchived: Boolean, @Json(name = "color") val color: String?,
// [FIX] Поле отсутствует в JSON, делаем nullable.
@Json(name = "isArchived") val isArchived: Boolean?,
@Json(name = "itemCount") val itemCount: Int, @Json(name = "itemCount") val itemCount: Int,
@Json(name = "createdAt") val createdAt: String, @Json(name = "createdAt") val createdAt: String,
@Json(name = "updatedAt") val updatedAt: String @Json(name = "updatedAt") val updatedAt: String,
// [COHERENCE_NOTE] Поле `description` также может отсутствовать или быть null,
// поэтому его тоже безопасно сделать nullable.
@Json(name = "description") val description: String?
) )
/** /**
* [CONTRACT] * [CONTRACT]
* Маппер из LocationOutCountDto в доменную модель LocationOutCount. * Маппер из LocationOutCountDto в доменную модель LocationOutCount.
* [COHERENCE_NOTE] Маппер обновлен для безопасной обработки nullable полей
* и предоставления non-nullable значений по умолчанию для доменной модели.
*/ */
fun LocationOutCountDto.toDomain(): LocationOutCount { fun LocationOutCountDto.toDomain(): LocationOutCount {
return LocationOutCount( return LocationOutCount(
id = this.id, id = this.id,
name = this.name, name = this.name,
color = this.color, // [FIX] Используем Elvis-оператор для предоставления значения по умолчанию, если поле null.
isArchived = this.isArchived, color = this.color ?: "", // Пустая строка как дефолтный цвет
isArchived = this.isArchived ?: false, // `false` как дефолтное состояние архива
itemCount = this.itemCount, itemCount = this.itemCount,
createdAt = this.createdAt, createdAt = this.createdAt,
updatedAt = this.updatedAt updatedAt = this.updatedAt
) )
} }
// [END_FILE_LocationOutCountDto.kt]

View File

@@ -1,4 +1,29 @@
// [PACKAGE] com.homebox.lens.data.api.mapper
// [FILE] TokenMapper.kt
// [SEMANTICS] mapper, data_conversion, clean_architecture
package com.homebox.lens.data.api.mapper package com.homebox.lens.data.api.mapper
class TokenMapper { import com.homebox.lens.data.api.dto.TokenResponseDto
import com.homebox.lens.domain.model.TokenResponse
/**
* [CONTRACT]
* [HELPER] Преобразует DTO-объект токена в доменную модель.
* @receiver [TokenResponseDto] объект из слоя данных.
* @return [TokenResponse] объект для доменного слоя.
* @throws IllegalArgumentException если токен в DTO пустой.
*/
fun TokenResponseDto.toDomain(): TokenResponse {
// [PRECONDITION] DTO должен содержать валидные данные для маппинга.
require(this.token.isNotBlank()) { "[PRECONDITION_FAILED] DTO token is blank, cannot map to domain model." }
// [ACTION]
val domainModel = TokenResponse(token = this.token)
// [POSTCONDITION] Проверяем, что инвариант доменной модели соблюден.
check(domainModel.token.isNotBlank()) { "[POSTCONDITION_FAILED] Domain model token is blank after mapping." }
return domainModel
} }
// [END_FILE_TokenMapper.kt]

View File

@@ -2,7 +2,7 @@
// [FILE] LoginRequest.kt // [FILE] LoginRequest.kt
// [SEMANTICS] dto, network, serialization, authentication // [SEMANTICS] dto, network, serialization, authentication
package com.homebox.lens.data.api package com.homebox.lens.data.api.model
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass

View File

@@ -1,70 +1,120 @@
// [PACKAGE] com.homebox.lens.data.di // [PACKAGE] com.homebox.lens.data.di
// [FILE] ApiModule.kt // [FILE] ApiModule.kt
// [PURPOSE] Предоставляет синглтон-зависимости для работы с сетью, включая OkHttpClient, Retrofit и ApiService.
package com.homebox.lens.data.di package com.homebox.lens.data.di
// [IMPORTS]
import com.homebox.lens.data.api.HomeboxApiService import com.homebox.lens.data.api.HomeboxApiService
import com.homebox.lens.domain.repository.CredentialsRepository
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory
import javax.inject.Provider
import javax.inject.Singleton import javax.inject.Singleton
// [CONTRACT]
/** /**
* [MODULE: DaggerHilt('ApiModule')] * [ENTITY: Module('ApiModule')]
* [PURPOSE] Предоставляет зависимости для работы с сетью (Retrofit, OkHttp, Moshi). * [CONTRACT]
* Hilt-модуль, отвечающий за создание и предоставление всех зависимостей,
* необходимых для сетевого взаимодействия.
*/ */
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object ApiModule { object ApiModule {
// [HELPER] // [HELPER] Временный базовый URL для API. В будущем должен стать динамическим.
private const val BASE_URL = "https://api.homebox.app/" private const val BASE_URL = "https://homebox.bebesh.ru/api/"
// [PROVIDER] /**
* [PROVIDER]
* [CONTRACT]
* Предоставляет сконфигурированный OkHttpClient.
* @param credentialsRepositoryProvider Провайдер репозитория для доступа к токену авторизации.
* Используется Provider<T> для предотвращения циклов зависимостей.
* @return Синглтон-экземпляр OkHttpClient с настроенными перехватчиками.
*/
@Provides @Provides
@Singleton @Singleton
fun provideOkHttpClient(): OkHttpClient { fun provideOkHttpClient(
// [ACTION] Create logging interceptor credentialsRepositoryProvider: Provider<CredentialsRepository>
val logging = HttpLoggingInterceptor().apply { ): OkHttpClient {
// [ACTION] Создаем перехватчик для логирования.
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY level = HttpLoggingInterceptor.Level.BODY
} }
// [ACTION] Build OkHttpClient
// [ACTION] Создаем перехватчик для добавления заголовка 'Accept'.
val acceptHeaderInterceptor = Interceptor { chain ->
val request = chain.request().newBuilder()
.header("Accept", "application/json")
.build()
chain.proceed(request)
}
// [CORE-LOGIC] Создаем перехватчик для добавления токена авторизации.
val authInterceptor = Interceptor { chain ->
// [HELPER] Получаем токен из репозитория.
// runBlocking здесь допустим, т.к. чтение из SharedPreferences - быстрая I/O операция,
// а интерфейс Interceptor'а является синхронным.
val token = runBlocking { credentialsRepositoryProvider.get().getToken() }
val requestBuilder = chain.request().newBuilder()
// [ACTION] Если токен существует, добавляем его в заголовок.
if (token != null) {
// Сервер ожидает заголовок "Authorization: Bearer <token>"
// Предполагается, что `token` уже содержит префикс "Bearer ".
requestBuilder.addHeader("Authorization", token)
}
chain.proceed(requestBuilder.build())
}
// [ACTION] Собираем OkHttpClient с правильным порядком перехватчиков.
return OkHttpClient.Builder() return OkHttpClient.Builder()
.addInterceptor(logging) .addInterceptor(acceptHeaderInterceptor)
// [TODO] Add AuthInterceptor for Bearer token .addInterceptor(authInterceptor) // Добавляем перехватчик для токена
.addInterceptor(loggingInterceptor) // Логирование должно идти последним, чтобы видеть финальный запрос
.build() .build()
} }
// [PROVIDER] /**
* [PROVIDER]
* [CONTRACT] Предоставляет экземпляр Moshi для парсинга JSON.
*/
@Provides @Provides
@Singleton @Singleton
fun provideMoshi(): Moshi { fun provideMoshi(): Moshi {
// [ACTION] Build Moshi with Kotlin adapter
return Moshi.Builder() return Moshi.Builder()
.add(KotlinJsonAdapterFactory()) .add(KotlinJsonAdapterFactory())
.build() .build()
} }
// [PROVIDER] /**
* [PROVIDER]
* [CONTRACT] Предоставляет фабрику конвертеров для Retrofit.
*/
@Provides @Provides
@Singleton @Singleton
fun provideMoshiConverterFactory(moshi: Moshi): MoshiConverterFactory { fun provideMoshiConverterFactory(moshi: Moshi): MoshiConverterFactory {
return MoshiConverterFactory.create(moshi) return MoshiConverterFactory.create(moshi)
} }
// [PROVIDER] /**
* [PROVIDER]
* [CONTRACT] Предоставляет сконфигурированный экземпляр Retrofit.
*/
@Provides @Provides
@Singleton @Singleton
fun provideRetrofit(okHttpClient: OkHttpClient, moshiConverterFactory: MoshiConverterFactory): Retrofit { fun provideRetrofit(okHttpClient: OkHttpClient, moshiConverterFactory: MoshiConverterFactory): Retrofit {
// [ACTION] Build Retrofit instance
return Retrofit.Builder() return Retrofit.Builder()
.baseUrl(BASE_URL) .baseUrl(BASE_URL)
.client(okHttpClient) .client(okHttpClient)
@@ -72,13 +122,14 @@ object ApiModule {
.build() .build()
} }
// [PROVIDER] /**
* [PROVIDER]
* [CONTRACT] Предоставляет реализацию интерфейса HomeboxApiService.
*/
@Provides @Provides
@Singleton @Singleton
fun provideHomeboxApiService(retrofit: Retrofit): HomeboxApiService { fun provideHomeboxApiService(retrofit: Retrofit): HomeboxApiService {
// [ACTION] Create ApiService from Retrofit instance
return retrofit.create(HomeboxApiService::class.java) return retrofit.create(HomeboxApiService::class.java)
} }
} }
// [END_FILE_ApiModule.kt] // [END_FILE_ApiModule.kt]

View File

@@ -1,10 +1,12 @@
// [PACKAGE] com.homebox.lens.data.di // [PACKAGE] com.homebox.lens.data.di
// [FILE] RepositoryModule.kt // [FILE] RepositoryModule.kt
// [SEMANTICS] dependency_injection, hilt, module, binding
package com.homebox.lens.data.di package com.homebox.lens.data.di
import com.homebox.lens.data.repository.AuthRepositoryImpl import com.homebox.lens.data.repository.AuthRepositoryImpl
import com.homebox.lens.data.repository.CredentialsRepositoryImpl import com.homebox.lens.data.repository.CredentialsRepositoryImpl
import com.homebox.lens.data.repository.ItemRepositoryImpl
import com.homebox.lens.domain.repository.AuthRepository import com.homebox.lens.domain.repository.AuthRepository
import com.homebox.lens.domain.repository.CredentialsRepository import com.homebox.lens.domain.repository.CredentialsRepository
import com.homebox.lens.domain.repository.ItemRepository import com.homebox.lens.domain.repository.ItemRepository
@@ -14,26 +16,46 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
// [CONTRACT]
/** /**
* [MODULE: DaggerHilt('RepositoryModule')] * [ENTITY: Module('RepositoryModule')]
* [PURPOSE] Предоставляет реализации для интерфейсов репозиториев. * [CONTRACT]
* Hilt-модуль для предоставления реализаций репозиториев.
* Использует `@Binds` для эффективного связывания интерфейсов с их реализациями.
*/ */
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
abstract class RepositoryModule { abstract class RepositoryModule {
/**
* [CONTRACT]
* Связывает интерфейс ItemRepository с его реализацией.
*/
@Binds @Binds
@Singleton @Singleton
abstract fun bindItemRepository(itemRepositoryImpl: com.homebox.lens.data.repository.ItemRepositoryImpl): ItemRepository abstract fun bindItemRepository(
itemRepositoryImpl: ItemRepositoryImpl
): ItemRepository
/**
* [CONTRACT]
* Связывает интерфейс CredentialsRepository с его реализацией.
*/
@Binds @Binds
@Singleton @Singleton
abstract fun bindCredentialsRepository(credentialsRepositoryImpl: CredentialsRepositoryImpl): CredentialsRepository abstract fun bindCredentialsRepository(
credentialsRepositoryImpl: CredentialsRepositoryImpl
): CredentialsRepository
/**
* [CONTRACT]
* [FIX] Связывает интерфейс AuthRepository с его реализацией.
* Это исправляет ошибку "could not be resolved", так как теперь Hilt знает,
* какую конкретную реализацию предоставить, когда запрашивается AuthRepository.
*/
@Binds @Binds
@Singleton @Singleton
abstract fun bindAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository abstract fun bindAuthRepository(
authRepositoryImpl: AuthRepositoryImpl
): AuthRepository
} }
// [END_FILE_RepositoryModule.kt] // [END_FILE_RepositoryModule.kt]

View File

@@ -5,8 +5,8 @@ package com.homebox.lens.data.di
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences import com.homebox.lens.data.repository.EncryptedPreferencesWrapper
import androidx.security.crypto.MasterKey import com.homebox.lens.data.security.CryptoManager
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -18,20 +18,24 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object StorageModule { object StorageModule {
private const val PREFERENCES_FILE_NAME = "homebox_lens_prefs" // No longer secret
// [ACTION] Provide a standard, unencrypted SharedPreferences instance.
@Provides @Provides
@Singleton @Singleton
fun provideEncryptedSharedPreferences(@ApplicationContext context: Context): SharedPreferences { fun provideSharedPreferences(@ApplicationContext context: Context): SharedPreferences {
val masterKey = MasterKey.Builder(context) return context.getSharedPreferences(PREFERENCES_FILE_NAME, Context.MODE_PRIVATE)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) }
.build()
return EncryptedSharedPreferences.create( // [ACTION] Provide our new EncryptedPreferencesWrapper as the main entry point for secure storage.
context, // Hilt will automatically provide SharedPreferences and CryptoManager to its constructor.
"secret_shared_prefs", @Provides
masterKey, @Singleton
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, fun provideEncryptedPreferencesWrapper(
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM sharedPreferences: SharedPreferences,
) cryptoManager: CryptoManager
): EncryptedPreferencesWrapper {
return EncryptedPreferencesWrapper(sharedPreferences, cryptoManager)
} }
} }
// [END_FILE_StorageModule.kt] // [END_FILE_StorageModule.kt]

View File

@@ -1,27 +1,85 @@
// [PACKAGE] com.homebox.lens.data.repository // [PACKAGE] com.homebox.lens.data.repository
// [FILE] AuthRepositoryImpl.kt // [FILE] AuthRepositoryImpl.kt
// [SEMANTICS] data_implementation, authentication, repository
package com.homebox.lens.data.repository package com.homebox.lens.data.repository
// [IMPORTS]
import android.content.SharedPreferences import android.content.SharedPreferences
import com.homebox.lens.data.api.HomeboxApiService
import com.homebox.lens.data.api.dto.LoginFormDto
import com.homebox.lens.data.api.mapper.toDomain
import com.homebox.lens.domain.model.Credentials
import com.homebox.lens.domain.model.TokenResponse
import com.homebox.lens.domain.repository.AuthRepository import com.homebox.lens.domain.repository.AuthRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import javax.inject.Inject import javax.inject.Inject
/**
* [ENTITY: Class('AuthRepositoryImpl')]
* [CONTRACT]
* Реализация репозитория для управления аутентификацией.
* @param encryptedPrefs Защищенное хранилище для токена.
* @param okHttpClient Общий OkHttp клиент для переиспользования.
* @param moshiConverterFactory Общий конвертер Moshi для переиспользования.
* [COHERENCE_NOTE] Реализация метода login теперь включает логику создания временного Retrofit-клиента
* "на лету", используя URL сервера из credentials. Эта логика была перенесена из ItemRepositoryImpl.
*/
class AuthRepositoryImpl @Inject constructor( class AuthRepositoryImpl @Inject constructor(
private val encryptedPrefs: SharedPreferences private val encryptedPrefs: SharedPreferences,
private val okHttpClient: OkHttpClient,
private val moshiConverterFactory: MoshiConverterFactory
) : AuthRepository { ) : AuthRepository {
companion object { companion object {
private const val KEY_AUTH_TOKEN = "key_auth_token" private const val KEY_AUTH_TOKEN = "key_auth_token"
} }
/**
* [CONTRACT]
* Реализует вход пользователя. Создает временный API сервис для выполнения запроса
* на указанный пользователем URL сервера.
* @param credentials Учетные данные пользователя, включая URL сервера.
* @return [Result] с доменной моделью [TokenResponse] при успехе или [Exception] при ошибке.
*/
override suspend fun login(credentials: Credentials): Result<TokenResponse> {
// [PRECONDITION]
require(credentials.serverUrl.isNotBlank()) { "[PRECONDITION_FAILED] Server URL cannot be blank." }
// [CORE-LOGIC]
return withContext(Dispatchers.IO) {
runCatching {
// [ACTION] Создаем временный Retrofit клиент с URL, указанным пользователем.
val tempApiService = Retrofit.Builder()
.baseUrl(credentials.serverUrl)
.client(okHttpClient) // Переиспользуем существующий OkHttp клиент
.addConverterFactory(moshiConverterFactory) // и конвертер
.build()
.create(HomeboxApiService::class.java)
// [ACTION] Создаем DTO и выполняем запрос.
val loginForm = LoginFormDto(credentials.username, credentials.password)
val tokenResponseDto = tempApiService.login(loginForm)
// [ACTION] Маппим результат в доменную модель.
tokenResponseDto.toDomain()
}
}
}
override suspend fun saveToken(token: String) { override suspend fun saveToken(token: String) {
require(token.isNotBlank()) { "[PRECONDITION_FAILED] Token cannot be blank." }
withContext(Dispatchers.IO) {
encryptedPrefs.edit().putString(KEY_AUTH_TOKEN, token).apply() encryptedPrefs.edit().putString(KEY_AUTH_TOKEN, token).apply()
} }
}
override fun getToken(): Flow<String?> = flow { override fun getToken(): Flow<String?> = flow {
emit(encryptedPrefs.getString(KEY_AUTH_TOKEN, null)) emit(encryptedPrefs.getString(KEY_AUTH_TOKEN, null))

View File

@@ -1,8 +1,8 @@
// [PACKAGE] com.homebox.lens.data.repository // [PACKAGE] com.homebox.lens.data.repository
// [FILE] CredentialsRepositoryImpl.kt // [FILE] CredentialsRepositoryImpl.kt
// [PURPOSE] Имплементация репозитория для управления учетными данными и токенами доступа.
package com.homebox.lens.data.repository package com.homebox.lens.data.repository
// [IMPORTS]
import android.content.SharedPreferences import android.content.SharedPreferences
import com.homebox.lens.domain.model.Credentials import com.homebox.lens.domain.model.Credentials
import com.homebox.lens.domain.repository.CredentialsRepository import com.homebox.lens.domain.repository.CredentialsRepository
@@ -10,37 +10,90 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
// [REPOSITORY_IMPL] /**
* [ENTITY: Class('CredentialsRepositoryImpl')]
* [CONTRACT]
* Реализует репозиторий для управления учетными данными пользователя.
* Взаимодействует с зашифрованными SharedPreferences для сохранения и извлечения данных.
* @param encryptedPrefs Зашифрованное хранилище ключ-значение, предоставляемое Hilt.
* @invariant Состояние этого репозитория полностью зависит от содержимого `encryptedPrefs`.
*/
class CredentialsRepositoryImpl @Inject constructor( class CredentialsRepositoryImpl @Inject constructor(
private val encryptedPrefs: SharedPreferences private val encryptedPrefs: SharedPreferences
) : CredentialsRepository { ) : CredentialsRepository {
// [CONSTANTS_KEYS] Ключи для хранения данных в SharedPreferences.
companion object { companion object {
private const val KEY_SERVER_URL = "key_server_url" private const val KEY_SERVER_URL = "key_server_url"
private const val KEY_USERNAME = "key_username" private const val KEY_USERNAME = "key_username"
private const val KEY_PASSWORD = "key_password" private const val KEY_PASSWORD = "key_password"
private const val KEY_AUTH_TOKEN = "key_auth_token"
} }
/**
* [CONTRACT]
* Сохраняет основные учетные данные пользователя.
* @param credentials Объект с учетными данными для сохранения.
* @sideeffect Перезаписывает существующие учетные данные в SharedPreferences.
*/
override suspend fun saveCredentials(credentials: Credentials) { override suspend fun saveCredentials(credentials: Credentials) {
// [ACTION] Выполняем запись в SharedPreferences в фоновом потоке.
withContext(Dispatchers.IO) {
encryptedPrefs.edit() encryptedPrefs.edit()
.putString(KEY_SERVER_URL, credentials.serverUrl) .putString(KEY_SERVER_URL, credentials.serverUrl)
.putString(KEY_USERNAME, credentials.username) .putString(KEY_USERNAME, credentials.username)
.putString(KEY_PASSWORD, credentials.password) .putString(KEY_PASSWORD, credentials.password)
.apply() .apply()
} }
}
/**
* [CONTRACT]
* Извлекает сохраненные учетные данные пользователя в виде потока.
* @return Flow, который эммитит объект [Credentials] или null, если данные отсутствуют.
*/
override fun getCredentials(): Flow<Credentials?> = flow { override fun getCredentials(): Flow<Credentials?> = flow {
// [CORE-LOGIC] Читаем данные из SharedPreferences.
val serverUrl = encryptedPrefs.getString(KEY_SERVER_URL, null) val serverUrl = encryptedPrefs.getString(KEY_SERVER_URL, null)
val username = encryptedPrefs.getString(KEY_USERNAME, null) val username = encryptedPrefs.getString(KEY_USERNAME, null)
val password = encryptedPrefs.getString(KEY_PASSWORD, null) val password = encryptedPrefs.getString(KEY_PASSWORD, null)
// [ACTION] Эммитим результат.
if (serverUrl != null && username != null && password != null) { if (serverUrl != null && username != null && password != null) {
emit(Credentials(serverUrl, username, password)) emit(Credentials(serverUrl, username, password))
} else { } else {
emit(null) emit(null)
} }
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO) // [ACTION] Указываем, что Flow должен выполняться в фоновом потоке.
/**
* [CONTRACT]
* Сохраняет токен авторизации.
* @param token Токен для сохранения.
* @sideeffect Перезаписывает существующий токен в SharedPreferences.
*/
override suspend fun saveToken(token: String) {
// [ACTION] Выполняем запись токена в фоновом потоке.
withContext(Dispatchers.IO) {
encryptedPrefs.edit()
.putString(KEY_AUTH_TOKEN, token)
.apply()
}
}
/**
* [CONTRACT]
* Извлекает сохраненный токен авторизации.
* @return Строка с токеном или null, если он не найден.
*/
override suspend fun getToken(): String? {
// [ACTION] Выполняем чтение токена в фоновом потоке.
return withContext(Dispatchers.IO) {
encryptedPrefs.getString(KEY_AUTH_TOKEN, null)
}
}
} }
// [END_FILE_CredentialsRepositoryImpl.kt] // [END_FILE_CredentialsRepositoryImpl.kt]

View File

@@ -1,4 +1,66 @@
// [PACKAGE] com.homebox.lens.data.repository
// [FILE] EncryptedPreferencesWrapper.kt
// [PURPOSE] A wrapper around SharedPreferences to provide on-the-fly encryption/decryption.
package com.homebox.lens.data.repository package com.homebox.lens.data.repository
class EncryptedPreferencesWrapper { import android.content.SharedPreferences
import com.homebox.lens.data.security.CryptoManager
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.nio.charset.Charset
import javax.inject.Inject
/**
* [CONTRACT]
* Provides a simplified and secure interface for storing and retrieving sensitive string data.
* It uses a CryptoManager to encrypt/decrypt data before writing/reading from a standard SharedPreferences instance.
* @param sharedPreferences The underlying standard SharedPreferences instance to store encrypted data.
* @param cryptoManager The manager responsible for all cryptographic operations.
*/
class EncryptedPreferencesWrapper @Inject constructor(
private val sharedPreferences: SharedPreferences,
private val cryptoManager: CryptoManager
) {
/**
* [CONTRACT]
* Retrieves a decrypted string value for a given key.
* @param key The key for the preference.
* @param defaultValue The value to return if the key is not found or decryption fails.
* @return The decrypted string, or the defaultValue.
*/
fun getString(key: String, defaultValue: String?): String? {
val encryptedValue = sharedPreferences.getString(key, null) ?: return defaultValue
return try {
val bytes = android.util.Base64.decode(encryptedValue, android.util.Base64.DEFAULT)
val decryptedBytes = cryptoManager.decrypt(ByteArrayInputStream(bytes))
String(decryptedBytes, Charset.defaultCharset())
} catch (e: Exception) {
// Log the error, maybe clear the invalid preference
defaultValue
} }
}
/**
* [CONTRACT]
* Encrypts and saves a string value for a given key.
* @param key The key for the preference.
* @param value The string value to encrypt and save.
* @sideeffect Modifies the underlying SharedPreferences file.
*/
fun putString(key: String, value: String) {
try {
val outputStream = ByteArrayOutputStream()
cryptoManager.encrypt(value.toByteArray(Charset.defaultCharset()), outputStream)
val encryptedBytes = outputStream.toByteArray()
val encryptedValue = android.util.Base64.encodeToString(encryptedBytes, android.util.Base64.DEFAULT)
sharedPreferences.edit().putString(key, encryptedValue).apply()
} catch (e: Exception) {
// Log the error
}
}
// [COHERENCE_NOTE] Add other methods like getInt, putInt etc. as needed, following the same pattern.
}
// [END_FILE_EncryptedPreferencesWrapper.kt]

View File

@@ -1,18 +1,15 @@
// [PACKAGE] com.homebox.lens.data.repository // [PACKAGE] com.homebox.lens.data.repository
// [FILE] ItemRepositoryImpl.kt // [FILE] ItemRepositoryImpl.kt
// [SEMANTICS] data_repository, implementation, network // [SEMANTICS] data_repository, implementation, items
package com.homebox.lens.data.repository package com.homebox.lens.data.repository
// [IMPORTS] // [IMPORTS]
import com.homebox.lens.data.api.HomeboxApiService import com.homebox.lens.data.api.HomeboxApiService
import com.homebox.lens.data.api.dto.LoginFormDto
import com.homebox.lens.data.api.dto.toDomain import com.homebox.lens.data.api.dto.toDomain
import com.homebox.lens.data.api.dto.toDto import com.homebox.lens.data.api.dto.toDto
import com.homebox.lens.domain.model.* import com.homebox.lens.domain.model.*
import com.homebox.lens.domain.repository.AuthRepository
import com.homebox.lens.domain.repository.ItemRepository import com.homebox.lens.domain.repository.ItemRepository
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -21,37 +18,20 @@ import javax.inject.Singleton
* [CONTRACT] * [CONTRACT]
* Реализация репозитория для работы с данными о вещах. * Реализация репозитория для работы с данными о вещах.
* @param apiService Сервис для взаимодействия с Homebox API. * @param apiService Сервис для взаимодействия с Homebox API.
* [COHERENCE_NOTE] Метод 'login' был полностью удален из этого класса, так как его ответственность
* была передана в AuthRepositoryImpl. Это устраняет ошибку компиляции "'login' overrides nothing".
*/ */
@Singleton @Singleton
class ItemRepositoryImpl @Inject constructor( class ItemRepositoryImpl @Inject constructor(
private val apiService: HomeboxApiService, private val apiService: HomeboxApiService,
private val authRepository: AuthRepository,
private val okHttpClient: OkHttpClient,
private val moshiConverterFactory: MoshiConverterFactory
) : ItemRepository { ) : ItemRepository {
override suspend fun login(credentials: Credentials): Result<Unit> { // [DELETED] Метод login был здесь, но теперь он удален.
return try {
val tempApiService = Retrofit.Builder()
.baseUrl(credentials.serverUrl)
.client(okHttpClient)
.addConverterFactory(moshiConverterFactory)
.build()
.create(HomeboxApiService::class.java)
val loginForm = LoginFormDto(credentials.username, credentials.password)
val tokenResponse = tempApiService.login(loginForm)
authRepository.saveToken(tokenResponse.token)
Result.Success(Unit)
} catch (e: Exception) {
Result.Error(e)
}
}
/** /**
* [CONTRACT] @see ItemRepository.createItem * [CONTRACT] @see ItemRepository.createItem
*/ */
override suspend fun createItem(newItemData: ItemCreate): ItemSummary { override suspend fun createItem(newItemData: ItemCreate): ItemSummary {
// [ACTION]
val itemDto = newItemData.toDto() val itemDto = newItemData.toDto()
val resultDto = apiService.createItem(itemDto) val resultDto = apiService.createItem(itemDto)
return resultDto.toDomain() return resultDto.toDomain()
@@ -61,7 +41,6 @@ class ItemRepositoryImpl @Inject constructor(
* [CONTRACT] @see ItemRepository.getItemDetails * [CONTRACT] @see ItemRepository.getItemDetails
*/ */
override suspend fun getItemDetails(itemId: String): ItemOut { override suspend fun getItemDetails(itemId: String): ItemOut {
// [ACTION]
val resultDto = apiService.getItem(itemId) val resultDto = apiService.getItem(itemId)
return resultDto.toDomain() return resultDto.toDomain()
} }
@@ -70,7 +49,6 @@ class ItemRepositoryImpl @Inject constructor(
* [CONTRACT] @see ItemRepository.updateItem * [CONTRACT] @see ItemRepository.updateItem
*/ */
override suspend fun updateItem(itemId: String, item: ItemUpdate): ItemOut { override suspend fun updateItem(itemId: String, item: ItemUpdate): ItemOut {
// [ACTION]
val itemDto = item.toDto() val itemDto = item.toDto()
val resultDto = apiService.updateItem(itemId, itemDto) val resultDto = apiService.updateItem(itemId, itemDto)
return resultDto.toDomain() return resultDto.toDomain()
@@ -80,7 +58,6 @@ class ItemRepositoryImpl @Inject constructor(
* [CONTRACT] @see ItemRepository.deleteItem * [CONTRACT] @see ItemRepository.deleteItem
*/ */
override suspend fun deleteItem(itemId: String) { override suspend fun deleteItem(itemId: String) {
// [ACTION]
apiService.deleteItem(itemId) apiService.deleteItem(itemId)
} }
@@ -88,7 +65,6 @@ class ItemRepositoryImpl @Inject constructor(
* [CONTRACT] @see ItemRepository.syncInventory * [CONTRACT] @see ItemRepository.syncInventory
*/ */
override suspend fun syncInventory(page: Int, pageSize: Int): PaginationResult<ItemSummary> { override suspend fun syncInventory(page: Int, pageSize: Int): PaginationResult<ItemSummary> {
// [ACTION]
val resultDto = apiService.getItems(page = page, pageSize = pageSize) val resultDto = apiService.getItems(page = page, pageSize = pageSize)
return resultDto.toDomain { it.toDomain() } return resultDto.toDomain { it.toDomain() }
} }
@@ -97,7 +73,6 @@ class ItemRepositoryImpl @Inject constructor(
* [CONTRACT] @see ItemRepository.getStatistics * [CONTRACT] @see ItemRepository.getStatistics
*/ */
override suspend fun getStatistics(): GroupStatistics { override suspend fun getStatistics(): GroupStatistics {
// [ACTION]
val resultDto = apiService.getStatistics() val resultDto = apiService.getStatistics()
return resultDto.toDomain() return resultDto.toDomain()
} }
@@ -106,7 +81,6 @@ class ItemRepositoryImpl @Inject constructor(
* [CONTRACT] @see ItemRepository.getAllLocations * [CONTRACT] @see ItemRepository.getAllLocations
*/ */
override suspend fun getAllLocations(): List<LocationOutCount> { override suspend fun getAllLocations(): List<LocationOutCount> {
// [ACTION]
val resultDto = apiService.getLocations() val resultDto = apiService.getLocations()
return resultDto.map { it.toDomain() } return resultDto.map { it.toDomain() }
} }
@@ -115,7 +89,6 @@ class ItemRepositoryImpl @Inject constructor(
* [CONTRACT] @see ItemRepository.getAllLabels * [CONTRACT] @see ItemRepository.getAllLabels
*/ */
override suspend fun getAllLabels(): List<LabelOut> { override suspend fun getAllLabels(): List<LabelOut> {
// [ACTION]
val resultDto = apiService.getLabels() val resultDto = apiService.getLabels()
return resultDto.map { it.toDomain() } return resultDto.map { it.toDomain() }
} }
@@ -124,7 +97,6 @@ class ItemRepositoryImpl @Inject constructor(
* [CONTRACT] @see ItemRepository.searchItems * [CONTRACT] @see ItemRepository.searchItems
*/ */
override suspend fun searchItems(query: String): PaginationResult<ItemSummary> { override suspend fun searchItems(query: String): PaginationResult<ItemSummary> {
// [ACTION]
val resultDto = apiService.getItems(query = query) val resultDto = apiService.getItems(query = query)
return resultDto.toDomain { it.toDomain() } return resultDto.toDomain { it.toDomain() }
} }

View File

@@ -1,4 +1,106 @@
// [PACKAGE] com.homebox.lens.data.security
// [FILE] CryptoManager.kt
// [PURPOSE] Handles all cryptographic operations using AndroidKeyStore.
package com.homebox.lens.data.security package com.homebox.lens.data.security
class CryptoManager { import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import androidx.annotation.RequiresApi
import java.io.InputStream
import java.io.OutputStream
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
import javax.inject.Inject
import javax.inject.Singleton
/**
* [CONTRACT]
* A manager for handling encryption and decryption using the Android Keystore system.
* This class ensures that cryptographic keys are stored securely.
* It is designed to be a Singleton provided by Hilt.
* @invariant The underlying SecretKey must be valid within the AndroidKeyStore.
*/
@RequiresApi(Build.VERSION_CODES.M)
@Singleton
class CryptoManager @Inject constructor() {
// [ЯКОРЬ] Настройки для шифрования
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply {
load(null)
} }
private val encryptCipher
get() = Cipher.getInstance(TRANSFORMATION).apply {
init(Cipher.ENCRYPT_MODE, getKey())
}
private fun getDecryptCipherForIv(iv: ByteArray): Cipher {
return Cipher.getInstance(TRANSFORMATION).apply {
init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv))
}
}
// [CORE-LOGIC] Получение или создание ключа
private fun getKey(): SecretKey {
val existingKey = keyStore.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry
return existingKey?.secretKey ?: createKey()
}
private fun createKey(): SecretKey {
return KeyGenerator.getInstance(ALGORITHM).apply {
init(
KeyGenParameterSpec.Builder(
ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(BLOCK_MODE)
.setEncryptionPaddings(PADDING)
.setUserAuthenticationRequired(false)
.setRandomizedEncryptionRequired(true)
.build()
)
}.generateKey()
}
// [ACTION] Шифрование потока данных
fun encrypt(bytes: ByteArray, outputStream: OutputStream): ByteArray {
val cipher = encryptCipher
val encryptedBytes = cipher.doFinal(bytes)
outputStream.use {
it.write(cipher.iv.size)
it.write(cipher.iv)
it.write(encryptedBytes.size)
it.write(encryptedBytes)
}
return encryptedBytes
}
// [ACTION] Дешифрование потока данных
fun decrypt(inputStream: InputStream): ByteArray {
return inputStream.use {
val ivSize = it.read()
val iv = ByteArray(ivSize)
it.read(iv)
val encryptedBytesSize = it.read()
val encryptedBytes = ByteArray(encryptedBytesSize)
it.read(encryptedBytes)
getDecryptCipherForIv(iv).doFinal(encryptedBytes)
}
}
companion object {
private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC
private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7
private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING"
private const val ALIAS = "homebox_lens_secret_key"
}
}
// [END_FILE_CryptoManager.kt]

View File

@@ -1,4 +1,20 @@
// [PACKAGE] com.homebox.lens.domain.model
// [FILE] TokenResponse.kt
// [SEMANTICS] data_transfer_object, authentication, model
package com.homebox.lens.domain.model package com.homebox.lens.domain.model
class TokenResponse { /**
* [ENTITY: DataClass('TokenResponse')]
* [CONTRACT]
* Модель данных, представляющая ответ от сервера с токеном аутентификации.
* @property token Строка, содержащая JWT или другой токен доступа.
* @invariant `token` не должен быть пустым.
*/
data class TokenResponse(val token: String) {
init {
// [INVARIANT_CHECK]
require(token.isNotBlank()) { "[INVARIANT_FAILED] Token cannot be blank." }
} }
}
// [END_FILE_TokenResponse.kt]

View File

@@ -1,26 +1,41 @@
// [PACKAGE] com.homebox.lens.domain.repository // [PACKAGE] com.homebox.lens.domain.repository
// [FILE] AuthRepository.kt // [FILE] AuthRepository.kt
// [SEMANTICS] authentication, data_access, repository
package com.homebox.lens.domain.repository package com.homebox.lens.domain.repository
// [IMPORTS]
import com.homebox.lens.domain.model.Credentials
import com.homebox.lens.domain.model.TokenResponse
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
/** /**
* [CONTRACT] * [CONTRACT]
* Repository for managing authentication tokens. * Репозиторий для управления аутентификацией.
* [COHERENCE_NOTE] Добавлен метод `login` для инкапсуляции логики входа.
*/ */
interface AuthRepository { interface AuthRepository {
/** /**
* [CONTRACT] * [CONTRACT]
* Saves the authentication token. * Выполняет вход в систему, используя предоставленные учетные данные.
* @param token The token to save. * @param credentials Учетные данные пользователя (URL сервера, логин, пароль).
* @return [Result] с [TokenResponse] в случае успеха, или с [Exception] в случае ошибки.
* @throws IllegalArgumentException если `credentials` невалидны (предусловие).
*/
suspend fun login(credentials: Credentials): Result<TokenResponse>
/**
* [CONTRACT]
* Сохраняет токен аутентификации.
* @param token Токен для сохранения.
* @throws IllegalArgumentException если `token` пустой (предусловие).
*/ */
suspend fun saveToken(token: String) suspend fun saveToken(token: String)
/** /**
* [CONTRACT] * [CONTRACT]
* Retrieves the authentication token. * Получает токен аутентификации.
* @return A Flow emitting the token, or null if not found. * @return [Flow], который эммитит токен в виде строки, или `null`, если токен отсутствует.
*/ */
fun getToken(): Flow<String?> fun getToken(): Flow<String?>
} }

View File

@@ -8,13 +8,14 @@ import kotlinx.coroutines.flow.Flow
/** /**
* [CONTRACT] * [CONTRACT]
* Repository for managing user credentials. * Repository for managing user credentials and session tokens.
*/ */
interface CredentialsRepository { interface CredentialsRepository {
/** /**
* [CONTRACT] * [CONTRACT]
* Saves the user credentials securely. * Saves the user's base credentials (URL, username, password) securely.
* @param credentials The credentials to save. * @param credentials The credentials to save.
* @sideeffect Overwrites any existing saved credentials.
*/ */
suspend fun saveCredentials(credentials: Credentials) suspend fun saveCredentials(credentials: Credentials)
@@ -24,5 +25,20 @@ interface CredentialsRepository {
* @return A Flow emitting the saved [Credentials], or null if none are saved. * @return A Flow emitting the saved [Credentials], or null if none are saved.
*/ */
fun getCredentials(): Flow<Credentials?> fun getCredentials(): Flow<Credentials?>
/**
* [CONTRACT]
* [ACTION] Saves the authorization token received after a successful login.
* @param token The authorization token (including "Bearer " prefix if provided by the server).
* @sideeffect Overwrites any existing saved token.
*/
suspend fun saveToken(token: String)
/**
* [CONTRACT]
* [ACTION] Retrieves the saved authorization token.
* @return The saved token as a String, or null if no token is saved.
*/
suspend fun getToken(): String?
} }
// [END_FILE_CredentialsRepository.kt] // [END_FILE_CredentialsRepository.kt]

View File

@@ -1,7 +1,9 @@
// [PACKAGE] com.homebox.lens.domain.repository // [PACKAGE] com.homebox.lens.domain.repository
// [FILE] ItemRepository.kt // [FILE] ItemRepository.kt
// [SEMANTICS] data_access, abstraction, repository // [SEMANTICS] data_access, abstraction, repository
package com.homebox.lens.domain.repository package com.homebox.lens.domain.repository
// [IMPORTS] // [IMPORTS]
import com.homebox.lens.domain.model.* import com.homebox.lens.domain.model.*
@@ -10,9 +12,10 @@ import com.homebox.lens.domain.model.*
* [CONTRACT] * [CONTRACT]
* Абстракция репозитория для работы с "Вещами". * Абстракция репозитория для работы с "Вещами".
* Определяет контракт, которому должен следовать слой данных. * Определяет контракт, которому должен следовать слой данных.
* [COHERENCE_NOTE] Метод `login` был удален, так как он относится к аутентификации и перенесен в `AuthRepository`.
*/ */
interface ItemRepository { interface ItemRepository {
suspend fun login(credentials: Credentials): Result<Unit> // [DELETED] suspend fun login(credentials: Credentials): Result<Unit>
suspend fun createItem(newItemData: ItemCreate): ItemSummary suspend fun createItem(newItemData: ItemCreate): ItemSummary
suspend fun getItemDetails(itemId: String): ItemOut suspend fun getItemDetails(itemId: String): ItemOut
suspend fun updateItem(itemId: String, item: ItemUpdate): ItemOut suspend fun updateItem(itemId: String, item: ItemUpdate): ItemOut

View File

@@ -1,25 +1,21 @@
// [PACKAGE] com.homebox.lens.domain.usecase // [PACKAGE] com.homebox.lens.domain.usecase
// [FILE] domain/src/main/java/com/homebox/lens/domain/usecase/GetAllLabelsUseCase.kt // [FILE] GetAllLabelsUseCase.kt
// [SEMANTICS] domain, usecase, label, list
// [IMPORTS]
package com.homebox.lens.domain.usecase package com.homebox.lens.domain.usecase
import com.homebox.lens.domain.model.LabelOut import com.homebox.lens.domain.model.LabelOut
import com.homebox.lens.domain.repository.ItemRepository import com.homebox.lens.domain.repository.ItemRepository
import javax.inject.Inject import javax.inject.Inject
// [CORE-LOGIC] class GetAllLabelsUseCase @Inject constructor(private val repository: ItemRepository) {
class GetAllLabelsUseCase @Inject constructor( /**
private val itemRepository: ItemRepository * [CONTRACT]
) { * Получает список всех меток.
suspend operator fun invoke(): List<LabelOut>? { * @return Список [LabelOut].
return try { * @throws Exception в случае ошибки сети или API.
itemRepository.getAllLabels() */
} catch (e: Exception) { suspend operator fun invoke(): List<LabelOut> {
// [ERROR_HANDLER] Просто возвращаем null. // [FIX] Упрощено.
null return repository.getAllLabels()
} }
} }
}
// [END_FILE_GetAllLabelsUseCase.kt]

View File

@@ -1,25 +1,21 @@
// [PACKAGE] com.homebox.lens.domain.usecase // [PACKAGE] com.homebox.lens.domain.usecase
// [FILE] domain/src/main/java/com/homebox/lens/domain/usecase/GetAllLocationsUseCase.kt // [FILE] GetAllLocationsUseCase.kt
// [SEMANTICS] domain, usecase, location, list
// [IMPORTS]
package com.homebox.lens.domain.usecase package com.homebox.lens.domain.usecase
import com.homebox.lens.domain.model.LocationOutCount import com.homebox.lens.domain.model.LocationOutCount
import com.homebox.lens.domain.repository.ItemRepository import com.homebox.lens.domain.repository.ItemRepository
import javax.inject.Inject import javax.inject.Inject
// [CORE-LOGIC] class GetAllLocationsUseCase @Inject constructor(private val repository: ItemRepository) {
class GetAllLocationsUseCase @Inject constructor( /**
private val itemRepository: ItemRepository * [CONTRACT]
) { * Получает список всех локаций.
suspend operator fun invoke(): List<LocationOutCount>? { * @return Список [LocationOutCount].
return try { * @throws Exception в случае ошибки сети или API.
itemRepository.getAllLocations() */
} catch (e: Exception) { suspend operator fun invoke(): List<LocationOutCount> {
// [ERROR_HANDLER] Просто возвращаем null. // [FIX] Упрощено.
null return repository.getAllLocations()
} }
} }
}
// [END_FILE_GetAllLocationsUseCase.kt]

View File

@@ -1,25 +1,22 @@
// [PACKAGE] com.homebox.lens.domain.usecase // [PACKAGE] com.homebox.lens.domain.usecase
// [FILE] domain/src/main/java/com/homebox/lens/domain/usecase/GetStatisticsUseCase.kt // [FILE] GetStatisticsUseCase.kt
// [SEMANTICS] domain, usecase, statistics
// [IMPORTS]
package com.homebox.lens.domain.usecase package com.homebox.lens.domain.usecase
import com.homebox.lens.domain.model.GroupStatistics import com.homebox.lens.domain.model.GroupStatistics
import com.homebox.lens.domain.repository.ItemRepository import com.homebox.lens.domain.repository.ItemRepository
import javax.inject.Inject import javax.inject.Inject
// [CORE-LOGIC] class GetStatisticsUseCase @Inject constructor(private val repository: ItemRepository) {
class GetStatisticsUseCase @Inject constructor( /**
private val itemRepository: ItemRepository * [CONTRACT]
) { * Получает статистику инвентаря.
suspend operator fun invoke(): GroupStatistics? { * @return [GroupStatistics] объект.
return try { * @throws Exception в случае ошибки сети или API.
itemRepository.getStatistics() */
} catch (e: Exception) { suspend operator fun invoke(): GroupStatistics {
// [ERROR_HANDLER] Просто возвращаем null, вызывающий слой обработает это. // [FIX] Упрощено. Просто вызываем репозиторий и возвращаем его результат.
null // Обработка ошибок делегирована вызывающей стороне (ViewModel).
return repository.getStatistics()
} }
} }
}
// [END_FILE_GetStatisticsUseCase.kt]

View File

@@ -1,29 +1,52 @@
// [PACKAGE] com.homebox.lens.domain.usecase // [PACKAGE] com.homebox.lens.domain.usecase
// [FILE] LoginUseCase.kt // [FILE] LoginUseCase.kt
// [PURPOSE] Инкапсулирует бизнес-логику процесса входа пользователя в систему.
package com.homebox.lens.domain.usecase package com.homebox.lens.domain.usecase
// [IMPORTS]
import com.homebox.lens.domain.model.Credentials import com.homebox.lens.domain.model.Credentials
import com.homebox.lens.domain.repository.ItemRepository import com.homebox.lens.domain.model.TokenResponse
import com.homebox.lens.domain.repository.AuthRepository
import javax.inject.Inject import javax.inject.Inject
import com.homebox.lens.domain.model.Result
/** /**
* [ENTITY: Class('LoginUseCase')]
* [CONTRACT] * [CONTRACT]
* Use case for user login. * Use case для выполнения входа пользователя.
* @param itemRepository The repository to handle item and auth operations. * @param authRepository Репозиторий для выполнения сетевого запроса на вход и сохранения токена.
* [COHERENCE_NOTE] Удалена зависимость от CredentialsRepository для сохранения токена.
* Эту ответственность теперь несет AuthRepository.
*/ */
class LoginUseCase @Inject constructor( class LoginUseCase @Inject constructor(
private val itemRepository: ItemRepository private val authRepository: AuthRepository
) { ) {
/** /**
* [CONTRACT] * [CONTRACT]
* Executes the login process. * Выполняет процесс входа в систему.
* @param credentials The user's credentials. * @param credentials Учетные данные пользователя.
* @return A [Result] object indicating success or failure. * @return [Result] с [Unit] в случае успеха или с [Exception] в случае ошибки.
* @sideeffect В случае успеха, сохраняет токен авторизации через `authRepository`.
*/ */
suspend operator fun invoke(credentials: Credentials): Result<Unit> { suspend operator fun invoke(credentials: Credentials): Result<Unit> {
return itemRepository.login(credentials) // [PRECONDITION]
require(credentials.serverUrl.isNotBlank() && credentials.username.isNotBlank()) {
"[PRECONDITION_FAILED] Server URL and username must not be blank."
}
// [ACTION] Выполняем вход через authRepository.
val loginResult: Result<TokenResponse> = authRepository.login(credentials)
// [CORE-LOGIC] Обрабатываем результат с помощью `fold`.
return loginResult.fold(
onSuccess = { tokenResponse ->
// [ACTION] В случае успеха, сохраняем токен через тот же репозиторий.
authRepository.saveToken(tokenResponse.token)
Result.success(Unit)
},
onFailure = { exception ->
// [ACTION] В случае ошибки, просто пробрасываем ее дальше.
Result.failure(exception)
}
)
} }
} }
// [END_FILE_LoginUseCase.kt] // [END_FILE_LoginUseCase.kt]