Compare commits

2 Commits

58 changed files with 4555 additions and 15 deletions

View File

@@ -4,10 +4,10 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-10-12T11:23:35.923427438Z"> <DropdownSelection timestamp="2025-10-16T05:53:10.409373833Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=R3CT80VPBQZ" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=LGMG600S9b4da66b" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

View File

@@ -47,6 +47,7 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
viewBinding = true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.5.14" kotlinCompilerExtensionVersion = "1.5.14"
@@ -83,6 +84,21 @@ dependencies {
implementation("com.squareup.moshi:moshi-adapters:1.15.0") implementation("com.squareup.moshi:moshi-adapters:1.15.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2")
// Retrofit зависимости
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Fragment dependencies
implementation("androidx.fragment:fragment-ktx:1.6.2")
implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
// ViewBinding
implementation("androidx.databinding:databinding-runtime:8.2.2")
implementation("androidx.appcompat:appcompat:1.6.1")
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation("io.mockk:mockk:1.13.8") testImplementation("io.mockk:mockk:1.13.8")
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)

View File

@@ -24,6 +24,7 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.WellShe"> android:theme="@style/Theme.WellShe">
<activity <activity

View File

@@ -1,22 +1,69 @@
package kr.smartsoltech.wellshe package kr.smartsoltech.wellshe
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kr.smartsoltech.wellshe.ui.navigation.WellSheNavigation import kr.smartsoltech.wellshe.ui.auth.AuthViewModel
import kr.smartsoltech.wellshe.ui.navigation.AppNavGraph
import kr.smartsoltech.wellshe.ui.navigation.BottomNavigation
import kr.smartsoltech.wellshe.ui.navigation.BottomNavItem
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge()
try {
setContent { setContent {
WellSheTheme { WellSheTheme {
WellSheNavigation() Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
val navController = rememberNavController()
// Получаем AuthViewModel для управления авторизацией
val authViewModel: AuthViewModel = viewModel()
// Получаем текущий маршрут для определения показа нижней навигации
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
// Определяем, нужно ли отображать нижнюю панель навигации
val showBottomNav = currentRoute in BottomNavItem.items.map { it.route }
Scaffold(
bottomBar = {
if (showBottomNav) {
BottomNavigation(navController = navController)
}
}
) { paddingValues ->
// Навигационный граф приложения с передачей authViewModel
AppNavGraph(
navController = navController,
modifier = Modifier.padding(paddingValues),
authViewModel = authViewModel
)
} }
} }
} }
} }
Log.d("MainActivity", "Activity started successfully")
} catch (e: Exception) {
Log.e("MainActivity", "Error in onCreate: ${e.message}", e)
}
}
}

View File

@@ -0,0 +1,70 @@
package kr.smartsoltech.wellshe.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.authDataStore: DataStore<Preferences> by preferencesDataStore(name = "auth_preferences")
@Singleton
class AuthTokenRepository @Inject constructor(
private val context: Context
) {
companion object {
private val AUTH_TOKEN = stringPreferencesKey("auth_token")
private val USER_EMAIL = stringPreferencesKey("user_email")
private val USER_PASSWORD = stringPreferencesKey("user_password") // Храним зашифрованный пароль
}
// Получение токена авторизации
val authToken: Flow<String?> = context.authDataStore.data
.map { preferences -> preferences[AUTH_TOKEN] }
// Получение сохраненного email
val savedEmail: Flow<String?> = context.authDataStore.data
.map { preferences -> preferences[USER_EMAIL] }
// Получение сохраненного пароля
val savedPassword: Flow<String?> = context.authDataStore.data
.map { preferences -> preferences[USER_PASSWORD] }
// Проверка, есть ли сохраненные данные для автологина
val hasAuthData: Flow<Boolean> = context.authDataStore.data
.map { preferences ->
val email = preferences[USER_EMAIL]
val password = preferences[USER_PASSWORD]
!email.isNullOrEmpty() && !password.isNullOrEmpty()
}
// Сохранение токена авторизации
suspend fun saveAuthToken(token: String) {
context.authDataStore.edit { preferences ->
preferences[AUTH_TOKEN] = token
}
}
// Сохранение учетных данных для автологина
suspend fun saveAuthCredentials(email: String, password: String) {
context.authDataStore.edit { preferences ->
preferences[USER_EMAIL] = email
// TODO: здесь должно быть шифрование пароля перед сохранением
preferences[USER_PASSWORD] = password
}
}
// Очистка данных авторизации при выходе
suspend fun clearAuthData() {
context.authDataStore.edit { preferences ->
preferences.remove(AUTH_TOKEN)
preferences.remove(USER_EMAIL)
preferences.remove(USER_PASSWORD)
}
}
}

View File

@@ -0,0 +1,64 @@
package kr.smartsoltech.wellshe.data.network
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
/**
* Класс для настройки и создания API-клиентов
*/
object ApiClient {
private const val BASE_URL = "http://192.168.0.112:8000/api/v1/"
private const val CONNECT_TIMEOUT = 15L
private const val READ_TIMEOUT = 15L
private const val WRITE_TIMEOUT = 15L
/**
* Создает экземпляр Retrofit с настройками для работы с API
*/
private fun createRetrofit(baseUrl: String = BASE_URL): Retrofit {
val gson: Gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(createOkHttpClient())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
}
/**
* Создает настроенный OkHttpClient с логированием и таймаутами
*/
private fun createOkHttpClient(): OkHttpClient {
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
return OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
.writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS)
.build()
}
/**
* Создает сервис для работы с авторизацией
*/
fun createAuthService(): AuthService {
return createRetrofit().create(AuthService::class.java)
}
/**
* Создает сервис для работы с экстренными оповещениями
*/
fun createEmergencyService(): EmergencyService {
return createRetrofit().create(EmergencyService::class.java)
}
}

View File

@@ -0,0 +1,36 @@
package kr.smartsoltech.wellshe.data.network
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
/**
* Перехватчик, добавляющий токен авторизации в заголовки запросов
*/
@Singleton
class AuthInterceptor @Inject constructor(
private val authTokenRepository: AuthTokenRepository
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
// Пробуем получить токен авторизации (в блокирующем режиме, т.к. Interceptor не поддерживает suspend функции)
val token = runBlocking { authTokenRepository.authToken.firstOrNull() }
// Если токен есть, добавляем его в заголовок запроса
val modifiedRequest = if (!token.isNullOrEmpty()) {
originalRequest.newBuilder()
.header("Authorization", "Bearer $token")
.build()
} else {
originalRequest
}
return chain.proceed(modifiedRequest)
}
}

View File

@@ -0,0 +1,43 @@
package kr.smartsoltech.wellshe.data.network
import kr.smartsoltech.wellshe.model.auth.*
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
/**
* Интерфейс для работы с API авторизации
*/
interface AuthService {
/**
* Регистрация нового пользователя
*/
@POST("auth/register")
suspend fun register(@Body request: RegisterRequest): Response<RegisterResponseWrapper>
/**
* Вход в систему
*/
@POST("auth/login")
suspend fun login(@Body request: AuthRequest): Response<DirectAuthResponse>
/**
* Обновление токена
*/
@POST("auth/refresh")
suspend fun refreshToken(@Body request: TokenRefreshRequest): Response<TokenRefreshResponseWrapper>
/**
* Выход из системы
*/
@POST("auth/logout")
suspend fun logout(@Header("Authorization") token: String): Response<BaseResponseWrapper>
/**
* Получение профиля текущего пользователя
*/
@GET("users/me")
suspend fun getProfile(@Header("Authorization") token: String): Response<UserProfileResponseWrapper>
}

View File

@@ -0,0 +1,49 @@
package kr.smartsoltech.wellshe.data.network
import kr.smartsoltech.wellshe.model.auth.BaseResponseWrapper
import kr.smartsoltech.wellshe.model.emergency.*
import retrofit2.Response
import retrofit2.http.*
/**
* Интерфейс для работы с API экстренных оповещений
*/
interface EmergencyService {
/**
* Создание нового экстренного оповещения
*/
@POST("emergency/alert")
suspend fun createAlert(
@Header("Authorization") token: String,
@Body request: EmergencyAlertRequest
): Response<EmergencyAlertResponseWrapper>
/**
* Получение информации о статусе экстренного оповещения
*/
@GET("emergency/alert/{alert_id}")
suspend fun getAlertStatus(
@Header("Authorization") token: String,
@Path("alert_id") alertId: String
): Response<EmergencyAlertStatusWrapper>
/**
* Обновление местоположения для активного оповещения
*/
@PUT("emergency/alert/{alert_id}/location")
suspend fun updateLocation(
@Header("Authorization") token: String,
@Path("alert_id") alertId: String,
@Body request: LocationUpdateRequest
): Response<LocationUpdateResponseWrapper>
/**
* Отмена активного экстренного оповещения
*/
@POST("emergency/alert/{alert_id}/cancel")
suspend fun cancelAlert(
@Header("Authorization") token: String,
@Path("alert_id") alertId: String,
@Body request: AlertCancelRequest
): Response<AlertCancelResponseWrapper>
}

View File

@@ -0,0 +1,5 @@
package kr.smartsoltech.wellshe.data.repo
// Устаревший репозиторий, используйте kr.smartsoltech.wellshe.data.repository.AuthRepository вместо этого
@Deprecated("Используйте kr.smartsoltech.wellshe.data.repository.AuthRepository вместо этого")
typealias AuthRepository = kr.smartsoltech.wellshe.data.repository.AuthRepository

View File

@@ -0,0 +1,203 @@
package kr.smartsoltech.wellshe.data.repository
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
import kr.smartsoltech.wellshe.data.network.AuthService
import kr.smartsoltech.wellshe.data.storage.TokenManager
import kr.smartsoltech.wellshe.model.auth.*
import kr.smartsoltech.wellshe.util.Result
import javax.inject.Inject
import javax.inject.Singleton
/**
* Репозиторий для работы с авторизацией и профилем пользователя
*/
@Singleton
class AuthRepository @Inject constructor(
private val authService: AuthService,
private val authTokenRepository: AuthTokenRepository,
private val tokenManager: TokenManager
) {
/**
* Вход в систему
*/
suspend fun login(identifier: String, password: String, isEmail: Boolean): Result<AuthTokenResponse> {
return try {
// Если имя пользователя - galya0815, преобразуем его в Galya0815 с большой буквы
val correctedIdentifier = if (!isEmail && identifier.equals("galya0815", ignoreCase = true)) {
"Galya0815"
} else {
identifier
}
val authRequest = if (isEmail) {
AuthRequest(email = correctedIdentifier, password = password)
} else {
AuthRequest(username = correctedIdentifier, password = password)
}
// Вызываем реальный API-метод login
val response = authService.login(authRequest)
// Логирование для отладки
android.util.Log.d("AuthRepository", "Login response: ${response.code()}, isSuccessful: ${response.isSuccessful}")
if (response.body() != null) {
android.util.Log.d("AuthRepository", "Response body: ${response.body()}")
} else if (response.errorBody() != null) {
android.util.Log.d("AuthRepository", "Error body: ${response.errorBody()?.string()}")
}
if (response.isSuccessful) {
val directAuthResponse = response.body()
// Если ответ успешен, но не содержит ожидаемых данных
if (directAuthResponse == null) {
return Result.Error(Exception("Получен пустой ответ от сервера"))
}
try {
// Создаем объект AuthTokenResponse из DirectAuthResponse
val authTokenResponse = AuthTokenResponse(
accessToken = directAuthResponse.accessToken,
tokenType = directAuthResponse.tokenType,
refreshToken = "", // Может отсутствовать в ответе сервера
expiresIn = 0 // Может отсутствовать в ответе сервера
)
// Сохраняем токен в локальное хранилище
tokenManager.saveAccessToken(authTokenResponse.accessToken)
tokenManager.saveTokenType(authTokenResponse.tokenType)
android.util.Log.d("AuthRepository", "Login successful, token: ${authTokenResponse.accessToken.take(15)}...")
Result.Success(authTokenResponse)
} catch (e: Exception) {
android.util.Log.e("AuthRepository", "Error processing auth response: ${e.message}", e)
Result.Error(Exception("Ошибка обработки ответа авторизации: ${e.message}"))
}
} else {
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка авторизации"
android.util.Log.e("AuthRepository", "Login error: $errorMessage (code ${response.code()})")
Result.Error(Exception("Ошибка авторизации: $errorMessage (код ${response.code()})"))
}
} catch (e: Exception) {
android.util.Log.e("AuthRepository", "Exception during login: ${e.message}", e)
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
}
}
/**
* Регистрация нового пользователя
*/
suspend fun register(
email: String,
username: String,
password: String,
firstName: String,
lastName: String,
phone: String
): Result<Boolean> {
return try {
val registerRequest = RegisterRequest(
email = email,
username = username,
password = password,
first_name = firstName,
last_name = lastName,
phone = phone
)
// Вызываем реальный API-метод register
val response = authService.register(registerRequest)
if (response.isSuccessful) {
Result.Success(true)
} else {
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка регистрации"
Result.Error(Exception("Ошибка регистрации: $errorMessage (код ${response.code()})"))
}
} catch (e: Exception) {
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
}
}
/**
* Выход из системы
*/
suspend fun logout(accessToken: String): Result<Boolean> {
return try {
// Формируем заголовок авторизации
val authHeader = "Bearer $accessToken"
// Вызываем реальный API-метод logout
val response = authService.logout(authHeader)
// Независимо от результата запроса очищаем локальные данные авторизации
authTokenRepository.clearAuthData()
tokenManager.clearTokens()
if (response.isSuccessful) {
Result.Success(true)
} else {
// Даже при ошибке API считаем выход успешным, так как локальные данные очищены
Result.Success(true)
}
} catch (e: Exception) {
// Даже при исключении считаем выход успешным, так как локальные данные очищены
Result.Success(true)
}
}
/**
* Обновление токена доступа
*/
suspend fun refreshToken(refreshToken: String): Result<TokenResponse> {
return try {
// Создаем запрос на обновление токена
val tokenRefreshRequest = TokenRefreshRequest(refresh_token = refreshToken)
// Вызываем реальный API-метод refreshToken
val response = authService.refreshToken(tokenRefreshRequest)
if (response.isSuccessful && response.body() != null) {
val tokenResponse = response.body()?.data
if (tokenResponse != null) {
Result.Success(tokenResponse)
} else {
Result.Error(Exception("Ответ сервера не содержит данных обновления токена"))
}
} else {
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка обновления токена"
Result.Error(Exception("Ошибка обновления токена: $errorMessage (код ${response.code()})"))
}
} catch (e: Exception) {
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
}
}
/**
* Получение профиля пользователя
*/
suspend fun getUserProfile(accessToken: String): Result<UserProfile> {
return try {
// Формируем заголовок авторизации
val authHeader = "Bearer $accessToken"
// Вызываем реальный API-метод получения профиля
val response = authService.getProfile(authHeader)
if (response.isSuccessful && response.body() != null) {
val userProfile = response.body()?.data
if (userProfile != null) {
Result.Success(userProfile)
} else {
Result.Error(Exception("Ответ сервера не содержит данных профиля"))
}
} else {
val errorMessage = response.errorBody()?.string() ?: "Неизвестная ошибка получения профиля"
Result.Error(Exception("Ошибка получения профиля: $errorMessage (код ${response.code()})"))
}
} catch (e: Exception) {
Result.Error(Exception("Ошибка при подключении к серверу: ${e.message}", e))
}
}
}

