add giga wear auth ui

This commit is contained in:
2026-09-14 19:25:24 +03:00
parent 53453c2688
commit fce29831a2
16 changed files with 383 additions and 15 deletions
@@ -12,6 +12,7 @@ import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.gigawear.backend.AppDatabase
import ru.shadowsparky.gigawear.common.AuthRepository
import ru.shadowsparky.gigawear.common.LoginRequest
import ru.shadowsparky.gigawear.common.UpdateTokenRequest
import ru.shadowsparky.http.domain.BadRequestException
import ru.shadowsparky.http.domain.TokenInfo
import java.time.ZoneOffset
@@ -31,16 +32,14 @@ class BackendAuthRepositoryImpl(
private val tokenQueries = appDatabase.refresh_tokensQueries
override suspend fun login(request: LoginRequest) = withContext(dispatcherProvider.io) {
val userCode = request.code
val user = usersQueries.selectByCode(userCode).executeAsOneOrNull()
val user = usersQueries.selectByCode(request.code).executeAsOneOrNull()
?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
userVerifier.verify(user.user_id)
create(user.user_id)
}
override suspend fun update(oldTokenInfo: TokenInfo) = withContext(dispatcherProvider.io) {
checkNotNull(oldTokenInfo.refresh)
val tokenHash = oldTokenInfo.refresh!!.toTokenHash(SALT)
override suspend fun update(refreshToken: UpdateTokenRequest) = withContext(dispatcherProvider.io) {
val tokenHash = refreshToken.refreshToken.toTokenHash(SALT)
val savedToken = tokenQueries.selectByHash(tokenHash)
.executeAsOneOrNull()
?: throw BadRequestException("Token not found")
+2
View File
@@ -32,6 +32,8 @@ dependencies {
implementation(project(":feature:chat:chat-common"))
implementation(project(":feature:updater:updater-common"))
implementation(project(":feature:updater:updater-client"))
implementation(libs.androidx.wear.input)
implementation(libs.multiplatform.settings)
implementation(libs.activity.compose)
implementation(libs.androidx.compose.foundation)
@@ -0,0 +1,29 @@
package ru.shadowsparky.gigawear.data
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import org.koin.core.annotation.Factory
import ru.shadowsparky.gigawear.common.AuthRepository
import ru.shadowsparky.gigawear.common.LoginRequest
import ru.shadowsparky.gigawear.common.UpdateTokenRequest
import ru.shadowsparky.http.domain.TokenInfo
@Factory
class AuthRepositoryImpl(
private val httpClient: HttpClient
) : AuthRepository {
override suspend fun login(request: LoginRequest): TokenInfo {
return httpClient.post("$ENDPOINT/${AuthRepository.LOGIN_PATH}") {
setBody(request)
}.body()
}
override suspend fun update(refreshToken: UpdateTokenRequest): TokenInfo {
return httpClient.post("$ENDPOINT/${AuthRepository.UPDATE_TOKEN_PATH}") {
setBody(refreshToken)
}.body()
}
}
@@ -0,0 +1,19 @@
package ru.shadowsparky.gigawear.data
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import org.koin.core.annotation.Factory
import ru.shadowsparky.gigawear.common.AuthRepository
import ru.shadowsparky.http.domain.RefreshTokenAction
import ru.shadowsparky.http.domain.TokenInfo
@Factory
class RefreshTokenActionImpl : RefreshTokenAction {
override suspend fun refresh(client: HttpClient, refreshToken: String): TokenInfo {
return client.post("$ENDPOINT/${AuthRepository.UPDATE_TOKEN_PATH}") {
setBody(refreshToken)
}.body()
}
}
@@ -0,0 +1,32 @@
package ru.shadowsparky.gigawear.data
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Single
import ru.shadowsparky.domain.KeyValueStorage
import ru.shadowsparky.http.domain.TokenInfo
import ru.shadowsparky.http.domain.TokenStorage
@Single
class TokenStorageImpl(
private val keyValueStorage: KeyValueStorage,
private val json: Json
) : TokenStorage {
override val realm: String = "wear"
override val token: Flow<TokenInfo?> = keyValueStorage.get(KEY).map {
it?.let { json.decodeFromString(it) }
}
override suspend fun updateToken(newToken: TokenInfo) {
keyValueStorage.put(KEY, json.encodeToString(newToken))
}
override suspend fun clear() {
keyValueStorage.remove(KEY)
}
private companion object {
const val KEY = "token"
}
}
@@ -10,7 +10,7 @@ import ru.shadowsparky.ui.nav.RootComponent
class GigaWearRootComponent(
context: ComponentContext
) : RootComponent<Route>(context) {
override val initStack: List<Route> = listOf(Route.ChatScreen())
override val initStack: List<Route> = listOf(Route.AuthScreen())
override val serializer: KSerializer<Route> = Route.serializer()
override val koin: Koin = ru.shadowsparky.koin
}
@@ -5,6 +5,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import com.arkivanov.decompose.retainedComponent
import org.koin.android.ext.android.inject
import ru.shadowsparky.gigawear.presentation.auth.AuthScreen
import ru.shadowsparky.gigawear.presentation.chat.ChatScreen
import ru.shadowsparky.gigawear.presentation.nav.Child
import ru.shadowsparky.ui.nav.Nav
@@ -19,6 +20,7 @@ class MainActivity : ComponentActivity() {
Nav(root) { child, _ ->
when (child) {
is Child.MainChild -> ChatScreen(child.component)
is Child.AuthChild -> AuthScreen(child.component)
}
}
}
@@ -0,0 +1,75 @@
package ru.shadowsparky.gigawear.presentation.auth
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.essenty.lifecycle.coroutines.coroutineScope
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.gigawear.common.AuthRepository
import ru.shadowsparky.gigawear.common.LoginRequest
import ru.shadowsparky.gigawear.presentation.nav.Child
import ru.shadowsparky.gigawear.presentation.nav.Route
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator
class AuthComponent(
private val context: ComponentContext,
private val tokenStorage: TokenStorage,
private val authRepository: AuthRepository,
private val goToChat: () -> Unit
) : ComponentContext by context {
private val scope = coroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val _state = MutableStateFlow(AuthState())
val state = _state.asStateFlow()
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
_state.value = AuthState(isLoading = false, error = throwable)
}
init {
scope.launch(exceptionHandler) {
if (tokenStorage.token.firstOrNull() != null) goToChat()
}
}
fun login(code: Int) {
scope.launch(exceptionHandler) {
_state.value = AuthState(isLoading = true, error = null)
val token = authRepository.login(LoginRequest(code))
tokenStorage.updateToken(token)
goToChat()
_state.value = AuthState(isLoading = false, error = null)
}
}
}
data class AuthState(
val isLoading: Boolean = false,
val error: Throwable? = null
)
@Factory
@Named(Route.AUTH)
class AuthComponentFactory(
private val tokenStorage: TokenStorage,
private val authRepository: AuthRepository,
) : ComponentFactory<Child.AuthChild, Route.AuthScreen, Route> {
override fun create(
route: Route.AuthScreen,
child: ComponentContext,
root: RouteNavigator<Route>
): Child.AuthChild {
val component = AuthComponent(
child,
tokenStorage,
authRepository
) { root.nav(Route.ChatScreen()) }
return Child.AuthChild(component)
}
}
@@ -0,0 +1,185 @@
package ru.shadowsparky.gigawear.presentation.auth
import android.app.RemoteInput
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.material3.AppScaffold
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.CircularProgressIndicator
import androidx.wear.input.RemoteInputIntentHelper
import ru.shadowsparky.gigawear.R
import androidx.wear.compose.material3.Text as WearText
private const val CODE_LENGTH = 6
private const val CODE_INPUT_KEY = "code_input"
@Composable
fun AuthScreen(component: AuthComponent) {
val state by component.state.collectAsState()
AuthScreen(
state = state,
onLogin = { code -> component.login(code) }
)
}
@Composable
private fun AuthScreen(
state: AuthState,
onLogin: (code: Int) -> Unit,
modifier: Modifier = Modifier
) {
var code by rememberSaveable { mutableStateOf("") }
AppScaffold(modifier = modifier) {
CodeStep(
code = code,
onCodeChange = { code = it },
onSubmit = {
code.toIntOrNull()?.let { onLogin(it) }
},
isLoading = state.isLoading,
error = state.error
)
}
}
@Composable
private fun CodeStep(
code: String,
onCodeChange: (String) -> Unit,
onSubmit: () -> Unit,
isLoading: Boolean,
error: Throwable?
) {
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val text = result.data
?.let { RemoteInput.getResultsFromIntent(it) }
?.getCharSequence(CODE_INPUT_KEY)
?.toString()
?: return@rememberLauncherForActivityResult
val clean = text.filter(Char::isDigit).take(CODE_LENGTH)
onCodeChange(clean)
if (clean.length == CODE_LENGTH) {
onSubmit()
}
}
val openCodeInput = {
val intent = RemoteInputIntentHelper.createActionRemoteInputIntent()
val remoteInputs = listOf(
RemoteInput.Builder(CODE_INPUT_KEY)
.setLabel("Код")
.build()
)
RemoteInputIntentHelper.putRemoteInputsExtra(intent, remoteInputs)
launcher.launch(intent)
}
ScalingLazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
item {
WearText(
text = stringResource(R.string.auth_code_title),
style = MaterialTheme.typography.bodyMedium
)
}
item {
CodeDots(
code = code,
onClick = openCodeInput
)
}
item {
WearText(
text = stringResource(
R.string.auth_code_counter,
code.length,
CODE_LENGTH
),
style = MaterialTheme.typography.labelMedium
)
}
item {
Button(
onClick = onSubmit,
enabled = code.length == CODE_LENGTH && !isLoading
) {
if (isLoading) {
Box(contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
} else {
WearText(stringResource(R.string.auth_submit))
}
}
}
if (error != null) {
item {
WearText(
text = error.message
?: stringResource(R.string.auth_error_unknown),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
@Composable
private fun CodeDots(
code: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
repeat(CODE_LENGTH) { index ->
val filled = index < code.length
Box(
modifier = Modifier
.padding(horizontal = 4.dp)
.size(20.dp)
.clip(CircleShape)
.background(
if (filled) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant
)
.clickable(onClick = onClick)
)
}
}
}
@@ -34,9 +34,7 @@ class ChatComponent(
private val _events = Channel<ComponentEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()
init {
updateComponent.checkUpdates(true)
}
init { updateComponent.checkUpdates() }
fun onMicClicked() {
if (_state.value.isLoading) return
@@ -1,7 +1,9 @@
package ru.shadowsparky.gigawear.presentation.nav
import ru.shadowsparky.gigawear.presentation.auth.AuthComponent
import ru.shadowsparky.gigawear.presentation.chat.ChatComponent
sealed class Child : ru.shadowsparky.ui.nav.Child {
class MainChild(val component: ChatComponent) : Child()
class AuthChild(val component: AuthComponent) : Child()
}
@@ -8,7 +8,11 @@ sealed class Route : ru.shadowsparky.ui.nav.Route() {
@Serializable
data class ChatScreen(override val routeId: String = CHAT) : Route()
@Serializable
data class AuthScreen(override val routeId: String = AUTH) : Route()
companion object {
const val CHAT = "chat"
const val AUTH = "auth"
}
}
@@ -2,4 +2,17 @@
<string name="app_name">Свин Wear</string>
<string name="input_text_to_assist">Введите запрос к ассистенту</string>
<string name="error_title">Произошла ошибка</string>
<!-- Экран входа: шаг логина -->
<string name="auth_title">Вход</string>
<string name="auth_login_label">Логин</string>
<string name="auth_next">Далее</string>
<!-- Экран входа: шаг кода -->
<string name="auth_code_title">Код</string>
<string name="auth_code_label">%1$d цифр</string>
<string name="auth_code_counter">%1$d / %2$d</string>
<string name="auth_submit">Войти</string>
<string name="auth_back">Назад</string>
<string name="auth_error_unknown">Ошибка входа</string>
</resources>
@@ -5,7 +5,7 @@ import ru.shadowsparky.http.domain.TokenInfo
interface AuthRepository {
suspend fun login(request: LoginRequest): TokenInfo
suspend fun update(oldTokenInfo: TokenInfo): TokenInfo
suspend fun update(refreshToken: UpdateTokenRequest): TokenInfo
companion object {
const val LOGIN_PATH = "login"
@@ -14,7 +14,9 @@ interface AuthRepository {
}
@Serializable
data class LoginRequest(
val login: String,
val code: Int
class UpdateTokenRequest(
val refreshToken: String
)
@Serializable
data class LoginRequest(val code: Int)