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

View File

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

View File

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

View File

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

@@ -5,9 +5,6 @@ import android.graphics.Bitmap
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -16,39 +13,29 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape
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.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Edit 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.IosShare
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.QrCode2 import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Update
import androidx.compose.material.icons.outlined.CreateNewFolder import androidx.compose.material.icons.outlined.CreateNewFolder
import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.FileUpload import androidx.compose.material.icons.outlined.FileUpload
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
@@ -56,7 +43,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -66,10 +52,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.ProfileContent import io.nekohasekai.libbox.ProfileContent
@@ -77,11 +63,9 @@ import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.NewProfileComposeActivity import io.nekohasekai.sfa.compose.NewProfileComposeActivity
import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler
import io.nekohasekai.sfa.compose.screen.configuration.QRCodeDialog 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.QRCodeGenerator
import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter
import io.nekohasekai.sfa.database.Profile import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.database.ProfileManager
import io.nekohasekai.sfa.database.TypedProfile import io.nekohasekai.sfa.database.TypedProfile
import io.nekohasekai.sfa.ktx.errorDialogBuilder import io.nekohasekai.sfa.ktx.errorDialogBuilder
import io.nekohasekai.sfa.ktx.shareProfile import io.nekohasekai.sfa.ktx.shareProfile
@@ -89,16 +73,15 @@ import io.nekohasekai.sfa.ui.profile.QRScanActivity
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ProfilesCard( fun ProfilesCard(
profiles: List<Profile>, profiles: List<Profile>,
selectedProfileId: Long, selectedProfileId: Long,
isLoading: Boolean, isLoading: Boolean,
showAddProfileSheet: Boolean, showAddProfileSheet: Boolean,
showProfilePickerSheet: Boolean,
updatingProfileId: Long? = null, updatingProfileId: Long? = null,
updatedProfileId: Long? = null, updatedProfileId: Long? = null,
onProfileSelected: (Long) -> Unit, onProfileSelected: (Long) -> Unit,
@@ -110,6 +93,8 @@ fun ProfilesCard(
onProfileMove: (Int, Int) -> Unit, onProfileMove: (Int, Int) -> Unit,
onShowAddProfileSheet: () -> Unit, onShowAddProfileSheet: () -> Unit,
onHideAddProfileSheet: () -> Unit, onHideAddProfileSheet: () -> Unit,
onShowProfilePickerSheet: () -> Unit,
onHideProfilePickerSheet: () -> Unit,
onImportFromFile: () -> Unit, onImportFromFile: () -> Unit,
onScanQrCode: () -> Unit, onScanQrCode: () -> Unit,
onCreateManually: () -> Unit, onCreateManually: () -> Unit,
@@ -119,14 +104,11 @@ fun ProfilesCard(
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
// Import handler
val importHandler = remember { ProfileImportHandler(context) } val importHandler = remember { ProfileImportHandler(context) }
// QR code dialog state
var showQRCodeDialog by remember { mutableStateOf(false) } var showQRCodeDialog by remember { mutableStateOf(false) }
var qrCodeProfile by remember { mutableStateOf<Profile?>(null) } var qrCodeProfile by remember { mutableStateOf<Profile?>(null) }
// Activity result launchers
val newProfileLauncher = val newProfileLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(), ActivityResultContracts.StartActivityForResult(),
@@ -134,11 +116,10 @@ fun ProfilesCard(
if (result.resultCode == android.app.Activity.RESULT_OK) { if (result.resultCode == android.app.Activity.RESULT_OK) {
val profileId = result.data?.getLongExtra(NewProfileComposeActivity.EXTRA_PROFILE_ID, -1L) val profileId = result.data?.getLongExtra(NewProfileComposeActivity.EXTRA_PROFILE_ID, -1L)
if (profileId != null && profileId != -1L) { if (profileId != null && profileId != -1L) {
// Find the profile and open edit screen
coroutineScope.launch { coroutineScope.launch {
val profile = val profile =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
ProfileManager.get(profileId) io.nekohasekai.sfa.database.ProfileManager.get(profileId)
} }
profile?.let { profile?.let {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -158,7 +139,6 @@ fun ProfilesCard(
coroutineScope.launch { coroutineScope.launch {
when (val result = importHandler.importFromUri(uri)) { when (val result = importHandler.importFromUri(uri)) {
is ProfileImportHandler.ImportResult.Success -> { is ProfileImportHandler.ImportResult.Success -> {
// Profile imported successfully, open edit screen
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
onProfileEdit(result.profile) onProfileEdit(result.profile)
} }
@@ -220,21 +200,52 @@ fun ProfilesCard(
} }
} }
// Handle import events val saveFileLauncher = rememberLauncherForActivityResult(
LaunchedEffect(onImportFromFile, onScanQrCode) { contract = ActivityResultContracts.CreateDocument("application/octet-stream"),
// These are just to trigger the launchers ) { uri ->
if (uri != null) {
val selectedProfile = profiles.find { it.id == selectedProfileId }
if (selectedProfile != null) {
coroutineScope.launch(Dispatchers.IO) {
try {
val profileData = createProfileContent(selectedProfile)
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()
}
}
}
}
}
}
LaunchedEffect(onImportFromFile, onScanQrCode) {
}
val selectedProfile = profiles.find { it.id == selectedProfileId }
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Column( Column(
modifier = modifier = Modifier
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
) { ) {
// Header with title and add button
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -269,7 +280,7 @@ fun ProfilesCard(
} }
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(12.dp))
if (profiles.isEmpty()) { if (profiles.isEmpty()) {
Text( Text(
@@ -279,42 +290,66 @@ fun ProfilesCard(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
ProfileList( ProfileSelectorButton(
profiles = profiles, selectedProfile = selectedProfile,
selectedProfileId = selectedProfileId, onClick = onShowProfilePickerSheet,
isLoading = isLoading, )
updatingProfileId = updatingProfileId,
updatedProfileId = updatedProfileId, Spacer(modifier = Modifier.height(12.dp))
onProfileClick = { profile ->
if (profile.id != selectedProfileId) { ProfileInfoRow(profile = selectedProfile)
onProfileSelected(profile.id)
} Spacer(modifier = Modifier.height(16.dp))
},
onEditProfile = onProfileEdit, ProfileActionRow(
onDeleteProfile = onProfileDelete, profile = selectedProfile,
onShareProfile = { profile -> isUpdating = selectedProfile?.id == updatingProfileId,
showUpdateSuccess = selectedProfile?.id == updatedProfileId,
onEdit = { selectedProfile?.let { onProfileEdit(it) } },
onUpdate = { selectedProfile?.let { onProfileUpdate(it) } },
onShareFile = {
selectedProfile?.let {
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
try { try {
context.shareProfile(profile) context.shareProfile(it)
} catch (e: Exception) { } catch (e: Exception) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
context.errorDialogBuilder(e).show() context.errorDialogBuilder(e).show()
} }
} }
} }
}
}, },
onShareProfileURL = { profile -> onSaveFile = {
qrCodeProfile = profile selectedProfile?.let {
saveFileLauncher.launch("${it.name}.bpf")
}
},
onShareURL = {
selectedProfile?.let {
qrCodeProfile = it
showQRCodeDialog = true showQRCodeDialog = true
}
}, },
onUpdateProfile = onProfileUpdate,
onMove = onProfileMove,
) )
} }
} }
} }
// Add profile bottom sheet if (showProfilePickerSheet) {
ProfilePickerSheet(
profiles = profiles,
selectedProfileId = selectedProfileId,
onProfileSelected = { profile -> onProfileSelected(profile.id) },
onProfileEdit = onProfileEdit,
onProfileDelete = onProfileDelete,
onProfileMove = onProfileMove,
onDismiss = onHideProfilePickerSheet,
shareQRCodeImage = shareQRCodeImage,
saveQRCodeToGallery = saveQRCodeToGallery,
)
}
if (showAddProfileSheet) { if (showAddProfileSheet) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onHideAddProfileSheet, onDismissRequest = onHideAddProfileSheet,
@@ -322,8 +357,7 @@ fun ProfilesCard(
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
) { ) {
Column( Column(
modifier = modifier = Modifier
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(bottom = 32.dp), .padding(bottom = 32.dp),
) { ) {
@@ -334,10 +368,8 @@ fun ProfilesCard(
) )
ListItem( ListItem(
modifier = modifier = Modifier.clickable {
Modifier.clickable {
onHideAddProfileSheet() onHideAddProfileSheet()
// Accept any file type to support both JSON and encoded profile files
importFromFileLauncher.launch("*/*") importFromFileLauncher.launch("*/*")
}, },
leadingContent = { leadingContent = {
@@ -356,8 +388,7 @@ fun ProfilesCard(
) )
ListItem( ListItem(
modifier = modifier = Modifier.clickable {
Modifier.clickable {
onHideAddProfileSheet() onHideAddProfileSheet()
scanQrCodeLauncher.launch(null) scanQrCodeLauncher.launch(null)
}, },
@@ -377,8 +408,7 @@ fun ProfilesCard(
) )
ListItem( ListItem(
modifier = modifier = Modifier.clickable {
Modifier.clickable {
onHideAddProfileSheet() onHideAddProfileSheet()
val intent = Intent(context, NewProfileComposeActivity::class.java) val intent = Intent(context, NewProfileComposeActivity::class.java)
newProfileLauncher.launch(intent) newProfileLauncher.launch(intent)
@@ -401,18 +431,15 @@ fun ProfilesCard(
} }
} }
// QR Code dialog
if (showQRCodeDialog && qrCodeProfile != null) { if (showQRCodeDialog && qrCodeProfile != null) {
val profile = qrCodeProfile!! val profile = qrCodeProfile!!
val link = val link = remember(profile) {
remember(profile) {
Libbox.generateRemoteProfileImportLink( Libbox.generateRemoteProfileImportLink(
profile.name, profile.name,
profile.typed.remoteURL, profile.typed.remoteURL,
) )
} }
val qrBitmap = val qrBitmap = remember(link) {
remember(link) {
QRCodeGenerator.generate(link) QRCodeGenerator.generate(link)
} }
@@ -440,63 +467,6 @@ fun ProfilesCard(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ProfileList(
profiles: List<Profile>,
selectedProfileId: Long,
isLoading: Boolean,
updatingProfileId: Long? = null,
updatedProfileId: Long? = null,
onProfileClick: (Profile) -> Unit,
onEditProfile: (Profile) -> Unit,
onDeleteProfile: (Profile) -> Unit,
onShareProfile: (Profile) -> Unit,
onShareProfileURL: (Profile) -> Unit,
onUpdateProfile: (Profile) -> Unit,
onMove: (Int, Int) -> Unit,
) {
val lazyListState = rememberLazyListState()
val reorderableLazyListState =
rememberReorderableLazyListState(lazyListState) { from, to ->
onMove(from.index, to.index)
}
LazyColumn(
state = lazyListState,
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 60.dp, max = 400.dp),
// Flexible height with min/max constraints
verticalArrangement = Arrangement.spacedBy(4.dp),
userScrollEnabled = profiles.size > 6, // Only enable scroll if more than 6 profiles
) {
itemsIndexed(profiles, key = { _, profile -> profile.id }) { index, profile ->
ReorderableItem(
reorderableLazyListState,
key = profile.id,
) { isDragging ->
ProfileItem(
profile = profile,
isSelected = profile.id == selectedProfileId,
isDragging = isDragging,
isLoading = isLoading,
isUpdating = profile.id == updatingProfileId,
showUpdateSuccess = profile.id == updatedProfileId,
onProfileClick = onProfileClick,
onEditProfile = onEditProfile,
onDeleteProfile = onDeleteProfile,
onShareProfile = onShareProfile,
onShareProfileURL = onShareProfileURL,
onUpdateProfile = onUpdateProfile,
modifier = Modifier.longPressDraggableHandle(),
)
}
}
}
}
private suspend fun createProfileContent(profile: Profile): ByteArray { private suspend fun createProfileContent(profile: Profile): ByteArray {
val content = ProfileContent() val content = ProfileContent()
content.name = profile.name content.name = profile.name
@@ -516,357 +486,205 @@ private suspend fun createProfileContent(profile: Profile): ByteArray {
return content.encode() return content.encode()
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ProfileItem( private fun ProfileInfoRow(profile: Profile?) {
profile: Profile, if (profile == null) return
isSelected: Boolean,
isDragging: Boolean,
isLoading: Boolean,
isUpdating: Boolean = false,
showUpdateSuccess: Boolean = false,
onProfileClick: (Profile) -> Unit,
onEditProfile: (Profile) -> Unit,
onDeleteProfile: (Profile) -> Unit,
onShareProfile: (Profile) -> Unit,
onShareProfileURL: (Profile) -> Unit,
onUpdateProfile: (Profile) -> Unit,
modifier: Modifier = Modifier,
) {
var showMenu by remember { mutableStateOf(false) }
var expandedShareSubmenu by remember { mutableStateOf(false) }
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
// Animated values for visual feedback
val animatedElevation by animateFloatAsState(
targetValue =
when {
isDragging -> 8.dp.value
isSelected -> 3.dp.value
else -> 1.dp.value
},
animationSpec = tween(300),
label = "Elevation",
)
val animatedBorderAlpha by animateFloatAsState(
targetValue = if (isSelected) 0.8f else 0.3f,
animationSpec = tween(300),
label = "BorderAlpha",
)
// File save launcher
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) {
val successMessage = context.getString(R.string.profile_saved_successfully)
Toast.makeText(
context,
successMessage,
Toast.LENGTH_SHORT,
).show()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
val failedMessage = context.getString(R.string.profile_save_failed)
Toast.makeText(
context,
"$failedMessage: ${e.message}",
Toast.LENGTH_SHORT,
).show()
}
}
}
}
}
Surface(
onClick = { if (!isLoading) onProfileClick(profile) },
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color =
when {
isDragging -> MaterialTheme.colorScheme.tertiaryContainer
isSelected -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.surface
},
tonalElevation = animatedElevation.dp,
border =
androidx.compose.foundation.BorderStroke(
width = if (isSelected) 2.dp else 1.dp,
color =
when {
isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = animatedBorderAlpha)
else -> MaterialTheme.colorScheme.outline.copy(alpha = animatedBorderAlpha)
},
),
) {
Row( Row(
modifier = horizontalArrangement = Arrangement.spacedBy(16.dp),
Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// Profile icon - use custom icon if set, otherwise default Row(
val profileIcon = verticalAlignment = Alignment.CenterVertically,
ProfileIcons.getIconById(profile.icon) horizontalArrangement = Arrangement.spacedBy(4.dp),
?: 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))
// Profile info
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
// Profile name Icon(
Text( imageVector = if (profile.typed.type == TypedProfile.Type.Remote) {
text = profile.name, Icons.Default.Cloud
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
color =
if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant Icons.Outlined.Description
}, },
maxLines = 1, contentDescription = null,
overflow = TextOverflow.Ellipsis, modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
// Second line: Type and last updated
val context = LocalContext.current
Text( Text(
text = text = if (profile.typed.type == TypedProfile.Type.Remote) {
when (profile.typed.type) { stringResource(R.string.profile_type_remote)
TypedProfile.Type.Local -> stringResource(R.string.profile_type_local) } else {
TypedProfile.Type.Remote -> stringResource(R.string.profile_type_local)
stringResource(
R.string.profile_type_remote_updated,
RelativeTimeFormatter.format(context, profile.typed.lastUpdated),
)
}, },
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = color = MaterialTheme.colorScheme.onSurfaceVariant,
if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
},
) )
} }
// Update button for remote profiles
if (profile.typed.type == TypedProfile.Type.Remote) { if (profile.typed.type == TypedProfile.Type.Remote) {
IconButton( Row(
onClick = { verticalAlignment = Alignment.CenterVertically,
if (!isUpdating && !showUpdateSuccess) { horizontalArrangement = Arrangement.spacedBy(4.dp),
onUpdateProfile(profile)
}
},
modifier = Modifier.size(32.dp),
enabled = !isUpdating && !showUpdateSuccess,
) { ) {
when { Icon(
isUpdating -> { imageVector = Icons.Default.AccessTime,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = RelativeTimeFormatter.format(context, profile.typed.lastUpdated),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ProfileActionRow(
profile: Profile?,
isUpdating: Boolean,
showUpdateSuccess: Boolean,
onEdit: () -> Unit,
onUpdate: () -> Unit,
onShareFile: () -> Unit,
onSaveFile: () -> Unit,
onShareURL: () -> Unit,
) {
if (profile == null) return
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
ActionButton(
icon = Icons.Default.Edit,
contentDescription = stringResource(R.string.edit),
onClick = onEdit,
)
if (profile.typed.type == TypedProfile.Type.Remote) {
ActionButton(
icon = when {
showUpdateSuccess -> Icons.Default.Check
else -> Icons.Default.Refresh
},
contentDescription = stringResource(R.string.update_profile),
onClick = onUpdate,
enabled = !isUpdating && !showUpdateSuccess,
isLoading = isUpdating,
)
}
ShareButton(
profile = profile,
onShareFile = onShareFile,
onSaveFile = onSaveFile,
onShareURL = onShareURL,
)
}
}
@Composable
private fun ActionButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
enabled: Boolean = true,
isLoading: Boolean = false,
) {
Surface(
onClick = onClick,
enabled = enabled,
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(44.dp),
) {
Box(contentAlignment = Alignment.Center) {
if (isLoading) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(18.dp), modifier = Modifier.size(20.dp),
strokeWidth = 2.dp, strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
} } else {
showUpdateSuccess -> {
Icon( Icon(
imageVector = Icons.Default.Check, imageVector = icon,
contentDescription = stringResource(R.string.update_successful), contentDescription = contentDescription,
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary, tint = if (enabled) {
) MaterialTheme.colorScheme.onSurfaceVariant
} } else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
else -> { },
Icon(
imageVector = Icons.Default.Update,
contentDescription = stringResource(R.string.update_profile),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
) )
} }
} }
} }
} }
// More options button @Composable
Spacer(modifier = Modifier.width(4.dp)) private fun ShareButton(
profile: Profile,
onShareFile: () -> Unit,
onSaveFile: () -> Unit,
onShareURL: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box { Box {
IconButton( ActionButton(
onClick = { icon = Icons.Default.IosShare,
showMenu = true contentDescription = stringResource(R.string.menu_share),
expandedShareSubmenu = false // Always start with submenu collapsed onClick = { expanded = true },
},
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( DropdownMenu(
expanded = showMenu, expanded = expanded,
onDismissRequest = { onDismissRequest = { expanded = false },
showMenu = false
expandedShareSubmenu = false // Reset submenu state when closing
},
modifier = Modifier.widthIn(min = 200.dp),
) { ) {
DropdownMenuItem(
text = { Text(stringResource(R.string.edit)) },
onClick = {
showMenu = false
onEditProfile(profile)
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
)
// Share submenu header
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,
)
},
)
// Share submenu items (shown inline when expanded)
if (expandedShareSubmenu) {
// Save As File
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.save_as_file)) }, text = { Text(stringResource(R.string.save_as_file)) },
onClick = { onClick = {
showMenu = false expanded = false
saveFileLauncher.launch("${profile.name}.bpf") onSaveFile()
}, },
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = Icons.Default.Save, imageVector = Icons.Default.Save,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp),
) )
}, },
) )
// Share As File
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.share_as_file)) }, text = { Text(stringResource(R.string.share_as_file)) },
onClick = { onClick = {
showMenu = false expanded = false
onShareProfile(profile) onShareFile()
}, },
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = Icons.Default.IosShare, imageVector = Icons.Default.IosShare,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp),
) )
}, },
) )
// Share URL as QR Code (only for remote profiles)
if (profile.typed.type == TypedProfile.Type.Remote) { if (profile.typed.type == TypedProfile.Type.Remote) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.profile_share_url)) }, text = { Text(stringResource(R.string.profile_share_url)) },
onClick = { onClick = {
showMenu = false expanded = false
onShareProfileURL(profile) onShareURL()
}, },
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = Icons.Default.QrCode2, imageVector = Icons.Default.QrCode2,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp),
)
},
)
}
}
HorizontalDivider()
DropdownMenuItem(
text = {
Text(
stringResource(R.string.menu_delete),
color = MaterialTheme.colorScheme.error,
)
},
onClick = {
showMenu = false
onDeleteProfile(profile)
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
) )
}, },
) )
@@ -874,4 +692,3 @@ private fun ProfileItem(
} }
} }
} }
}

View File

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

View File

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