Refactor: Profile card

This commit is contained in:
世界
2025-12-07 20:42:26 +08:00
parent 19da240d5b
commit e7892096cc
9 changed files with 931 additions and 498 deletions

View File

@@ -20,6 +20,7 @@ fun DashboardCardRenderer(
selectedProfileId: Long = -1L,
isLoading: Boolean = false,
showAddProfileSheet: Boolean = false,
showProfilePickerSheet: Boolean = false,
updatingProfileId: Long? = null,
updatedProfileId: Long? = null,
onProfileSelected: (Long) -> Unit = {},
@@ -31,6 +32,8 @@ fun DashboardCardRenderer(
onProfileMove: (Int, Int) -> Unit = { _, _ -> },
onShowAddProfileSheet: () -> Unit = {},
onHideAddProfileSheet: () -> Unit = {},
onShowProfilePickerSheet: () -> Unit = {},
onHideProfilePickerSheet: () -> Unit = {},
shareQRCodeImage: (Bitmap, String) -> Unit = { _, _ -> },
saveQRCodeToGallery: (Bitmap, String) -> Unit = { _, _ -> },
commandClient: CommandClient? = null,
@@ -107,6 +110,7 @@ fun DashboardCardRenderer(
selectedProfileId = selectedProfileId,
isLoading = isLoading,
showAddProfileSheet = showAddProfileSheet,
showProfilePickerSheet = showProfilePickerSheet,
updatingProfileId = updatingProfileId,
updatedProfileId = updatedProfileId,
onProfileSelected = onProfileSelected,
@@ -118,6 +122,8 @@ fun DashboardCardRenderer(
onProfileMove = onProfileMove,
onShowAddProfileSheet = onShowAddProfileSheet,
onHideAddProfileSheet = onHideAddProfileSheet,
onShowProfilePickerSheet = onShowProfilePickerSheet,
onHideProfilePickerSheet = onHideProfilePickerSheet,
onImportFromFile = { /* Handled in ProfilesCard */ },
onScanQrCode = { /* Handled in ProfilesCard */ },
onCreateManually = { /* Handled in ProfilesCard */ },

View File

@@ -165,6 +165,7 @@ fun DashboardScreen(
selectedProfileId = uiState.selectedProfileId,
isLoading = uiState.isLoading,
showAddProfileSheet = uiState.showAddProfileSheet,
showProfilePickerSheet = uiState.showProfilePickerSheet,
updatingProfileId = uiState.updatingProfileId,
updatedProfileId = uiState.updatedProfileId,
onProfileSelected = viewModel::selectProfile,
@@ -176,6 +177,8 @@ fun DashboardScreen(
onProfileMove = viewModel::moveProfile,
onShowAddProfileSheet = viewModel::showAddProfileSheet,
onHideAddProfileSheet = viewModel::hideAddProfileSheet,
onShowProfilePickerSheet = viewModel::showProfilePickerSheet,
onHideProfilePickerSheet = viewModel::hideProfilePickerSheet,
shareQRCodeImage = { bitmap, name ->
scope.launch {
shareQRCodeImage(context, bitmap, name)
@@ -211,6 +214,7 @@ fun DashboardScreen(
selectedProfileId = uiState.selectedProfileId,
isLoading = uiState.isLoading,
showAddProfileSheet = uiState.showAddProfileSheet,
showProfilePickerSheet = uiState.showProfilePickerSheet,
updatingProfileId = uiState.updatingProfileId,
updatedProfileId = uiState.updatedProfileId,
onProfileSelected = viewModel::selectProfile,
@@ -222,6 +226,8 @@ fun DashboardScreen(
onProfileMove = viewModel::moveProfile,
onShowAddProfileSheet = viewModel::showAddProfileSheet,
onHideAddProfileSheet = viewModel::hideAddProfileSheet,
onShowProfilePickerSheet = viewModel::showProfilePickerSheet,
onHideProfilePickerSheet = viewModel::hideProfilePickerSheet,
shareQRCodeImage = { bitmap, name ->
scope.launch {
shareQRCodeImage(context, bitmap, name)

View File

@@ -53,6 +53,7 @@ data class DashboardUiState(
val deprecatedNotes: List<DeprecatedNote> = emptyList(),
val showDeprecatedDialog: Boolean = false,
val showAddProfileSheet: Boolean = false,
val showProfilePickerSheet: Boolean = false,
val updatingProfileId: Long? = null,
val updatedProfileId: Long? = null,
// Status
@@ -428,6 +429,14 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
updateState { copy(showAddProfileSheet = false) }
}
fun showProfilePickerSheet() {
updateState { copy(showProfilePickerSheet = true) }
}
fun hideProfilePickerSheet() {
updateState { copy(showProfilePickerSheet = false) }
}
fun updateServiceStatus(status: Status) {
viewModelScope.launch {
_serviceStatus.emit(status)

View File

@@ -46,7 +46,7 @@ fun DownloadTrafficCard(
imageVector = Icons.Outlined.Download,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.secondary,
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
@@ -75,7 +75,7 @@ fun DownloadTrafficCard(
LineChart(
data = downlinkHistory,
lineColor = MaterialTheme.colorScheme.secondary,
lineColor = MaterialTheme.colorScheme.primary,
animate = false,
modifier = Modifier.fillMaxWidth(),
)

View File

@@ -0,0 +1,505 @@
package io.nekohasekai.sfa.compose.screen.dashboard
import android.graphics.Bitmap
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.IosShare
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.ProfileContent
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.screen.configuration.QRCodeDialog
import io.nekohasekai.sfa.compose.util.ProfileIcons
import io.nekohasekai.sfa.compose.util.QRCodeGenerator
import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter
import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.database.TypedProfile
import io.nekohasekai.sfa.ktx.shareProfile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun ProfilePickerSheet(
profiles: List<Profile>,
selectedProfileId: Long,
onProfileSelected: (Profile) -> Unit,
onProfileEdit: (Profile) -> Unit,
onProfileDelete: (Profile) -> Unit,
onProfileMove: (Int, Int) -> Unit,
onDismiss: () -> Unit,
shareQRCodeImage: suspend (Bitmap, String) -> Unit,
saveQRCodeToGallery: suspend (Bitmap, String) -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var showQRCodeDialog by remember { mutableStateOf(false) }
var qrCodeProfile by remember { mutableStateOf<Profile?>(null) }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 32.dp),
) {
Text(
text = stringResource(R.string.title_configuration),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(
start = 24.dp,
end = 24.dp,
top = 8.dp,
bottom = 16.dp,
),
)
val lazyListState = rememberLazyListState()
val reorderableLazyListState =
rememberReorderableLazyListState(lazyListState) { from, to ->
onProfileMove(from.index, to.index)
}
LazyColumn(
state = lazyListState,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 100.dp, max = 400.dp)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
itemsIndexed(profiles, key = { _, profile -> profile.id }) { _, profile ->
ReorderableItem(
reorderableLazyListState,
key = profile.id,
) { isDragging ->
ProfilePickerRow(
profile = profile,
isSelected = profile.id == selectedProfileId,
isDragging = isDragging,
onSelect = {
onProfileSelected(profile)
onDismiss()
},
onEdit = { onProfileEdit(profile) },
onShare = {
coroutineScope.launch(Dispatchers.IO) {
try {
context.shareProfile(profile)
} catch (_: Exception) {
}
}
},
onShareURL = {
qrCodeProfile = profile
showQRCodeDialog = true
},
onDelete = { onProfileDelete(profile) },
modifier = Modifier.longPressDraggableHandle(),
)
}
}
}
}
}
if (showQRCodeDialog && qrCodeProfile != null) {
val profile = qrCodeProfile!!
val link = remember(profile) {
Libbox.generateRemoteProfileImportLink(
profile.name,
profile.typed.remoteURL,
)
}
val qrBitmap = remember(link) {
QRCodeGenerator.generate(link)
}
QRCodeDialog(
bitmap = qrBitmap,
onDismiss = {
showQRCodeDialog = false
qrCodeProfile = null
},
onShare = {
coroutineScope.launch {
shareQRCodeImage(qrBitmap, profile.name)
}
showQRCodeDialog = false
qrCodeProfile = null
},
onSave = {
coroutineScope.launch {
saveQRCodeToGallery(qrBitmap, profile.name)
showQRCodeDialog = false
qrCodeProfile = null
}
},
)
}
}
private suspend fun createProfileContent(profile: Profile): ByteArray {
val content = ProfileContent()
content.name = profile.name
when (profile.typed.type) {
TypedProfile.Type.Local -> {
content.type = Libbox.ProfileTypeLocal
}
TypedProfile.Type.Remote -> {
content.type = Libbox.ProfileTypeRemote
}
}
content.config = java.io.File(profile.typed.path).readText()
content.remotePath = profile.typed.remoteURL
content.autoUpdate = profile.typed.autoUpdate
content.autoUpdateInterval = profile.typed.autoUpdateInterval
content.lastUpdated = profile.typed.lastUpdated.time
return content.encode()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ProfilePickerRow(
profile: Profile,
isSelected: Boolean,
isDragging: Boolean,
onSelect: () -> Unit,
onEdit: () -> Unit,
onShare: () -> Unit,
onShareURL: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
var showMenu by remember { mutableStateOf(false) }
var expandedShareSubmenu by remember { mutableStateOf(false) }
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val animatedElevation by animateFloatAsState(
targetValue = when {
isDragging -> 8.dp.value
isSelected -> 2.dp.value
else -> 0.dp.value
},
animationSpec = tween(300),
label = "Elevation",
)
val saveFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/octet-stream"),
) { uri ->
if (uri != null) {
coroutineScope.launch(Dispatchers.IO) {
try {
val profileData = createProfileContent(profile)
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(profileData)
}
withContext(Dispatchers.Main) {
Toast.makeText(
context,
context.getString(R.string.profile_saved_successfully),
Toast.LENGTH_SHORT,
).show()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(
context,
"${context.getString(R.string.profile_save_failed)}: ${e.message}",
Toast.LENGTH_SHORT,
).show()
}
}
}
}
}
Surface(
onClick = onSelect,
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = when {
isDragging -> MaterialTheme.colorScheme.tertiaryContainer
isSelected -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
},
tonalElevation = animatedElevation.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val profileIcon =
ProfileIcons.getIconById(profile.icon)
?: Icons.AutoMirrored.Default.InsertDriveFile
Icon(
imageVector = profileIcon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = profile.name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
color = if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = when (profile.typed.type) {
TypedProfile.Type.Local -> stringResource(R.string.profile_type_local)
TypedProfile.Type.Remote -> stringResource(
R.string.profile_type_remote_updated,
RelativeTimeFormatter.format(context, profile.typed.lastUpdated),
)
},
style = MaterialTheme.typography.labelSmall,
color = if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
},
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Box {
IconButton(
onClick = {
showMenu = true
expandedShareSubmenu = false
},
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.more_options),
modifier = Modifier.size(20.dp),
tint = if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = {
showMenu = false
expandedShareSubmenu = false
},
modifier = Modifier.widthIn(min = 200.dp),
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.edit)) },
onClick = {
showMenu = false
onEdit()
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_share)) },
onClick = {
expandedShareSubmenu = !expandedShareSubmenu
},
leadingIcon = {
Icon(
imageVector = Icons.Default.IosShare,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
trailingIcon = {
Icon(
imageVector = if (expandedShareSubmenu) {
Icons.Default.ExpandLess
} else {
Icons.Default.ExpandMore
},
contentDescription = null,
)
},
)
if (expandedShareSubmenu) {
DropdownMenuItem(
text = { Text(stringResource(R.string.save_as_file)) },
onClick = {
showMenu = false
saveFileLauncher.launch("${profile.name}.bpf")
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Save,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp),
)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.share_as_file)) },
onClick = {
showMenu = false
onShare()
},
leadingIcon = {
Icon(
imageVector = Icons.Default.IosShare,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp),
)
},
)
if (profile.typed.type == TypedProfile.Type.Remote) {
DropdownMenuItem(
text = { Text(stringResource(R.string.profile_share_url)) },
onClick = {
showMenu = false
onShareURL()
},
leadingIcon = {
Icon(
imageVector = Icons.Default.QrCode2,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp),
)
},
)
}
}
DropdownMenuItem(
text = {
Text(
stringResource(R.string.menu_delete),
color = MaterialTheme.colorScheme.error,
)
},
onClick = {
showMenu = false
onDelete()
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
)
}
}
}
}
}
}

