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

@@ -5,9 +5,6 @@ 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.clickable
import androidx.compose.foundation.layout.Arrangement
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.fillMaxWidth
import androidx.compose.foundation.layout.height
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.foundation.shape.CircleShape
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.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.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.QrCodeScanner
import androidx.compose.material.icons.filled.Refresh
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.Description
import androidx.compose.material.icons.outlined.FileUpload
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
@@ -56,7 +43,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -66,10 +52,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
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
@@ -77,11 +63,9 @@ import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.NewProfileComposeActivity
import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler
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.ProfileManager
import io.nekohasekai.sfa.database.TypedProfile
import io.nekohasekai.sfa.ktx.errorDialogBuilder
import io.nekohasekai.sfa.ktx.shareProfile
@@ -89,16 +73,15 @@ import io.nekohasekai.sfa.ui.profile.QRScanActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfilesCard(
profiles: List<Profile>,
selectedProfileId: Long,
isLoading: Boolean,
showAddProfileSheet: Boolean,
showProfilePickerSheet: Boolean,
updatingProfileId: Long? = null,
updatedProfileId: Long? = null,
onProfileSelected: (Long) -> Unit,
@@ -110,6 +93,8 @@ fun ProfilesCard(
onProfileMove: (Int, Int) -> Unit,
onShowAddProfileSheet: () -> Unit,
onHideAddProfileSheet: () -> Unit,
onShowProfilePickerSheet: () -> Unit,
onHideProfilePickerSheet: () -> Unit,
onImportFromFile: () -> Unit,
onScanQrCode: () -> Unit,
onCreateManually: () -> Unit,
@@ -119,14 +104,11 @@ fun ProfilesCard(
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
// Import handler
val importHandler = remember { ProfileImportHandler(context) }
// QR code dialog state
var showQRCodeDialog by remember { mutableStateOf(false) }
var qrCodeProfile by remember { mutableStateOf<Profile?>(null) }
// Activity result launchers
val newProfileLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
@@ -134,11 +116,10 @@ fun ProfilesCard(
if (result.resultCode == android.app.Activity.RESULT_OK) {
val profileId = result.data?.getLongExtra(NewProfileComposeActivity.EXTRA_PROFILE_ID, -1L)
if (profileId != null && profileId != -1L) {
// Find the profile and open edit screen
coroutineScope.launch {
val profile =
withContext(Dispatchers.IO) {
ProfileManager.get(profileId)
io.nekohasekai.sfa.database.ProfileManager.get(profileId)
}
profile?.let {
withContext(Dispatchers.Main) {
@@ -158,7 +139,6 @@ fun ProfilesCard(
coroutineScope.launch {
when (val result = importHandler.importFromUri(uri)) {
is ProfileImportHandler.ImportResult.Success -> {
// Profile imported successfully, open edit screen
withContext(Dispatchers.Main) {
onProfileEdit(result.profile)
}
@@ -220,21 +200,52 @@ fun ProfilesCard(
}
}
// Handle import events
LaunchedEffect(onImportFromFile, onScanQrCode) {
// These are just to trigger the launchers
val saveFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/octet-stream"),
) { 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(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier =
Modifier
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
// Header with title and add button
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -269,7 +280,7 @@ fun ProfilesCard(
}
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(12.dp))
if (profiles.isEmpty()) {
Text(
@@ -279,42 +290,66 @@ fun ProfilesCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
ProfileList(
profiles = profiles,
selectedProfileId = selectedProfileId,
isLoading = isLoading,
updatingProfileId = updatingProfileId,
updatedProfileId = updatedProfileId,
onProfileClick = { profile ->
if (profile.id != selectedProfileId) {
onProfileSelected(profile.id)
}
},
onEditProfile = onProfileEdit,
onDeleteProfile = onProfileDelete,
onShareProfile = { profile ->
ProfileSelectorButton(
selectedProfile = selectedProfile,
onClick = onShowProfilePickerSheet,
)
Spacer(modifier = Modifier.height(12.dp))
ProfileInfoRow(profile = selectedProfile)
Spacer(modifier = Modifier.height(16.dp))
ProfileActionRow(
profile = selectedProfile,
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) {
try {
context.shareProfile(profile)
context.shareProfile(it)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
context.errorDialogBuilder(e).show()
}
}
}
}
},
onShareProfileURL = { profile ->
qrCodeProfile = profile
onSaveFile = {
selectedProfile?.let {
saveFileLauncher.launch("${it.name}.bpf")
}
},
onShareURL = {
selectedProfile?.let {
qrCodeProfile = it
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) {
ModalBottomSheet(
onDismissRequest = onHideAddProfileSheet,
@@ -322,8 +357,7 @@ fun ProfilesCard(
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Column(
modifier =
Modifier
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 32.dp),
) {
@@ -334,10 +368,8 @@ fun ProfilesCard(
)
ListItem(
modifier =
Modifier.clickable {
modifier = Modifier.clickable {
onHideAddProfileSheet()
// Accept any file type to support both JSON and encoded profile files
importFromFileLauncher.launch("*/*")
},
leadingContent = {
@@ -356,8 +388,7 @@ fun ProfilesCard(
)
ListItem(
modifier =
Modifier.clickable {
modifier = Modifier.clickable {
onHideAddProfileSheet()
scanQrCodeLauncher.launch(null)
},
@@ -377,8 +408,7 @@ fun ProfilesCard(
)
ListItem(
modifier =
Modifier.clickable {
modifier = Modifier.clickable {
onHideAddProfileSheet()
val intent = Intent(context, NewProfileComposeActivity::class.java)
newProfileLauncher.launch(intent)
@@ -401,18 +431,15 @@ fun ProfilesCard(
}
}
// QR Code dialog
if (showQRCodeDialog && qrCodeProfile != null) {
val profile = qrCodeProfile!!
val link =
remember(profile) {
val link = remember(profile) {
Libbox.generateRemoteProfileImportLink(
profile.name,
profile.typed.remoteURL,
)
}
val qrBitmap =
remember(link) {
val qrBitmap = remember(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 {
val content = ProfileContent()
content.name = profile.name
@@ -516,362 +486,209 @@ private suspend fun createProfileContent(profile: Profile): ByteArray {
return content.encode()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ProfileItem(
profile: Profile,
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) }
private fun ProfileInfoRow(profile: Profile?) {
if (profile == null) return
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(
modifier =
Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Profile icon - use custom icon if set, otherwise default
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))
// Profile info
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
// Profile name
Text(
text = profile.name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
color =
if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer
Icon(
imageVector = if (profile.typed.type == TypedProfile.Type.Remote) {
Icons.Default.Cloud
} else {
MaterialTheme.colorScheme.onSurfaceVariant
Icons.Outlined.Description
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Second line: Type and last updated
val context = LocalContext.current
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),
)
text = if (profile.typed.type == TypedProfile.Type.Remote) {
stringResource(R.string.profile_type_remote)
} else {
stringResource(R.string.profile_type_local)
},
style = MaterialTheme.typography.labelSmall,
color =
if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Update button for remote profiles
if (profile.typed.type == TypedProfile.Type.Remote) {
IconButton(
onClick = {
if (!isUpdating && !showUpdateSuccess) {
onUpdateProfile(profile)
}
},
modifier = Modifier.size(32.dp),
enabled = !isUpdating && !showUpdateSuccess,
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
when {
isUpdating -> {
Icon(
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(
modifier = Modifier.size(18.dp),
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
}
showUpdateSuccess -> {
} else {
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(R.string.update_successful),
imageVector = icon,
contentDescription = contentDescription,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
else -> {
Icon(
imageVector = Icons.Default.Update,
contentDescription = stringResource(R.string.update_profile),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
tint = if (enabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
},
)
}
}
}
}
}
// More options button
Spacer(modifier = Modifier.width(4.dp))
@Composable
private fun ShareButton(
profile: Profile,
onShareFile: () -> Unit,
onSaveFile: () -> Unit,
onShareURL: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box {
IconButton(
onClick = {
showMenu = true
expandedShareSubmenu = false // Always start with submenu collapsed
},
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
},
ActionButton(
icon = Icons.Default.IosShare,
contentDescription = stringResource(R.string.menu_share),
onClick = { expanded = true },
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = {
showMenu = false
expandedShareSubmenu = false // Reset submenu state when closing
},
modifier = Modifier.widthIn(min = 200.dp),
expanded = expanded,
onDismissRequest = { expanded = false },
) {
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(
text = { Text(stringResource(R.string.save_as_file)) },
onClick = {
showMenu = false
saveFileLauncher.launch("${profile.name}.bpf")
expanded = false
onSaveFile()
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Save,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp),
)
},
)
// Share As File
DropdownMenuItem(
text = { Text(stringResource(R.string.share_as_file)) },
onClick = {
showMenu = false
onShareProfile(profile)
expanded = false
onShareFile()
},
leadingIcon = {
Icon(
imageVector = Icons.Default.IosShare,
contentDescription = null,
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) {
DropdownMenuItem(
text = { Text(stringResource(R.string.profile_share_url)) },
onClick = {
showMenu = false
onShareProfileURL(profile)
expanded = false
onShareURL()
},
leadingIcon = {
Icon(
imageVector = Icons.Default.QrCode2,
contentDescription = null,
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,
)
},
)
}
}
}
}
}

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