ДОбавлены авторизация, выход из профиляю ПРи авторизации приложение запоминает username, password и при входе авторизуется само, чтобы работать с актуальным токеном

This commit is contained in:
2025-10-16 15:58:20 +09:00
parent 6395c0fc36
commit 5128762d91
58 changed files with 4555 additions and 15 deletions

View File

@@ -47,6 +47,7 @@ android {
}
buildFeatures {
compose = true
viewBinding = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
@@ -83,6 +84,21 @@ dependencies {
implementation("com.squareup.moshi:moshi-adapters:1.15.0")
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("io.mockk:mockk:1.13.8")
androidTestImplementation(libs.androidx.junit)

View File

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

View File

@@ -1,22 +1,69 @@
package kr.smartsoltech.wellshe
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
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 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
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
WellSheTheme {
WellSheNavigation()
try {
setContent {
WellSheTheme {
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(
modifier: Modifier = Modifier,
viewModel: CycleViewModel = hiltViewModel(),
onNavigateToSettings: () -> Unit = {} // Добавляем параметр для навигации к настройкам
onNavigateToSettings: () -> Unit = {}, // Параметр для навигации к настройкам
onNavigateToEmergency: () -> Unit = {} // Добавляем параметр для навигации к экрану экстренной помощи
) {
val uiState by viewModel.uiState.collectAsState()
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
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
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.mood.MoodScreen
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
fun AppNavGraph(
navController: NavHostController,
startDestination: String = BottomNavItem.Cycle.route
startDestination: String = "login", // Меняем стартовый экран на авторизацию
modifier: Modifier = Modifier,
authViewModel: AuthViewModel // Добавляем параметр для доступа к AuthViewModel
) {
NavHost(
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) {
CycleScreen(
onNavigateToSettings = {
navController.navigate("cycle_settings")
},
onNavigateToEmergency = {
navController.navigate("emergency")
}
)
}
@@ -49,7 +95,17 @@ fun AppNavGraph(
}
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.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.rememberNavController
import kr.smartsoltech.wellshe.ui.auth.AuthViewModel
import kr.smartsoltech.wellshe.ui.theme.WellSheTheme
// Добавляем явный импорт для BottomNavigation из того же пакета
@@ -20,6 +22,8 @@ import kr.smartsoltech.wellshe.ui.navigation.BottomNavigation
@Composable
fun WellSheNavigation() {
val navController = rememberNavController()
// Получаем AuthViewModel с помощью viewModel() делегата
val authViewModel: AuthViewModel = viewModel()
Scaffold(
bottomBar = {
@@ -31,7 +35,11 @@ fun WellSheNavigation() {
.fillMaxSize()
.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)
@Composable
fun ProfileScreen(
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onLogout: () -> Unit = {} // Добавляем параметр для выхода из аккаунта
) {
val scrollState = rememberScrollState()
@@ -295,6 +296,19 @@ fun ProfileScreen(
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"?>
<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>

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>