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