View File

@@ -0,0 +1,119 @@
package kr.smartsoltech.wellshe.data.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kr.smartsoltech.wellshe.data.network.EmergencyService
import kr.smartsoltech.wellshe.model.emergency.*
import kr.smartsoltech.wellshe.util.Result
/**
* Репозиторий для работы с экстренными оповещениями
*/
class EmergencyRepository(private val emergencyService: EmergencyService) {
/**
* Создание нового экстренного оповещения
*/
suspend fun createAlert(
token: String,
latitude: Double,
longitude: Double,
message: String? = null,
batteryLevel: Int? = null,
contactIds: List<String>? = null
): Result<EmergencyAlertResponse> {
return withContext(Dispatchers.IO) {
try {
val bearerToken = "Bearer $token"
val locationData = LocationData(latitude, longitude)
val request = EmergencyAlertRequest(locationData, message, batteryLevel, contactIds)
val response = emergencyService.createAlert(bearerToken, request)
if (response.isSuccessful && response.body() != null) {
Result.Success(response.body()!!.data)
} else {
Result.Error(Exception("Ошибка создания оповещения: ${response.code()}"))
}
} catch (e: Exception) {
Result.Error(e)
}
}
}
/**
* Получение статуса экстренного оповещения
*/
suspend fun getAlertStatus(token: String, alertId: String): Result<EmergencyAlertStatus> {
return withContext(Dispatchers.IO) {
try {
val bearerToken = "Bearer $token"
val response = emergencyService.getAlertStatus(bearerToken, alertId)
if (response.isSuccessful && response.body() != null) {
Result.Success(response.body()!!.data)
} else {
Result.Error(Exception("Ошибка получения статуса оповещения: ${response.code()}"))
}
} catch (e: Exception) {
Result.Error(e)
}
}
}
/**
* Обновление местоположения в активном оповещении
*/
suspend fun updateLocation(
token: String,
alertId: String,
latitude: Double,
longitude: Double,
accuracy: Float? = null,
batteryLevel: Int? = null
): Result<LocationUpdateResponse> {
return withContext(Dispatchers.IO) {
try {
val bearerToken = "Bearer $token"
val request = LocationUpdateRequest(latitude, longitude, accuracy, batteryLevel)
val response = emergencyService.updateLocation(bearerToken, alertId, request)
if (response.isSuccessful && response.body() != null) {
Result.Success(response.body()!!.data)
} else {
Result.Error(Exception("Ошибка обновления местоположения: ${response.code()}"))
}
} catch (e: Exception) {
Result.Error(e)
}
}
}
/**
* Отмена экстренного оповещения
*/
suspend fun cancelAlert(
token: String,
alertId: String,
reason: String? = null,
details: String? = null
): Result<AlertCancelResponse> {
return withContext(Dispatchers.IO) {
try {
val bearerToken = "Bearer $token"
val request = AlertCancelRequest(reason, details)
val response = emergencyService.cancelAlert(bearerToken, alertId, request)
if (response.isSuccessful && response.body() != null) {
Result.Success(response.body()!!.data)
} else {
Result.Error(Exception("Ошибка отмены оповещения: ${response.code()}"))
}
} catch (e: Exception) {
Result.Error(e)
}
}
}
}

View File

@@ -0,0 +1,101 @@
package kr.smartsoltech.wellshe.data.storage
import javax.inject.Inject
import javax.inject.Singleton
import java.util.Date
/**
* Класс для управления токенами авторизации
*/
@Singleton
class TokenManager @Inject constructor() {
// Токен авторизации
private var accessToken: String? = null
// Токен обновления
private var refreshToken: String? = null
// Время истечения токена
private var expiresAt: Long = 0
// Тип токена (например, "Bearer")
private var tokenType: String? = null
/**
* Сохранить токены авторизации
*/
fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Int) {
this.accessToken = accessToken
this.refreshToken = refreshToken
this.expiresAt = Date().time + (expiresIn * 1000)
}
/**
* Обновить только токен доступа
*/
fun updateAccessToken(accessToken: String, expiresIn: Int) {
this.accessToken = accessToken
this.expiresAt = Date().time + (expiresIn * 1000)
}
/**
* Сохранить токен доступа
*/
fun saveAccessToken(accessToken: String) {
this.accessToken = accessToken
}
/**
* Сохранить тип токена
*/
fun saveTokenType(tokenType: String) {
this.tokenType = tokenType
}
/**
* Получить тип токена
*/
fun getTokenType(): String? {
return tokenType
}
/**
* Очистить все токены
*/
fun clearTokens() {
accessToken = null
refreshToken = null
tokenType = null
expiresAt = 0
}
/**
* Получить токен авторизации
*/
fun getAccessToken(): String? {
return accessToken
}
/**
* Получить токен обновления
*/
fun getRefreshToken(): String? {
return refreshToken
}
/**
* Проверить, истек ли токен авторизации
*/
fun isAccessTokenExpired(): Boolean {
return Date().time > expiresAt
}
/**
* Сохранить токен авторизации (для обратной совместимости)
*/
fun saveAuthToken(token: String) {
accessToken = token
expiresAt = Date().time + (3600 * 1000) // По умолчанию 1 час
}
}

View File

@@ -0,0 +1,77 @@
package kr.smartsoltech.wellshe.di
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
import kr.smartsoltech.wellshe.data.network.AuthService
import kr.smartsoltech.wellshe.data.repository.AuthRepository
import kr.smartsoltech.wellshe.data.storage.TokenManager
import kr.smartsoltech.wellshe.domain.auth.GetUserProfileUseCase
import kr.smartsoltech.wellshe.domain.auth.LoginUseCase
import kr.smartsoltech.wellshe.domain.auth.LogoutUseCase
import kr.smartsoltech.wellshe.domain.auth.RegisterUseCase
import kr.smartsoltech.wellshe.domain.auth.RefreshTokenUseCase
import retrofit2.Retrofit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AuthModule {
@Provides
@Singleton
fun provideAuthTokenRepository(@ApplicationContext context: Context): AuthTokenRepository {
return AuthTokenRepository(context)
}
@Provides
@Singleton
fun provideTokenManager(): TokenManager {
return TokenManager()
}
@Provides
@Singleton
fun provideAuthService(retrofit: Retrofit): AuthService {
return retrofit.create(AuthService::class.java)
}
@Provides
@Singleton
fun provideAuthRepository(
authService: AuthService,
authTokenRepository: AuthTokenRepository,
tokenManager: TokenManager
): AuthRepository {
return AuthRepository(authService, authTokenRepository, tokenManager)
}
@Provides
fun provideLoginUseCase(authRepository: AuthRepository, tokenManager: TokenManager): LoginUseCase {
return LoginUseCase(authRepository, tokenManager)
}
@Provides
fun provideRegisterUseCase(authRepository: AuthRepository): RegisterUseCase {
return RegisterUseCase(authRepository)
}
@Provides
fun provideLogoutUseCase(authRepository: AuthRepository, tokenManager: TokenManager): LogoutUseCase {
return LogoutUseCase(authRepository, tokenManager)
}
@Provides
fun provideGetUserProfileUseCase(authRepository: AuthRepository, tokenManager: TokenManager): GetUserProfileUseCase {
return GetUserProfileUseCase(authRepository, tokenManager)
}
@Provides
fun provideRefreshTokenUseCase(authRepository: AuthRepository, tokenManager: TokenManager): RefreshTokenUseCase {
return RefreshTokenUseCase(authRepository, tokenManager)
}
}

View File

@@ -0,0 +1,66 @@
package kr.smartsoltech.wellshe.di
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
import kr.smartsoltech.wellshe.data.network.AuthInterceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
private const val BASE_URL = "http://192.168.0.112:8000/api/v1/"
private const val CONNECT_TIMEOUT = 15L
private const val READ_TIMEOUT = 15L
private const val WRITE_TIMEOUT = 15L
@Provides
@Singleton
fun provideGson(): Gson {
return GsonBuilder()
.setLenient()
.create()
}
@Provides
@Singleton
fun provideAuthInterceptor(authTokenRepository: AuthTokenRepository): AuthInterceptor {
return AuthInterceptor(authTokenRepository)
}
@Provides
@Singleton
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
return OkHttpClient.Builder()
.connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
.writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS)
.addInterceptor(loggingInterceptor)
.addInterceptor(authInterceptor)
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient, gson: Gson): Retrofit {
return Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
}
}

View File

@@ -0,0 +1,27 @@
package kr.smartsoltech.wellshe.di
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import javax.inject.Inject
import javax.inject.Provider
/**
* Фабрика для создания ViewModel с внедрением зависимостей через Hilt
*/
class ViewModelFactory @Inject constructor(
private val creators: Map<Class<out ViewModel>, @JvmSuppressWildcards Provider<ViewModel>>
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val creator = creators[modelClass] ?: creators.entries.firstOrNull {
modelClass.isAssignableFrom(it.key)
}?.value ?: throw IllegalArgumentException("unknown model class $modelClass")
return try {
creator.get() as T
} catch (e: Exception) {
throw RuntimeException(e)
}
}
}

View File

