Login to dashboard worked
This commit is contained in:
@@ -82,9 +82,6 @@ dependencies {
|
||||
// [DEPENDENCY] Logging
|
||||
implementation(Libs.timber)
|
||||
|
||||
// [DEPENDENCY] Security
|
||||
implementation(Libs.securityCrypto)
|
||||
|
||||
// [DEPENDENCY] Testing
|
||||
testImplementation(Libs.junit)
|
||||
androidTestImplementation(Libs.extJunit)
|
||||
|
||||
@@ -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,20 +34,35 @@ 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 {
|
||||
// Параллельно запрашиваем все данные
|
||||
_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 locationsDeferred = async { getAllLocationsUseCase() }
|
||||
val labelsDeferred = async { getAllLabelsUseCase() }
|
||||
@@ -49,29 +71,35 @@ class DashboardViewModel @Inject constructor(
|
||||
val locations = locationsDeferred.await()
|
||||
val labels = labelsDeferred.await()
|
||||
|
||||
// [ACTION] Логируем результат здесь, во ViewModel
|
||||
if (stats != null && locations != null && labels != null) {
|
||||
// [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.")
|
||||
}
|
||||
} 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")
|
||||
},
|
||||
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."
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
// [END_CLASS_DashboardViewModel]
|
||||
}
|
||||
// [END_FILE_DashboardViewModel.kt]
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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]
|
||||
@@ -95,8 +95,6 @@ object Libs {
|
||||
const val composeUiTooling = "androidx.compose.ui:ui-tooling"
|
||||
const val composeUiTestManifest = "androidx.compose.ui:ui-test-manifest"
|
||||
|
||||
// Security
|
||||
const val securityCrypto = "androidx.security:security-crypto:${Versions.securityCrypto}"
|
||||
}
|
||||
|
||||
// [END_FILE_Dependencies.kt]
|
||||
|
||||
@@ -17,6 +17,7 @@ import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Headers
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
@@ -30,6 +31,9 @@ import retrofit2.http.Query
|
||||
interface HomeboxApiService {
|
||||
|
||||
// [ENDPOINT] Auth
|
||||
// [FIX] Явно указываем заголовок Content-Type, чтобы переопределить
|
||||
// значение по умолчанию от Moshi, которое содержит "; charset=UTF-8".
|
||||
@Headers("Content-Type: application/json")
|
||||
@POST("v1/users/login")
|
||||
suspend fun login(@Body loginForm: LoginFormDto): TokenResponseDto
|
||||
|
||||
|
||||
@@ -13,24 +13,34 @@ import com.homebox.lens.domain.model.GroupStatistics
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* DTO для статистики.
|
||||
* [COHERENCE_NOTE] Этот DTO был исправлен, чтобы точно соответствовать JSON-ответу от сервера.
|
||||
* Поля `items`, `labels`, `locations`, `totalValue` были заменены на `totalItems`, `totalLabels`,
|
||||
* `totalLocations`, `totalItemPrice` и т.д., чтобы устранить ошибку парсинга `JsonDataException`.
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GroupStatisticsDto(
|
||||
@Json(name = "items") val items: Int,
|
||||
@Json(name = "labels") val labels: Int,
|
||||
@Json(name = "locations") val locations: Int,
|
||||
@Json(name = "totalValue") val totalValue: Double
|
||||
@Json(name = "totalItems") val totalItems: Int,
|
||||
@Json(name = "totalLabels") val totalLabels: Int,
|
||||
@Json(name = "totalLocations") val totalLocations: Int,
|
||||
@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]
|
||||
* Маппер из GroupStatisticsDto в доменную модель GroupStatistics.
|
||||
* [COHERENCE_NOTE] Маппер обновлен для использования правильных полей из исправленного DTO.
|
||||
*/
|
||||
fun GroupStatisticsDto.toDomain(): GroupStatistics {
|
||||
// [ACTION] Маппим данные из DTO в доменную модель.
|
||||
return GroupStatistics(
|
||||
items = this.items,
|
||||
labels = this.labels,
|
||||
locations = this.locations,
|
||||
totalValue = this.totalValue
|
||||
items = this.totalItems,
|
||||
labels = this.totalLabels,
|
||||
locations = this.totalLocations,
|
||||
totalValue = this.totalItemPrice
|
||||
)
|
||||
}
|
||||
// [END_FILE_GroupStatisticsDto.kt]
|
||||
@@ -13,28 +13,39 @@ import com.homebox.lens.domain.model.LabelOut
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* DTO для метки.
|
||||
* [COHERENCE_NOTE] Поле `isArchived` сделано nullable (`Boolean?`),
|
||||
* так как оно отсутствует в JSON-ответе от сервера. Это исправляет ошибку парсинга
|
||||
* `JsonDataException: Required value 'isArchived' missing`.
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class LabelOutDto(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "name") val name: String,
|
||||
@Json(name = "color") val color: String,
|
||||
@Json(name = "isArchived") val isArchived: Boolean,
|
||||
// [COHERENCE_NOTE] Поле `color` может быть null или отсутствовать, делаем его nullable для безопасности.
|
||||
@Json(name = "color") val color: String?,
|
||||
// [FIX] Поле отсутствует в JSON, делаем nullable.
|
||||
@Json(name = "isArchived") val isArchived: Boolean?,
|
||||
@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]
|
||||
* Маппер из LabelOutDto в доменную модель LabelOut.
|
||||
* [COHERENCE_NOTE] Маппер обновлен для безопасной обработки nullable полей
|
||||
* и предоставления non-nullable значений по умолчанию для доменной модели.
|
||||
*/
|
||||
fun LabelOutDto.toDomain(): LabelOut {
|
||||
return LabelOut(
|
||||
id = this.id,
|
||||
name = this.name,
|
||||
color = this.color,
|
||||
isArchived = this.isArchived,
|
||||
// [FIX] Используем Elvis-оператор для предоставления значения по умолчанию.
|
||||
color = this.color ?: "", // Пустая строка как дефолтный цвет
|
||||
isArchived = this.isArchived ?: false, // `false` как дефолтное состояние архива
|
||||
createdAt = this.createdAt,
|
||||
updatedAt = this.updatedAt
|
||||
)
|
||||
}
|
||||
// [END_FILE_LabelOutDto.kt]
|
||||
@@ -13,30 +13,42 @@ import com.homebox.lens.domain.model.LocationOutCount
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* DTO для местоположения со счетчиком.
|
||||
* [COHERENCE_NOTE] Поля `color` и `isArchived` сделаны nullable (`String?`, `Boolean?`),
|
||||
* так как они отсутствуют в JSON-ответе от сервера. Это исправляет ошибку парсинга
|
||||
* `JsonDataException: Required value '...' missing`.
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class LocationOutCountDto(
|
||||
@Json(name = "id") val id: String,
|
||||
@Json(name = "name") val name: String,
|
||||
@Json(name = "color") val color: String,
|
||||
@Json(name = "isArchived") val isArchived: Boolean,
|
||||
// [FIX] Поле отсутствует в JSON, делаем nullable.
|
||||
@Json(name = "color") val color: String?,
|
||||
// [FIX] Поле отсутствует в JSON, делаем nullable.
|
||||
@Json(name = "isArchived") val isArchived: Boolean?,
|
||||
@Json(name = "itemCount") val itemCount: Int,
|
||||
@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]
|
||||
* Маппер из LocationOutCountDto в доменную модель LocationOutCount.
|
||||
* [COHERENCE_NOTE] Маппер обновлен для безопасной обработки nullable полей
|
||||
* и предоставления non-nullable значений по умолчанию для доменной модели.
|
||||
*/
|
||||
fun LocationOutCountDto.toDomain(): LocationOutCount {
|
||||
return LocationOutCount(
|
||||
id = this.id,
|
||||
name = this.name,
|
||||
color = this.color,
|
||||
isArchived = this.isArchived,
|
||||
// [FIX] Используем Elvis-оператор для предоставления значения по умолчанию, если поле null.
|
||||
color = this.color ?: "", // Пустая строка как дефолтный цвет
|
||||
isArchived = this.isArchived ?: false, // `false` как дефолтное состояние архива
|
||||
itemCount = this.itemCount,
|
||||
createdAt = this.createdAt,
|
||||
updatedAt = this.updatedAt
|
||||
)
|
||||
}
|
||||
// [END_FILE_LocationOutCountDto.kt]
|
||||
@@ -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
|
||||
|
||||
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]
|
||||
@@ -2,7 +2,7 @@
|
||||
// [FILE] LoginRequest.kt
|
||||
// [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.JsonClass
|
||||
|
||||
@@ -1,70 +1,120 @@
|
||||
// [PACKAGE] com.homebox.lens.data.di
|
||||
// [FILE] ApiModule.kt
|
||||
|
||||
// [PURPOSE] Предоставляет синглтон-зависимости для работы с сетью, включая OkHttpClient, Retrofit и ApiService.
|
||||
package com.homebox.lens.data.di
|
||||
|
||||
// [IMPORTS]
|
||||
import com.homebox.lens.data.api.HomeboxApiService
|
||||
import com.homebox.lens.domain.repository.CredentialsRepository
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
|
||||
// [CONTRACT]
|
||||
/**
|
||||
* [MODULE: DaggerHilt('ApiModule')]
|
||||
* [PURPOSE] Предоставляет зависимости для работы с сетью (Retrofit, OkHttp, Moshi).
|
||||
* [ENTITY: Module('ApiModule')]
|
||||
* [CONTRACT]
|
||||
* Hilt-модуль, отвечающий за создание и предоставление всех зависимостей,
|
||||
* необходимых для сетевого взаимодействия.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ApiModule {
|
||||
|
||||
// [HELPER]
|
||||
private const val BASE_URL = "https://api.homebox.app/"
|
||||
// [HELPER] Временный базовый URL для API. В будущем должен стать динамическим.
|
||||
private const val BASE_URL = "https://homebox.bebesh.ru/api/"
|
||||
|
||||
// [PROVIDER]
|
||||
/**
|
||||
* [PROVIDER]
|
||||
* [CONTRACT]
|
||||
* Предоставляет сконфигурированный OkHttpClient.
|
||||
* @param credentialsRepositoryProvider Провайдер репозитория для доступа к токену авторизации.
|
||||
* Используется Provider<T> для предотвращения циклов зависимостей.
|
||||
* @return Синглтон-экземпляр OkHttpClient с настроенными перехватчиками.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(): OkHttpClient {
|
||||
// [ACTION] Create logging interceptor
|
||||
val logging = HttpLoggingInterceptor().apply {
|
||||
fun provideOkHttpClient(
|
||||
credentialsRepositoryProvider: Provider<CredentialsRepository>
|
||||
): OkHttpClient {
|
||||
// [ACTION] Создаем перехватчик для логирования.
|
||||
val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
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()
|
||||
.addInterceptor(logging)
|
||||
// [TODO] Add AuthInterceptor for Bearer token
|
||||
.addInterceptor(acceptHeaderInterceptor)
|
||||
.addInterceptor(authInterceptor) // Добавляем перехватчик для токена
|
||||
.addInterceptor(loggingInterceptor) // Логирование должно идти последним, чтобы видеть финальный запрос
|
||||
.build()
|
||||
}
|
||||
|
||||
// [PROVIDER]
|
||||
/**
|
||||
* [PROVIDER]
|
||||
* [CONTRACT] Предоставляет экземпляр Moshi для парсинга JSON.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMoshi(): Moshi {
|
||||
// [ACTION] Build Moshi with Kotlin adapter
|
||||
return Moshi.Builder()
|
||||
.add(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
}
|
||||
|
||||
// [PROVIDER]
|
||||
/**
|
||||
* [PROVIDER]
|
||||
* [CONTRACT] Предоставляет фабрику конвертеров для Retrofit.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMoshiConverterFactory(moshi: Moshi): MoshiConverterFactory {
|
||||
return MoshiConverterFactory.create(moshi)
|
||||
}
|
||||
|
||||
// [PROVIDER]
|
||||
/**
|
||||
* [PROVIDER]
|
||||
* [CONTRACT] Предоставляет сконфигурированный экземпляр Retrofit.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(okHttpClient: OkHttpClient, moshiConverterFactory: MoshiConverterFactory): Retrofit {
|
||||
// [ACTION] Build Retrofit instance
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(BASE_URL)
|
||||
.client(okHttpClient)
|
||||
@@ -72,13 +122,14 @@ object ApiModule {
|
||||
.build()
|
||||
}
|
||||
|
||||
// [PROVIDER]
|
||||
/**
|
||||
* [PROVIDER]
|
||||
* [CONTRACT] Предоставляет реализацию интерфейса HomeboxApiService.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideHomeboxApiService(retrofit: Retrofit): HomeboxApiService {
|
||||
// [ACTION] Create ApiService from Retrofit instance
|
||||
return retrofit.create(HomeboxApiService::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// [END_FILE_ApiModule.kt]
|
||||
@@ -1,10 +1,12 @@
|
||||
// [PACKAGE] com.homebox.lens.data.di
|
||||
// [FILE] RepositoryModule.kt
|
||||
// [SEMANTICS] dependency_injection, hilt, module, binding
|
||||
|
||||
package com.homebox.lens.data.di
|
||||
|
||||
import com.homebox.lens.data.repository.AuthRepositoryImpl
|
||||
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.CredentialsRepository
|
||||
import com.homebox.lens.domain.repository.ItemRepository
|
||||
@@ -14,26 +16,46 @@ import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
// [CONTRACT]
|
||||
/**
|
||||
* [MODULE: DaggerHilt('RepositoryModule')]
|
||||
* [PURPOSE] Предоставляет реализации для интерфейсов репозиториев.
|
||||
* [ENTITY: Module('RepositoryModule')]
|
||||
* [CONTRACT]
|
||||
* Hilt-модуль для предоставления реализаций репозиториев.
|
||||
* Использует `@Binds` для эффективного связывания интерфейсов с их реализациями.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class RepositoryModule {
|
||||
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Связывает интерфейс ItemRepository с его реализацией.
|
||||
*/
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindItemRepository(itemRepositoryImpl: com.homebox.lens.data.repository.ItemRepositoryImpl): ItemRepository
|
||||
abstract fun bindItemRepository(
|
||||
itemRepositoryImpl: ItemRepositoryImpl
|
||||
): ItemRepository
|
||||
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Связывает интерфейс CredentialsRepository с его реализацией.
|
||||
*/
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindCredentialsRepository(credentialsRepositoryImpl: CredentialsRepositoryImpl): CredentialsRepository
|
||||
abstract fun bindCredentialsRepository(
|
||||
credentialsRepositoryImpl: CredentialsRepositoryImpl
|
||||
): CredentialsRepository
|
||||
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* [FIX] Связывает интерфейс AuthRepository с его реализацией.
|
||||
* Это исправляет ошибку "could not be resolved", так как теперь Hilt знает,
|
||||
* какую конкретную реализацию предоставить, когда запрашивается AuthRepository.
|
||||
*/
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository
|
||||
abstract fun bindAuthRepository(
|
||||
authRepositoryImpl: AuthRepositoryImpl
|
||||
): AuthRepository
|
||||
}
|
||||
|
||||
// [END_FILE_RepositoryModule.kt]
|
||||
@@ -5,8 +5,8 @@ package com.homebox.lens.data.di
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.homebox.lens.data.repository.EncryptedPreferencesWrapper
|
||||
import com.homebox.lens.data.security.CryptoManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -18,20 +18,24 @@ import javax.inject.Singleton
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object StorageModule {
|
||||
|
||||
private const val PREFERENCES_FILE_NAME = "homebox_lens_prefs" // No longer secret
|
||||
|
||||
// [ACTION] Provide a standard, unencrypted SharedPreferences instance.
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideEncryptedSharedPreferences(@ApplicationContext context: Context): SharedPreferences {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
fun provideSharedPreferences(@ApplicationContext context: Context): SharedPreferences {
|
||||
return context.getSharedPreferences(PREFERENCES_FILE_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"secret_shared_prefs",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
// [ACTION] Provide our new EncryptedPreferencesWrapper as the main entry point for secure storage.
|
||||
// Hilt will automatically provide SharedPreferences and CryptoManager to its constructor.
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideEncryptedPreferencesWrapper(
|
||||
sharedPreferences: SharedPreferences,
|
||||
cryptoManager: CryptoManager
|
||||
): EncryptedPreferencesWrapper {
|
||||
return EncryptedPreferencesWrapper(sharedPreferences, cryptoManager)
|
||||
}
|
||||
}
|
||||
// [END_FILE_StorageModule.kt]
|
||||
@@ -1,27 +1,85 @@
|
||||
// [PACKAGE] com.homebox.lens.data.repository
|
||||
// [FILE] AuthRepositoryImpl.kt
|
||||
// [SEMANTICS] data_implementation, authentication, repository
|
||||
|
||||
package com.homebox.lens.data.repository
|
||||
|
||||
// [IMPORTS]
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
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(
|
||||
private val encryptedPrefs: SharedPreferences
|
||||
private val encryptedPrefs: SharedPreferences,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val moshiConverterFactory: MoshiConverterFactory
|
||||
) : AuthRepository {
|
||||
|
||||
companion object {
|
||||
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) {
|
||||
require(token.isNotBlank()) { "[PRECONDITION_FAILED] Token cannot be blank." }
|
||||
withContext(Dispatchers.IO) {
|
||||
encryptedPrefs.edit().putString(KEY_AUTH_TOKEN, token).apply()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getToken(): Flow<String?> = flow {
|
||||
emit(encryptedPrefs.getString(KEY_AUTH_TOKEN, null))
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// [PACKAGE] com.homebox.lens.data.repository
|
||||
// [FILE] CredentialsRepositoryImpl.kt
|
||||
|
||||
// [PURPOSE] Имплементация репозитория для управления учетными данными и токенами доступа.
|
||||
package com.homebox.lens.data.repository
|
||||
|
||||
// [IMPORTS]
|
||||
import android.content.SharedPreferences
|
||||
import com.homebox.lens.domain.model.Credentials
|
||||
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.flowOn
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
// [REPOSITORY_IMPL]
|
||||
/**
|
||||
* [ENTITY: Class('CredentialsRepositoryImpl')]
|
||||
* [CONTRACT]
|
||||
* Реализует репозиторий для управления учетными данными пользователя.
|
||||
* Взаимодействует с зашифрованными SharedPreferences для сохранения и извлечения данных.
|
||||
* @param encryptedPrefs Зашифрованное хранилище ключ-значение, предоставляемое Hilt.
|
||||
* @invariant Состояние этого репозитория полностью зависит от содержимого `encryptedPrefs`.
|
||||
*/
|
||||
class CredentialsRepositoryImpl @Inject constructor(
|
||||
private val encryptedPrefs: SharedPreferences
|
||||
) : CredentialsRepository {
|
||||
|
||||
// [CONSTANTS_KEYS] Ключи для хранения данных в SharedPreferences.
|
||||
companion object {
|
||||
private const val KEY_SERVER_URL = "key_server_url"
|
||||
private const val KEY_USERNAME = "key_username"
|
||||
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) {
|
||||
// [ACTION] Выполняем запись в SharedPreferences в фоновом потоке.
|
||||
withContext(Dispatchers.IO) {
|
||||
encryptedPrefs.edit()
|
||||
.putString(KEY_SERVER_URL, credentials.serverUrl)
|
||||
.putString(KEY_USERNAME, credentials.username)
|
||||
.putString(KEY_PASSWORD, credentials.password)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Извлекает сохраненные учетные данные пользователя в виде потока.
|
||||
* @return Flow, который эммитит объект [Credentials] или null, если данные отсутствуют.
|
||||
*/
|
||||
override fun getCredentials(): Flow<Credentials?> = flow {
|
||||
// [CORE-LOGIC] Читаем данные из SharedPreferences.
|
||||
val serverUrl = encryptedPrefs.getString(KEY_SERVER_URL, null)
|
||||
val username = encryptedPrefs.getString(KEY_USERNAME, null)
|
||||
val password = encryptedPrefs.getString(KEY_PASSWORD, null)
|
||||
|
||||
// [ACTION] Эммитим результат.
|
||||
if (serverUrl != null && username != null && password != null) {
|
||||
emit(Credentials(serverUrl, username, password))
|
||||
} else {
|
||||
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]
|
||||
@@ -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
|
||||
|
||||
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]
|
||||
@@ -1,18 +1,15 @@
|
||||
// [PACKAGE] com.homebox.lens.data.repository
|
||||
// [FILE] ItemRepositoryImpl.kt
|
||||
// [SEMANTICS] data_repository, implementation, network
|
||||
// [SEMANTICS] data_repository, implementation, items
|
||||
|
||||
package com.homebox.lens.data.repository
|
||||
|
||||
// [IMPORTS]
|
||||
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.toDto
|
||||
import com.homebox.lens.domain.model.*
|
||||
import com.homebox.lens.domain.repository.AuthRepository
|
||||
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.Singleton
|
||||
|
||||
@@ -21,37 +18,20 @@ import javax.inject.Singleton
|
||||
* [CONTRACT]
|
||||
* Реализация репозитория для работы с данными о вещах.
|
||||
* @param apiService Сервис для взаимодействия с Homebox API.
|
||||
* [COHERENCE_NOTE] Метод 'login' был полностью удален из этого класса, так как его ответственность
|
||||
* была передана в AuthRepositoryImpl. Это устраняет ошибку компиляции "'login' overrides nothing".
|
||||
*/
|
||||
@Singleton
|
||||
class ItemRepositoryImpl @Inject constructor(
|
||||
private val apiService: HomeboxApiService,
|
||||
private val authRepository: AuthRepository,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val moshiConverterFactory: MoshiConverterFactory
|
||||
) : ItemRepository {
|
||||
|
||||
override suspend fun login(credentials: Credentials): Result<Unit> {
|
||||
return try {
|
||||
val tempApiService = Retrofit.Builder()
|
||||
.baseUrl(credentials.serverUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(moshiConverterFactory)
|
||||
.build()
|
||||
.create(HomeboxApiService::class.java)
|
||||
// [DELETED] Метод login был здесь, но теперь он удален.
|
||||
|
||||
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
|
||||
*/
|
||||
override suspend fun createItem(newItemData: ItemCreate): ItemSummary {
|
||||
// [ACTION]
|
||||
val itemDto = newItemData.toDto()
|
||||
val resultDto = apiService.createItem(itemDto)
|
||||
return resultDto.toDomain()
|
||||
@@ -61,7 +41,6 @@ class ItemRepositoryImpl @Inject constructor(
|
||||
* [CONTRACT] @see ItemRepository.getItemDetails
|
||||
*/
|
||||
override suspend fun getItemDetails(itemId: String): ItemOut {
|
||||
// [ACTION]
|
||||
val resultDto = apiService.getItem(itemId)
|
||||
return resultDto.toDomain()
|
||||
}
|
||||
@@ -70,7 +49,6 @@ class ItemRepositoryImpl @Inject constructor(
|
||||
* [CONTRACT] @see ItemRepository.updateItem
|
||||
*/
|
||||
override suspend fun updateItem(itemId: String, item: ItemUpdate): ItemOut {
|
||||
// [ACTION]
|
||||
val itemDto = item.toDto()
|
||||
val resultDto = apiService.updateItem(itemId, itemDto)
|
||||
return resultDto.toDomain()
|
||||
@@ -80,7 +58,6 @@ class ItemRepositoryImpl @Inject constructor(
|
||||
* [CONTRACT] @see ItemRepository.deleteItem
|
||||
*/
|
||||
override suspend fun deleteItem(itemId: String) {
|
||||
// [ACTION]
|
||||
apiService.deleteItem(itemId)
|
||||
}
|
||||
|
||||
@@ -88,7 +65,6 @@ class ItemRepositoryImpl @Inject constructor(
|
||||
* [CONTRACT] @see ItemRepository.syncInventory
|
||||
*/
|
||||
override suspend fun syncInventory(page: Int, pageSize: Int): PaginationResult<ItemSummary> {
|
||||
// [ACTION]
|
||||
val resultDto = apiService.getItems(page = page, pageSize = pageSize)
|
||||
return resultDto.toDomain { it.toDomain() }
|
||||
}
|
||||
@@ -97,7 +73,6 @@ class ItemRepositoryImpl @Inject constructor(
|
||||
* [CONTRACT] @see ItemRepository.getStatistics
|
||||
*/
|
||||
override suspend fun getStatistics(): GroupStatistics {
|
||||
// [ACTION]
|
||||
val resultDto = apiService.getStatistics()
|
||||
return resultDto.toDomain()
|
||||
}
|
||||
@@ -106,7 +81,6 @@ class ItemRepositoryImpl @Inject constructor(
|
||||
* [CONTRACT] @see ItemRepository.getAllLocations
|
||||
*/
|
||||
override suspend fun getAllLocations(): List<LocationOutCount> {
|
||||
// [ACTION]
|
||||
val resultDto = apiService.getLocations()
|
||||
return resultDto.map { it.toDomain() }
|
||||
}
|
||||
@@ -115,7 +89,6 @@ class ItemRepositoryImpl @Inject constructor(
|
||||
* [CONTRACT] @see ItemRepository.getAllLabels
|
||||
*/
|
||||
override suspend fun getAllLabels(): List<LabelOut> {
|
||||
// [ACTION]
|
||||
val resultDto = apiService.getLabels()
|
||||
return resultDto.map { it.toDomain() }
|
||||
}
|
||||
@@ -124,7 +97,6 @@ class ItemRepositoryImpl @Inject constructor(
|
||||
* [CONTRACT] @see ItemRepository.searchItems
|
||||
*/
|
||||
override suspend fun searchItems(query: String): PaginationResult<ItemSummary> {
|
||||
// [ACTION]
|
||||
val resultDto = apiService.getItems(query = query)
|
||||
return resultDto.toDomain { it.toDomain() }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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]
|
||||
@@ -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
|
||||
|
||||
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]
|
||||
@@ -1,26 +1,41 @@
|
||||
// [PACKAGE] com.homebox.lens.domain.repository
|
||||
// [FILE] AuthRepository.kt
|
||||
// [SEMANTICS] authentication, data_access, 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
|
||||
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Repository for managing authentication tokens.
|
||||
* Репозиторий для управления аутентификацией.
|
||||
* [COHERENCE_NOTE] Добавлен метод `login` для инкапсуляции логики входа.
|
||||
*/
|
||||
interface AuthRepository {
|
||||
/**
|
||||
* [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)
|
||||
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Retrieves the authentication token.
|
||||
* @return A Flow emitting the token, or null if not found.
|
||||
* Получает токен аутентификации.
|
||||
* @return [Flow], который эммитит токен в виде строки, или `null`, если токен отсутствует.
|
||||
*/
|
||||
fun getToken(): Flow<String?>
|
||||
}
|
||||
|
||||
@@ -8,13 +8,14 @@ import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Repository for managing user credentials.
|
||||
* Repository for managing user credentials and session tokens.
|
||||
*/
|
||||
interface CredentialsRepository {
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Saves the user credentials securely.
|
||||
* Saves the user's base credentials (URL, username, password) securely.
|
||||
* @param credentials The credentials to save.
|
||||
* @sideeffect Overwrites any existing saved 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.
|
||||
*/
|
||||
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]
|
||||
@@ -1,7 +1,9 @@
|
||||
// [PACKAGE] com.homebox.lens.domain.repository
|
||||
// [FILE] ItemRepository.kt
|
||||
// [SEMANTICS] data_access, abstraction, repository
|
||||
|
||||
package com.homebox.lens.domain.repository
|
||||
|
||||
// [IMPORTS]
|
||||
import com.homebox.lens.domain.model.*
|
||||
|
||||
@@ -10,9 +12,10 @@ import com.homebox.lens.domain.model.*
|
||||
* [CONTRACT]
|
||||
* Абстракция репозитория для работы с "Вещами".
|
||||
* Определяет контракт, которому должен следовать слой данных.
|
||||
* [COHERENCE_NOTE] Метод `login` был удален, так как он относится к аутентификации и перенесен в `AuthRepository`.
|
||||
*/
|
||||
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 getItemDetails(itemId: String): ItemOut
|
||||
suspend fun updateItem(itemId: String, item: ItemUpdate): ItemOut
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
// [PACKAGE] com.homebox.lens.domain.usecase
|
||||
// [FILE] domain/src/main/java/com/homebox/lens/domain/usecase/GetAllLabelsUseCase.kt
|
||||
// [SEMANTICS] domain, usecase, label, list
|
||||
// [FILE] GetAllLabelsUseCase.kt
|
||||
|
||||
// [IMPORTS]
|
||||
package com.homebox.lens.domain.usecase
|
||||
|
||||
import com.homebox.lens.domain.model.LabelOut
|
||||
import com.homebox.lens.domain.repository.ItemRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
// [CORE-LOGIC]
|
||||
class GetAllLabelsUseCase @Inject constructor(
|
||||
private val itemRepository: ItemRepository
|
||||
) {
|
||||
suspend operator fun invoke(): List<LabelOut>? {
|
||||
return try {
|
||||
itemRepository.getAllLabels()
|
||||
} catch (e: Exception) {
|
||||
// [ERROR_HANDLER] Просто возвращаем null.
|
||||
null
|
||||
}
|
||||
class GetAllLabelsUseCase @Inject constructor(private val repository: ItemRepository) {
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Получает список всех меток.
|
||||
* @return Список [LabelOut].
|
||||
* @throws Exception в случае ошибки сети или API.
|
||||
*/
|
||||
suspend operator fun invoke(): List<LabelOut> {
|
||||
// [FIX] Упрощено.
|
||||
return repository.getAllLabels()
|
||||
}
|
||||
}
|
||||
// [END_FILE_GetAllLabelsUseCase.kt]
|
||||
@@ -1,25 +1,21 @@
|
||||
// [PACKAGE] com.homebox.lens.domain.usecase
|
||||
// [FILE] domain/src/main/java/com/homebox/lens/domain/usecase/GetAllLocationsUseCase.kt
|
||||
// [SEMANTICS] domain, usecase, location, list
|
||||
// [FILE] GetAllLocationsUseCase.kt
|
||||
|
||||
// [IMPORTS]
|
||||
package com.homebox.lens.domain.usecase
|
||||
|
||||
import com.homebox.lens.domain.model.LocationOutCount
|
||||
import com.homebox.lens.domain.repository.ItemRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
// [CORE-LOGIC]
|
||||
class GetAllLocationsUseCase @Inject constructor(
|
||||
private val itemRepository: ItemRepository
|
||||
) {
|
||||
suspend operator fun invoke(): List<LocationOutCount>? {
|
||||
return try {
|
||||
itemRepository.getAllLocations()
|
||||
} catch (e: Exception) {
|
||||
// [ERROR_HANDLER] Просто возвращаем null.
|
||||
null
|
||||
}
|
||||
class GetAllLocationsUseCase @Inject constructor(private val repository: ItemRepository) {
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Получает список всех локаций.
|
||||
* @return Список [LocationOutCount].
|
||||
* @throws Exception в случае ошибки сети или API.
|
||||
*/
|
||||
suspend operator fun invoke(): List<LocationOutCount> {
|
||||
// [FIX] Упрощено.
|
||||
return repository.getAllLocations()
|
||||
}
|
||||
}
|
||||
// [END_FILE_GetAllLocationsUseCase.kt]
|
||||
@@ -1,25 +1,22 @@
|
||||
// [PACKAGE] com.homebox.lens.domain.usecase
|
||||
// [FILE] domain/src/main/java/com/homebox/lens/domain/usecase/GetStatisticsUseCase.kt
|
||||
// [SEMANTICS] domain, usecase, statistics
|
||||
// [FILE] GetStatisticsUseCase.kt
|
||||
|
||||
// [IMPORTS]
|
||||
package com.homebox.lens.domain.usecase
|
||||
|
||||
import com.homebox.lens.domain.model.GroupStatistics
|
||||
import com.homebox.lens.domain.repository.ItemRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
// [CORE-LOGIC]
|
||||
class GetStatisticsUseCase @Inject constructor(
|
||||
private val itemRepository: ItemRepository
|
||||
) {
|
||||
suspend operator fun invoke(): GroupStatistics? {
|
||||
return try {
|
||||
itemRepository.getStatistics()
|
||||
} catch (e: Exception) {
|
||||
// [ERROR_HANDLER] Просто возвращаем null, вызывающий слой обработает это.
|
||||
null
|
||||
}
|
||||
class GetStatisticsUseCase @Inject constructor(private val repository: ItemRepository) {
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Получает статистику инвентаря.
|
||||
* @return [GroupStatistics] объект.
|
||||
* @throws Exception в случае ошибки сети или API.
|
||||
*/
|
||||
suspend operator fun invoke(): GroupStatistics {
|
||||
// [FIX] Упрощено. Просто вызываем репозиторий и возвращаем его результат.
|
||||
// Обработка ошибок делегирована вызывающей стороне (ViewModel).
|
||||
return repository.getStatistics()
|
||||
}
|
||||
}
|
||||
// [END_FILE_GetStatisticsUseCase.kt]
|
||||
@@ -1,29 +1,52 @@
|
||||
// [PACKAGE] com.homebox.lens.domain.usecase
|
||||
// [FILE] LoginUseCase.kt
|
||||
|
||||
// [PURPOSE] Инкапсулирует бизнес-логику процесса входа пользователя в систему.
|
||||
package com.homebox.lens.domain.usecase
|
||||
|
||||
// [IMPORTS]
|
||||
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 com.homebox.lens.domain.model.Result
|
||||
|
||||
/**
|
||||
* [ENTITY: Class('LoginUseCase')]
|
||||
* [CONTRACT]
|
||||
* Use case for user login.
|
||||
* @param itemRepository The repository to handle item and auth operations.
|
||||
* Use case для выполнения входа пользователя.
|
||||
* @param authRepository Репозиторий для выполнения сетевого запроса на вход и сохранения токена.
|
||||
* [COHERENCE_NOTE] Удалена зависимость от CredentialsRepository для сохранения токена.
|
||||
* Эту ответственность теперь несет AuthRepository.
|
||||
*/
|
||||
class LoginUseCase @Inject constructor(
|
||||
private val itemRepository: ItemRepository
|
||||
private val authRepository: AuthRepository
|
||||
) {
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Executes the login process.
|
||||
* @param credentials The user's credentials.
|
||||
* @return A [Result] object indicating success or failure.
|
||||
* Выполняет процесс входа в систему.
|
||||
* @param credentials Учетные данные пользователя.
|
||||
* @return [Result] с [Unit] в случае успеха или с [Exception] в случае ошибки.
|
||||
* @sideeffect В случае успеха, сохраняет токен авторизации через `authRepository`.
|
||||
*/
|
||||
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]
|
||||
Reference in New Issue
Block a user