From c32a7a2af15a9b4a4339a1184243ed85aabd6dbd Mon Sep 17 00:00:00 2001 From: aaa1115910 Date: Mon, 3 Jun 2024 23:18:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=94=A8=E6=88=B7=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolves #133 --- .../dev.aaa1115910.bv.dao.AppDatabase/3.json | 97 +++++ app/src/main/AndroidManifest.xml | 5 + .../aaa1115910/bv/activities/MainActivity.kt | 40 +- .../user/UserLockSettingsActivity.kt | 34 ++ .../dev/aaa1115910/bv/dao/AppDatabase.kt | 5 +- .../dev/aaa1115910/bv/entity/db/UserDB.kt | 4 +- .../bv/repository/UserRepository.kt | 8 + .../bv/screen/user/UserSwitchScreen.kt | 352 ++++++++++++------ .../user/lock/UnlockSwitchUserContent.kt | 161 ++++++++ .../bv/screen/user/lock/UnlockUserScreen.kt | 236 ++++++++++++ .../user/lock/UserLockSettingsScreen.kt | 247 ++++++++++++ app/src/main/res/values/strings.xml | 11 + 12 files changed, 1088 insertions(+), 112 deletions(-) create mode 100644 app/schemas/dev.aaa1115910.bv.dao.AppDatabase/3.json create mode 100644 app/src/main/kotlin/dev/aaa1115910/bv/activities/user/UserLockSettingsActivity.kt create mode 100644 app/src/main/kotlin/dev/aaa1115910/bv/screen/user/lock/UnlockSwitchUserContent.kt create mode 100644 app/src/main/kotlin/dev/aaa1115910/bv/screen/user/lock/UnlockUserScreen.kt create mode 100644 app/src/main/kotlin/dev/aaa1115910/bv/screen/user/lock/UserLockSettingsScreen.kt diff --git a/app/schemas/dev.aaa1115910.bv.dao.AppDatabase/3.json b/app/schemas/dev.aaa1115910.bv.dao.AppDatabase/3.json new file mode 100644 index 00000000..1b1a283f --- /dev/null +++ b/app/schemas/dev.aaa1115910.bv.dao.AppDatabase/3.json @@ -0,0 +1,97 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "ad0905227bbe6c87b6048b4124cf310d", + "entities": [ + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `keyword` TEXT NOT NULL, `search_date` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "searchDate", + "columnName": "search_date", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "user", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `uid` INTEGER NOT NULL, `username` TEXT NOT NULL, `avatar` TEXT NOT NULL, `auth` TEXT NOT NULL, `lock` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auth", + "columnName": "auth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lock", + "columnName": "lock", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ad0905227bbe6c87b6048b4124cf310d')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dcfb6d49..dfdde6e0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,6 +30,11 @@ android:supportsRtl="true" android:theme="@style/Theme.BV" tools:ignore="UnusedAttribute"> + + logger.info { "unlock user lock for user ${user.uid}" } + userLockLocked = false + } + ) + } + } } } } diff --git a/app/src/main/kotlin/dev/aaa1115910/bv/activities/user/UserLockSettingsActivity.kt b/app/src/main/kotlin/dev/aaa1115910/bv/activities/user/UserLockSettingsActivity.kt new file mode 100644 index 00000000..b60f54a5 --- /dev/null +++ b/app/src/main/kotlin/dev/aaa1115910/bv/activities/user/UserLockSettingsActivity.kt @@ -0,0 +1,34 @@ +package dev.aaa1115910.bv.activities.user + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import dev.aaa1115910.bv.screen.user.lock.UserLockSettingsScreen +import dev.aaa1115910.bv.ui.theme.BVTheme + +class UserLockSettingsActivity : ComponentActivity() { + + companion object { + fun actionStart( + context: Context, + uid: Long + ) { + context.startActivity( + Intent(context, UserLockSettingsActivity::class.java).apply { + putExtra("uid", uid) + } + ) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + BVTheme { + UserLockSettingsScreen() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/aaa1115910/bv/dao/AppDatabase.kt b/app/src/main/kotlin/dev/aaa1115910/bv/dao/AppDatabase.kt index 7c6c70b7..1ea84752 100644 --- a/app/src/main/kotlin/dev/aaa1115910/bv/dao/AppDatabase.kt +++ b/app/src/main/kotlin/dev/aaa1115910/bv/dao/AppDatabase.kt @@ -16,10 +16,11 @@ import java.util.concurrent.Executors @Database( entities = [SearchHistoryDB::class, UserDB::class], - version = 2, + version = 3, exportSchema = true, autoMigrations = [ - AutoMigration(from = 1, to = 2) + AutoMigration(from = 1, to = 2), + AutoMigration(from = 2, to = 3) ] ) @TypeConverters(Converters::class) diff --git a/app/src/main/kotlin/dev/aaa1115910/bv/entity/db/UserDB.kt b/app/src/main/kotlin/dev/aaa1115910/bv/entity/db/UserDB.kt index bc0bdafa..c30ff33e 100644 --- a/app/src/main/kotlin/dev/aaa1115910/bv/entity/db/UserDB.kt +++ b/app/src/main/kotlin/dev/aaa1115910/bv/entity/db/UserDB.kt @@ -5,10 +5,12 @@ import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "user") + data class UserDB( @PrimaryKey(autoGenerate = true) val id: Int? = null, @ColumnInfo(name = "uid") val uid: Long, @ColumnInfo(name = "username") var username: String, @ColumnInfo(name = "avatar") var avatar: String, - @ColumnInfo(name = "auth") var auth: String + @ColumnInfo(name = "auth") var auth: String, + @ColumnInfo(name = "lock", defaultValue = "") var lock: String = "", ) \ No newline at end of file diff --git a/app/src/main/kotlin/dev/aaa1115910/bv/repository/UserRepository.kt b/app/src/main/kotlin/dev/aaa1115910/bv/repository/UserRepository.kt index c67dbc51..16d7c621 100644 --- a/app/src/main/kotlin/dev/aaa1115910/bv/repository/UserRepository.kt +++ b/app/src/main/kotlin/dev/aaa1115910/bv/repository/UserRepository.kt @@ -172,4 +172,12 @@ class UserRepository( avatar = it.avatar } } + + suspend fun findUserByUid(uid: Long): UserDB? { + return db.userDao().findUserByUid(uid) + } + + suspend fun updateUser(user: UserDB){ + db.userDao().update(user) + } } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/aaa1115910/bv/screen/user/UserSwitchScreen.kt b/app/src/main/kotlin/dev/aaa1115910/bv/screen/user/UserSwitchScreen.kt index a44b1dac..eb35b1c4 100644 --- a/app/src/main/kotlin/dev/aaa1115910/bv/screen/user/UserSwitchScreen.kt +++ b/app/src/main/kotlin/dev/aaa1115910/bv/screen/user/UserSwitchScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -23,9 +22,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ExitToApp import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog +import androidx.compose.material3.BadgedBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -48,8 +50,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewModelScope +import androidx.tv.foundation.lazy.list.TvLazyColumn import androidx.tv.foundation.lazy.list.TvLazyRow import androidx.tv.foundation.lazy.list.items import androidx.tv.material3.Button @@ -60,14 +66,18 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.OutlinedButton import androidx.tv.material3.Surface +import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.Text import coil.compose.AsyncImage import dev.aaa1115910.bv.BVApp import dev.aaa1115910.bv.R import dev.aaa1115910.bv.activities.user.LoginActivity +import dev.aaa1115910.bv.activities.user.UserLockSettingsActivity +import dev.aaa1115910.bv.component.ifElse import dev.aaa1115910.bv.dao.AppDatabase import dev.aaa1115910.bv.entity.db.UserDB import dev.aaa1115910.bv.repository.UserRepository +import dev.aaa1115910.bv.screen.user.lock.UnlockSwitchUserContent import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.requestFocus import io.github.g0dkar.qrcode.QRCode @@ -75,41 +85,99 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.androidx.compose.koinViewModel +import org.koin.compose.getKoin import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream @Composable fun UserSwitchScreen( modifier: Modifier = Modifier, - userSwitchViewModel: UserSwitchViewModel = koinViewModel() + userSwitchViewModel: UserSwitchViewModel = koinViewModel(), + userRepository: UserRepository = getKoin().get() ) { val context = LocalContext.current val scope = rememberCoroutineScope() + val lifecycleOwner = LocalLifecycleOwner.current + val userList = userSwitchViewModel.userDbList + var showUnlock by remember { mutableStateOf(false) } + var unlockUser: UserDB? by remember { mutableStateOf(null) } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + scope.launch { + //userSwitchViewModel.updateUserDbList() + userSwitchViewModel.updateData() + } + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + val unlockFocusRequester = remember { FocusRequester() } + + LaunchedEffect(showUnlock) { + if (showUnlock) unlockFocusRequester.requestFocus() + } + Surface( modifier = modifier, shape = RoundedCornerShape(0.dp) ) { - UserSwitchContent( - userList = userList, - loadingUserList = userSwitchViewModel.loading, - onAddUser = { - context.startActivity(Intent(context, LoginActivity::class.java)) - }, - onDeleteUser = { user -> - scope.launch(Dispatchers.IO) { - userSwitchViewModel.deleteUser(user) - if (userList.isEmpty()) (context as Activity).finish() - } - }, - onSwitchUser = { user -> - scope.launch(Dispatchers.IO) { - userSwitchViewModel.switchUser(user) - (context as Activity).finish() + Box { + UserSwitchContent( + userList = userList, + currentUid = userRepository.uid, + loadingUserList = userSwitchViewModel.loading, + onAddUser = { + context.startActivity(Intent(context, LoginActivity::class.java)) + }, + onDeleteUser = { user -> + scope.launch(Dispatchers.IO) { + userSwitchViewModel.deleteUser(user) + if (userList.isEmpty()) (context as Activity).finish() + } + }, + onSwitchUser = { user -> + if (user.uid != userRepository.uid && user.lock.isNotBlank()) { + unlockUser = user + showUnlock = true + } else { + scope.launch(Dispatchers.IO) { + userSwitchViewModel.switchUser(user) + (context as Activity).finish() + } + } + }, + onShowUserLockSettings = { uid -> + UserLockSettingsActivity.actionStart(context, uid) } + ) + + if (showUnlock) { + UnlockSwitchUserContent( + modifier = Modifier.focusRequester(unlockFocusRequester), + userList = userList, + unlockUser = unlockUser!!, + onUnlockSuccess = { user -> + scope.launch(Dispatchers.IO) { + userSwitchViewModel.switchUser(user) + (context as Activity).finish() + } + }, + onCancel = { + showUnlock = false + } + ) } - ) + } } } @@ -117,13 +185,15 @@ fun UserSwitchScreen( private fun UserSwitchContent( modifier: Modifier = Modifier, userList: List = emptyList(), + currentUid: Long, loadingUserList: Boolean, onSwitchUser: (UserDB) -> Unit, onDeleteUser: (UserDB) -> Unit, - onAddUser: () -> Unit + onAddUser: () -> Unit, + onShowUserLockSettings: (Long) -> Unit ) { val focusRequester = remember { FocusRequester() } - var currentUser by remember { + var choosedUser by remember { mutableStateOf( UserDB( uid = -1, @@ -176,9 +246,10 @@ private fun UserSwitchContent( UserItem( avatar = user.avatar, username = user.username, + lockEnabled = user.lock.isNotBlank(), onClick = { if (isInManagerMode) { - currentUser = user + choosedUser = user showUserMenuDialog = true } else { onSwitchUser(user) @@ -228,23 +299,29 @@ private fun UserSwitchContent( UserMenuDialog( show = showUserMenuDialog, onHideDialog = { showUserMenuDialog = false }, - username = currentUser.username, + username = choosedUser.username, + uid = choosedUser.uid, + showTokenButton = choosedUser.uid == currentUid || choosedUser.lock.isBlank(), onShowUserAuthData = { showAuthDataDialog = true }, - onDeleteUser = { showDeleteConfirmDialog = true } + onDeleteUser = { showDeleteConfirmDialog = true }, + onShowUserLockSettings = { uid -> + isInManagerMode = false + onShowUserLockSettings(uid) + } ) UserAuthDataDialog( show = showAuthDataDialog, onHideDialog = { showAuthDataDialog = false }, - userDB = currentUser + userDB = choosedUser ) DeleteConfirmDialog( show = showDeleteConfirmDialog, onHideDialog = { showDeleteConfirmDialog = false }, - userDB = currentUser, + userDB = choosedUser, onConfirm = { - onDeleteUser(currentUser) + onDeleteUser(choosedUser) showDeleteConfirmDialog = false } ) @@ -256,8 +333,11 @@ fun UserMenuDialog( show: Boolean, onHideDialog: () -> Unit, username: String, + uid: Long, + showTokenButton: Boolean, onShowUserAuthData: () -> Unit, - onDeleteUser: () -> Unit + onDeleteUser: () -> Unit, + onShowUserLockSettings: (Long) -> Unit ) { val menuFocusRequester = remember { FocusRequester() } @@ -273,57 +353,49 @@ fun UserMenuDialog( onDismissRequest = onHideDialog, title = { Text(text = username) }, text = { - Column( + TvLazyColumn( modifier = Modifier.width(240.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 12.dp) ) { - Button( - modifier = Modifier - .focusRequester(menuFocusRequester) - .fillMaxWidth() - .height(64.dp) - .padding(horizontal = 12.dp), - shape = ButtonDefaults.shape( - shape = MaterialTheme.shapes.medium - ), - onClick = { - onHideDialog() - onShowUserAuthData() - } - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text(text = stringResource(R.string.user_switch_menu_show_token)) - } - } - Button( - modifier = Modifier - .fillMaxWidth() - .height(64.dp) - .padding(horizontal = 12.dp), - shape = ButtonDefaults.shape( - shape = MaterialTheme.shapes.medium - ), - colors = ButtonDefaults.colors( - containerColor = MaterialTheme.colorScheme.errorContainer - ), - onClick = { - onHideDialog() - onDeleteUser() - } - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(R.string.user_switch_menu_delete_account), - style = MaterialTheme.typography.bodyLarge + if (showTokenButton) { + item { + UserMenuButton( + modifier = Modifier.focusRequester(menuFocusRequester), + text = stringResource(R.string.user_switch_menu_show_token), + onClick = { + onHideDialog() + onShowUserAuthData() + } ) } } + + item { + UserMenuButton( + modifier = Modifier + .ifElse( + !showTokenButton, + Modifier.focusRequester(menuFocusRequester) + ), + text = stringResource(R.string.user_switch_menu_user_lock), + onClick = { + onHideDialog() + onShowUserLockSettings(uid) + } + ) + } + + item { + UserMenuButton( + text = stringResource(R.string.user_switch_menu_delete_account), + onClick = { + onHideDialog() + onDeleteUser() + }, + color = MaterialTheme.colorScheme.errorContainer + ) + } } }, dismissButton = {}, @@ -436,44 +508,74 @@ private fun DeleteConfirmDialog( } @Composable -private fun UserItem( +fun UserItem( modifier: Modifier = Modifier, avatar: String, username: String, - onClick: () -> Unit + lockEnabled: Boolean = false, + onClick: (() -> Unit)? = null ) { Column( modifier = modifier.width(120.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Surface( - modifier = Modifier - .size(80.dp), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.DarkGray, - focusedContainerColor = Color.Gray - ), - shape = ClickableSurfaceDefaults.shape( + if (onClick != null) { + BadgedBox( + modifier = Modifier.padding(18.dp), + badge = { + if (lockEnabled) { + Icon(imageVector = Icons.Default.Lock, contentDescription = null) + } + } + ) { + Surface( + modifier = Modifier + .size(80.dp), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.DarkGray, + focusedContainerColor = Color.Gray + ), + shape = ClickableSurfaceDefaults.shape( + shape = CircleShape + ), + glow = ClickableSurfaceDefaults.glow( + focusedGlow = Glow( + elevationColor = MaterialTheme.colorScheme.inverseSurface, + elevation = 16.dp + ) + ), + onClick = onClick + ) { + AsyncImage( + modifier = Modifier + .size(80.dp) + .clip(CircleShape), + model = avatar, + contentDescription = null, + contentScale = ContentScale.FillBounds + ) + } + } + } else { + Surface( + modifier = Modifier + .padding(18.dp) + .size(80.dp), + colors = SurfaceDefaults.colors( + containerColor = Color.DarkGray + ), shape = CircleShape - ), - glow = ClickableSurfaceDefaults.glow( - focusedGlow = Glow( - elevationColor = MaterialTheme.colorScheme.inverseSurface, - elevation = 16.dp + ) { + AsyncImage( + modifier = Modifier + .size(80.dp) + .clip(CircleShape), + model = avatar, + contentDescription = null, + contentScale = ContentScale.FillBounds ) - ), - onClick = onClick - ) { - AsyncImage( - modifier = Modifier - .size(80.dp) - .clip(CircleShape), - model = avatar, - contentDescription = null, - contentScale = ContentScale.FillBounds - ) + } } - Spacer(modifier = Modifier.height(18.dp)) Box( modifier = Modifier.height(26.dp), contentAlignment = Alignment.Center @@ -502,6 +604,7 @@ private fun AddUserItem( ) { Surface( modifier = Modifier + .padding(18.dp) .size(80.dp), colors = ClickableSurfaceDefaults.colors( containerColor = Color.DarkGray, @@ -529,7 +632,6 @@ private fun AddUserItem( ) } } - Spacer(modifier = Modifier.height(18.dp)) Box( modifier = Modifier.height(26.dp), contentAlignment = Alignment.Center @@ -554,7 +656,8 @@ fun UserItemPreview() { UserItem( avatar = "", username = "This is a user name", - onClick = {} + onClick = {}, + lockEnabled = true ) } } @@ -585,7 +688,8 @@ fun UserSwitchContentPreview() { uid = 1, username = "This is a long username", avatar = "0https://i0.hdslb.com/bfs/article/b6b843d84b84a3ba5526b09ebf538cd4b4c8c3f3.jpg", - auth = "{xxx2}" + auth = "{xxx2}", + lock = "rdrd" ), UserDB( uid = 2, @@ -594,10 +698,12 @@ fun UserSwitchContentPreview() { auth = "{xxx3}" ) ), + currentUid = 0L, loadingUserList = false, onSwitchUser = {}, onDeleteUser = {}, - onAddUser = {} + onAddUser = {}, + onShowUserLockSettings = {} ) } } @@ -610,8 +716,11 @@ fun UserMenuDialogPreview() { show = true, onHideDialog = {}, username = "This is a user name", + uid = 0, + showTokenButton = true, onShowUserAuthData = {}, - onDeleteUser = {} + onDeleteUser = {}, + onShowUserLockSettings = {} ) } } @@ -640,14 +749,14 @@ class UserSwitchViewModel( var loading by mutableStateOf(true) val userDbList = mutableStateListOf() - init { + fun updateData() { viewModelScope.launch(Dispatchers.IO) { updateUserDbList() withContext(Dispatchers.Main) { loading = false } } } - suspend fun updateUserDbList() { + private suspend fun updateUserDbList() { withContext(Dispatchers.Main) { userDbList.clear() userDbList.addAll(db.userDao().getAll()) @@ -667,4 +776,31 @@ class UserSwitchViewModel( userRepository.logout() } } +} + +@Composable +private fun UserMenuButton( + modifier: Modifier = Modifier, + text: String, + onClick: () -> Unit, + color: Color? = null +) { + Button( + modifier = modifier + .fillMaxWidth() + .height(48.dp), + shape = ButtonDefaults.shape(shape = MaterialTheme.shapes.medium), + colors = if (color != null) ButtonDefaults.colors(containerColor = color) else ButtonDefaults.colors(), + onClick = onClick + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + ) + } + } } \ No newline at end of file diff --git a/app/src/main/kotlin/dev/aaa1115910/bv/screen/user/lock/UnlockSwitchUserContent.kt b/app/src/main/kotlin/dev/aaa1115910/bv/screen/user/lock/UnlockSwitchUserContent.kt new file mode 100644 index 00000000..0de0c3f8 --- /dev/null +++ b/app/src/main/kotlin/dev/aaa1115910/bv/screen/user/lock/UnlockSwitchUserContent.kt @@ -0,0 +1,161 @@ +package dev.aaa1115910.bv.screen.user.lock + +import android.view.KeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.foundation.lazy.list.TvLazyRow +import androidx.tv.foundation.lazy.list.items +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import dev.aaa1115910.bv.R +import dev.aaa1115910.bv.component.ifElse +import dev.aaa1115910.bv.entity.db.UserDB +import dev.aaa1115910.bv.screen.user.UserItem +import dev.aaa1115910.bv.util.toast +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun UnlockSwitchUserContent( + modifier: Modifier = Modifier, + userList: List, + unlockUser: UserDB?, + onUnlockSuccess: (UserDB) -> Unit, + onCancel: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val defaultFocusRequester = remember { FocusRequester() } + var inputPassword by remember { mutableStateOf("") } + val inputShow by remember { + derivedStateOf { + inputPassword + .replace("u", "*") + .replace("d", "*") + .replace("l", "*") + .replace("r", "*") + } + } + val unselectedUserAlpha by remember { mutableFloatStateOf(0.4f) } + + LaunchedEffect(Unit) { + scope.launch { + delay(200) + println("request default focus") + defaultFocusRequester.requestFocus() + } + } + + BackHandler(true) { + } + + Surface( + modifier = modifier + .clickable {} + .focusRequester(defaultFocusRequester) + .onPreviewKeyEvent { + if (it.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) return@onPreviewKeyEvent true + when (it.key) { + Key.DirectionUp -> inputPassword += "u" + Key.DirectionDown -> inputPassword += "d" + Key.DirectionLeft -> inputPassword += "l" + Key.DirectionRight -> inputPassword += "r" + Key.DirectionCenter -> { + if (unlockUser?.lock == inputPassword) { + onUnlockSuccess(unlockUser) + } else { + R.string.user_lock_toast_password_error.toast(context) + inputPassword = "" + } + } + + Key.Back -> { + if (inputPassword.isNotBlank()) { + inputPassword = inputPassword.drop(1) + } else { + onCancel() + } + } + } + return@onPreviewKeyEvent true + }, + shape = RoundedCornerShape(0.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 64.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.user_lock_title_input_password), + style = MaterialTheme.typography.displaySmall + ) + } + + TvLazyRow( + horizontalArrangement = Arrangement.spacedBy(24.dp), + contentPadding = PaddingValues(horizontal = 12.dp) + ) { + items(items = userList) { user -> + UserItem( + modifier = Modifier + .ifElse({ user != unlockUser }, Modifier.alpha(unselectedUserAlpha)), + avatar = user.avatar, + username = user.username, + lockEnabled = user.lock.isNotBlank(), + ) + } + } + + Text( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 96.dp), + text = inputShow, + style = MaterialTheme.typography.displayLarge + ) + + Text( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 48.dp), + text = stringResource(R.string.user_lock_input_tip), + color = MaterialTheme.colorScheme.onSurface.copy(0.6f) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/aaa1115910/bv/screen/user/lock/UnlockUserScreen.kt b/app/src/main/kotlin/dev/aaa1115910/bv/screen/user/lock/UnlockUserScreen.kt new file mode 100644 index 00000000..fa1f2500 --- /dev/null +++ b/app/src/main/kotlin/dev/aaa1115910/bv/screen/user/lock/UnlockUserScreen.kt @@ -0,0 +1,236 @@ +package dev.aaa1115910.bv.screen.user.lock + +import android.view.KeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.foundation.lazy.list.TvLazyRow +import androidx.tv.foundation.lazy.list.items +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import dev.aaa1115910.bv.R +import dev.aaa1115910.bv.component.ifElse +import dev.aaa1115910.bv.entity.db.UserDB +import dev.aaa1115910.bv.screen.user.UserItem +import dev.aaa1115910.bv.screen.user.UserSwitchViewModel +import dev.aaa1115910.bv.util.toast +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.androidx.compose.koinViewModel + +@Composable +fun UnlockUserScreen( + modifier: Modifier = Modifier, + userSwitchViewModel: UserSwitchViewModel = koinViewModel(), + onUnlockSuccess: (UserDB) -> Unit +) { + val scope = rememberCoroutineScope() + val userList = userSwitchViewModel.userDbList + var selectedUser: UserDB? by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + userSwitchViewModel.updateData() + } + + UnlockUserContent( + modifier = modifier, + userList = userList, + selectedUser = selectedUser, + onSelectedUserChange = { user -> + selectedUser = user + }, + onUnlockSuccess = { user -> + scope.launch { + userSwitchViewModel.switchUser(user) + onUnlockSuccess(user) + } + } + ) +} + +@Composable +private fun UnlockUserContent( + modifier: Modifier = Modifier, + userList: List, + selectedUser: UserDB?, + onSelectedUserChange: (UserDB) -> Unit, + onUnlockSuccess: (UserDB) -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val logger = KotlinLogging.logger("UnlockUserContent") + + val inputFocusRequester = remember { FocusRequester() } + val defaultFocusRequester = remember { FocusRequester() } + var inputPassword by remember { mutableStateOf("") } + val inputShow by remember { + derivedStateOf { + inputPassword + .replace("u", "*") + .replace("d", "*") + .replace("l", "*") + .replace("r", "*") + } + } + var unlockState by remember { mutableStateOf(UnlockState.ChooseUser) } + val unChosenUserAlpha by animateFloatAsState( + targetValue = when (unlockState) { + UnlockState.ChooseUser -> 1f + UnlockState.InputPassword -> 0.4f + }, + label = "unchosen user alpha" + ) + + LaunchedEffect(userList) { + scope.launch { + delay(200) + defaultFocusRequester.requestFocus() + } + } + + LaunchedEffect(unlockState) { + scope.launch { + delay(100) + inputFocusRequester.requestFocus() + } + } + + BackHandler(true) { + } + + Surface( + modifier = modifier + .ifElse({ unlockState == UnlockState.InputPassword }, Modifier.clickable {}) + .focusRequester(inputFocusRequester) + .onPreviewKeyEvent { + when (unlockState) { + UnlockState.ChooseUser -> return@onPreviewKeyEvent false + UnlockState.InputPassword -> { + if (it.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) return@onPreviewKeyEvent true + when (it.key) { + Key.DirectionUp -> inputPassword += "u" + Key.DirectionDown -> inputPassword += "d" + Key.DirectionLeft -> inputPassword += "l" + Key.DirectionRight -> inputPassword += "r" + Key.DirectionCenter -> { + if (selectedUser?.lock == inputPassword) { + onUnlockSuccess(selectedUser) + } else { + "密码错误".toast(context) + inputPassword = "" + } + } + + Key.Back -> { + if (inputPassword.isNotBlank()) { + inputPassword = inputPassword.drop(1) + } else { + unlockState = UnlockState.ChooseUser + defaultFocusRequester.requestFocus() + } + } + } + return@onPreviewKeyEvent true + } + } + }, + shape = RoundedCornerShape(0.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 64.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = when (unlockState) { + UnlockState.ChooseUser -> stringResource(R.string.user_lock_title_choose_user) + UnlockState.InputPassword -> stringResource(R.string.user_lock_title_input_password) + }, + style = MaterialTheme.typography.displaySmall + ) + } + + TvLazyRow( + modifier = Modifier.focusRequester(defaultFocusRequester), + horizontalArrangement = Arrangement.spacedBy(24.dp), + contentPadding = PaddingValues(horizontal = 12.dp) + ) { + items(items = userList) { user -> + UserItem( + modifier = Modifier + .ifElse({ user != selectedUser }, Modifier.alpha(unChosenUserAlpha)), + avatar = user.avatar, + username = user.username, + lockEnabled = user.lock.isNotBlank(), + onClick = { + logger.info { "Choose user ${user.uid}" } + if (user.lock.isNotBlank()) { + onSelectedUserChange(user) + unlockState = UnlockState.InputPassword + } else { + onSelectedUserChange(user) + onUnlockSuccess(user) + } + }.takeIf { unlockState == UnlockState.ChooseUser } + ) + } + } + + Text( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 96.dp), + text = inputShow, + style = MaterialTheme.typography.displayLarge + ) + + if (unlockState == UnlockState.InputPassword) { + Text( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 48.dp), + text = stringResource(R.string.user_lock_input_tip), + color = MaterialTheme.colorScheme.onSurface.copy(0.6f) + ) + } + } + } +} + +private enum class UnlockState { + ChooseUser, + InputPassword +} \ No newline at end of file diff --git a/app/src/main/kotlin/dev/aaa1115910/bv/screen/user/lock/UserLockSettingsScreen.kt b/app/src/main/kotlin/dev/aaa1115910/bv/screen/user/lock/UserLockSettingsScreen.kt new file mode 100644 index 00000000..6192617f --- /dev/null +++ b/app/src/main/kotlin/dev/aaa1115910/bv/screen/user/lock/UserLockSettingsScreen.kt @@ -0,0 +1,247 @@ +package dev.aaa1115910.bv.screen.user.lock + +import android.app.Activity +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.foundation.lazy.list.TvLazyRow +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import dev.aaa1115910.bv.R +import dev.aaa1115910.bv.entity.db.UserDB +import dev.aaa1115910.bv.repository.UserRepository +import dev.aaa1115910.bv.screen.user.UserItem +import dev.aaa1115910.bv.util.toast +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.launch +import org.koin.compose.getKoin + +@Composable +fun UserLockSettingsScreen( + modifier: Modifier = Modifier, + userRepository: UserRepository = getKoin().get() +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val logger = KotlinLogging.logger("UserLockSettingsScreen") + + var user by remember { + mutableStateOf( + UserDB( + uid = -1, + username = "None", + avatar = "", + auth = "" + ) + ) + } + + LaunchedEffect(Unit) { + val intent = (context as Activity).intent + if (intent.hasExtra("uid")) { + val uid = intent.getLongExtra("uid", 0) + userRepository.findUserByUid(uid) + ?.let { user = it } + ?: let { context.finish() } + logger.debug { "user $uid lock: ${user.lock}" } + } else { + context.finish() + } + } + + UserLockSettingsContent( + modifier = modifier, + user = user, + onUpdateUser = { + scope.launch { + userRepository.updateUser(it) + (context as Activity).finish() + } + }, + onExit = { + (context as Activity).finish() + } + ) +} + +@Composable +private fun UserLockSettingsContent( + modifier: Modifier = Modifier, + user: UserDB, + onUpdateUser: (UserDB) -> Unit, + onExit: () -> Unit +) { + val context = LocalContext.current + + val focusRequester = remember { FocusRequester() } + var inputState by remember { mutableStateOf(InputState.InputOldPassword) } + var inputPassword by remember { mutableStateOf("") } + var lastInput by remember { mutableStateOf("") } + val inputShow by remember { + derivedStateOf { + inputPassword + .replace("u", "↑") + .replace("d", "↓") + .replace("l", "←") + .replace("r", "→") + } + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + + } + LaunchedEffect(user) { + inputState = if (user.lock.isNotBlank()) InputState.InputOldPassword + else InputState.InputNewPassword + } + + BackHandler(inputPassword.isNotEmpty()) { + } + + Surface( + modifier = modifier + .clickable {} + .focusRequester(focusRequester) + .onPreviewKeyEvent { keyEvent -> + if (keyEvent.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) { + return@onPreviewKeyEvent true + } + + when (keyEvent.key) { + Key.DirectionUp -> inputPassword += "u" + Key.DirectionDown -> inputPassword += "d" + Key.DirectionLeft -> inputPassword += "l" + Key.DirectionRight -> inputPassword += "r" + + Key.DirectionCenter -> { + when (inputState) { + InputState.InputOldPassword -> { + if (inputPassword == user.lock) { + inputState = InputState.InputNewPassword + inputPassword = "" + } else { + R.string.user_lock_toast_password_error.toast(context) + inputPassword = "" + } + } + + InputState.InputNewPassword -> { + if (inputPassword.isBlank()) { + R.string.user_lock_toast_password_removed.toast(context) + user.lock = "" + onUpdateUser(user) + } else { + lastInput = inputPassword + inputPassword = "" + inputState = InputState.ConfirmNewPassword + } + } + + InputState.ConfirmNewPassword -> { + if (inputPassword == lastInput) { + user.lock = inputPassword + onUpdateUser(user) + } else { + R.string.user_lock_toast_password_different.toast(context) + inputPassword = "" + inputState = InputState.InputNewPassword + } + } + } + } + + Key.Back -> { + if (inputPassword.isNotEmpty()) { + inputPassword = inputPassword.dropLast(1) + } else { + onExit() + } + } + } + true + }, + shape = RoundedCornerShape(0.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 64.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = when (inputState) { + InputState.InputOldPassword -> stringResource(R.string.user_lock_title_input_old_password) + InputState.InputNewPassword -> stringResource(R.string.user_lock_title_input_new_password) + InputState.ConfirmNewPassword -> stringResource(R.string.user_lock_title_input_new_password_again) + }, + style = MaterialTheme.typography.displaySmall + ) + } + + TvLazyRow( + modifier = Modifier.focusRequester(focusRequester), + horizontalArrangement = Arrangement.spacedBy(24.dp), + contentPadding = PaddingValues(horizontal = 12.dp) + ) { + item { + UserItem( + avatar = user.avatar, + username = user.username + ) + } + } + + Text( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 96.dp), + text = inputShow, + style = MaterialTheme.typography.displayLarge + ) + + Text( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 48.dp), + text = stringResource(R.string.user_lock_input_tip), + color = MaterialTheme.colorScheme.onSurface.copy(0.6f) + ) + } + } +} + +private enum class InputState { + InputOldPassword, + InputNewPassword, + ConfirmNewPassword +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index beb1e200..0dde2afc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -266,6 +266,7 @@ 视频标签 UP 投稿 用户信息 + 用户锁设置 用户切换 视频信息 视频播放 @@ -288,11 +289,21 @@ 隐身模式 Lv%1$s UID: %1$s + 方向键输入密码 / Center 键确认输入 / 返回键退格 + 选择账户 + 請輸入新密碼 + 請再次輸入新密碼 + 請輸入舊密碼 + 输入密码 + 密码不一致 + 密码错误 + 密码已移除 添加账号 退出管理 管理账号 移除账号 显示 Token + 用户锁设置 选择账号 默认