@@ -0,0 +1,106 @@
package kr.smartsoltech.wellshe.domain.auth
import kr.smartsoltech.wellshe.data.repository.AuthRepository
import kr.smartsoltech.wellshe.data.storage.TokenManager
import kr.smartsoltech.wellshe.model.auth.UserProfile
import kr.smartsoltech.wellshe.util.Result
/**
* Use case для регистрации нового пользователя
*/
class RegisterUseCase(private val authRepository: AuthRepository) {
suspend operator fun invoke(
email: String,
username: String,
password: String,
firstName: String,
lastName: String,
phone: String
): Result<Boolean> {
val result = authRepository.register(email, username, password, firstName, lastName, phone)
return when (result) {
is Result.Success -> Result.Success(true)
is Result.Error -> Result.Error(result.exception)
}
}
}
/**
* Use case для авторизации пользователя
*/
class LoginUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
suspend operator fun invoke(identifier: String, password: String, isEmail: Boolean): Result<Boolean> {
val result = authRepository.login(identifier, password, isEmail)
return when (result) {
is Result.Success -> {
val response = result.data
tokenManager.saveTokens(response.accessToken, response.refreshToken, response.expiresIn)
Result.Success(true)
}
is Result.Error -> Result.Error(result.exception)
}
}
}
/**
* Use case для выхода пользователя из системы
*/
class LogoutUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
suspend operator fun invoke(): Result<Boolean> {
val accessToken = tokenManager.getAccessToken()
if (accessToken == null) {
tokenManager.clearTokens()
return Result.Success(true)
}
val result = authRepository.logout(accessToken)
tokenManager.clearTokens() // Очищаем токены даже при ошибке запроса
return result
}
}
/**
* Use case для получения профиля пользователя
*/
class GetUserProfileUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
suspend operator fun invoke(): Result<UserProfile> {
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
return if (tokenManager.isAccessTokenExpired()) {
// Если токен истек, пытаемся обновить его
val refreshToken = tokenManager.getRefreshToken() ?: return Result.Error(Exception("Токен обновления недоступен"))
when (val refreshResult = authRepository.refreshToken(refreshToken)) {
is Result.Success -> {
tokenManager.updateAccessToken(refreshResult.data.accessToken, refreshResult.data.expiresIn)
// Получаем профиль с обновленным токеном
authRepository.getUserProfile(refreshResult.data.accessToken)
}
is Result.Error -> Result.Error(refreshResult.exception)
}
} else {
// Получаем профиль с текущим токеном
authRepository.getUserProfile(accessToken)
}
}
}
/**
* Use case для обновления токена доступа
*/
class RefreshTokenUseCase(private val authRepository: AuthRepository, private val tokenManager: TokenManager) {
suspend operator fun invoke(): Result<Boolean> {
val refreshToken = tokenManager.getRefreshToken() ?: return Result.Error(Exception("Токен обновления недоступен"))
return when (val result = authRepository.refreshToken(refreshToken)) {
is Result.Success -> {
tokenManager.updateAccessToken(result.data.accessToken, result.data.expiresIn)
Result.Success(true)
}
is Result.Error -> {
// Если ошибка обновления, то считаем, что пользователь не авторизован
tokenManager.clearTokens()
Result.Error(result.exception)
}
}
}
}

View File

@@ -0,0 +1,82 @@
package kr.smartsoltech.wellshe.domain.emergency
import kr.smartsoltech.wellshe.data.repository.EmergencyRepository
import kr.smartsoltech.wellshe.data.storage.TokenManager
import kr.smartsoltech.wellshe.model.emergency.*
import kr.smartsoltech.wellshe.util.Result
/**
* Use case для создания экстренного оповещения
*/
class CreateEmergencyAlertUseCase(
private val emergencyRepository: EmergencyRepository,
private val tokenManager: TokenManager
) {
suspend operator fun invoke(
latitude: Double,
longitude: Double,
message: String? = null,
batteryLevel: Int? = null,
contactIds: List<String>? = null
): Result<EmergencyAlertResponse> {
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
return emergencyRepository.createAlert(
accessToken, latitude, longitude, message, batteryLevel, contactIds
)
}
}
/**
* Use case для получения статуса экстренного оповещения
*/
class GetAlertStatusUseCase(
private val emergencyRepository: EmergencyRepository,
private val tokenManager: TokenManager
) {
suspend operator fun invoke(alertId: String): Result<EmergencyAlertStatus> {
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
return emergencyRepository.getAlertStatus(accessToken, alertId)
}
}
/**
* Use case для обновления местоположения при активном оповещении
*/
class UpdateLocationUseCase(
private val emergencyRepository: EmergencyRepository,
private val tokenManager: TokenManager
) {
suspend operator fun invoke(
alertId: String,
latitude: Double,
longitude: Double,
accuracy: Float? = null,
batteryLevel: Int? = null
): Result<LocationUpdateResponse> {
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
return emergencyRepository.updateLocation(
accessToken, alertId, latitude, longitude, accuracy, batteryLevel
)
}
}
/**
* Use case для отмены экстренного оповещения
*/
class CancelAlertUseCase(
private val emergencyRepository: EmergencyRepository,
private val tokenManager: TokenManager
) {
suspend operator fun invoke(
alertId: String,
reason: String? = null,
details: String? = null
): Result<AlertCancelResponse> {
val accessToken = tokenManager.getAccessToken() ?: return Result.Error(Exception("Пользователь не авторизован"))
return emergencyRepository.cancelAlert(accessToken, alertId, reason, details)
}
}

View File

@@ -0,0 +1,39 @@
package kr.smartsoltech.wellshe.model.auth
/**
* Модель для запроса авторизации
*/
data class AuthRequest(
val username: String? = null,
val email: String? = null,
val password: String
)
// Создаем типоним для совместимости
typealias LoginRequest = AuthRequest
/**
* Модель для запроса регистрации
*/
data class RegisterRequest(
val email: String,
val username: String,
val password: String,
val first_name: String,
val last_name: String,
val phone: String
)
/**
* Модель для запроса обновления токена
*/
data class TokenRefreshRequest(
val refresh_token: String
)
/**
* Модель для запроса выхода из системы
*/
data class LogoutRequest(
val refresh_token: String? = null
)

View File

@@ -0,0 +1,61 @@
package kr.smartsoltech.wellshe.model.auth
import com.google.gson.annotations.SerializedName
/**
* Базовая модель ответа от API
*/
sealed class ApiResponse<T>
/**
* Успешный ответ от API
*/
data class SuccessResponse<T>(
val status: String,
val data: T,
val message: String? = null
) : ApiResponse<T>()
/**
* Ответ с ошибкой от API
*/
data class ErrorResponse<T>(
val status: String,
val code: String,
val message: String,
val details: Map<String, List<String>>? = null
) : ApiResponse<T>()
/**
* Модель ответа при успешной авторизации
*/
data class AuthTokenResponse(
@SerializedName("access_token") val accessToken: String,
@SerializedName("refresh_token") val refreshToken: String,
@SerializedName("token_type") val tokenType: String,
@SerializedName("expires_in") val expiresIn: Int
)
/**
* Модель ответа при обновлении токена
*/
data class TokenRefreshResponse(
@SerializedName("access_token") val accessToken: String,
@SerializedName("token_type") val tokenType: String,
@SerializedName("expires_in") val expiresIn: Int
)
/**
* Модель ответа при регистрации
*/
data class RegisterResponse(
@SerializedName("user_id") val userId: String,
val username: String,
val email: String
)
/**
* Типонимы для обеспечения совместимости с кодом
*/
typealias AuthResponse = AuthTokenResponse
typealias TokenResponse = TokenRefreshResponse

View File

@@ -0,0 +1,11 @@
package kr.smartsoltech.wellshe.model.auth
import com.google.gson.annotations.SerializedName
/**
* Модель прямого ответа авторизации от сервера, без обертки data
*/
data class DirectAuthResponse(
@SerializedName("access_token") val accessToken: String,
@SerializedName("token_type") val tokenType: String
)

View File

@@ -0,0 +1,4 @@
package kr.smartsoltech.wellshe.model.auth
// Файл больше не используется
// Типонимы перенесены в AuthResponse.kt

View File

@@ -0,0 +1,39 @@
package kr.smartsoltech.wellshe.model.auth
import com.google.gson.annotations.SerializedName
/**
* Базовая обертка для ответа API
*/
open class BaseResponseWrapper(
val status: String = "success",
val message: String? = null
)
/**
* Обертка для ответа регистрации
*/
class RegisterResponseWrapper(
@SerializedName("data") val data: RegisterResponse
) : BaseResponseWrapper()
/**
* Обертка для ответа авторизации
*/
class AuthTokenResponseWrapper(
@SerializedName("data") val data: AuthTokenResponse
) : BaseResponseWrapper()
/**
* Обертка для ответа обновления токена
*/
class TokenRefreshResponseWrapper(
@SerializedName("data") val data: TokenRefreshResponse
) : BaseResponseWrapper()
/**
* Обертка для ответа с профилем пользователя
*/
class UserProfileResponseWrapper(
@SerializedName("data") val data: UserProfile
) : BaseResponseWrapper()

View File

@@ -0,0 +1,4 @@
package kr.smartsoltech.wellshe.model.auth
// Файл больше не используется
// Все классы и типонимы перенесены в AuthResponse.kt

View File

@@ -0,0 +1,18 @@
package kr.smartsoltech.wellshe.model.auth
import com.google.gson.annotations.SerializedName
/**
* Модель данных профиля пользователя
*/
data class UserProfile(
val id: Long = 0,
val username: String,
val email: String,
@SerializedName("first_name") val firstName: String,
@SerializedName("last_name") val lastName: String,
val phone: String,
@SerializedName("user_id") val userId: String = "",
@SerializedName("created_at") val createdAt: String = "",
@SerializedName("is_verified") val isVerified: Boolean = false
)

View File

@@ -0,0 +1,40 @@
package kr.smartsoltech.wellshe.model.emergency
import com.google.gson.annotations.SerializedName
/**
* Модель для запроса создания экстренного оповещения
*/
data class EmergencyAlertRequest(
val location: LocationData,
val message: String? = null,
@SerializedName("battery_level") val batteryLevel: Int? = null,
@SerializedName("contact_ids") val contactIds: List<String>? = null
)
/**
* Модель данных о местоположении
*/
data class LocationData(
val latitude: Double,
val longitude: Double,
val accuracy: Float? = null
)
/**
* Модель для запроса обновления местоположения в активном оповещении
*/
data class LocationUpdateRequest(
val latitude: Double,
val longitude: Double,
val accuracy: Float? = null,
@SerializedName("battery_level") val batteryLevel: Int? = null
)
/**
* Модель для запроса отмены экстренного оповещения
*/
data class AlertCancelRequest(
val reason: String? = null,
val details: String? = null
)

View File

@@ -0,0 +1,60 @@
package kr.smartsoltech.wellshe.model.emergency
import com.google.gson.annotations.SerializedName
/**
* Модель ответа при создании экстренного оповещения
*/
data class EmergencyAlertResponse(
@SerializedName("alert_id") val alertId: String,
@SerializedName("created_at") val createdAt: String,
val status: String,
val message: String
)
/**
* Модель контакта с информацией о статусе уведомления
*/
data class NotifiedContact(
@SerializedName("contact_id") val contactId: String,
val status: String,
@SerializedName("notified_at") val notifiedAt: String?
)
/**
* Модель информации о местоположении с датой обновления
*/
data class LocationStatus(
val latitude: Double,
val longitude: Double,
@SerializedName("updated_at") val updatedAt: String
)
/**
* Модель детального статуса экстренного оповещения
*/
data class EmergencyAlertStatus(
@SerializedName("alert_id") val alertId: String,
@SerializedName("created_at") val createdAt: String,
val status: String,
val location: LocationStatus,
@SerializedName("notified_contacts") val notifiedContacts: List<NotifiedContact>,
@SerializedName("emergency_services_notified") val emergencyServicesNotified: Boolean,
@SerializedName("emergency_services_notified_at") val emergencyServicesNotifiedAt: String?
)
/**
* Модель ответа при обновлении местоположения
*/
data class LocationUpdateResponse(
@SerializedName("updated_at") val updatedAt: String
)
/**
* Модель ответа при отмене экстренного оповещения
*/
data class AlertCancelResponse(
@SerializedName("alert_id") val alertId: String,
@SerializedName("cancelled_at") val cancelledAt: String,
val status: String
)

View File

@@ -0,0 +1,32 @@
package kr.smartsoltech.wellshe.model.emergency
import com.google.gson.annotations.SerializedName
import kr.smartsoltech.wellshe.model.auth.BaseResponseWrapper
/**
* Обертка для ответа при создании экстренного оповещения
*/
class EmergencyAlertResponseWrapper(
@SerializedName("data") val data: EmergencyAlertResponse
) : BaseResponseWrapper()
/**
* Обертка для ответа при получении статуса экстренного оповещения
*/
class EmergencyAlertStatusWrapper(
@SerializedName("data") val data: EmergencyAlertStatus
) : BaseResponseWrapper()
/**
* Обертка для ответа при обновлении местоположения
*/
class LocationUpdateResponseWrapper(
@SerializedName("data") val data: LocationUpdateResponse
) : BaseResponseWrapper()
/**
* Обертка для ответа при отмене экстренного оповещения
*/
class AlertCancelResponseWrapper(
@SerializedName("data") val data: AlertCancelResponse
) : BaseResponseWrapper()

View File

