diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4636843..c0b20e0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -82,6 +82,9 @@ dependencies { // [DEPENDENCY] Logging implementation(Libs.timber) + // [DEPENDENCY] Security + implementation(Libs.securityCrypto) + // [DEPENDENCY] Testing testImplementation(Libs.junit) androidTestImplementation(Libs.extJunit) diff --git a/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt b/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt index 91be926..06bb5c5 100644 --- a/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt +++ b/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt @@ -8,6 +8,13 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.homebox.lens.ui.screen.dashboard.DashboardScreen +import com.homebox.lens.ui.screen.inventorylist.InventoryListScreen +import com.homebox.lens.ui.screen.itemdetails.ItemDetailsScreen +import com.homebox.lens.ui.screen.itemedit.ItemEditScreen +import com.homebox.lens.ui.screen.labelslist.LabelsListScreen +import com.homebox.lens.ui.screen.locationslist.LocationsListScreen +import com.homebox.lens.ui.screen.search.SearchScreen +import com.homebox.lens.ui.screen.setup.SetupScreen // [CORE-LOGIC] /** @@ -19,12 +26,36 @@ fun NavGraph() { val navController = rememberNavController() NavHost( navController = navController, - startDestination = Screen.Dashboard.route + startDestination = Screen.Setup.route ) { + composable(route = Screen.Setup.route) { + SetupScreen(onSetupComplete = { + navController.navigate(Screen.Dashboard.route) { + popUpTo(Screen.Setup.route) { inclusive = true } + } + }) + } composable(route = Screen.Dashboard.route) { DashboardScreen() } - // TODO: Добавить остальные экраны в граф навигации + composable(route = Screen.InventoryList.route) { + InventoryListScreen() + } + composable(route = Screen.ItemDetails.route) { + ItemDetailsScreen() + } + composable(route = Screen.ItemEdit.route) { + ItemEditScreen() + } + composable(route = Screen.LabelsList.route) { + LabelsListScreen() + } + composable(route = Screen.LocationsList.route) { + LocationsListScreen() + } + composable(route = Screen.Search.route) { + SearchScreen() + } } } // [END_FILE_NavGraph.kt] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/navigation/Screen.kt b/app/src/main/java/com/homebox/lens/navigation/Screen.kt index a588907..9f6e8d4 100644 --- a/app/src/main/java/com/homebox/lens/navigation/Screen.kt +++ b/app/src/main/java/com/homebox/lens/navigation/Screen.kt @@ -11,14 +11,17 @@ package com.homebox.lens.navigation * @property route Строковый идентификатор маршрута. */ sealed class Screen(val route: String) { - /** - * [CONTRACT] - * Представляет экран "Дэшборд". - */ + data object Setup : Screen("setup_screen") data object Dashboard : Screen("dashboard_screen") - - // TODO: Добавить объекты для остальных экранов: - // data object ItemDetails : Screen("item_details_screen") - // data object Search : Screen("search_screen") + data object InventoryList : Screen("inventory_list_screen") + data object ItemDetails : Screen("item_details_screen/{itemId}") { + fun createRoute(itemId: String) = "item_details_screen/$itemId" + } + data object ItemEdit : Screen("item_edit_screen/{itemId}") { + fun createRoute(itemId: String) = "item_edit_screen/$itemId" + } + data object LabelsList : Screen("labels_list_screen") + data object LocationsList : Screen("locations_list_screen") + data object Search : Screen("search_screen") } // [END_FILE_Screen.kt] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupScreen.kt b/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupScreen.kt new file mode 100644 index 0000000..7df5d60 --- /dev/null +++ b/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupScreen.kt @@ -0,0 +1,113 @@ +// [PACKAGE] com.homebox.lens.ui.screen.setup +// [FILE] SetupScreen.kt + +package com.homebox.lens.ui.screen.setup + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel + +// [ENTRYPOINT] +@Composable +fun SetupScreen( + viewModel: SetupViewModel = hiltViewModel(), + onSetupComplete: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + if (uiState.isSetupComplete) { + onSetupComplete() + } + + SetupScreenContent( + uiState = uiState, + onServerUrlChange = viewModel::onServerUrlChange, + onUsernameChange = viewModel::onUsernameChange, + onPasswordChange = viewModel::onPasswordChange, + onConnectClick = viewModel::connect + ) +} + +// [CONTENT] +@Composable +private fun SetupScreenContent( + uiState: SetupUiState, + onServerUrlChange: (String) -> Unit, + onUsernameChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onConnectClick: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar(title = { Text("Server Setup") }) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = uiState.serverUrl, + onValueChange = onServerUrlChange, + label = { Text("Server URL") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = uiState.username, + onValueChange = onUsernameChange, + label = { Text("Username") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = uiState.password, + onValueChange = onPasswordChange, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = onConnectClick, + enabled = !uiState.isLoading, + modifier = Modifier.fillMaxWidth() + ) { + if (uiState.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } else { + Text("Connect") + } + } + uiState.error?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text(text = it, color = MaterialTheme.colorScheme.error) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun SetupScreenPreview() { + SetupScreenContent( + uiState = SetupUiState(error = "Failed to connect"), + onServerUrlChange = {}, + onUsernameChange = {}, + onPasswordChange = {}, + onConnectClick = {} + ) +} +// [END_FILE_SetupScreen.kt] diff --git a/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupUiState.kt b/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupUiState.kt new file mode 100644 index 0000000..65f3604 --- /dev/null +++ b/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupUiState.kt @@ -0,0 +1,15 @@ +// [PACKAGE] com.homebox.lens.ui.screen.setup +// [FILE] SetupUiState.kt + +package com.homebox.lens.ui.screen.setup + +// [STATE] +data class SetupUiState( + val serverUrl: String = "", + val username: String = "", + val password: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val isSetupComplete: Boolean = false +) +// [END_FILE_SetupUiState.kt] diff --git a/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupViewModel.kt b/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupViewModel.kt new file mode 100644 index 0000000..7f1a6ed --- /dev/null +++ b/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupViewModel.kt @@ -0,0 +1,88 @@ +// [PACKAGE] com.homebox.lens.ui.screen.setup +// [FILE] SetupViewModel.kt + +package com.homebox.lens.ui.screen.setup + +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 dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +// [VIEWMODEL] +@HiltViewModel +class SetupViewModel @Inject constructor( + private val credentialsRepository: CredentialsRepository, + private val loginUseCase: LoginUseCase +) : ViewModel() { + + // [STATE] + private val _uiState = MutableStateFlow(SetupUiState()) + val uiState = _uiState.asStateFlow() + + init { + loadCredentials() + } + + private fun loadCredentials() { + viewModelScope.launch { + credentialsRepository.getCredentials().collect { credentials -> + if (credentials != null) { + _uiState.update { + it.copy( + serverUrl = credentials.serverUrl, + username = credentials.username, + password = credentials.password + ) + } + } + } + } + } + + // [ACTION] + fun onServerUrlChange(newUrl: String) { + _uiState.update { it.copy(serverUrl = newUrl) } + } + + // [ACTION] + fun onUsernameChange(newUsername: String) { + _uiState.update { it.copy(username = newUsername) } + } + + // [ACTION] + fun onPasswordChange(newPassword: String) { + _uiState.update { it.copy(password = newPassword) } + } + + // [ACTION] + fun connect() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + val credentials = Credentials( + serverUrl = _uiState.value.serverUrl.trim(), + username = _uiState.value.username.trim(), + password = _uiState.value.password + ) + + credentialsRepository.saveCredentials(credentials) + + when (val result = loginUseCase(credentials)) { + is Result.Success -> { + _uiState.update { it.copy(isLoading = false, isSetupComplete = true) } + } + is Result.Error -> { + _uiState.update { it.copy(isLoading = false, error = result.exception.message ?: "Login failed") } + } + } + } + } +} +// [END_FILE_SetupViewModel.kt] diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 11707b7..bf6ea46 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -94,6 +94,9 @@ object Libs { const val composeUiTestJunit4 = "androidx.compose.ui:ui-test-junit4" 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] diff --git a/data/src/main/java/com/homebox/lens/data/api/HomeboxApiService.kt b/data/src/main/java/com/homebox/lens/data/api/HomeboxApiService.kt index f52c669..9e59546 100644 --- a/data/src/main/java/com/homebox/lens/data/api/HomeboxApiService.kt +++ b/data/src/main/java/com/homebox/lens/data/api/HomeboxApiService.kt @@ -10,7 +10,9 @@ import com.homebox.lens.data.api.dto.ItemSummaryDto import com.homebox.lens.data.api.dto.ItemUpdateDto import com.homebox.lens.data.api.dto.LabelOutDto import com.homebox.lens.data.api.dto.LocationOutCountDto +import com.homebox.lens.data.api.dto.LoginFormDto import com.homebox.lens.data.api.dto.PaginationResultDto +import com.homebox.lens.data.api.dto.TokenResponseDto import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE @@ -27,6 +29,10 @@ import retrofit2.http.Query */ interface HomeboxApiService { + // [ENDPOINT] Auth + @POST("v1/users/login") + suspend fun login(@Body loginForm: LoginFormDto): TokenResponseDto + // [ENDPOINT] Items @GET("v1/items") suspend fun getItems( diff --git a/data/src/main/java/com/homebox/lens/data/api/dto/LoginFormDto.kt b/data/src/main/java/com/homebox/lens/data/api/dto/LoginFormDto.kt new file mode 100644 index 0000000..3c912cf --- /dev/null +++ b/data/src/main/java/com/homebox/lens/data/api/dto/LoginFormDto.kt @@ -0,0 +1,15 @@ +// [PACKAGE] com.homebox.lens.data.api.dto +// [FILE] LoginFormDto.kt + +package com.homebox.lens.data.api.dto + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class LoginFormDto( + @Json(name = "username") val username: String, + @Json(name = "password") val password: String, + @Json(name = "stayLoggedIn") val stayLoggedIn: Boolean = true +) +// [END_FILE_LoginFormDto.kt] diff --git a/data/src/main/java/com/homebox/lens/data/api/dto/TokenResponseDto.kt b/data/src/main/java/com/homebox/lens/data/api/dto/TokenResponseDto.kt new file mode 100644 index 0000000..7f4d505 --- /dev/null +++ b/data/src/main/java/com/homebox/lens/data/api/dto/TokenResponseDto.kt @@ -0,0 +1,15 @@ +// [PACKAGE] com.homebox.lens.data.api.dto +// [FILE] TokenResponseDto.kt + +package com.homebox.lens.data.api.dto + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class TokenResponseDto( + @Json(name = "token") val token: String, + @Json(name = "attachmentToken") val attachmentToken: String, + @Json(name = "expiresAt") val expiresAt: String +) +// [END_FILE_TokenResponseDto.kt] diff --git a/data/src/main/java/com/homebox/lens/data/di/ApiModule.kt b/data/src/main/java/com/homebox/lens/data/di/ApiModule.kt index e377bb1..917da7a 100644 --- a/data/src/main/java/com/homebox/lens/data/di/ApiModule.kt +++ b/data/src/main/java/com/homebox/lens/data/di/ApiModule.kt @@ -56,12 +56,19 @@ object ApiModule { // [PROVIDER] @Provides @Singleton - fun provideRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit { + fun provideMoshiConverterFactory(moshi: Moshi): MoshiConverterFactory { + return MoshiConverterFactory.create(moshi) + } + + // [PROVIDER] + @Provides + @Singleton + fun provideRetrofit(okHttpClient: OkHttpClient, moshiConverterFactory: MoshiConverterFactory): Retrofit { // [ACTION] Build Retrofit instance return Retrofit.Builder() .baseUrl(BASE_URL) .client(okHttpClient) - .addConverterFactory(MoshiConverterFactory.create(moshi)) + .addConverterFactory(moshiConverterFactory) .build() } diff --git a/data/src/main/java/com/homebox/lens/data/di/RepositoryModule.kt b/data/src/main/java/com/homebox/lens/data/di/RepositoryModule.kt index 00d0d1a..98b9ad9 100644 --- a/data/src/main/java/com/homebox/lens/data/di/RepositoryModule.kt +++ b/data/src/main/java/com/homebox/lens/data/di/RepositoryModule.kt @@ -3,11 +3,13 @@ package com.homebox.lens.data.di -import com.homebox.lens.data.api.HomeboxApiService -import com.homebox.lens.data.repository.ItemRepositoryImpl +import com.homebox.lens.data.repository.AuthRepositoryImpl +import com.homebox.lens.data.repository.CredentialsRepositoryImpl +import com.homebox.lens.domain.repository.AuthRepository +import com.homebox.lens.domain.repository.CredentialsRepository import com.homebox.lens.domain.repository.ItemRepository +import dagger.Binds import dagger.Module -import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @@ -15,18 +17,23 @@ import javax.inject.Singleton // [CONTRACT] /** * [MODULE: DaggerHilt('RepositoryModule')] - * [PURPOSE] Предоставляет реализацию для интерфейса ItemRepository. + * [PURPOSE] Предоставляет реализации для интерфейсов репозиториев. */ @Module @InstallIn(SingletonComponent::class) -object RepositoryModule { +abstract class RepositoryModule { - // [PROVIDER] - @Provides + @Binds @Singleton - fun provideItemRepository(apiService: HomeboxApiService): ItemRepository { - return ItemRepositoryImpl(apiService) - } + abstract fun bindItemRepository(itemRepositoryImpl: com.homebox.lens.data.repository.ItemRepositoryImpl): ItemRepository + + @Binds + @Singleton + abstract fun bindCredentialsRepository(credentialsRepositoryImpl: CredentialsRepositoryImpl): CredentialsRepository + + @Binds + @Singleton + abstract fun bindAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository } // [END_FILE_RepositoryModule.kt] diff --git a/data/src/main/java/com/homebox/lens/data/di/StorageModule.kt b/data/src/main/java/com/homebox/lens/data/di/StorageModule.kt new file mode 100644 index 0000000..2f11a61 --- /dev/null +++ b/data/src/main/java/com/homebox/lens/data/di/StorageModule.kt @@ -0,0 +1,37 @@ +// [PACKAGE] com.homebox.lens.data.di +// [FILE] StorageModule.kt + +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 dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object StorageModule { + + @Provides + @Singleton + fun provideEncryptedSharedPreferences(@ApplicationContext context: Context): SharedPreferences { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + return EncryptedSharedPreferences.create( + context, + "secret_shared_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } +} +// [END_FILE_StorageModule.kt] diff --git a/data/src/main/java/com/homebox/lens/data/repository/AuthRepositoryImpl.kt b/data/src/main/java/com/homebox/lens/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..4b6f8e2 --- /dev/null +++ b/data/src/main/java/com/homebox/lens/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,30 @@ +// [PACKAGE] com.homebox.lens.data.repository +// [FILE] AuthRepositoryImpl.kt + +package com.homebox.lens.data.repository + +import android.content.SharedPreferences +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 javax.inject.Inject + +class AuthRepositoryImpl @Inject constructor( + private val encryptedPrefs: SharedPreferences +) : AuthRepository { + + companion object { + private const val KEY_AUTH_TOKEN = "key_auth_token" + } + + override suspend fun saveToken(token: String) { + encryptedPrefs.edit().putString(KEY_AUTH_TOKEN, token).apply() + } + + override fun getToken(): Flow = flow { + emit(encryptedPrefs.getString(KEY_AUTH_TOKEN, null)) + }.flowOn(Dispatchers.IO) +} +// [END_FILE_AuthRepositoryImpl.kt] diff --git a/data/src/main/java/com/homebox/lens/data/repository/CredentialsRepositoryImpl.kt b/data/src/main/java/com/homebox/lens/data/repository/CredentialsRepositoryImpl.kt new file mode 100644 index 0000000..1013d15 --- /dev/null +++ b/data/src/main/java/com/homebox/lens/data/repository/CredentialsRepositoryImpl.kt @@ -0,0 +1,46 @@ +// [PACKAGE] com.homebox.lens.data.repository +// [FILE] CredentialsRepositoryImpl.kt + +package com.homebox.lens.data.repository + +import android.content.SharedPreferences +import com.homebox.lens.domain.model.Credentials +import com.homebox.lens.domain.repository.CredentialsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import javax.inject.Inject + +// [REPOSITORY_IMPL] +class CredentialsRepositoryImpl @Inject constructor( + private val encryptedPrefs: SharedPreferences +) : CredentialsRepository { + + 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" + } + + override suspend fun saveCredentials(credentials: Credentials) { + encryptedPrefs.edit() + .putString(KEY_SERVER_URL, credentials.serverUrl) + .putString(KEY_USERNAME, credentials.username) + .putString(KEY_PASSWORD, credentials.password) + .apply() + } + + override fun getCredentials(): Flow = flow { + val serverUrl = encryptedPrefs.getString(KEY_SERVER_URL, null) + val username = encryptedPrefs.getString(KEY_USERNAME, null) + val password = encryptedPrefs.getString(KEY_PASSWORD, null) + + if (serverUrl != null && username != null && password != null) { + emit(Credentials(serverUrl, username, password)) + } else { + emit(null) + } + }.flowOn(Dispatchers.IO) +} +// [END_FILE_CredentialsRepositoryImpl.kt] diff --git a/data/src/main/java/com/homebox/lens/data/repository/ItemRepositoryImpl.kt b/data/src/main/java/com/homebox/lens/data/repository/ItemRepositoryImpl.kt index c6f6656..ebd17bc 100644 --- a/data/src/main/java/com/homebox/lens/data/repository/ItemRepositoryImpl.kt +++ b/data/src/main/java/com/homebox/lens/data/repository/ItemRepositoryImpl.kt @@ -4,10 +4,15 @@ 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 @@ -19,9 +24,29 @@ import javax.inject.Singleton */ @Singleton 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 { + override suspend fun login(credentials: Credentials): Result { + 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 */ diff --git a/domain/src/main/java/com/homebox/lens/domain/model/Credentials.kt b/domain/src/main/java/com/homebox/lens/domain/model/Credentials.kt new file mode 100644 index 0000000..97595c6 --- /dev/null +++ b/domain/src/main/java/com/homebox/lens/domain/model/Credentials.kt @@ -0,0 +1,18 @@ +// [PACKAGE] com.homebox.lens.domain.model +// [FILE] Credentials.kt + +package com.homebox.lens.domain.model + +/** + * [CONTRACT] + * Data class to hold server credentials. + * @property serverUrl The URL of the Homebox server. + * @property username The username for authentication. + * @property password The password for authentication. + */ +data class Credentials( + val serverUrl: String, + val username: String, + val password: String +) +// [END_FILE_Credentials.kt] diff --git a/domain/src/main/java/com/homebox/lens/domain/repository/AuthRepository.kt b/domain/src/main/java/com/homebox/lens/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..116e8e0 --- /dev/null +++ b/domain/src/main/java/com/homebox/lens/domain/repository/AuthRepository.kt @@ -0,0 +1,27 @@ +// [PACKAGE] com.homebox.lens.domain.repository +// [FILE] AuthRepository.kt + +package com.homebox.lens.domain.repository + +import kotlinx.coroutines.flow.Flow + +/** + * [CONTRACT] + * Repository for managing authentication tokens. + */ +interface AuthRepository { + /** + * [CONTRACT] + * Saves the authentication token. + * @param token The token to save. + */ + suspend fun saveToken(token: String) + + /** + * [CONTRACT] + * Retrieves the authentication token. + * @return A Flow emitting the token, or null if not found. + */ + fun getToken(): Flow +} +// [END_FILE_AuthRepository.kt] diff --git a/domain/src/main/java/com/homebox/lens/domain/repository/CredentialsRepository.kt b/domain/src/main/java/com/homebox/lens/domain/repository/CredentialsRepository.kt new file mode 100644 index 0000000..43913f3 --- /dev/null +++ b/domain/src/main/java/com/homebox/lens/domain/repository/CredentialsRepository.kt @@ -0,0 +1,28 @@ +// [PACKAGE] com.homebox.lens.domain.repository +// [FILE] CredentialsRepository.kt + +package com.homebox.lens.domain.repository + +import com.homebox.lens.domain.model.Credentials +import kotlinx.coroutines.flow.Flow + +/** + * [CONTRACT] + * Repository for managing user credentials. + */ +interface CredentialsRepository { + /** + * [CONTRACT] + * Saves the user credentials securely. + * @param credentials The credentials to save. + */ + suspend fun saveCredentials(credentials: Credentials) + + /** + * [CONTRACT] + * Retrieves the saved user credentials. + * @return A Flow emitting the saved [Credentials], or null if none are saved. + */ + fun getCredentials(): Flow +} +// [END_FILE_CredentialsRepository.kt] diff --git a/domain/src/main/java/com/homebox/lens/domain/repository/ItemRepository.kt b/domain/src/main/java/com/homebox/lens/domain/repository/ItemRepository.kt index 3e97000..4389f10 100644 --- a/domain/src/main/java/com/homebox/lens/domain/repository/ItemRepository.kt +++ b/domain/src/main/java/com/homebox/lens/domain/repository/ItemRepository.kt @@ -12,6 +12,7 @@ import com.homebox.lens.domain.model.* * Определяет контракт, которому должен следовать слой данных. */ interface ItemRepository { + suspend fun login(credentials: Credentials): Result suspend fun createItem(newItemData: ItemCreate): ItemSummary suspend fun getItemDetails(itemId: String): ItemOut suspend fun updateItem(itemId: String, item: ItemUpdate): ItemOut diff --git a/domain/src/main/java/com/homebox/lens/domain/usecase/LoginUseCase.kt b/domain/src/main/java/com/homebox/lens/domain/usecase/LoginUseCase.kt new file mode 100644 index 0000000..2e2d05e --- /dev/null +++ b/domain/src/main/java/com/homebox/lens/domain/usecase/LoginUseCase.kt @@ -0,0 +1,29 @@ +// [PACKAGE] com.homebox.lens.domain.usecase +// [FILE] LoginUseCase.kt + +package com.homebox.lens.domain.usecase + +import com.homebox.lens.domain.model.Credentials +import com.homebox.lens.domain.repository.ItemRepository +import javax.inject.Inject +import com.homebox.lens.domain.model.Result + +/** + * [CONTRACT] + * Use case for user login. + * @param itemRepository The repository to handle item and auth operations. + */ +class LoginUseCase @Inject constructor( + private val itemRepository: ItemRepository +) { + /** + * [CONTRACT] + * Executes the login process. + * @param credentials The user's credentials. + * @return A [Result] object indicating success or failure. + */ + suspend operator fun invoke(credentials: Credentials): Result { + return itemRepository.login(credentials) + } +} +// [END_FILE_LoginUseCase.kt] diff --git a/tech_spec/project_structure.txt b/tech_spec/project_structure.txt index dbcd635..15af92e 100644 --- a/tech_spec/project_structure.txt +++ b/tech_spec/project_structure.txt @@ -59,6 +59,15 @@ ViewModel for the Search screen. + + UI for the Setup screen. + + + ViewModel for the Setup screen. + + + UI state for the Setup screen. + Data layer, responsible for data sources (network, local DB) and repository implementations. @@ -80,12 +89,33 @@ Hilt module for binding repository interfaces to their implementations. + + Hilt module for providing storage-related dependencies (EncryptedSharedPreferences). + + + Implementation of the CredentialsRepository. + + + Implementation of the AuthRepository. + Domain layer, contains business logic, use cases, and repository interfaces. Pure Kotlin module. + + Data class for holding user credentials. + + + Interface for the auth repository. + + + Interface for the credentials repository. + Interface defining the contract for data operations related to items. + + Use case for user login. + Use case for creating a new item. diff --git a/tech_spec/tech_spec.txt b/tech_spec/tech_spec.txt index c220b4b..e92b749 100644 --- a/tech_spec/tech_spec.txt +++ b/tech_spec/tech_spec.txt @@ -117,6 +117,7 @@ + @@ -126,5 +127,6 @@ + \ No newline at end of file