View File

@@ -0,0 +1,89 @@
package io.nekohasekai.sfa.compose.screen.dashboard
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.UnfoldMore
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.util.ProfileIcons
import io.nekohasekai.sfa.database.Profile
@Composable
fun ProfileSelectorButton(
selectedProfile: Profile?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
onClick = onClick,
modifier = modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (selectedProfile != null) {
val profileIcon =
ProfileIcons.getIconById(selectedProfile.icon)
?: Icons.AutoMirrored.Default.InsertDriveFile
Icon(
imageVector = profileIcon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = selectedProfile.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
} else {
Box(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.not_selected),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Icon(
imageVector = Icons.Default.UnfoldMore,
contentDescription = stringResource(R.string.expand),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@@ -231,6 +231,7 @@
<string name="update_profile">Update profile</string>
<string name="more_options">More options</string>
<string name="edit">Edit</string>
<string name="done">Done</string>
<string name="save_as_file">Save As File</string>
<string name="share_as_file">Share As File</string>
<string name="service">Service</string>

View File

@@ -5,8 +5,8 @@ buildscript {
}
plugins {
id 'com.android.application' version '8.13.0' apply false
id 'com.android.library' version '8.13.0' apply false
id 'com.android.application' version '8.13.1' apply false
id 'com.android.library' version '8.13.1' apply false
id 'org.jetbrains.kotlin.android' version '2.2.0' apply false
id 'com.google.devtools.ksp' version '2.2.0-2.0.2' apply false
id 'com.github.triplet.play' version '3.12.1' apply false