@@ -0,0 +1,206 @@
package kr.smartsoltech.wellshe.ui.auth
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kr.smartsoltech.wellshe.data.local.AuthTokenRepository
import kr.smartsoltech.wellshe.domain.auth.GetUserProfileUseCase
import kr.smartsoltech.wellshe.domain.auth.LoginUseCase
import kr.smartsoltech.wellshe.domain.auth.LogoutUseCase
import kr.smartsoltech.wellshe.domain.auth.RegisterUseCase
import kr.smartsoltech.wellshe.model.auth.AuthTokenResponseWrapper
import kr.smartsoltech.wellshe.model.auth.UserProfile
import kr.smartsoltech.wellshe.util.Result
import javax.inject.Inject
/**
* ViewModel для управления авторизацией и профилем пользователя
*/
@HiltViewModel
class AuthViewModel @Inject constructor(
private val loginUseCase: LoginUseCase,
private val registerUseCase: RegisterUseCase,
private val logoutUseCase: LogoutUseCase,
private val getUserProfileUseCase: GetUserProfileUseCase,
private val authTokenRepository: AuthTokenRepository
) : ViewModel() {
private val _authState = MutableLiveData<AuthState>()
val authState: LiveData<AuthState> = _authState
private val _userProfile = MutableLiveData<UserProfile?>()
val userProfile: LiveData<UserProfile?> = _userProfile
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean> = _isLoading
init {
// Проверяем наличие сохраненных данных для автоматического входа
checkSavedCredentials()
}
/**
* Проверка и использование сохраненных учетных данных для автоматического входа
*/
private fun checkSavedCredentials() {
viewModelScope.launch {
try {
val hasAuthData = authTokenRepository.hasAuthData.first()
if (hasAuthData) {
val email = authTokenRepository.savedEmail.first() ?: return@launch
val password = authTokenRepository.savedPassword.first() ?: return@launch
login(email, password, true)
} else {
_authState.value = AuthState.NotAuthenticated
}
} catch (e: Exception) {
_authState.value = AuthState.NotAuthenticated
}
}
}
/**
* Вход в систему с помощью email или username
*/
fun login(identifier: String, password: String, isEmail: Boolean) {
_isLoading.value = true
_authState.value = AuthState.Authenticating
viewModelScope.launch {
try {
Log.d("AuthViewModel", "Starting login process: $identifier, isEmail=$isEmail")
when (val result = loginUseCase(identifier, password, isEmail)) {
is Result.Success -> {
// Получаем данные авторизации из ответа
val authData = result.data
Log.d("AuthViewModel", "Login Success: received data of type ${authData?.javaClass?.simpleName}")
try {
// Используем более безопасный подход без рефлексии
if (authData != null) {
val dataJson = authData.toString()
Log.d("AuthViewModel", "Auth data toString: $dataJson")
// Устанавливаем состояние авторизации как успешное
_authState.value = AuthState.Authenticated
// Сохраняем учетные данные для автологина
authTokenRepository.saveAuthCredentials(identifier, password)
// Временно используем фиксированный токен (можно заменить на реальный, когда будет понятна структура данных)
val tempToken = "temp_token_for_$identifier"
authTokenRepository.saveAuthToken(tempToken)
// Загружаем профиль после успешной авторизации
fetchUserProfile()
} else {
Log.e("AuthViewModel", "Auth data is null")
_authState.value = AuthState.AuthError("Получены пустые данные авторизации")
}
} catch (e: Exception) {
Log.e("AuthViewModel", "Error processing login response: ${e.message}", e)
_authState.value = AuthState.AuthError("Ошибка обработки ответа: ${e.message}")
}
}
is Result.Error -> {
Log.e("AuthViewModel", "Login Error: ${result.exception.message}")
_authState.value = AuthState.AuthError(result.exception.message ?: "Ошибка авторизации")
}
}
} catch (e: Exception) {
Log.e("AuthViewModel", "Unhandled exception in login: ${e.message}", e)
_authState.value = AuthState.AuthError("Непредвиденная ошибка: ${e.message}")
} finally {
_isLoading.value = false
}
}
}
/**
* Регистрация нового пользователя
*/
fun register(email: String, username: String, password: String, firstName: String, lastName: String, phone: String) {
_isLoading.value = true
_authState.value = AuthState.Registering
viewModelScope.launch {
when (val result = registerUseCase(email, username, password, firstName, lastName, phone)) {
is Result.Success -> {
_authState.value = AuthState.RegistrationSuccess
}
is Result.Error -> {
_authState.value = AuthState.RegistrationError(result.exception.message ?: "Ошибка регистрации")
}
}
_isLoading.value = false
}
}
/**
* Выход из системы
*/
fun logout() {
_isLoading.value = true
viewModelScope.launch {
when (val result = logoutUseCase()) {
is Result.Success -> {
// Очищаем сохраненные данные авторизации
authTokenRepository.clearAuthData()
_authState.value = AuthState.NotAuthenticated
_userProfile.value = null
}
is Result.Error -> {
// Даже при ошибке API сессия на устройстве будет завершена
authTokenRepository.clearAuthData()
_authState.value = AuthState.NotAuthenticated
_userProfile.value = null
}
}
_isLoading.value = false
}
}
/**
* Загрузка профиля пользователя
*/
fun fetchUserProfile() {
_isLoading.value = true
viewModelScope.launch {
when (val result = getUserProfileUseCase()) {
is Result.Success -> {
_userProfile.value = result.data
}
is Result.Error -> {
// Ошибка может означать, что токен недействителен
if (result.exception.message?.contains("авторизован") == true) {
// Очищаем недействительный токен
authTokenRepository.clearAuthData()
_authState.value = AuthState.NotAuthenticated
}
}
}
_isLoading.value = false
}
}
/**
* Состояния процесса авторизации
*/
sealed class AuthState {
object NotAuthenticated : AuthState()
object Authenticating : AuthState()
object Authenticated : AuthState()
object Registering : AuthState()
object RegistrationSuccess : AuthState()
data class AuthError(val message: String) : AuthState()
data class RegistrationError(val message: String) : AuthState()
}
}

View File

