add giga wear auth ui
This commit is contained in:
+4
-5
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
+29
@@ -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()
|
||||
}
|
||||
}
|
||||
+19
@@ -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()
|
||||
}
|
||||
}
|
||||
+32
@@ -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"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
+2
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+75
@@ -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)
|
||||
}
|
||||
}
|
||||
+185
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -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
|
||||
|
||||
+2
@@ -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()
|
||||
}
|
||||
|
||||
+4
@@ -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>
|
||||
|
||||
+6
-4
@@ -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)
|
||||
|
||||
@@ -69,6 +69,7 @@ guava = "33.2.1-android"
|
||||
tilesTooling = "1.5.0"
|
||||
tilesToolingPreview = "1.5.0"
|
||||
watchfaceComplicationsDataSourceKtx = "1.2.1"
|
||||
wearInput = "1.2.0"
|
||||
|
||||
[libraries]
|
||||
adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "adaptiveLayout" }
|
||||
@@ -189,6 +190,7 @@ guava = { group = "com.google.guava", name = "guava", version.ref = "guava" }
|
||||
androidx-tiles-tooling = { group = "androidx.wear.tiles", name = "tiles-tooling", version.ref = "tilesTooling" }
|
||||
androidx-tiles-tooling-preview = { group = "androidx.wear.tiles", name = "tiles-tooling-preview", version.ref = "tilesToolingPreview" }
|
||||
androidx-watchface-complications-data-source-ktx = { group = "androidx.wear.watchface", name = "watchface-complications-data-source-ktx", version.ref = "watchfaceComplicationsDataSourceKtx" }
|
||||
androidx-wear-input = { group = "androidx.wear", name = "wear-input", version.ref = "wearInput" }
|
||||
|
||||
[plugins]
|
||||
jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "compose-version" }
|
||||
|
||||
@@ -14,8 +14,12 @@ class JwtPreparer(
|
||||
private val envFetcher: EnvFetcher
|
||||
) {
|
||||
private val tokenTimeInMinutes = envFetcher.get("JWT_DURATION", "30").toLong()
|
||||
fun prepare(userId: Long): String {
|
||||
val exp = Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(tokenTimeInMinutes))
|
||||
|
||||
fun prepare(
|
||||
userId: Long,
|
||||
expMillis: Long = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(tokenTimeInMinutes)
|
||||
): String {
|
||||
val exp = Date(expMillis)
|
||||
val token = JWT.create()
|
||||
.withAudience(jwtInfo.audience)
|
||||
.withIssuer(jwtInfo.issuer)
|
||||
|
||||
Reference in New Issue
Block a user