diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt index 5ab332b..02d3ab2 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt @@ -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 */ }, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt index e1dda41..fcf20ed 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt @@ -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) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt index 9ef36ff..cbe9f7b 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt @@ -53,6 +53,7 @@ data class DashboardUiState( val deprecatedNotes: List = 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(), 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) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DownloadTrafficCard.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DownloadTrafficCard.kt index dca3f21..9cff4cc 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DownloadTrafficCard.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DownloadTrafficCard.kt @@ -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(), ) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt new file mode 100644 index 0000000..cc51cf0 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt @@ -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, + 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(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, + ) + }, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfileSelectorButton.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfileSelectorButton.kt new file mode 100644 index 0000000..d2342bc --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfileSelectorButton.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt index 884b85b..64fa573 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt @@ -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, 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(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 - .fillMaxWidth() - .padding(16.dp), + 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 -> - coroutineScope.launch(Dispatchers.IO) { - try { - context.shareProfile(profile) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - context.errorDialogBuilder(e).show() + 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(it) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + context.errorDialogBuilder(e).show() + } } } } }, - onShareProfileURL = { profile -> - qrCodeProfile = profile - showQRCodeDialog = true + 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,10 +357,9 @@ fun ProfilesCard( contentColor = MaterialTheme.colorScheme.onSurface, ) { Column( - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 32.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp), ) { Text( text = stringResource(R.string.add_profile), @@ -334,12 +368,10 @@ fun ProfilesCard( ) ListItem( - modifier = - Modifier.clickable { - onHideAddProfileSheet() - // Accept any file type to support both JSON and encoded profile files - importFromFileLauncher.launch("*/*") - }, + modifier = Modifier.clickable { + onHideAddProfileSheet() + importFromFileLauncher.launch("*/*") + }, leadingContent = { Icon( imageVector = Icons.Outlined.FileUpload, @@ -356,11 +388,10 @@ fun ProfilesCard( ) ListItem( - modifier = - Modifier.clickable { - onHideAddProfileSheet() - scanQrCodeLauncher.launch(null) - }, + modifier = Modifier.clickable { + onHideAddProfileSheet() + scanQrCodeLauncher.launch(null) + }, leadingContent = { Icon( imageVector = Icons.Default.QrCodeScanner, @@ -377,12 +408,11 @@ fun ProfilesCard( ) ListItem( - modifier = - Modifier.clickable { - onHideAddProfileSheet() - val intent = Intent(context, NewProfileComposeActivity::class.java) - newProfileLauncher.launch(intent) - }, + modifier = Modifier.clickable { + onHideAddProfileSheet() + val intent = Intent(context, NewProfileComposeActivity::class.java) + newProfileLauncher.launch(intent) + }, leadingContent = { Icon( imageVector = Icons.Outlined.CreateNewFolder, @@ -401,20 +431,17 @@ fun ProfilesCard( } } - // QR Code dialog 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) - } + val link = remember(profile) { + Libbox.generateRemoteProfileImportLink( + profile.name, + profile.typed.remoteURL, + ) + } + val qrBitmap = remember(link) { + QRCodeGenerator.generate(link) + } QRCodeDialog( bitmap = qrBitmap, @@ -440,63 +467,6 @@ fun ProfilesCard( } } -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun ProfileList( - profiles: List, - 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( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, ) { Row( - modifier = - Modifier - .fillMaxWidth() - .padding(12.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - // Profile icon - use custom icon if set, otherwise default - val profileIcon = - ProfileIcons.getIconById(profile.icon) - ?: Icons.AutoMirrored.Default.InsertDriveFile - Icon( - imageVector = profileIcon, + imageVector = if (profile.typed.type == TypedProfile.Type.Remote) { + Icons.Default.Cloud + } else { + Icons.Outlined.Description + }, contentDescription = null, - modifier = Modifier.size(24.dp), - tint = - if (isSelected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) + Text( + 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 = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - Spacer(modifier = Modifier.width(12.dp)) - - // Profile info - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), + if (profile.typed.type == TypedProfile.Type.Remote) { + 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 - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + Icon( + imageVector = Icons.Default.AccessTime, + 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 = 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) - }, + 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(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier.size(20.dp), + tint = if (enabled) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + }, + ) + } + } + } +} + +@Composable +private fun ShareButton( + profile: Profile, + onShareFile: () -> Unit, + onSaveFile: () -> Unit, + onShareURL: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + Box { + ActionButton( + icon = Icons.Default.IosShare, + contentDescription = stringResource(R.string.menu_share), + onClick = { expanded = true }, + ) + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.save_as_file)) }, + onClick = { + expanded = false + onSaveFile() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Save, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.share_as_file)) }, + onClick = { + expanded = false + onShareFile() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.IosShare, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + if (profile.typed.type == TypedProfile.Type.Remote) { + DropdownMenuItem( + text = { Text(stringResource(R.string.profile_share_url)) }, + onClick = { + expanded = false + onShareURL() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.QrCode2, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, ) } - - // 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, - ) { - when { - isUpdating -> { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary, - ) - } - - showUpdateSuccess -> { - Icon( - imageVector = Icons.Default.Check, - contentDescription = stringResource(R.string.update_successful), - 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, - ) - } - } - } - } - - // More options button - Spacer(modifier = Modifier.width(4.dp)) - - 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 - }, - ) - } - - DropdownMenu( - expanded = showMenu, - onDismissRequest = { - 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( - 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), - ) - }, - ) - - // Share As File - DropdownMenuItem( - text = { Text(stringResource(R.string.share_as_file)) }, - onClick = { - showMenu = false - onShareProfile(profile) - }, - 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) - }, - 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, - ) - }, - ) - } - } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c8d442d..0d2fe07 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -231,6 +231,7 @@ Update profile More options Edit + Done Save As File Share As File Service diff --git a/build.gradle b/build.gradle index fdb16ae..beb4586 100644 --- a/build.gradle +++ b/build.gradle @@ -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