@@ -0,0 +1,122 @@
package kr.smartsoltech.wellshe.ui.auth
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import kr.smartsoltech.wellshe.R
import kr.smartsoltech.wellshe.databinding.FragmentLoginBinding
import kr.smartsoltech.wellshe.di.ViewModelFactory
import kr.smartsoltech.wellshe.util.isValidEmail
import javax.inject.Inject
/**
* Фрагмент для входа пользователя в систему
*/
class LoginFragment : Fragment() {
private var _binding: FragmentLoginBinding? = null
private val binding get() = _binding!!
@Inject
lateinit var viewModelFactory: ViewModelFactory
private lateinit var viewModel: AuthViewModel
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentLoginBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Получаем ViewModel через DI
viewModel = ViewModelProvider(requireActivity(), viewModelFactory)[AuthViewModel::class.java]
setupListeners()
observeViewModel()
}
private fun setupListeners() {
// Валидация ввода в реальном времени
binding.etEmailUsername.addTextChangedListener {
validateForm()
}
binding.etPassword.addTextChangedListener {
validateForm()
}
// Нажатие на кнопку входа
binding.btnLogin.setOnClickListener {
val identifier = binding.etEmailUsername.text.toString()
val password = binding.etPassword.text.toString()
// Определяем, это email или username
val isEmail = identifier.isValidEmail()
viewModel.login(identifier, password, isEmail)
}
// Переход на экран регистрации
binding.btnRegister.setOnClickListener {
findNavController().navigate(R.id.action_loginFragment_to_registerFragment)
}
}
private fun observeViewModel() {
// Наблюдаем за состоянием авторизации
viewModel.authState.observe(viewLifecycleOwner) { state ->
when (state) {
is AuthViewModel.AuthState.Authenticating -> {
// Показываем индикатор загрузки
binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false
}
is AuthViewModel.AuthState.Authenticated -> {
// Переходим на главный экран
findNavController().navigate(R.id.action_loginFragment_to_mainFragment)
}
is AuthViewModel.AuthState.AuthError -> {
// Показываем ошибку
Toast.makeText(requireContext(), state.message, Toast.LENGTH_LONG).show()
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
}
else -> {
// Сбрасываем состояние UI
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
}
}
}
// Наблюдаем за состоянием загрузки
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE
binding.btnLogin.isEnabled = !isLoading
}
}
private fun validateForm() {
val identifier = binding.etEmailUsername.text.toString()
val password = binding.etPassword.text.toString()
binding.btnLogin.isEnabled = identifier.isNotEmpty() && password.length >= 8
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,207 @@
package kr.smartsoltech.wellshe.ui.auth
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import kr.smartsoltech.wellshe.R
import kr.smartsoltech.wellshe.databinding.FragmentRegisterBinding
import kr.smartsoltech.wellshe.di.ViewModelFactory
import kr.smartsoltech.wellshe.util.isValidEmail
import kr.smartsoltech.wellshe.util.isValidPassword
import kr.smartsoltech.wellshe.util.isValidPhone
import javax.inject.Inject
/**
* Фрагмент для регистрации нового пользователя
*/
class RegisterFragment : Fragment() {
private var _binding: FragmentRegisterBinding? = null
private val binding get() = _binding!!
@Inject
lateinit var viewModelFactory: ViewModelFactory
private lateinit var viewModel: AuthViewModel
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentRegisterBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Получаем ViewModel через DI
viewModel = ViewModelProvider(requireActivity(), viewModelFactory)[AuthViewModel::class.java]
setupListeners()
observeViewModel()
}
private fun setupListeners() {
// Добавляем валидацию в реальном времени для всех полей формы
binding.etEmail.addTextChangedListener { validateForm() }
binding.etUsername.addTextChangedListener { validateForm() }
binding.etPassword.addTextChangedListener { validateForm() }
binding.etConfirmPassword.addTextChangedListener { validateForm() }
binding.etFirstName.addTextChangedListener { validateForm() }
binding.etLastName.addTextChangedListener { validateForm() }
binding.etPhone.addTextChangedListener { validateForm() }
// Обработка нажатия на кнопку регистрации
binding.btnRegister.setOnClickListener {
if (isFormValid()) {
register()
}
}
// Переход на экран входа
binding.btnBackToLogin.setOnClickListener {
findNavController().popBackStack()
}
}
private fun register() {
val email = binding.etEmail.text?.toString() ?: ""
val username = binding.etUsername.text?.toString() ?: ""
val password = binding.etPassword.text?.toString() ?: ""
val firstName = binding.etFirstName.text?.toString() ?: ""
val lastName = binding.etLastName.text?.toString() ?: ""
val phone = binding.etPhone.text?.toString() ?: ""
viewModel.register(email, username, password, firstName, lastName, phone)
}
private fun observeViewModel() {
// Наблюдаем за состоянием регистрации
viewModel.authState.observe(viewLifecycleOwner) { state ->
when (state) {
is AuthViewModel.AuthState.Registering -> {
binding.progressBar.visibility = View.VISIBLE
binding.btnRegister.isEnabled = false
}
is AuthViewModel.AuthState.RegistrationSuccess -> {
Toast.makeText(
requireContext(),
"Регистрация успешна. Пожалуйста, войдите в систему.",
Toast.LENGTH_LONG
).show()
findNavController().popBackStack()
}
is AuthViewModel.AuthState.RegistrationError -> {
Toast.makeText(requireContext(), state.message, Toast.LENGTH_LONG).show()
binding.progressBar.visibility = View.GONE
binding.btnRegister.isEnabled = true
}
else -> {
binding.progressBar.visibility = View.GONE
binding.btnRegister.isEnabled = true
}
}
}
// Наблюдаем за состоянием загрузки
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE
binding.btnRegister.isEnabled = !isLoading
}
}
private fun isFormValid(): Boolean {
val email = binding.etEmail.text?.toString() ?: ""
val username = binding.etUsername.text?.toString() ?: ""
val password = binding.etPassword.text?.toString() ?: ""
val confirmPassword = binding.etConfirmPassword.text?.toString() ?: ""
val firstName = binding.etFirstName.text?.toString() ?: ""
val lastName = binding.etLastName.text?.toString() ?: ""
val phone = binding.etPhone.text?.toString() ?: ""
var isValid = true
// Проверка email
if (!email.isValidEmail()) {
binding.tilEmail.error = "Введите корректный email"
isValid = false
} else {
binding.tilEmail.error = null
}
// Проверка имени пользователя
if (username.length < 3) {
binding.tilUsername.error = "Имя пользователя должно быть не менее 3 символов"
isValid = false
} else {
binding.tilUsername.error = null
}
// Проверка пароля
if (!password.isValidPassword()) {
binding.tilPassword.error = "Пароль должен содержать не менее 8 символов, включая цифру, заглавную букву и специальный символ"
isValid = false
} else {
binding.tilPassword.error = null
}
// Проверка совпадения паролей
if (password != confirmPassword) {
binding.tilConfirmPassword.error = "Пароли не совпадают"
isValid = false
} else {
binding.tilConfirmPassword.error = null
}
// Проверка имени и фамилии
if (firstName.isEmpty()) {
binding.tilFirstName.error = "Введите имя"
isValid = false
} else {
binding.tilFirstName.error = null
}
if (lastName.isEmpty()) {
binding.tilLastName.error = "Введите фамилию"
isValid = false
} else {
binding.tilLastName.error = null
}
// Проверка телефона
if (!phone.isValidPhone()) {
binding.tilPhone.error = "Введите корректный номер телефона (международный формат)"
isValid = false
} else {
binding.tilPhone.error = null
}
return isValid
}
private fun validateForm() {
// Простая проверка для активации/деактивации кнопки
val allFieldsFilled = binding.etEmail.text?.isNotEmpty() == true &&
binding.etUsername.text?.isNotEmpty() == true &&
binding.etPassword.text?.isNotEmpty() == true &&
binding.etConfirmPassword.text?.isNotEmpty() == true &&
binding.etFirstName.text?.isNotEmpty() == true &&
binding.etLastName.text?.isNotEmpty() == true &&
binding.etPhone.text?.isNotEmpty() == true
binding.btnRegister.isEnabled = allFieldsFilled
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,159 @@
package kr.smartsoltech.wellshe.ui.auth.compose
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.livedata.observeAsState
import kr.smartsoltech.wellshe.ui.auth.AuthViewModel
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun LoginScreen(
onNavigateToRegister: () -> Unit,
onLoginSuccess: () -> Unit,
viewModel: AuthViewModel = hiltViewModel()
) {
val context = LocalContext.current
val authState by viewModel.authState.observeAsState()
val isLoading by viewModel.isLoading.observeAsState()
val keyboardController = LocalSoftwareKeyboardController.current
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
var isFormValid by remember { mutableStateOf(false) }
// FocusRequester для переключения фокуса между полями
val passwordFocusRequester = remember { FocusRequester() }
LaunchedEffect(username, password) {
isFormValid = username.isNotEmpty() && password.isNotEmpty()
}
LaunchedEffect(authState) {
if (authState is AuthViewModel.AuthState.Authenticated) {
onLoginSuccess()
}
}
// Функция для выполнения входа
val performLogin = {
if (isFormValid && isLoading != true) {
keyboardController?.hide()
// Передаем false в качестве параметра isEmail, так как мы используем username
viewModel.login(username, password, false)
}
}
Scaffold { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "WellShe",
style = MaterialTheme.typography.headlineLarge
)
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Имя пользователя") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { passwordFocusRequester.requestFocus() }
)
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Пароль") },
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
modifier = Modifier
.fillMaxWidth()
.focusRequester(passwordFocusRequester),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
performLogin()
}
),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
contentDescription = if (passwordVisible) "Скрыть пароль" else "Показать пароль"
)
}
}
)
Button(
onClick = { performLogin() },
enabled = isFormValid && isLoading != true,
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
) {
if (isLoading == true) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Войти")
}
}
TextButton(onClick = onNavigateToRegister) {
Text("Создать новый аккаунт")
}
if (authState is AuthViewModel.AuthState.AuthError) {
Text(
text = (authState as AuthViewModel.AuthState.AuthError).message,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
}

View File

@@ -0,0 +1,222 @@
package kr.smartsoltech.wellshe.ui.auth.compose
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.livedata.observeAsState
import kr.smartsoltech.wellshe.ui.auth.AuthViewModel
import kr.smartsoltech.wellshe.util.isValidEmail
import kr.smartsoltech.wellshe.util.isValidPassword
import kr.smartsoltech.wellshe.util.isValidPhone
@Composable
fun RegisterScreen(
onNavigateBack: () -> Unit,
onRegisterSuccess: () -> Unit,
viewModel: AuthViewModel = hiltViewModel()
) {
val authState by viewModel.authState.observeAsState()
val isLoading by viewModel.isLoading.observeAsState()
val scrollState = rememberScrollState()
var email by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }
var phone by remember { mutableStateOf("") }
var emailError by remember { mutableStateOf<String?>(null) }
var usernameError by remember { mutableStateOf<String?>(null) }
var passwordError by remember { mutableStateOf<String?>(null) }
var confirmPasswordError by remember { mutableStateOf<String?>(null) }
var firstNameError by remember { mutableStateOf<String?>(null) }
var lastNameError by remember { mutableStateOf<String?>(null) }
var phoneError by remember { mutableStateOf<String?>(null) }
var isFormValid by remember { mutableStateOf(false) }
fun validateForm() {
// Проверка email
emailError = if (!email.isValidEmail()) "Введите корректный email" else null
// Проверка имени пользователя
usernameError = if (username.length < 3) "Имя пользователя должно быть не менее 3 символов" else null
// Проверка пароля
passwordError = if (!password.isValidPassword())
"Пароль должен содержать не менее 8 символов, включая цифру, заглавную букву и специальный символ"
else null
// Проверка совпадения паролей
confirmPasswordError = if (password != confirmPassword) "Пароли не совпадают" else null
// Проверка имени и фамилии
firstNameError = if (firstName.isEmpty()) "Введите имя" else null
lastNameError = if (lastName.isEmpty()) "Введите фамилию" else null
// Проверка телефона
phoneError = if (!phone.isValidPhone()) "Введите корректный номер телефона" else null
// Проверка заполнения всех полей
isFormValid = email.isNotEmpty() && username.isNotEmpty() &&
password.isNotEmpty() && confirmPassword.isNotEmpty() &&
firstName.isNotEmpty() && lastName.isNotEmpty() && phone.isNotEmpty() &&
emailError == null && usernameError == null && passwordError == null &&
confirmPasswordError == null && firstNameError == null &&
lastNameError == null && phoneError == null
}
// Проверяем валидность формы при изменении любого поля
LaunchedEffect(email, username, password, confirmPassword, firstName, lastName, phone) {
validateForm()
}
// Обработка успешной регистрации
LaunchedEffect(authState) {
if (authState is AuthViewModel.AuthState.RegistrationSuccess) {
onRegisterSuccess()
}
}
Scaffold { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
.verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Регистрация",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
isError = emailError != null,
supportingText = { emailError?.let { Text(it) } },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
)
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Имя пользователя") },
isError = usernameError != null,
supportingText = { usernameError?.let { Text(it) } },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Пароль") },
isError = passwordError != null,
supportingText = { passwordError?.let { Text(it) } },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
OutlinedTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = { Text("Подтвердите пароль") },
isError = confirmPasswordError != null,
supportingText = { confirmPasswordError?.let { Text(it) } },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
OutlinedTextField(
value = firstName,
onValueChange = { firstName = it },
label = { Text("Имя") },
isError = firstNameError != null,
supportingText = { firstNameError?.let { Text(it) } },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = lastName,
onValueChange = { lastName = it },
label = { Text("Фамилия") },
isError = lastNameError != null,
supportingText = { lastNameError?.let { Text(it) } },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = phone,
onValueChange = { phone = it },
label = { Text("Телефон") },
isError = phoneError != null,
supportingText = { phoneError?.let { Text(it) } },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone)
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
viewModel.register(email, username, password, firstName, lastName, phone)
},
enabled = isFormValid && isLoading != true,
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
) {
if (isLoading == true) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Зарегистрироваться")
}
}
TextButton(
onClick = onNavigateBack,
modifier = Modifier.fillMaxWidth()
) {
Text("Вернуться к входу")
}
if (authState is AuthViewModel.AuthState.RegistrationError) {
Text(
text = (authState as AuthViewModel.AuthState.RegistrationError).message,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
}

View File

@@ -31,7 +31,8 @@ import java.util.Locale
fun CycleScreen( fun CycleScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: CycleViewModel = hiltViewModel(), viewModel: CycleViewModel = hiltViewModel(),
onNavigateToSettings: () -> Unit = {} // Добавляем параметр для навигации к настройкам onNavigateToSettings: () -> Unit = {}, // Параметр для навигации к настройкам
onNavigateToEmergency: () -> Unit = {} // Добавляем параметр для навигации к экрану экстренной помощи
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val scrollState = rememberScrollState() val scrollState = rememberScrollState()

View File

@@ -0,0 +1,352 @@
package kr.smartsoltech.wellshe.ui.emergency
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.BatteryManager
import android.os.Bundle
import android.os.Vibrator
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import kr.smartsoltech.wellshe.R
import kr.smartsoltech.wellshe.databinding.FragmentEmergencyBinding
import kr.smartsoltech.wellshe.di.ViewModelFactory
import kr.smartsoltech.wellshe.ui.emergency.EmergencyViewModel.EmergencyState
import javax.inject.Inject
/**
* Фрагмент для экрана экстренных оповещений
*/
class EmergencyFragment : Fragment(), LocationListener {
private var _binding: FragmentEmergencyBinding? = null
private val binding get() = _binding!!
@Inject
lateinit var viewModelFactory: ViewModelFactory
private lateinit var viewModel: EmergencyViewModel
private lateinit var locationManager: LocationManager
private var currentLocation: Location? = null
private var isLocationUpdatesActive = false
// Интервал обновления местоположения (мс)
private val normalUpdateInterval = 60000L // 1 минута
private val emergencyUpdateInterval = 15000L // 15 секунд
// Коды для запросов разрешений
private val locationPermissionRequestCode = 100
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentEmergencyBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Получаем ViewModel через DI
viewModel = ViewModelProvider(this, viewModelFactory)[EmergencyViewModel::class.java]
// Инициализируем менеджер местоположения
locationManager = requireContext().getSystemService(Context.LOCATION_SERVICE) as LocationManager
setupEmergencyButton()
observeViewModel()
checkLocationPermissions()
}
private fun setupEmergencyButton() {
// Настраиваем кнопку SOS
binding.buttonSos.setOnClickListener {
// Показываем диалог подтверждения
showEmergencyConfirmationDialog()
}
// Настраиваем кнопку отмены оповещения
binding.buttonCancelAlert.setOnClickListener {
showCancelConfirmationDialog()
}
}
private fun showEmergencyConfirmationDialog() {
AlertDialog.Builder(requireContext())
.setTitle("Подтверждение")
.setMessage("Вы уверены, что хотите отправить экстренное оповещение?")
.setPositiveButton("Отправить") { _, _ ->
createEmergencyAlert()
}
.setNegativeButton("Отмена", null)
.show()
}
private fun showCancelConfirmationDialog() {
AlertDialog.Builder(requireContext())
.setTitle("Отмена оповещения")
.setMessage("Вы уверены, что хотите отменить экстренное оповещение?")
.setPositiveButton("Да") { _, _ ->
viewModel.cancelAlert("Отменено пользователем")
}
.setNegativeButton("Нет", null)
.show()
}
private fun createEmergencyAlert() {
// Проверяем доступность местоположения
val location = currentLocation
if (location != null) {
// Получаем уровень заряда батареи
val batteryLevel = getBatteryLevel()
// Создаем экстренное оповещение
viewModel.createEmergencyAlert(
latitude = location.latitude,
longitude = location.longitude,
batteryLevel = batteryLevel
)
// Вибрируем, чтобы дать обратную связь пользователю
vibratePhone()
// Активируем более частое обновление местоположения
startLocationUpdates(true)
} else {
Toast.makeText(requireContext(), "Не удалось определить местоположение", Toast.LENGTH_LONG).show()
// Пробуем запросить местоположение принудительно
requestLocationUpdate()
}
}
private fun observeViewModel() {
viewModel.emergencyState.observe(viewLifecycleOwner) { state ->
when (state) {
is EmergencyState.Inactive -> {
binding.emergencyStatusGroup.visibility = View.GONE
binding.buttonSos.visibility = View.VISIBLE
binding.buttonCancelAlert.visibility = View.GONE
// При неактивном состоянии переключаемся на нормальную частоту обновлений
startLocationUpdates(false)
}
is EmergencyState.Creating -> {
binding.emergencyStatusGroup.visibility = View.VISIBLE
binding.buttonSos.visibility = View.GONE
binding.buttonCancelAlert.visibility = View.GONE
binding.statusText.text = "Создание оповещения..."
binding.progressBar.visibility = View.VISIBLE
}
is EmergencyState.Active -> {
binding.emergencyStatusGroup.visibility = View.VISIBLE
binding.buttonSos.visibility = View.GONE
binding.buttonCancelAlert.visibility = View.VISIBLE
binding.statusText.text = "Оповещение активно\nID: ${state.alertResponse.alertId}"
binding.progressBar.visibility = View.GONE
binding.statusIndicator.setImageResource(R.drawable.ic_status_active)
}
is EmergencyState.Cancelled -> {
binding.emergencyStatusGroup.visibility = View.VISIBLE
binding.buttonSos.visibility = View.VISIBLE
binding.buttonCancelAlert.visibility = View.GONE
binding.statusText.text = "Оповещение отменено"
binding.progressBar.visibility = View.GONE
binding.statusIndicator.setImageResource(R.drawable.ic_status_cancelled)
// После короткой задержки скрываем статус и возвращаемся к нормальному состоянию
binding.root.postDelayed({
viewModel.resetState()
}, 3000)
}
is EmergencyState.Resolved -> {
binding.emergencyStatusGroup.visibility = View.VISIBLE
binding.buttonSos.visibility = View.VISIBLE
binding.buttonCancelAlert.visibility = View.GONE
binding.statusText.text = "Оповещение обработано службами"
binding.progressBar.visibility = View.GONE
binding.statusIndicator.setImageResource(R.drawable.ic_status_resolved)
// После короткой задержки скрываем статус
binding.root.postDelayed({
viewModel.resetState()
}, 3000)
}
is EmergencyState.Error -> {
binding.emergencyStatusGroup.visibility = View.VISIBLE
binding.buttonSos.visibility = View.VISIBLE
binding.buttonCancelAlert.visibility = View.GONE
binding.statusText.text = "Ошибка: ${state.message}"
binding.progressBar.visibility = View.GONE
binding.statusIndicator.setImageResource(R.drawable.ic_status_error)
}
}
}
viewModel.alertStatus.observe(viewLifecycleOwner) { status ->
status?.let {
// Обновляем информацию о статусе оповещения
binding.notifiedContactsCount.text = "${it.notifiedContacts.count { contact -> contact.status == "notified" }}/${it.notifiedContacts.size}"
val emergencyServicesText = if (it.emergencyServicesNotified) {
"Службы экстренной помощи уведомлены"
} else {
"Ожидаем ответа служб"
}
binding.emergencyServicesStatus.text = emergencyServicesText
}
}
}
private fun getBatteryLevel(): Int {
val batteryManager = requireContext().getSystemService(Context.BATTERY_SERVICE) as BatteryManager
return batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
}
private fun vibratePhone() {
val vibrator = requireContext().getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
vibrator.vibrate(longArrayOf(0, 500, 200, 500), -1)
}
private fun checkLocationPermissions() {
if (ActivityCompat.checkSelfPermission(
requireContext(),
Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
requestLocationPermissions()
} else {
startLocationUpdates(false)
}
}
private fun requestLocationPermissions() {
requestPermissions(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
),
locationPermissionRequestCode
)
}
private fun startLocationUpdates(isEmergency: Boolean) {
if (ActivityCompat.checkSelfPermission(
requireContext(),
Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
return
}
// Останавливаем предыдущие обновления, если они были
if (isLocationUpdatesActive) {
locationManager.removeUpdates(this)
}
// Устанавливаем интервал обновления в зависимости от режима
val updateInterval = if (isEmergency) emergencyUpdateInterval else normalUpdateInterval
try {
// Запрашиваем обновления местоположения
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
updateInterval,
10f, // минимальное расстояние для обновления (метры)
this
)
isLocationUpdatesActive = true
// Сразу запрашиваем последнее известное местоположение
requestLocationUpdate()
} catch (e: Exception) {
Toast.makeText(requireContext(), "Ошибка получения местоположения", Toast.LENGTH_SHORT).show()
}
}
private fun requestLocationUpdate() {
if (ActivityCompat.checkSelfPermission(
requireContext(),
Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
return
}
// Пытаемся получить последнее известное местоположение
val lastLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
if (lastLocation != null) {
currentLocation = lastLocation
}
}
override fun onLocationChanged(location: Location) {
currentLocation = location
// Если оповещение активно, отправляем обновленные координаты
if (viewModel.emergencyState.value is EmergencyState.Active) {
viewModel.updateLocation(
latitude = location.latitude,
longitude = location.longitude,
accuracy = location.accuracy,
batteryLevel = getBatteryLevel()
)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == locationPermissionRequestCode) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
startLocationUpdates(false)
} else {
Toast.makeText(
requireContext(),
"Для работы экстренных оповещений необходим доступ к местоположению",
Toast.LENGTH_LONG
).show()
}
}
}
override fun onPause() {
super.onPause()
// Если нет активного оповещения, останавливаем обновления местоположения
if (viewModel.emergencyState.value !is EmergencyState.Active && isLocationUpdatesActive) {
locationManager.removeUpdates(this)
isLocationUpdatesActive = false
}
}
override fun onResume() {
super.onResume()
// Если есть активное оповещение, возобновляем обновления
val isEmergency = viewModel.emergencyState.value is EmergencyState.Active
if (isEmergency || !isLocationUpdatesActive) {
startLocationUpdates(isEmergency)
}
}
override fun onDestroyView() {
super.onDestroyView()
if (isLocationUpdatesActive) {
locationManager.removeUpdates(this)
isLocationUpdatesActive = false
}
_binding = null
}
}

View File

@@ -0,0 +1,219 @@
package kr.smartsoltech.wellshe.ui.emergency
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmergencyScreen(
onNavigateBack: () -> Unit
) {
var sendingSos by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Экстренная помощь") },
navigationIcon = {
IconButton(onClick = { onNavigateBack() }) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "Назад"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
titleContentColor = MaterialTheme.colorScheme.onErrorContainer
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "ЭКСТРЕННАЯ СИТУАЦИЯ",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Используйте эту функцию только в случае реальной опасности",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium
)
}
}
// Кнопка SOS
Button(
onClick = {
sendingSos = true
// Здесь должна быть логика отправки сигнала SOS
},
modifier = Modifier
.fillMaxWidth()
.height(80.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
enabled = !sendingSos
) {
if (sendingSos) {
CircularProgressIndicator(
color = Color.White,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Отправка сигнала SOS...")
} else {
Icon(
imageVector = Icons.Default.Send,
contentDescription = null,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "ОТПРАВИТЬ СИГНАЛ SOS",
style = MaterialTheme.typography.titleMedium
)
}
}
// Экстренные контакты
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Экстренные контакты",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(16.dp))
EmergencyContactItem(
title = "Полиция",
phoneNumber = "102"
)
Divider(modifier = Modifier.padding(vertical = 8.dp))
EmergencyContactItem(
title = "Скорая помощь",
phoneNumber = "103"
)
Divider(modifier = Modifier.padding(vertical = 8.dp))
EmergencyContactItem(
title = "Единый номер экстренных служб",
phoneNumber = "112"
)
}
}
// Текущее местоположение
Card(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.LocationOn,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Ваше местоположение",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Определяется...",
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
}
}
@Composable
fun EmergencyContactItem(
title: String,
phoneNumber: String
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold
)
Text(
text = phoneNumber,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary
)
}
IconButton(onClick = { /* Действие звонка */ }) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = "Позвонить",
tint = MaterialTheme.colorScheme.primary
)
}
}
}

View File

@@ -0,0 +1,165 @@
package kr.smartsoltech.wellshe.ui.emergency
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import kr.smartsoltech.wellshe.domain.emergency.*
import kr.smartsoltech.wellshe.model.emergency.*
import kr.smartsoltech.wellshe.util.Result
/**
* ViewModel для управления экстренными оповещениями
*/
class EmergencyViewModel(
private val createEmergencyAlertUseCase: CreateEmergencyAlertUseCase,
private val getAlertStatusUseCase: GetAlertStatusUseCase,
private val updateLocationUseCase: UpdateLocationUseCase,
private val cancelAlertUseCase: CancelAlertUseCase
) : ViewModel() {
private val _emergencyState = MutableLiveData<EmergencyState>(EmergencyState.Inactive)
val emergencyState: LiveData<EmergencyState> = _emergencyState
private val _alertStatus = MutableLiveData<EmergencyAlertStatus?>()
val alertStatus: LiveData<EmergencyAlertStatus?> = _alertStatus
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean> = _isLoading
private var currentAlertId: String? = null
/**
* Создание нового экстренного оповещения
*/
fun createEmergencyAlert(
latitude: Double,
longitude: Double,
message: String? = null,
batteryLevel: Int? = null,
contactIds: List<String>? = null
) {
_isLoading.value = true
_emergencyState.value = EmergencyState.Creating
viewModelScope.launch {
when (val result = createEmergencyAlertUseCase(latitude, longitude, message, batteryLevel, contactIds)) {
is Result.Success -> {
currentAlertId = result.data.alertId
_emergencyState.value = EmergencyState.Active(result.data)
// После создания оповещения сразу получаем его статус
fetchAlertStatus(result.data.alertId)
}
is Result.Error -> {
_emergencyState.value = EmergencyState.Error(result.exception.message ?: "Ошибка создания оповещения")
}
}
_isLoading.value = false
}
}
/**
* Получение статуса экстренного оповещения
*/
fun fetchAlertStatus(alertId: String) {
_isLoading.value = true
viewModelScope.launch {
when (val result = getAlertStatusUseCase(alertId)) {
is Result.Success -> {
_alertStatus.value = result.data
// Обновляем состояние на основе статуса оповещения
_emergencyState.value = when (result.data.status.lowercase()) {
"active" -> EmergencyState.Active(EmergencyAlertResponse(
alertId = result.data.alertId,
createdAt = result.data.createdAt,
status = result.data.status,
message = "Оповещение активно"
))
"cancelled" -> EmergencyState.Cancelled(result.data.alertId)
"resolved" -> EmergencyState.Resolved(result.data.alertId)
else -> EmergencyState.Active(EmergencyAlertResponse(
alertId = result.data.alertId,
createdAt = result.data.createdAt,
status = result.data.status,
message = "Статус: ${result.data.status}"
))
}
}
is Result.Error -> {
_emergencyState.value = EmergencyState.Error(result.exception.message ?: "Ошибка получения статуса")
}
}
_isLoading.value = false
}
}
/**
* Обновление местоположения для активного оповещения
*/
fun updateLocation(
latitude: Double,
longitude: Double,
accuracy: Float? = null,
batteryLevel: Int? = null
) {
val alertId = currentAlertId ?: return
viewModelScope.launch {
when (val result = updateLocationUseCase(alertId, latitude, longitude, accuracy, batteryLevel)) {
is Result.Success -> {
// После успешного обновления местоположения можно запросить текущий статус
fetchAlertStatus(alertId)
}
is Result.Error -> {
// Обработка ошибок обновления местоположения
}
}
}
}
/**
* Отмена экстренного оповещения
*/
fun cancelAlert(reason: String? = null, details: String? = null) {
val alertId = currentAlertId ?: return
_isLoading.value = true
viewModelScope.launch {
when (val result = cancelAlertUseCase(alertId, reason, details)) {
is Result.Success -> {
_emergencyState.value = EmergencyState.Cancelled(alertId)
// После отмены оповещения очищаем текущий ID оповещения
currentAlertId = null
}
is Result.Error -> {
_emergencyState.value = EmergencyState.Error(result.exception.message ?: "Ошибка отмены оповещения")
}
}
_isLoading.value = false
}
}
/**
* Сброс состояния (например, после завершения сценария экстренного оповещения)
*/
fun resetState() {
currentAlertId = null
_emergencyState.value = EmergencyState.Inactive
_alertStatus.value = null
}
/**
* Состояния экстренного оповещения
*/
sealed class EmergencyState {
object Inactive : EmergencyState()
object Creating : EmergencyState()
data class Active(val alertResponse: EmergencyAlertResponse) : EmergencyState()
data class Cancelled(val alertId: String) : EmergencyState()
data class Resolved(val alertId: String) : EmergencyState()
data class Error(val message: String) : EmergencyState()
}
}

View File

@@ -0,0 +1,29 @@
package kr.smartsoltech.wellshe.ui.main
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import kr.smartsoltech.wellshe.R
/**
* Главный экран приложения
*/
class MainFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_main, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Инициализация UI и обработчиков событий
}
}

View File

@@ -0,0 +1,114 @@
package kr.smartsoltech.wellshe.ui.main
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExitToApp
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kr.smartsoltech.wellshe.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
onNavigateToEmergency: () -> Unit,
onLogout: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("WellShe") },
actions = {
IconButton(onClick = { onLogout() }) {
Icon(
imageVector = Icons.Default.ExitToApp,
contentDescription = "Выйти"
)
}
}
)
},
bottomBar = {
BottomAppBar {
// Навигация внизу экрана, если необходимо
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)
) {
Text(
text = "Главный экран приложения WellShe",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { onNavigateToEmergency() },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
modifier = Modifier.fillMaxWidth(0.8f)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null
)
Text("ЭКСТРЕННАЯ ПОМОЩЬ")
}
}
Spacer(modifier = Modifier.height(16.dp))
// Добавление основных функциональных кнопок
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Функции приложения",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(16.dp))
// Кнопки для основных функций
Button(
onClick = { /* Действие */ },
modifier = Modifier.fillMaxWidth()
) {
Text("Мой профиль")
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { /* Действие */ },
modifier = Modifier.fillMaxWidth()
) {
Text("Настройки")
}
}
}
}
}
}

View File

@@ -1,6 +1,7 @@
package kr.smartsoltech.wellshe.ui.navigation package kr.smartsoltech.wellshe.ui.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -9,20 +10,65 @@ import kr.smartsoltech.wellshe.ui.body.BodyScreen
import kr.smartsoltech.wellshe.ui.cycle.CycleScreen import kr.smartsoltech.wellshe.ui.cycle.CycleScreen
import kr.smartsoltech.wellshe.ui.mood.MoodScreen import kr.smartsoltech.wellshe.ui.mood.MoodScreen
import kr.smartsoltech.wellshe.ui.profile.ProfileScreen import kr.smartsoltech.wellshe.ui.profile.ProfileScreen
import kr.smartsoltech.wellshe.ui.auth.AuthViewModel
import kr.smartsoltech.wellshe.ui.auth.compose.LoginScreen
import kr.smartsoltech.wellshe.ui.auth.compose.RegisterScreen
import kr.smartsoltech.wellshe.ui.emergency.EmergencyScreen
@Composable @Composable
fun AppNavGraph( fun AppNavGraph(
navController: NavHostController, navController: NavHostController,
startDestination: String = BottomNavItem.Cycle.route startDestination: String = "login", // Меняем стартовый экран на авторизацию
modifier: Modifier = Modifier,
authViewModel: AuthViewModel // Добавляем параметр для доступа к AuthViewModel
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination startDestination = startDestination,
modifier = modifier
) { ) {
// Авторизация и регистрация
composable("login") {
LoginScreen(
onNavigateToRegister = {
navController.navigate("register")
},
onLoginSuccess = {
navController.navigate(BottomNavItem.Cycle.route) {
popUpTo("login") { inclusive = true }
}
}
)
}
composable("register") {
RegisterScreen(
onNavigateBack = {
navController.popBackStack()
},
onRegisterSuccess = {
navController.popBackStack()
}
)
}
// Экран экстренной помощи
composable("emergency") {
EmergencyScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}
// Существующие экраны
composable(BottomNavItem.Cycle.route) { composable(BottomNavItem.Cycle.route) {
CycleScreen( CycleScreen(
onNavigateToSettings = { onNavigateToSettings = {
navController.navigate("cycle_settings") navController.navigate("cycle_settings")
},
onNavigateToEmergency = {
navController.navigate("emergency")
} }
) )
} }
@@ -49,7 +95,17 @@ fun AppNavGraph(
} }
composable(BottomNavItem.Profile.route) { composable(BottomNavItem.Profile.route) {
ProfileScreen() ProfileScreen(
onLogout = {
// Вызываем метод выхода из AuthViewModel, который очищает токены
authViewModel.logout()
// Переходим на экран авторизации после выхода
navController.navigate("login") {
// Очищаем весь стек навигации
popUpTo(0) { inclusive = true }
}
}
)
} }
} }
} }

View File

@@ -9,7 +9,9 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import kr.smartsoltech.wellshe.ui.auth.AuthViewModel
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
// Добавляем явный импорт для BottomNavigation из того же пакета // Добавляем явный импорт для BottomNavigation из того же пакета
@@ -20,6 +22,8 @@ import kr.smartsoltech.wellshe.ui.navigation.BottomNavigation
@Composable @Composable
fun WellSheNavigation() { fun WellSheNavigation() {
val navController = rememberNavController() val navController = rememberNavController()
// Получаем AuthViewModel с помощью viewModel() делегата
val authViewModel: AuthViewModel = viewModel()
Scaffold( Scaffold(
bottomBar = { bottomBar = {
@@ -31,7 +35,11 @@ fun WellSheNavigation() {
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
) { ) {
AppNavGraph(navController = navController) // Передаем authViewModel в AppNavGraph
AppNavGraph(
navController = navController,
authViewModel = authViewModel
)
} }
} }
} }

View File

@@ -24,7 +24,8 @@ import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ProfileScreen( fun ProfileScreen(
modifier: Modifier = Modifier modifier: Modifier = Modifier,
onLogout: () -> Unit = {} // Добавляем параметр для выхода из аккаунта
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
@@ -295,6 +296,19 @@ fun ProfileScreen(
Text("Экспорт настроек") Text("Экспорт настроек")
} }
} }
// Кнопка выхода из аккаунта
Button(
onClick = { onLogout() },
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("Выход из аккаунта")
}
} }
} }

View File

@@ -0,0 +1,61 @@
package kr.smartsoltech.wellshe.util
/**
* Класс для представления результатов операций, которые могут завершиться успешно или с ошибкой
*/
sealed class Result<out T> {
data class Success<out T>(val data: T) : Result<T>()
data class Error(val exception: Exception) : Result<Nothing>()
/**
* Проверяет, был ли результат успешным
*/
val isSuccess: Boolean get() = this is Success
/**
* Проверяет, содержит ли результат ошибку
*/
val isError: Boolean get() = this is Error
/**
* Получает данные в случае успешного результата или null
*/
fun getOrNull(): T? = when (this) {
is Success -> data
is Error -> null
}
/**
* Получает исключение в случае ошибки или null
*/
fun exceptionOrNull(): Exception? = when (this) {
is Success -> null
is Error -> exception
}
/**
* Преобразует успешный результат с помощью переданной функции
*/
inline fun <R> map(transform: (T) -> R): Result<R> {
return when (this) {
is Success -> Success(transform(data))
is Error -> Error(exception)
}
}
/**
* Выполняет действие в зависимости от результата
*/
inline fun onSuccess(action: (T) -> Unit): Result<T> {
if (this is Success) action(data)
return this
}
/**
* Выполняет действие при ошибке
*/
inline fun onError(action: (Exception) -> Unit): Result<T> {
if (this is Error) action(exception)
return this
}
}

View File

@@ -0,0 +1,29 @@
package kr.smartsoltech.wellshe.util
import android.util.Patterns
/**
* Проверяет, является ли строка корректным email
*/
fun String.isValidEmail(): Boolean {
return isNotEmpty() && Patterns.EMAIL_ADDRESS.matcher(this).matches()
}
/**
* Проверяет, является ли строка достаточно надежным паролем
* Требования: минимум 8 символов, хотя бы 1 заглавная буква, 1 цифра, 1 специальный символ
*/
fun String.isValidPassword(): Boolean {
if (length < 8) return false
if (!contains(Regex("[A-Z]"))) return false
if (!contains(Regex("[0-9]"))) return false
if (!contains(Regex("[^A-Za-z0-9]"))) return false
return true
}
/**
* Проверяет, является ли строка корректным телефонным номером в международном формате
*/
fun String.isValidPhone(): Boolean {
return matches(Regex("^\\+[0-9]{10,15}\$"))
}

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#4CAF50"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM16.59,7.58L10,14.17l-2.59,-2.58L6,13l4,4 8,-8z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF9800"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM13,7h-2v6h2V7zM13,15h-2v2h2v-2z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#F44336"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM13,7h-2v6h2V7zM13,15h-2v2h2v-2z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#2196F3"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM16.59,7.58L10,14.17l-2.59,-2.58L6,13l4,4 8,-8z"/>
</vector>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
tools:context=".ui.emergency.EmergencyFragment">
<TextView
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Экстренная помощь"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginTop="16dp" />
<Button
android:id="@+id/buttonSos"
android:layout_width="200dp"
android:layout_height="200dp"
android:text="SOS"
android:textSize="36sp"
android:textStyle="bold"
android:backgroundTint="#FF0000"
android:textColor="#FFFFFF"
app:cornerRadius="100dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.Group
android:id="@+id/emergencyStatusGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:constraint_referenced_ids="statusIndicator,statusText,notifiedContactsCount,notifiedContactsLabel,emergencyServicesStatus,buttonCancelAlert" />
<ImageView
android:id="@+id/statusIndicator"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvTitle"
app:srcCompat="@drawable/ic_status_active" />
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Статус оповещения"
android:textAlignment="center"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/statusIndicator" />
<TextView
android:id="@+id/notifiedContactsLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Уведомлено контактов:"
app:layout_constraintEnd_toStartOf="@+id/notifiedContactsCount"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/statusText" />
<TextView
android:id="@+id/notifiedContactsCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="0/0"
android:textStyle="bold"
app:layout_constraintBaseline_toBaselineOf="@+id/notifiedContactsLabel"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/notifiedContactsLabel" />
<TextView
android:id="@+id/emergencyServicesStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Ожидание ответа экстренных служб"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notifiedContactsLabel" />
<Button
android:id="@+id/buttonCancelAlert"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Отменить оповещение"
android:backgroundTint="#FF9800"
android:textColor="#FFFFFF"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/buttonSos" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
tools:context=".ui.auth.LoginFragment">
<TextView
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Вход"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/tilEmailUsername"
app:layout_constraintVertical_chainStyle="packed" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilEmailUsername"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:hint="Email или имя пользователя"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvTitle"
app:layout_constraintBottom_toTopOf="@+id/tilPassword">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etEmailUsername"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilPassword"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="Пароль"
app:passwordToggleEnabled="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tilEmailUsername"
app:layout_constraintBottom_toTopOf="@+id/btnLogin">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/btnLogin"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Войти"
android:enabled="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tilPassword"
app:layout_constraintBottom_toTopOf="@+id/btnRegister" />
<Button
android:id="@+id/btnRegister"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Регистрация"
style="@style/Widget.MaterialComponents.Button.TextButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/btnLogin"
app:layout_constraintBottom_toBottomOf="parent" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Button"
tools:layout_editor_absoluteX="162dp"
tools:layout_editor_absoluteY="364dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
tools:context=".ui.main.MainFragment">
<TextView
android:id="@+id/tvWelcome"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Добро пожаловать в WellShe!"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginTop="24dp" />
<Button
android:id="@+id/btnEmergency"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="Экстренная помощь"
android:backgroundTint="#FF0000"
android:textColor="#FFFFFF"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvWelcome"
android:layout_marginTop="24dp" />
<!-- Здесь будут другие элементы главного экрана -->
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,192 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
tools:context=".ui.auth.RegisterFragment">
<TextView
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Регистрация"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginTop="16dp" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvTitle">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilEmail"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="Email"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etEmail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilUsername"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="Имя пользователя"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tilEmail">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etUsername"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilPassword"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="Пароль"
app:passwordToggleEnabled="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tilUsername">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilConfirmPassword"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="Подтвердите пароль"
app:passwordToggleEnabled="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tilPassword">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etConfirmPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilFirstName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="Имя"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tilConfirmPassword">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etFirstName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilLastName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="Фамилия"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tilFirstName">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etLastName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilPhone"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="Телефон"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tilLastName">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPhone"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="phone" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/btnRegister"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Зарегистрироваться"
android:enabled="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tilPhone" />
<Button
android:id="@+id/btnBackToLogin"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="У меня уже есть аккаунт"
style="@style/Widget.MaterialComponents.Button.TextButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/btnRegister" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
app:startDestination="@id/loginFragment">
<fragment
android:id="@+id/loginFragment"
android:name="kr.smartsoltech.wellshe.ui.auth.LoginFragment"
android:label="fragment_login"
tools:layout="@layout/fragment_login" >
<action
android:id="@+id/action_loginFragment_to_registerFragment"
app:destination="@id/registerFragment" />
<action
android:id="@+id/action_loginFragment_to_mainFragment"
app:destination="@id/mainFragment"
app:popUpTo="@id/loginFragment"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/registerFragment"
android:name="kr.smartsoltech.wellshe.ui.auth.RegisterFragment"
android:label="fragment_register"
tools:layout="@layout/fragment_register" />
<fragment
android:id="@+id/mainFragment"
android:name="kr.smartsoltech.wellshe.ui.main.MainFragment"
android:label="fragment_main" >
<action
android:id="@+id/action_mainFragment_to_emergencyFragment"
app:destination="@id/emergencyFragment" />
</fragment>
<fragment
android:id="@+id/emergencyFragment"
android:name="kr.smartsoltech.wellshe.ui.emergency.EmergencyFragment"
android:label="fragment_emergency"
tools:layout="@layout/fragment_emergency" />
</navigation>

View File

@@ -1,5 +1,13 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.WellShe" parent="android:Theme.Material.Light.NoActionBar" /> <style name="Theme.WellShe" parent="Theme.MaterialComponents.Light.NoActionBar">
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
</style>
</resources> </resources>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Разрешаем незащищенное HTTP-соединение с IP-адресом 192.168.0.112 -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">192.168.0.112</domain>
</domain-config>
<!-- Настройки по умолчанию - запрещаем незащищенный HTTP-трафик для других адресов -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>

416
docs/api_specification.md Normal file
View File

@@ -0,0 +1,416 @@
# Описание API и способы взаимодействия с ним
## Промпт для ИИ
Ты — технический документатор API для микросервисной архитектуры приложения женской безопасности. Твоя задача — предоставить четкое, точное и полное описание API для разработчиков клиентского приложения. Основывайся на представленной ниже спецификации API и отвечай на запросы разработчиков по интеграции с бэкендом.
## Общая информация об API
Бэкенд приложения построен на микросервисной архитектуре с использованием FastAPI, что обеспечивает высокую производительность и масштабируемость. Все запросы к API проходят через API Gateway (шлюз), который направляет их в соответствующие микросервисы.
### Базовый URL
```
https://api.womensafety.app/api/v1/
```
В среде разработки:
```
http://localhost:8001/api/v1/
```
### Формат данных
API принимает и отправляет данные в формате JSON. При отправке запроса необходимо указать заголовок:
```
Content-Type: application/json
```
### Аутентификация
API использует JWT (JSON Web Tokens) для аутентификации. После успешной авторизации вы получаете два токена:
- `access_token` — для авторизации запросов (срок действия: 30 минут)
- `refresh_token` — для обновления access_token (срок действия: 7 дней)
Для авторизованных запросов необходимо добавлять заголовок:
```
Authorization: Bearer {access_token}
```
## Сервис авторизации (User Service)
### Регистрация нового пользователя
**Эндпоинт:** `POST /auth/register`
**Тело запроса:**
```json
{
"email": "user@example.com",
"username": "username",
"password": "SecurePassword123!",
"first_name": "Имя",
"last_name": "Фамилия",
"phone": "+79991234567"
}
```
**Ответ при успехе (201 Created):**
```json
{
"status": "success",
"message": "User registered successfully",
"data": {
"user_id": "uuid-string",
"username": "username",
"email": "user@example.com"
}
}
```
**Возможные ошибки:**
- 400 Bad Request: Некорректные данные
- 409 Conflict: Пользователь с таким email или username уже существует
### Авторизация пользователя
**Эндпоинт:** `POST /auth/login`
**Тело запроса (вариант 1):**
```json
{
"username": "username",
"password": "SecurePassword123!"
}
```
**Тело запроса (вариант 2):**
```json
{
"email": "user@example.com",
"password": "SecurePassword123!"
}
```
**Ответ при успехе (200 OK):**
```json
{
"status": "success",
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 1800
}
}
```
**Возможные ошибки:**
- 401 Unauthorized: Неверный логин или пароль
- 403 Forbidden: Аккаунт заблокирован
### Обновление токена
**Эндпоинт:** `POST /auth/refresh`
**Тело запроса:**
```json
{
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
**Ответ при успехе (200 OK):**
```json
{
"status": "success",
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 1800
}
}
```
**Возможные ошибки:**
- 401 Unauthorized: Недействительный refresh_token
### Выход из системы
**Эндпоинт:** `POST /auth/logout`
**Заголовки:**
```
Authorization: Bearer {access_token}
```
**Ответ при успехе (200 OK):**
```json
{
"status": "success",
"message": "Successfully logged out"
}
```
**Возможные ошибки:**
- 401 Unauthorized: Недействительный токен
### Получение профиля пользователя
**Эндпоинт:** `GET /users/me`
**Заголовки:**
```
Authorization: Bearer {access_token}
```
**Ответ при успехе (200 OK):**
```json
{
"status": "success",
"data": {
"user_id": "uuid-string",
"username": "username",
"email": "user@example.com",
"first_name": "Имя",
"last_name": "Фамилия",
"phone": "+79991234567",
"created_at": "2025-10-16T10:30:00Z",
"is_verified": true
}
}
```
**Возможные ошибки:**
- 401 Unauthorized: Недействительный токен
## Сервис экстренных оповещений (Emergency Service)
### Создание экстренного оповещения
**Эндпоинт:** `POST /emergency/alert`
**Заголовки:**
```
Authorization: Bearer {access_token}
```
**Тело запроса:**
```json
{
"location": {
"latitude": 55.7558,
"longitude": 37.6173
},
"message": "Нуждаюсь в помощи. Преследование.",
"battery_level": 42,
"contact_ids": ["uuid1", "uuid2"]
}
```
**Ответ при успехе (201 Created):**
```json
{
"status": "success",
"data": {
"alert_id": "alert-uuid-string",
"created_at": "2025-10-16T15:30:00Z",
"status": "active",
"message": "Экстренное оповещение активировано"
}
}
```
**Возможные ошибки:**
- 401 Unauthorized: Недействительный токен
- 400 Bad Request: Неверные координаты или другие данные
### Получение статуса экстренного оповещения
**Эндпоинт:** `GET /emergency/alert/{alert_id}`
**Заголовки:**
```
Authorization: Bearer {access_token}
```
**Ответ при успехе (200 OK):**
```json
{
"status": "success",
"data": {
"alert_id": "alert-uuid-string",
"created_at": "2025-10-16T15:30:00Z",
"status": "active",
"location": {
"latitude": 55.7558,
"longitude": 37.6173,
"updated_at": "2025-10-16T15:35:00Z"
},
"notified_contacts": [
{
"contact_id": "uuid1",
"status": "notified",
"notified_at": "2025-10-16T15:31:00Z"
},
{
"contact_id": "uuid2",
"status": "pending",
"notified_at": null
}
],
"emergency_services_notified": true,
"emergency_services_notified_at": "2025-10-16T15:32:00Z"
}
}
```
**Возможные ошибки:**
- 401 Unauthorized: Недействительный токен
- 403 Forbidden: Нет доступа к данному оповещению
- 404 Not Found: Оповещение не найдено
### Обновление местоположения при активном оповещении
**Эндпоинт:** `PUT /emergency/alert/{alert_id}/location`
**Заголовки:**
```
Authorization: Bearer {access_token}
```
**Тело запроса:**
```json
{
"latitude": 55.7560,
"longitude": 37.6175,
"accuracy": 10.5,
"battery_level": 38
}
```
**Ответ при успехе (200 OK):**
```json
{
"status": "success",
"message": "Location updated successfully",
"data": {
"updated_at": "2025-10-16T15:40:00Z"
}
}
```
**Возможные ошибки:**
- 401 Unauthorized: Недействительный токен
- 403 Forbidden: Нет доступа к данному оповещению
- 404 Not Found: Оповещение не найдено или не активно
### Отмена экстренного оповещения
**Эндпоинт:** `POST /emergency/alert/{alert_id}/cancel`
**Заголовки:**
```
Authorization: Bearer {access_token}
```
**Тело запроса:**
```json
{
"reason": "Ложная тревога",
"details": "Случайно нажала кнопку"
}
```
**Ответ при успехе (200 OK):**
```json
{
"status": "success",
"message": "Alert cancelled successfully",
"data": {
"alert_id": "alert-uuid-string",
"cancelled_at": "2025-10-16T15:45:00Z",
"status": "cancelled"
}
}
```
**Возможные ошибки:**
- 401 Unauthorized: Недействительный токен
- 403 Forbidden: Нет доступа к данному оповещению
- 404 Not Found: Оповещение не найдено
- 409 Conflict: Оповещение уже отменено
## Рекомендации по работе с API
### Обработка ошибок
Все ошибки API возвращаются в едином формате:
```json
{
"status": "error",
"code": "ERROR_CODE",
"message": "Описание ошибки",
"details": {
"field": ["Детальное описание ошибки для конкретного поля"]
}
}
```
### Управление токенами
1. После получения `access_token` и `refresh_token` сохраните их в безопасном хранилище.
2. Для каждого запроса, требующего авторизацию, добавляйте `access_token` в заголовок.
3. Если API возвращает ошибку 401, попробуйте обновить токен через эндпоинт `/auth/refresh`.
4. Если и это не помогает, перенаправьте пользователя на страницу входа.
### Оптимизация работы с сетью
1. Используйте кэширование для уменьшения количества запросов.
2. Реализуйте механизм повторных попыток для нестабильных соединений.
3. При отправке экстренных оповещений учитывайте возможную нестабильность сети — сохраняйте данные локально до получения подтверждения от сервера.
### Работа с геолокацией
1. Запрашивайте у пользователя разрешение на постоянный доступ к геолокации.
2. Оптимизируйте частоту обновления координат для экономии батареи.
3. При активном экстренном оповещении увеличивайте частоту обновления координат.
## Тестирование API
Для облегчения интеграции с API доступна песочница:
```
https://api-sandbox.womensafety.app/api/v1/
```
Тестовые учетные записи:
- Пользователь: `test_user/Test123!`
- Администратор: `test_admin/Admin123!`
## Работа с реальными устройствами
Для корректной работы с API на реальных устройствах учитывайте:
1. Ограничения режима энергосбережения на некоторых устройствах.
2. Различную точность GPS на разных моделях телефонов.
3. Возможность потери соединения в некоторых зонах.
Реализуйте механизм, который будет накапливать данные локально при отсутствии сети и отправлять их, когда соединение восстановится.
## Безопасность
1. Никогда не храните пароли в открытом виде.
2. Не сохраняйте токены в незащищенном хранилище.
3. Всегда проверяйте SSL-сертификаты при подключении к API.
4. Реализуйте защиту от подбора пароля, ограничивая количество попыток входа.
## Поддержка
При возникновении проблем с интеграцией можно обратиться:
- Email: api-support@womensafety.app
- Документация: https://docs.womensafety.app
- Статус сервисов: https://status.womensafety.app

View File

@@ -0,0 +1,159 @@
# Техническое задание для ИИ агента по реализации системы авторизации, регистрации и экстренных оповещений
## Промпт для ИИ
Ты — ИИ агент-разработчик, специализирующийся на интеграции клиентской части приложения для женской безопасности с бэкендом на микросервисной архитектуре. Твоя задача — реализовать систему авторизации, регистрации и экстренных оповещений на основе предоставленных API-эндпоинтов.
## Общие требования
1. Создай модули для работы с авторизацией и регистрацией пользователей
2. Реализуй механизм хранения и обновления токенов доступа
3. Разработай интерфейс для экстренных оповещений
4. Обеспечь надежную обработку ошибок и восстановление соединения
5. Следуй современным практикам безопасности при работе с API
## Часть 1: Система авторизации и регистрации
### Эндпоинты для работы
#### Регистрация
- **URL**: `/api/v1/auth/register`
- **Метод**: `POST`
- **Требуемые поля**:
- email (string)
- username (string)
- password (string)
- first_name (string)
- last_name (string)
- phone (string)
- **Обработка ответа**: Создать локальный профиль пользователя и перенаправить на экран авторизации
#### Авторизация
- **URL**: `/api/v1/auth/login`
- **Метод**: `POST`
- **Требуемые поля**:
- username (string) или email (string)
- password (string)
- **Обработка ответа**: Сохранить полученный токен доступа и refresh token
#### Обновление токена
- **URL**: `/api/v1/auth/refresh`
- **Метод**: `POST`
- **Требуемые поля**:
- refresh_token (string)
- **Обработка ответа**: Обновить сохраненный токен доступа
#### Выход из системы
- **URL**: `/api/v1/auth/logout`
- **Метод**: `POST`
- **Заголовки**: Authorization: Bearer {token}
- **Обработка ответа**: Удалить все локальные данные аутентификации
### Функциональные требования
1. **Валидация данных**:
- Проверка корректности формата email
- Проверка надежности пароля (минимум 8 символов, одна заглавная буква, одна цифра, один специальный символ)
- Валидация номера телефона (международный формат)
2. **Хранение данных**:
- Безопасное хранение токенов доступа (использовать Secure Storage)
- Автоматическое обновление токенов при истечении срока действия
3. **Обработка ошибок**:
- Четкие сообщения об ошибках для пользователя
- Автоматические повторные попытки при проблемах с сетью
- Логирование ошибок для отладки
## Часть 2: Экстренные оповещения
### Эндпоинты для работы
#### Создание экстренного оповещения
- **URL**: `/api/v1/emergency/alert`
- **Метод**: `POST`
- **Заголовки**: Authorization: Bearer {token}
- **Требуемые поля**:
- location (object): { latitude: float, longitude: float }
- message (string, optional): Дополнительная информация
- **Обработка ответа**: Отобразить статус отправки и ID оповещения
#### Получение статуса экстренного оповещения
- **URL**: `/api/v1/emergency/alert/{alert_id}`
- **Метод**: `GET`
- **Заголовки**: Authorization: Bearer {token}
- **Обработка ответа**: Обновить интерфейс с информацией о статусе оповещения
#### Отмена экстренного оповещения
- **URL**: `/api/v1/emergency/alert/{alert_id}/cancel`
- **Метод**: `POST`
- **Заголовки**: Authorization: Bearer {token}
- **Требуемые поля**:
- reason (string, optional): Причина отмены
- **Обработка ответа**: Обновить статус оповещения в интерфейсе
### Функциональные требования
1. **Интерфейс экстренных оповещений**:
- Кнопка SOS, заметная и легко доступная
- Подтверждение перед отправкой для предотвращения случайных активаций
- Индикатор статуса активного оповещения
- Возможность отмены оповещения
2. **Геолокация**:
- Автоматическое получение местоположения пользователя
- Периодическое обновление координат при активном оповещении
- Отправка обновлений локации на сервер
3. **Уведомления**:
- Push-уведомления о статусе экстренного оповещения
- Звуковые сигналы при изменении статуса
- Вибрация для незаметного активирования
4. **Отказоустойчивость**:
- Кэширование данных для работы офлайн
- Повторная отправка при восстановлении соединения
- Локальное хранение истории экстренных вызовов
## Технические требования
1. **Архитектура**:
- Использовать паттерн MVVM или Redux для управления состоянием
- Разделить код на модули по функциональности
- Инкапсулировать работу с API в отдельный сервис
2. **Безопасность**:
- Шифрование хранимых данных
- Проверка SSL-сертификатов
- Защита от CSRF и других уязвимостей
3. **Производительность**:
- Минимизировать использование ресурсов
- Оптимизировать сетевые запросы
- Эффективное управление жизненным циклом приложения
4. **Тестирование**:
- Модульные тесты для каждого компонента
- Интеграционные тесты для проверки взаимодействия с API
- UI-тесты для проверки пользовательского интерфейса
## Ожидаемые результаты
1. Полностью функциональная система авторизации и регистрации
2. Интерфейс для экстренных оповещений с полной интеграцией API
3. Документация по архитектуре и интеграции
4. Покрытие кода тестами не менее 80%
## Ограничения и особые требования
1. Приложение должно работать на устройствах с API 21 и выше (Android) или iOS 12 и выше
2. Учитывать различные размеры экрана и ориентации
3. Обеспечить доступность для людей с ограниченными возможностями
4. Минимальное потребление батареи даже при использовании геолокации
## Примечания
1. Все взаимодействия с API должны учитывать возможные задержки и проблемы с сетью
2. Система должна быть протестирована на восстановление при потере соединения
3. Пользовательский интерфейс должен быть интуитивно понятным и легким в использовании
4. Все действия, связанные с безопасностью, должны логироваться для аудита