diff --git a/app/src/main/java/com/stormpanda/megingiard/macropad/ButtonListItem.kt b/app/src/main/java/com/stormpanda/megingiard/macropad/ButtonListItem.kt index 63184f63b..fbbd00a73 100644 --- a/app/src/main/java/com/stormpanda/megingiard/macropad/ButtonListItem.kt +++ b/app/src/main/java/com/stormpanda/megingiard/macropad/ButtonListItem.kt @@ -14,13 +14,19 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.DragHandle +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -44,11 +50,14 @@ internal fun ButtonListItem( enableTouch: Boolean, isDragging: Boolean, onEdit: () -> Unit, + onDuplicate: () -> Unit, + onCopyToLayout: () -> Unit, onDelete: () -> Unit, dragHandleModifier: Modifier, modifier: Modifier = Modifier, ) { val colors = LocalAppColors.current + var menuExpanded by remember { mutableStateOf(false) } val isTrackpoint = btn.action is PadAction.TrackpointMove val isDeviceDisabled = when (btn.action) { @@ -146,8 +155,32 @@ internal fun ButtonListItem( } } - IconButton(onClick = { onDelete() }) { - Icon(Icons.Rounded.Delete, contentDescription = stringResource(R.string.macropad_editor_delete_button), tint = colors.onSurfaceSecondary) + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Rounded.MoreVert, contentDescription = stringResource(R.string.cd_more_options), tint = colors.onSurfaceSecondary) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + modifier = Modifier.background(colors.surface), + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.settings_macropad_edit), color = colors.onSurface, style = MaterialTheme.typography.bodyMedium) }, + onClick = { menuExpanded = false; onEdit() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.macropad_editor_copy_button_duplicate), color = colors.onSurface, style = MaterialTheme.typography.bodyMedium) }, + onClick = { menuExpanded = false; onDuplicate() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.macropad_editor_copy_to_layout), color = colors.onSurface, style = MaterialTheme.typography.bodyMedium) }, + onClick = { menuExpanded = false; onCopyToLayout() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.macropad_editor_delete_button), color = colors.error, style = MaterialTheme.typography.bodyMedium) }, + onClick = { menuExpanded = false; onDelete() } + ) + } } Icon( imageVector = Icons.Rounded.DragHandle, diff --git a/app/src/main/java/com/stormpanda/megingiard/macropad/CopyDialogs.kt b/app/src/main/java/com/stormpanda/megingiard/macropad/CopyDialogs.kt new file mode 100644 index 000000000..120c5673c --- /dev/null +++ b/app/src/main/java/com/stormpanda/megingiard/macropad/CopyDialogs.kt @@ -0,0 +1,135 @@ +package com.stormpanda.megingiard.macropad + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +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.unit.dp +import com.stormpanda.megingiard.R +import com.stormpanda.megingiard.ui.LocalAppColors + +private const val TAG = "CopyDialogs" + +@Composable +internal fun InlineProfileSelectionOverlay( + title: String, + profiles: List, + excludeProfileId: String?, + onSelect: (String) -> Unit, + onDismiss: () -> Unit, +) { + val colors = LocalAppColors.current + val filteredProfiles = profiles.filter { it.id != excludeProfileId } + + InlineDialogOverlay( + title = title, + onDismiss = onDismiss, + ) { + if (filteredProfiles.isEmpty()) { + Text( + text = stringResource(R.string.macropad_copy_no_profiles_available), + color = colors.onSurfaceSecondary, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 16.dp) + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 240.dp) + ) { + items(filteredProfiles) { profile -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(profile.id) } + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = profile.name, + color = colors.onSurface, + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + } + } +} + +@Composable +internal fun InlineLayoutSelectionOverlay( + title: String, + profiles: List, + excludeLayoutId: String?, + onSelect: (targetProfileId: String, targetLayoutId: String) -> Unit, + onDismiss: () -> Unit, +) { + val colors = LocalAppColors.current + val hasSelectableLayouts = profiles.any { profile -> + profile.layouts.any { it.id != excludeLayoutId } + } + + InlineDialogOverlay( + title = title, + onDismiss = onDismiss, + ) { + if (!hasSelectableLayouts) { + Text( + text = stringResource(R.string.macropad_copy_no_layouts_available), + color = colors.onSurfaceSecondary, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 16.dp) + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 300.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + profiles.forEach { profile -> + val layouts = profile.layouts.filter { it.id != excludeLayoutId } + if (layouts.isNotEmpty()) { + item(key = "header_${profile.id}") { + Text( + text = profile.name, + color = colors.accent, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 8.dp, bottom = 4.dp, start = 8.dp) + ) + } + items(layouts) { layout -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(profile.id, layout.id) } + .padding(vertical = 10.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = layout.name, + color = colors.onSurface, + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/stormpanda/megingiard/macropad/EditorInlineOverlays.kt b/app/src/main/java/com/stormpanda/megingiard/macropad/EditorInlineOverlays.kt index 54a41d514..8cf258b95 100644 --- a/app/src/main/java/com/stormpanda/megingiard/macropad/EditorInlineOverlays.kt +++ b/app/src/main/java/com/stormpanda/megingiard/macropad/EditorInlineOverlays.kt @@ -11,7 +11,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -53,49 +55,103 @@ import com.stormpanda.megingiard.services.MegingiardAccessibilityService import com.stormpanda.megingiard.ui.AppDivider import com.stormpanda.megingiard.ui.AppTextField import com.stormpanda.megingiard.ui.LocalAppColors +import com.stormpanda.megingiard.ui.blockPointerEvents import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext private const val TAG = "EditorInlineOverlays" @Composable -internal fun InlineConfirmDeleteOverlay( - title: String, - body: String, - onConfirm: () -> Unit, +internal fun InlineDialogOverlay( + title: String, onDismiss: () -> Unit, + modifier: Modifier = Modifier, + widthFraction: Float = 0.85f, + titleAccessory: @Composable (() -> Unit)? = null, + buttonsArrangement: Arrangement.Horizontal = Arrangement.End, + buttonsRow: @Composable (RowScope.() -> Unit)? = { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(R.string.macropad_editor_cancel), + color = LocalAppColors.current.onSurfaceSecondary + ) + } + }, + content: @Composable ColumnScope.() -> Unit ) { val colors = LocalAppColors.current Box( modifier = Modifier .fillMaxSize() .background(Color.Black.copy(alpha = 0.5f)) - .clickable(onClick = onDismiss), + .clickable(onClick = onDismiss) + .blockPointerEvents() + .then(modifier), contentAlignment = Alignment.Center, ) { Column( modifier = Modifier - .fillMaxWidth(0.8f) + .fillMaxWidth(widthFraction) .background(colors.surface, RoundedCornerShape(12.dp)) .clickable(enabled = true, onClick = {}) .padding(MPE_PADDING), ) { - Text(title, color = colors.onSurface, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(8.dp)) - Text(body, color = colors.onSurfaceSecondary) - Spacer(Modifier.height(16.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.macropad_editor_cancel), color = colors.onSurfaceSecondary) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + color = colors.onSurface, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + if (titleAccessory != null) { + titleAccessory() } - TextButton(onClick = onConfirm) { - Text(stringResource(R.string.macropad_editor_confirm), color = LocalAppColors.current.error) + } + Spacer(modifier = Modifier.height(12.dp)) + content() + if (buttonsRow != null) { + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = buttonsArrangement, + verticalAlignment = Alignment.CenterVertically + ) { + buttonsRow() } } } } } +@Composable +internal fun InlineConfirmDeleteOverlay( + title: String, + body: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val colors = LocalAppColors.current + InlineDialogOverlay( + title = title, + onDismiss = onDismiss, + widthFraction = 0.8f, + buttonsRow = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.macropad_editor_cancel), color = colors.onSurfaceSecondary) + } + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.macropad_editor_confirm), color = colors.error) + } + } + ) { + Text(body, color = colors.onSurfaceSecondary) + } +} + @Composable internal fun InlineNameInputOverlay( title: String, @@ -110,52 +166,40 @@ internal fun InlineNameInputOverlay( val isDuplicate = existingNames.any { it.equals(normalizedName, ignoreCase = true) } val hasError = normalizedName.isEmpty() || isDuplicate val colors = LocalAppColors.current - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.5f)) - .clickable(onClick = onDismiss), - contentAlignment = Alignment.Center, - ) { - Column( - modifier = Modifier - .fillMaxWidth(0.8f) - .background(colors.surface, RoundedCornerShape(12.dp)) - .clickable(enabled = true, onClick = {}) - .padding(MPE_PADDING), - ) { - Text(title, color = colors.onSurface, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(12.dp)) - AppTextField( - value = text, - onValueChange = { text = it }, - label = { Text(title, color = colors.onSurfaceSecondary) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - isError = hasError, - supportingText = { - when { - normalizedName.isEmpty() -> Text(stringResource(R.string.settings_name_error_empty)) - isDuplicate -> Text(stringResource(R.string.settings_name_error_duplicate)) - } - }, - ) - Spacer(Modifier.height(16.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.macropad_editor_cancel), color = colors.onSurfaceSecondary) - } - TextButton( - onClick = { if (!hasError) onConfirm(normalizedName) }, - enabled = !hasError, - ) { - Text( - stringResource(R.string.macropad_editor_done), - color = if (!hasError) accentColor else colors.onSurfaceSecondary, - ) - } + + InlineDialogOverlay( + title = title, + onDismiss = onDismiss, + widthFraction = 0.8f, + buttonsRow = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.macropad_editor_cancel), color = colors.onSurfaceSecondary) + } + TextButton( + onClick = { if (!hasError) onConfirm(normalizedName) }, + enabled = !hasError, + ) { + Text( + stringResource(R.string.macropad_editor_done), + color = if (!hasError) accentColor else colors.onSurfaceSecondary, + ) } } + ) { + AppTextField( + value = text, + onValueChange = { text = it }, + label = { Text(title, color = colors.onSurfaceSecondary) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + isError = hasError, + supportingText = { + when { + normalizedName.isEmpty() -> Text(stringResource(R.string.settings_name_error_empty)) + isDuplicate -> Text(stringResource(R.string.settings_name_error_duplicate)) + } + }, + ) } } @@ -166,9 +210,6 @@ internal fun InlineProfileSettingsOverlay( initialPackage: String?, accentColor: Color, existingNames: List, - showDelete: Boolean, - canDelete: Boolean, - onDelete: () -> Unit, onConfirm: (String, String?) -> Unit, onDismiss: () -> Unit, ) { @@ -229,248 +270,200 @@ internal fun InlineProfileSettingsOverlay( } } - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.5f)) - .clickable(onClick = onDismiss), - contentAlignment = Alignment.Center, + InlineDialogOverlay( + title = if (showAppList) stringResource(R.string.profile_settings_app_mapping) else title, + onDismiss = onDismiss, + buttonsArrangement = if (showAppList) Arrangement.Start else Arrangement.End, + buttonsRow = { + if (showAppList) { + TextButton(onClick = { showAppList = false }) { + Text(stringResource(R.string.settings_back), color = colors.onSurface) + } + } else { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.macropad_editor_cancel), color = colors.onSurfaceSecondary) + } + TextButton( + onClick = { if (!hasError) onConfirm(normalizedName, selectedPackage) }, + enabled = !hasError, + ) { + Text( + text = stringResource(R.string.macropad_editor_done), + color = if (!hasError) accentColor else colors.onSurfaceSecondary, + ) + } + } + } ) { - Column( - modifier = Modifier - .fillMaxWidth(0.85f) - .background(colors.surface, RoundedCornerShape(12.dp)) - .clickable(enabled = true, onClick = {}) - .padding(MPE_PADDING), - ) { - if (!showAppList) { + if (!showAppList) { + AppTextField( + value = nameText, + onValueChange = { nameText = it }, + label = { Text(stringResource(R.string.profile_settings_name), color = colors.onSurfaceSecondary) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + isError = hasError, + supportingText = { + when { + normalizedName.isEmpty() -> Text(stringResource(R.string.settings_name_error_empty)) + isDuplicate -> Text(stringResource(R.string.settings_name_error_duplicate)) + } + }, + ) + Spacer(Modifier.height(12.dp)) + + Text( + text = stringResource(R.string.profile_settings_app_mapping), + color = colors.onSurfaceSecondary, + style = MaterialTheme.typography.labelSmall + ) + Spacer(Modifier.height(4.dp)) + + if (!isAccessibilityActive) { + Text( + text = stringResource(R.string.profile_settings_accessibility_required), + color = colors.onSurfaceSecondary, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) + } else { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text( - text = title, - color = colors.onSurface, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold - ) - if (showDelete) { - IconButton( - onClick = onDelete, - enabled = canDelete, - modifier = Modifier.size(24.dp) + if (selectedPackage != null) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = Icons.Rounded.Delete, - contentDescription = stringResource(R.string.macropad_editor_delete_profile), - tint = if (canDelete) colors.error else colors.onSurfaceSecondary.copy(alpha = 0.38f), - modifier = Modifier.size(20.dp) + AppIcon( + packageName = selectedPackage!!, + modifier = Modifier + .padding(end = 8.dp) + .size(36.dp) + ) + Text( + text = selectedAppName, + color = accentColor, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + } + TextButton(onClick = { selectedPackage = null }) { + Text( + text = stringResource(R.string.profile_settings_clear_app), + color = colors.error + ) + } + } else { + Text( + text = stringResource(R.string.macropad_modifier_none), + color = colors.onSurfaceSecondary, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = { showAppList = true }) { + Text( + text = stringResource(R.string.profile_settings_select_app), + color = accentColor ) } } } - Spacer(Modifier.height(12.dp)) - - AppTextField( - value = nameText, - onValueChange = { nameText = it }, - label = { Text(stringResource(R.string.profile_settings_name), color = colors.onSurfaceSecondary) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - isError = hasError, - supportingText = { - when { - normalizedName.isEmpty() -> Text(stringResource(R.string.settings_name_error_empty)) - isDuplicate -> Text(stringResource(R.string.settings_name_error_duplicate)) - } - }, - ) - Spacer(Modifier.height(12.dp)) + } + } else { + AppTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + label = { + Text( + text = stringResource(R.string.profile_settings_search_apps), + color = colors.onSurfaceSecondary + ) + }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + if (isLoadingApps) { Text( - text = stringResource(R.string.profile_settings_app_mapping), + text = stringResource(R.string.profile_settings_loading_apps), color = colors.onSurfaceSecondary, - style = MaterialTheme.typography.labelSmall + modifier = Modifier.padding(vertical = 16.dp) ) - Spacer(Modifier.height(4.dp)) - - if (!isAccessibilityActive) { + } else { + val filtered = appsList.filter { + it.first.contains(searchQuery, ignoreCase = true) || + it.second.contains(searchQuery, ignoreCase = true) + } + if (filtered.isEmpty()) { Text( - text = stringResource(R.string.profile_settings_accessibility_required), + text = stringResource(R.string.profile_settings_no_apps), color = colors.onSurfaceSecondary, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(vertical = 8.dp) + modifier = Modifier.padding(vertical = 16.dp) ) } else { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) ) { - if (selectedPackage != null) { + items(filtered) { (label, pkg) -> + val isAssigned = assignedPackages.contains(pkg.trim().lowercase()) Row( - modifier = Modifier.weight(1f), + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !isAssigned) { + selectedPackage = pkg + showAppList = false + } + .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { AppIcon( - packageName = selectedPackage!!, + packageName = pkg, modifier = Modifier - .padding(end = 8.dp) + .padding(end = 12.dp) .size(36.dp) + .alpha(if (isAssigned) 0.38f else 1f) ) - Text( - text = selectedAppName, - color = accentColor, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold - ) - } - TextButton(onClick = { selectedPackage = null }) { - Text( - text = stringResource(R.string.profile_settings_clear_app), - color = colors.error - ) - } - } else { - Text( - text = stringResource(R.string.macropad_modifier_none), - color = colors.onSurfaceSecondary, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f) - ) - TextButton(onClick = { showAppList = true }) { - Text( - text = stringResource(R.string.profile_settings_select_app), - color = accentColor - ) - } - } - } - } - - Spacer(Modifier.height(16.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.macropad_editor_cancel), color = colors.onSurfaceSecondary) - } - TextButton( - onClick = { if (!hasError) onConfirm(normalizedName, selectedPackage) }, - enabled = !hasError, - ) { - Text( - text = stringResource(R.string.macropad_editor_done), - color = if (!hasError) accentColor else colors.onSurfaceSecondary, - ) - } - } - } else { - Text( - text = stringResource(R.string.profile_settings_app_mapping), - color = colors.onSurface, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold - ) - Spacer(Modifier.height(12.dp)) - - AppTextField( - value = searchQuery, - onValueChange = { searchQuery = it }, - label = { - Text( - text = stringResource(R.string.profile_settings_search_apps), - color = colors.onSurfaceSecondary - ) - }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(12.dp)) - - if (isLoadingApps) { - Text( - text = stringResource(R.string.profile_settings_loading_apps), - color = colors.onSurfaceSecondary, - modifier = Modifier.padding(vertical = 16.dp) - ) - } else { - val filtered = appsList.filter { - it.first.contains(searchQuery, ignoreCase = true) || - it.second.contains(searchQuery, ignoreCase = true) - } - if (filtered.isEmpty()) { - Text( - text = stringResource(R.string.profile_settings_no_apps), - color = colors.onSurfaceSecondary, - modifier = Modifier.padding(vertical = 16.dp) - ) - } else { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - ) { - items(filtered) { (label, pkg) -> - val isAssigned = assignedPackages.contains(pkg.trim().lowercase()) - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = !isAssigned) { - selectedPackage = pkg - showAppList = false - } - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - AppIcon( - packageName = pkg, - modifier = Modifier - .padding(end = 12.dp) - .size(36.dp) - .alpha(if (isAssigned) 0.38f else 1f) - ) - Column(modifier = Modifier.weight(1f)) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + color = if (isAssigned) colors.onSurfaceSecondary.copy(alpha = 0.5f) else colors.onSurface, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + if (isAssigned) { + Spacer(Modifier.width(6.dp)) Text( - text = label, - color = if (isAssigned) colors.onSurfaceSecondary.copy(alpha = 0.5f) else colors.onSurface, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium + text = stringResource(R.string.profile_settings_app_assigned), + color = colors.onSurfaceSecondary.copy(alpha = 0.5f), + style = MaterialTheme.typography.bodySmall ) - if (isAssigned) { - Spacer(Modifier.width(6.dp)) - Text( - text = stringResource(R.string.profile_settings_app_assigned), - color = colors.onSurfaceSecondary.copy(alpha = 0.5f), - style = MaterialTheme.typography.bodySmall - ) - } } - Text( - text = pkg, - color = if (isAssigned) colors.onSurfaceSecondary.copy(alpha = 0.38f) else colors.onSurfaceSecondary, - style = MaterialTheme.typography.bodySmall - ) } + Text( + text = pkg, + color = if (isAssigned) colors.onSurfaceSecondary.copy(alpha = 0.38f) else colors.onSurfaceSecondary, + style = MaterialTheme.typography.bodySmall + ) } } } } } - - Spacer(Modifier.height(16.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) { - TextButton(onClick = { showAppList = false }) { - Text(stringResource(R.string.settings_back), color = colors.onSurface) - } - } } } } } + @Composable internal fun AppIcon( packageName: String, @@ -534,9 +527,6 @@ internal fun InlineLayoutSettingsOverlay( initialButtonColorMirror: ButtonColorStyle, accentColor: Color, existingNames: List, - showDelete: Boolean, - canDelete: Boolean, - onDelete: () -> Unit, onConfirm: (String, Boolean, ButtonColorStyle, ButtonColorStyle) -> Unit, onDismiss: () -> Unit, ) { @@ -549,121 +539,80 @@ internal fun InlineLayoutSettingsOverlay( val hasError = normalizedName.isEmpty() || isDuplicate val colors = LocalAppColors.current - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.5f)) - .clickable(onClick = onDismiss), - contentAlignment = Alignment.Center, - ) { - Column( - modifier = Modifier - .fillMaxWidth(0.85f) - .background(colors.surface, RoundedCornerShape(12.dp)) - .clickable(enabled = true, onClick = {}) - .padding(MPE_PADDING), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + InlineDialogOverlay( + title = title, + onDismiss = onDismiss, + buttonsRow = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.macropad_editor_cancel), color = colors.onSurfaceSecondary) + } + TextButton( + onClick = { if (!hasError) onConfirm(normalizedName, isEnabled, noMirrorStyle, mirrorStyle) }, + enabled = !hasError, ) { Text( - text = title, - color = colors.onSurface, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold + text = stringResource(R.string.macropad_editor_done), + color = if (!hasError) accentColor else colors.onSurfaceSecondary, ) - if (showDelete) { - IconButton( - onClick = onDelete, - enabled = canDelete, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Rounded.Delete, - contentDescription = stringResource(R.string.macropad_editor_delete_layout), - tint = if (canDelete) colors.error else colors.onSurfaceSecondary.copy(alpha = 0.38f), - modifier = Modifier.size(20.dp) - ) - } - } } - Spacer(Modifier.height(12.dp)) - - AppTextField( - value = nameText, - onValueChange = { nameText = it }, - label = { Text(stringResource(R.string.pill_menu_layout_name_hint), color = colors.onSurfaceSecondary) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - isError = hasError, - supportingText = { - when { - normalizedName.isEmpty() -> Text(stringResource(R.string.settings_name_error_empty)) - isDuplicate -> Text(stringResource(R.string.settings_name_error_duplicate)) - } - }, - ) - Spacer(Modifier.height(12.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.layout_settings_visibility_title), - color = colors.onSurface, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium - ) - Text( - text = stringResource(R.string.layout_settings_visibility_desc), - color = colors.onSurfaceSecondary, - style = MaterialTheme.typography.bodySmall - ) + } + ) { + AppTextField( + value = nameText, + onValueChange = { nameText = it }, + label = { Text(stringResource(R.string.pill_menu_layout_name_hint), color = colors.onSurfaceSecondary) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + isError = hasError, + supportingText = { + when { + normalizedName.isEmpty() -> Text(stringResource(R.string.settings_name_error_empty)) + isDuplicate -> Text(stringResource(R.string.settings_name_error_duplicate)) } + }, + ) + Spacer(Modifier.height(12.dp)) - Switch( - checked = isEnabled, - onCheckedChange = { isEnabled = it } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.layout_settings_visibility_title), + color = colors.onSurface, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = stringResource(R.string.layout_settings_visibility_desc), + color = colors.onSurfaceSecondary, + style = MaterialTheme.typography.bodySmall ) } - Spacer(Modifier.height(12.dp)) - AppDivider() - Spacer(Modifier.height(12.dp)) - - ButtonColorStyleRow( - label = stringResource(R.string.macropad_editor_button_color_no_mirror), - selected = noMirrorStyle, - onSelect = { noMirrorStyle = it } + Switch( + checked = isEnabled, + onCheckedChange = { isEnabled = it } ) - Spacer(Modifier.height(8.dp)) - ButtonColorStyleRow( - label = stringResource(R.string.macropad_editor_button_color_mirror), - selected = mirrorStyle, - onSelect = { mirrorStyle = it } - ) - - Spacer(Modifier.height(16.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.macropad_editor_cancel), color = colors.onSurfaceSecondary) - } - TextButton( - onClick = { if (!hasError) onConfirm(normalizedName, isEnabled, noMirrorStyle, mirrorStyle) }, - enabled = !hasError, - ) { - Text( - text = stringResource(R.string.macropad_editor_done), - color = if (!hasError) accentColor else colors.onSurfaceSecondary, - ) - } - } } + + Spacer(Modifier.height(12.dp)) + AppDivider() + Spacer(Modifier.height(12.dp)) + + ButtonColorStyleRow( + label = stringResource(R.string.macropad_editor_button_color_no_mirror), + selected = noMirrorStyle, + onSelect = { noMirrorStyle = it } + ) + Spacer(Modifier.height(8.dp)) + ButtonColorStyleRow( + label = stringResource(R.string.macropad_editor_button_color_mirror), + selected = mirrorStyle, + onSelect = { mirrorStyle = it } + ) } } diff --git a/app/src/main/java/com/stormpanda/megingiard/macropad/EditorLayoutComponents.kt b/app/src/main/java/com/stormpanda/megingiard/macropad/EditorLayoutComponents.kt index 0fff165da..361d1c8cc 100644 --- a/app/src/main/java/com/stormpanda/megingiard/macropad/EditorLayoutComponents.kt +++ b/app/src/main/java/com/stormpanda/megingiard/macropad/EditorLayoutComponents.kt @@ -2,6 +2,7 @@ package com.stormpanda.megingiard.macropad import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -12,15 +13,19 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.FormatListNumbered +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -44,10 +49,14 @@ internal fun EditorProfileChipsBar( activeProfile: PadProfile?, onSelectProfile: (String) -> Unit, onEditProfile: () -> Unit, + onDuplicateProfile: () -> Unit, onReorderProfiles: () -> Unit, + onDeleteProfile: () -> Unit, modifier: Modifier = Modifier, ) { val colors = LocalAppColors.current + var menuExpanded by remember { mutableStateOf(false) } + val canDelete = profiles.size > 1 Row( modifier = modifier.fillMaxWidth(), @@ -69,28 +78,47 @@ internal fun EditorProfileChipsBar( } } - IconButton( - onClick = onEditProfile, - modifier = Modifier.size(28.dp) - ) { - Icon( - imageVector = Icons.Rounded.Edit, - contentDescription = stringResource(R.string.macropad_editor_title_edit_profile), - tint = colors.onSurfaceSecondary, - modifier = Modifier.size(20.dp) - ) - } - - IconButton( - onClick = onReorderProfiles, - modifier = Modifier.size(28.dp) - ) { - Icon( - imageVector = Icons.Rounded.FormatListNumbered, - contentDescription = stringResource(R.string.macropad_reorder_profiles), - tint = colors.onSurfaceSecondary, - modifier = Modifier.size(20.dp) - ) + Box { + IconButton( + onClick = { menuExpanded = true }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.cd_more_options), + tint = colors.onSurfaceSecondary, + modifier = Modifier.size(20.dp) + ) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + modifier = Modifier.background(colors.surface), + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.macropad_editor_title_edit_profile), color = colors.onSurface, style = MaterialTheme.typography.bodyMedium) }, + onClick = { menuExpanded = false; onEditProfile() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.macropad_duplicate_profile), color = colors.onSurface, style = MaterialTheme.typography.bodyMedium) }, + onClick = { menuExpanded = false; onDuplicateProfile() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.macropad_reorder_profiles), color = colors.onSurface, style = MaterialTheme.typography.bodyMedium) }, + onClick = { menuExpanded = false; onReorderProfiles() } + ) + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.macropad_editor_delete_profile), + color = if (canDelete) colors.error else colors.onSurfaceSecondary.copy(alpha = 0.38f), + style = MaterialTheme.typography.bodyMedium + ) + }, + enabled = canDelete, + onClick = { menuExpanded = false; onDeleteProfile() } + ) + } } } } @@ -101,11 +129,16 @@ internal fun EditorLayoutChipsBar( activeLayout: PadLayout?, onSelectLayout: (String) -> Unit, onEditLayout: () -> Unit, + onDuplicateLayout: () -> Unit, + onCopyToProfile: () -> Unit, onReorderLayouts: () -> Unit, + onDeleteLayout: () -> Unit, modifier: Modifier = Modifier, ) { val colors = LocalAppColors.current val latestLayouts by rememberUpdatedState(layouts) + var menuExpanded by remember { mutableStateOf(false) } + val canDelete = layouts.size > 1 val lazyRowState = rememberLazyListState() val reorderState = rememberReorderableLazyListState(lazyRowState) { from, to -> @@ -146,28 +179,51 @@ internal fun EditorLayoutChipsBar( } } - IconButton( - onClick = onEditLayout, - modifier = Modifier.size(28.dp) - ) { - Icon( - imageVector = Icons.Rounded.Edit, - contentDescription = stringResource(R.string.macropad_editor_section_layout_settings), - tint = colors.onSurfaceSecondary, - modifier = Modifier.size(20.dp) - ) - } - - IconButton( - onClick = onReorderLayouts, - modifier = Modifier.size(28.dp) - ) { - Icon( - imageVector = Icons.Rounded.FormatListNumbered, - contentDescription = stringResource(R.string.macropad_reorder_layouts), - tint = colors.onSurfaceSecondary, - modifier = Modifier.size(20.dp) - ) + Box { + IconButton( + onClick = { menuExpanded = true }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.cd_more_options), + tint = colors.onSurfaceSecondary, + modifier = Modifier.size(20.dp) + ) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + modifier = Modifier.background(colors.surface), + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.macropad_editor_title), color = colors.onSurface, style = MaterialTheme.typography.bodyMedium) }, + onClick = { menuExpanded = false; onEditLayout() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.macropad_duplicate_layout), color = colors.onSurface, style = MaterialTheme.typography.bodyMedium) }, + onClick = { menuExpanded = false; onDuplicateLayout() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.macropad_editor_copy_to_profile), color = colors.onSurface, style = MaterialTheme.typography.bodyMedium) }, + onClick = { menuExpanded = false; onCopyToProfile() } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.macropad_reorder_layouts), color = colors.onSurface, style = MaterialTheme.typography.bodyMedium) }, + onClick = { menuExpanded = false; onReorderLayouts() } + ) + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.macropad_editor_delete_layout), + color = if (canDelete) colors.error else colors.onSurfaceSecondary.copy(alpha = 0.38f), + style = MaterialTheme.typography.bodyMedium + ) + }, + enabled = canDelete, + onClick = { menuExpanded = false; onDeleteLayout() } + ) + } } } } diff --git a/app/src/main/java/com/stormpanda/megingiard/macropad/MacroListEditor.kt b/app/src/main/java/com/stormpanda/megingiard/macropad/MacroListEditor.kt index 8b2cd2675..6bd823e1b 100644 --- a/app/src/main/java/com/stormpanda/megingiard/macropad/MacroListEditor.kt +++ b/app/src/main/java/com/stormpanda/megingiard/macropad/MacroListEditor.kt @@ -197,6 +197,10 @@ private fun MacroListView( ) { val colors = LocalAppColors.current + val profiles by MacroPadState.profiles.collectAsState() + val activeProfile by MacroPadState.activeProfile.collectAsState() + var copyingMacro by remember { mutableStateOf(null) } + var deletingMacroId by remember { mutableStateOf(null) } val lazyListState = rememberLazyListState() @@ -284,6 +288,7 @@ private fun MacroListView( isDragging = isDragging, onEdit = { onEditMacro(macro) }, onDuplicate = { onDuplicateMacro(macro) }, + onCopy = { copyingMacro = macro }, onDelete = { deletingMacroId = macro.id }, dragHandleModifier = Modifier.draggableHandle(), ) @@ -293,6 +298,21 @@ private fun MacroListView( } } + // ── Copy macro selection overlay ───────────────────────────────────────── + if (copyingMacro != null) { + val macro = copyingMacro!! + InlineProfileSelectionOverlay( + title = stringResource(R.string.macropad_editor_copy_profile_select), + profiles = profiles, + excludeProfileId = activeProfile?.id, + onSelect = { targetProfileId -> + MacroPadState.copyMacroToProfile(macro, targetProfileId) + copyingMacro = null + }, + onDismiss = { copyingMacro = null } + ) + } + // ── Delete macro confirmation ──────────────────────────────────────────── if (deletingMacroId != null) { val macroId = deletingMacroId!! @@ -341,6 +361,7 @@ private fun MacroRow( isDragging: Boolean, onEdit: () -> Unit, onDuplicate: () -> Unit, + onCopy: () -> Unit, onDelete: () -> Unit, dragHandleModifier: Modifier, ) { @@ -395,6 +416,10 @@ private fun MacroRow( text = { Text(stringResource(R.string.macropad_macro_duplicate), color = colors.onSurface, style = MaterialTheme.typography.bodyMedium) }, onClick = { menuExpanded = false; onDuplicate() }, ) + DropdownMenuItem( + text = { Text(stringResource(R.string.macropad_editor_copy_to_profile), color = colors.onSurface, style = MaterialTheme.typography.bodyMedium) }, + onClick = { menuExpanded = false; onCopy() }, + ) DropdownMenuItem( text = { Text(stringResource(R.string.macropad_macro_delete_title), color = LocalAppColors.current.error, style = MaterialTheme.typography.bodyMedium) }, onClick = { menuExpanded = false; onDelete() }, diff --git a/app/src/main/java/com/stormpanda/megingiard/macropad/MacroPadEditor.kt b/app/src/main/java/com/stormpanda/megingiard/macropad/MacroPadEditor.kt index df7f2f986..a3c245a77 100644 --- a/app/src/main/java/com/stormpanda/megingiard/macropad/MacroPadEditor.kt +++ b/app/src/main/java/com/stormpanda/megingiard/macropad/MacroPadEditor.kt @@ -120,6 +120,8 @@ fun MacroPadEditor(onDone: () -> Unit) { var showReorderProfilesOverlay by remember { mutableStateOf(false) } var showReorderLayoutsOverlay by remember { mutableStateOf(false) } var isCanvasLocked by remember { mutableStateOf(true) } + var showCopyLayoutProfileDialog by remember { mutableStateOf(false) } + var showCopyButtonLayoutDialog by remember { mutableStateOf(false) } // Intercept system Back when an overlay is visible, so Back closes the overlay // instead of dismissing the whole editor dialog. @@ -127,7 +129,8 @@ fun MacroPadEditor(onDone: () -> Unit) { editingButtonActive || buttonPendingDelete != null || showNewLayoutDialog || layoutPendingDelete != null || showNewProfileDialog || showRenameProfileDialog || showDeleteProfileConfirm || - showEditLayoutDialog || showReorderProfilesOverlay || showReorderLayoutsOverlay + showEditLayoutDialog || showReorderProfilesOverlay || showReorderLayoutsOverlay || + showCopyLayoutProfileDialog || showCopyButtonLayoutDialog BackHandler(enabled = anyOverlayVisible) { when { showMacroListEditor -> showMacroListEditor = false @@ -142,6 +145,8 @@ fun MacroPadEditor(onDone: () -> Unit) { showEditLayoutDialog -> showEditLayoutDialog = false showReorderProfilesOverlay -> showReorderProfilesOverlay = false showReorderLayoutsOverlay -> showReorderLayoutsOverlay = false + showCopyLayoutProfileDialog -> showCopyLayoutProfileDialog = false + showCopyButtonLayoutDialog -> showCopyButtonLayoutDialog = false } } @@ -186,6 +191,8 @@ fun MacroPadEditor(onDone: () -> Unit) { onManageMacros = { showMacroListEditor = true }, onAddButton = { showAddButton = true }, onEditButton = { btn -> editingButton = btn; editingButtonActive = true }, + onCopyToProfile = { showCopyLayoutProfileDialog = true }, + onCopyToLayout = { btn -> editingButton = btn; showCopyButtonLayoutDialog = true }, onDeleteRequested = { btn -> buttonPendingDelete = btn }, onReorderProfiles = { showReorderProfilesOverlay = true }, onReorderLayouts = { showReorderLayoutsOverlay = true }, @@ -308,9 +315,6 @@ fun MacroPadEditor(onDone: () -> Unit) { initialPackage = null, accentColor = colors.accent, existingNames = profiles.map { it.name }, - showDelete = false, - canDelete = false, - onDelete = {}, onConfirm = { name, pkg -> val newProfile = PadProfile(id = UUID.randomUUID().toString(), name = name, associatedPackage = pkg) MacroPadState.addProfile(newProfile) @@ -328,12 +332,6 @@ fun MacroPadEditor(onDone: () -> Unit) { initialPackage = profile.associatedPackage, accentColor = colors.accent, existingNames = profiles.filter { it.id != profile.id }.map { it.name }, - showDelete = true, - canDelete = profiles.size > 1, - onDelete = { - showRenameProfileDialog = false - showDeleteProfileConfirm = true - }, onConfirm = { name, pkg -> MacroPadState.renameProfile(profile.id, name, pkg) showRenameProfileDialog = false @@ -367,12 +365,6 @@ fun MacroPadEditor(onDone: () -> Unit) { initialButtonColorMirror = curLayout.buttonColorMirror, accentColor = colors.accent, existingNames = profile?.layouts?.filter { it.id != curLayout.id }?.map { it.name } ?: emptyList(), - showDelete = true, - canDelete = (profile?.layouts?.size ?: 0) > 1, - onDelete = { - showEditLayoutDialog = false - layoutPendingDelete = curLayout - }, onConfirm = { name, enabled, noMirrorStyle, mirrorStyle -> MacroPadState.updateLayout( curLayout.copy( @@ -445,6 +437,38 @@ fun MacroPadEditor(onDone: () -> Unit) { } ) } + + // Copy layout selection overlay + if (showCopyLayoutProfileDialog && activeLayout != null && profile != null) { + val curLayout = activeLayout!! + InlineProfileSelectionOverlay( + title = stringResource(R.string.macropad_editor_copy_profile_select), + profiles = profiles, + excludeProfileId = profile.id, + onSelect = { targetProfileId -> + MacroPadState.copyLayoutToProfile(curLayout, profile.id, targetProfileId) + showCopyLayoutProfileDialog = false + }, + onDismiss = { showCopyLayoutProfileDialog = false } + ) + } + + // Copy button selection overlay + if (showCopyButtonLayoutDialog && editingButton != null && profile != null) { + val curButton = editingButton!! + InlineLayoutSelectionOverlay( + title = stringResource(R.string.macropad_editor_copy_layout_select), + profiles = profiles, + excludeLayoutId = activeLayout?.id, + onSelect = { targetProfileId, targetLayoutId -> + MacroPadState.copyButtonToLayout(curButton, profile.id, targetProfileId, targetLayoutId) + showCopyButtonLayoutDialog = false + editingButtonActive = false + editingButton = null + }, + onDismiss = { showCopyButtonLayoutDialog = false } + ) + } } // end Box } @@ -501,6 +525,8 @@ private fun EditorBody( onManageMacros: () -> Unit, onAddButton: () -> Unit, onEditButton: (PadButton) -> Unit, + onCopyToProfile: () -> Unit, + onCopyToLayout: (PadButton) -> Unit, onDeleteRequested: (PadButton) -> Unit, onReorderProfiles: () -> Unit, onReorderLayouts: () -> Unit, @@ -549,7 +575,9 @@ private fun EditorBody( activeProfile = profile, onSelectProfile = onSelectProfile, onEditProfile = onEditProfile, + onDuplicateProfile = { profile?.id?.let { MacroPadState.duplicateProfile(it) } }, onReorderProfiles = onReorderProfiles, + onDeleteProfile = onDeleteProfile, modifier = Modifier .background(colors.surface) .padding(horizontal = MPE_PADDING) @@ -574,7 +602,10 @@ private fun EditorBody( activeLayout = layout, onSelectLayout = onSelectLayout, onEditLayout = onEditLayout, + onDuplicateLayout = { layout?.id?.let { MacroPadState.duplicateLayout(it) } }, + onCopyToProfile = onCopyToProfile, onReorderLayouts = onReorderLayouts, + onDeleteLayout = { layout?.let { onDeleteLayoutRequested(it) } }, modifier = Modifier .background(colors.surface) .padding(horizontal = MPE_PADDING) @@ -645,6 +676,8 @@ private fun EditorBody( enableTouch = profile.enableTouch, isDragging = isDragging, onEdit = { onEditButton(btn) }, + onDuplicate = { MacroPadState.duplicateButtonInLayout(btn, layout.id) }, + onCopyToLayout = { onCopyToLayout(btn) }, onDelete = { onDeleteRequested(btn) }, dragHandleModifier = Modifier.draggableHandle(), ) diff --git a/app/src/main/java/com/stormpanda/megingiard/macropad/PadButtonEditDialog.kt b/app/src/main/java/com/stormpanda/megingiard/macropad/PadButtonEditDialog.kt index f4768cc96..ff83e99ca 100644 --- a/app/src/main/java/com/stormpanda/megingiard/macropad/PadButtonEditDialog.kt +++ b/app/src/main/java/com/stormpanda/megingiard/macropad/PadButtonEditDialog.kt @@ -653,6 +653,7 @@ internal fun ButtonEditDialog( }, onChange = ::onActionChanged, ) + } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 68c533534..0c06ba5f4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -653,4 +653,15 @@ Einstellungen öffnen, um ein Layout zu erstellen. Externe Datei… Wählen Sie eine .mgrd-Sicherungsdatei aus %1$d Profile • %2$d Layouts • %3$d Makros + + + In Profil kopieren… + In Layout kopieren… + Zielprofil auswählen + Ziellayout auswählen + Taste duplizieren + Profil duplizieren + Layout duplizieren + Keine anderen Profile verfügbar. + Keine anderen Layouts verfügbar. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e1728def1..3e440e29f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -646,4 +646,15 @@ Open Settings to create a layout. External File… Select a .mgrd file from storage %1$d profiles • %2$d layouts • %3$d macros + + + Copy to Profile… + Copy to Layout… + Select Target Profile + Select Target Layout + Duplicate Button + Duplicate Profile + Duplicate Layout + No other profiles available. + No other layouts available. diff --git a/docs/features/macropad/FEATURE.md b/docs/features/macropad/FEATURE.md index ca234f1d0..5a389c3d9 100644 --- a/docs/features/macropad/FEATURE.md +++ b/docs/features/macropad/FEATURE.md @@ -291,6 +291,15 @@ Each button supports one of the following actions: - **Service Verification & Direct Setup:** The Global Settings UI displays the active accessibility service status using a premium indicator bubble mirroring the Privileged Mode card. The system service status is polled exactly once upon accessing or resuming the Global Settings screen, and provides a manual refresh button if the service is currently inactive. The entire settings row remains clickable in all states to navigate directly to Android's system Accessibility settings screen. - **Launcher App Picker Overlay:** The Profile Editor inside the MacroPad Editor replaces the simple text rename dialog with a unified `InlineProfileSettingsOverlay`. The overlay includes a search-filtered list of launcher applications compiled in the background (using `PackageManager.queryIntentActivities` with `Intent.CATEGORY_LAUNCHER`) to exclude internal services and background system apps. If the accessibility service is currently inactive, the app mapping picker area is greyed out and displays a descriptive warning: *"Accessibility service must be activated in Global Settings"*, preventing further application assignments until the service is active. Tapping a listed launcher application binds its package to the profile, and a "Clear App Mapping" option is available to remove the mapping. +### FR-P16: Entity Duplication and Copying + +- **Name Collision Formatting**: All copy and duplication operations MUST avoid adding a `(Copy)` suffix. Instead, standard conflict resolution numbering (e.g. `Combo (2)` or `Lay1 (2)`) is appended on name collision. If no collision occurs, the original name is kept. +- **Contextual Dropdowns ("...")**: Individual action buttons in management bars and lists are merged into unified contextual "..." dropdown menus to keep the editor interface clean: + - **Profiles**: Edit, Duplicate, and Reorder options are accessed via a "..." dropdown in the profiles management row. Duplicating a profile deep-copies all its layouts and macros, and maps macro IDs within layout buttons. + - **Layouts**: Edit, Duplicate, Copy to Profile, and Reorder options are accessed via a "..." dropdown in the layouts management bar. Duplicating a layout clones all its buttons with new UUIDs within the active profile. + - **Button List**: Each item in the button list replaces the individual Delete button with a "..." dropdown providing Edit, Duplicate, Copy to Layout, and Delete options. Drag-reorder handles remain separate. + - **Dialogs & Overlays**: Property configuration dialogs (e.g., `ButtonEditDialog`) and inline configuration overlays (e.g., `InlineLayoutSettingsOverlay`) remain focused strictly on metadata settings editing, without copy or duplicate options. + --- ## Technical Implementation @@ -582,7 +591,7 @@ The layout editor's `PadCanvas` reads the screen dimensions from `LocalConfigura ### Layout Editor -`MacroPadEditor` is rendered as a full-screen in-tree overlay (`Box` inside the same composition), controlled by UI state in the hosting screen. No separate `Dialog` window is created — this is intentional so that the editor works correctly both in the main `Activity` and inside `MirrorPresentation` (secondary display), where `AlertDialog`/`Dialog` would crash with `BadTokenException` due to a null window token. All confirmation and name-input overlays inside `MacroPadEditor` (delete button, delete profile, rename profile, new profile, new layout, edit layout) follow the same pattern: in-tree `Box` composables (`InlineConfirmDeleteOverlay`, `InlineNameInputOverlay`, `NewLayoutOverlay`, `InlineLayoutSettingsOverlay`, `ReorderProfilesOverlay`, `ReorderLayoutsOverlay`) instead of `AlertDialog`. Profile-level settings (shape, size) are also available directly in `MacroPadToolSettings` without opening the full editor. The editor features horizontally scrollable chip rows for both profile and layout selection (`EditorProfileChipsBar` and `EditorLayoutChipsBar`). Next to each row of chips is an Edit button that opens the corresponding settings dialog, and a Reorder button (using the standard numbered list icon) that opens a full-screen drag-reordering overlay (`ReorderProfilesOverlay` or `ReorderLayoutsOverlay`) enabling the user to reorganize profiles and layouts via drag handles. Deletion has been moved into the headers of both the profile and layout edit dialogs, aligned on the right-hand side. If a profile or layout is the only one in existence, it cannot be deleted; the delete button in the dialog header is disabled and styled with `0.38f` alpha. The Add button (plus icon) has been moved into the right-hand side of the Profile, Layout, and Buttons section separators as a premium clickable `Row` showing "+ Add", colored with the active accent color. Layout chips support drag-reordering via long press. When a layout is disabled (hidden), `(hidden)` is appended to its chip text and its opacity is reduced to `0.45f`. The layout-level settings (the two button color options: no-mirror and mirror styles) are configured directly inside `InlineLayoutSettingsOverlay` rather than in the main list, and are saved atomically upon confirmation. +`MacroPadEditor` is rendered as a full-screen in-tree overlay (`Box` inside the same composition), controlled by UI state in the hosting screen. No separate `Dialog` window is created — this is intentional so that the editor works correctly both in the main `Activity` and inside `MirrorPresentation` (secondary display), where `AlertDialog`/`Dialog` would crash with `BadTokenException` due to a null window token. All confirmation, selection, and name-input overlays inside `MacroPadEditor` (delete button, delete profile, rename profile, new profile, new layout, edit layout, and profile/layout selection for copies) follow the same pattern: in-tree `Box` composables (`InlineConfirmDeleteOverlay`, `InlineNameInputOverlay`, `NewLayoutOverlay`, `InlineLayoutSettingsOverlay`, `ReorderProfilesOverlay`, `ReorderLayoutsOverlay`, `InlineProfileSelectionOverlay`, `InlineLayoutSelectionOverlay`) instead of `AlertDialog`, unified using a common `InlineDialogOverlay` container to ensure a consistent appearance, scrim interaction, and input blocking. Profile-level settings (shape, size) are also available directly in `MacroPadToolSettings` without opening the full editor. The editor features horizontally scrollable chip rows for both profile and layout selection (`EditorProfileChipsBar` and `EditorLayoutChipsBar`). Next to each row of chips is a "..." menu button that opens a contextual dropdown menu, enabling actions such as editing properties, duplicating, copying, deleting (when more than one item exists), and initiating full-screen drag-reordering (`ReorderProfilesOverlay` or `ReorderLayoutsOverlay`). If a profile or layout is the only one in existence, it cannot be deleted; the delete option in the "..." dropdown menu is disabled and styled with `0.38f` alpha. The Add button (plus icon) has been moved into the right-hand side of the Profile, Layout, and Buttons section separators as a premium clickable `Row` showing "+ Add", colored with the active accent color. Layout chips support drag-reordering via long press. When a layout is disabled (hidden), `(hidden)` is appended to its chip text and its opacity is reduced to `0.45f`. The layout-level settings (the two button color options: no-mirror and mirror styles) are configured directly inside `InlineLayoutSettingsOverlay` rather than in the main list, and are saved atomically upon confirmation. The editor list features an action toolbar (`EditorToolbar`) containing four compact action chips: 1. **Button** (replaces "Add Button") — opens the button configuration dialog. diff --git a/domain/src/main/java/com/stormpanda/megingiard/macropad/MacroPadState.kt b/domain/src/main/java/com/stormpanda/megingiard/macropad/MacroPadState.kt index 36c5d417c..ab1975af1 100644 --- a/domain/src/main/java/com/stormpanda/megingiard/macropad/MacroPadState.kt +++ b/domain/src/main/java/com/stormpanda/megingiard/macropad/MacroPadState.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.stateIn private const val TAG = "MacroPadState" private const val MP_DEFAULT_PROFILE_NAME = "Profile" private const val MP_DEFAULT_LAYOUT_NAME = "Layout" +private const val DUPLICATE_BUTTON_OFFSET = 0.05f private fun List.nextUniqueName(baseName: String, fallback: String): String { val normalizedBase = baseName.trim().ifBlank { fallback } @@ -55,6 +56,22 @@ private fun PadProfile.withSyncedDeviceFlags(): PadProfile { else copy(enableKeyboard = kb, enableGamepad = gp, enableMouse = ms, enableTouch = ts) } +private fun List.cloneWithMacroMapping(macroMapping: Map): List { + return map { btn -> + val updatedAction = when (val action = btn.action) { + is PadAction.Macro -> { + val newMacroId = macroMapping[action.macroId] ?: action.macroId + PadAction.Macro(newMacroId) + } + else -> action + } + btn.copy( + id = UUID.randomUUID().toString(), + action = updatedAction + ) + } +} + /** * Runtime state holder for the MacroPad-centric UI. * @@ -250,6 +267,48 @@ object MacroPadState { MacroPadSettings.saveMacroPadData() } + fun duplicateProfile(profileId: String) { + val sourceProfile = _profiles.value.firstOrNull { it.id == profileId } ?: return + val existingNames = _profiles.value.map { it.name } + val uniqueName = existingNames.nextUniqueName(sourceProfile.name, MP_DEFAULT_PROFILE_NAME) + + val newProfileId = UUID.randomUUID().toString() + val macroMapping = mutableMapOf() // source macro ID -> target macro ID + + // Copy macros + val copiedMacros = sourceProfile.macros.map { macro -> + val targetMacroId = UUID.randomUUID().toString() + macroMapping[macro.id] = targetMacroId + macro.copy(id = targetMacroId) + } + + // Copy layouts + val copiedLayouts = sourceProfile.layouts.map { layout -> + layout.copy( + id = UUID.randomUUID().toString(), + buttons = layout.buttons.cloneWithMacroMapping(macroMapping) + ) + } + + val newActiveLayoutId = sourceProfile.activeLayoutId?.let { activeId -> + val sourceIndex = sourceProfile.layouts.indexOfFirst { it.id == activeId } + if (sourceIndex != -1) copiedLayouts[sourceIndex].id else copiedLayouts.firstOrNull()?.id + } ?: copiedLayouts.firstOrNull()?.id + + val duplicated = sourceProfile.copy( + id = newProfileId, + name = uniqueName, + layouts = copiedLayouts, + macros = copiedMacros, + activeLayoutId = newActiveLayoutId, + isDefault = false + ).withSyncedDeviceFlags() + + AppLog.d(TAG, "duplicateProfile originalId=$profileId newId=$newProfileId name='$uniqueName'") + _profiles.value = _profiles.value + duplicated + MacroPadSettings.saveMacroPadData() + } + // ───────────────────────────────────────────────────────────────────────── // Layout CRUD (within the active profile) // ───────────────────────────────────────────────────────────────────────── @@ -313,6 +372,29 @@ object MacroPadState { updateProfile(profile.copy(layouts = newOrder)) } + fun duplicateLayout(layoutId: String) { + val profile = activeProfile.value ?: return + val layout = profile.layouts.firstOrNull { it.id == layoutId } ?: return + val existingNames = profile.layouts.map { it.name } + val uniqueName = existingNames.nextUniqueName(layout.name, MP_DEFAULT_LAYOUT_NAME) + + val copiedButtons = layout.buttons.map { btn -> + btn.copy(id = UUID.randomUUID().toString()) + } + + val duplicatedLayout = layout.copy( + id = UUID.randomUUID().toString(), + name = uniqueName, + buttons = copiedButtons + ) + + AppLog.d(TAG, "duplicateLayout layoutId=$layoutId newId=${duplicatedLayout.id} name='$uniqueName' in profile=${profile.id}") + updateProfile(profile.copy( + layouts = profile.layouts + duplicatedLayout, + activeLayoutId = duplicatedLayout.id + )) + } + /** Switch to the next enabled layout, wrapping around. */ fun nextLayout() { val profile = activeProfile.value ?: return @@ -378,14 +460,142 @@ object MacroPadState { /** Copy a macro from any profile into the active profile with a new UUID. */ fun copyMacroToActiveProfile(macro: Macro) { val profile = activeProfile.value ?: return + val existingNames = profile.macros.map { it.name } + val uniqueName = existingNames.nextUniqueName(macro.name, "Macro") val copied = macro.copy( id = UUID.randomUUID().toString(), - name = "${macro.name} (Copy)", + name = uniqueName, ) - AppLog.d(TAG, "copyMacroToActiveProfile originalId=${macro.id} newId=${copied.id}") + AppLog.d(TAG, "copyMacroToActiveProfile originalId=${macro.id} newId=${copied.id} name='$uniqueName'") updateProfile(profile.copy(macros = profile.macros + copied)) } + /** Copy a macro to a specific profile, renaming on collision. */ + fun copyMacroToProfile(macro: Macro, targetProfileId: String) { + val targetProfile = _profiles.value.firstOrNull { it.id == targetProfileId } ?: return + val existingNames = targetProfile.macros.map { it.name } + val desiredName = macro.name + val uniqueName = existingNames.nextUniqueName(desiredName, "Macro") + val copied = macro.copy( + id = UUID.randomUUID().toString(), + name = uniqueName, + ) + AppLog.d(TAG, "copyMacroToProfile macroId=${macro.id} targetProfileId=$targetProfileId newId=${copied.id} name='$uniqueName'") + val updatedProfile = targetProfile.copy(macros = targetProfile.macros + copied) + updateProfile(updatedProfile) + } + + /** Copy a layout to a specific profile, cloning buttons and copying referenced macros if cross-profile. */ + fun copyLayoutToProfile(layout: PadLayout, sourceProfileId: String, targetProfileId: String) { + val sourceProfile = _profiles.value.firstOrNull { it.id == sourceProfileId } ?: return + val targetProfile = _profiles.value.firstOrNull { it.id == targetProfileId } ?: return + + val existingNames = targetProfile.layouts.map { it.name } + val desiredName = layout.name + val uniqueName = existingNames.nextUniqueName(desiredName, "Layout") + + val macroMapping = mutableMapOf() // source macro ID -> target macro ID + var updatedTargetProfile = targetProfile + + if (sourceProfileId != targetProfileId) { + val referencedMacroIds = layout.buttons.mapNotNull { (it.action as? PadAction.Macro)?.macroId }.distinct() + for (macroId in referencedMacroIds) { + val sourceMacro = sourceProfile.macros.firstOrNull { it.id == macroId } + if (sourceMacro != null) { + val targetExistingMacroNames = updatedTargetProfile.macros.map { it.name } + val targetMacroName = targetExistingMacroNames.nextUniqueName(sourceMacro.name, "Macro") + val targetMacroId = UUID.randomUUID().toString() + val copiedMacro = sourceMacro.copy( + id = targetMacroId, + name = targetMacroName + ) + macroMapping[macroId] = targetMacroId + updatedTargetProfile = updatedTargetProfile.copy(macros = updatedTargetProfile.macros + copiedMacro) + AppLog.d(TAG, "copyLayoutToProfile: copied referenced macro sourceId=$macroId targetId=$targetMacroId name='$targetMacroName'") + } + } + } + + val copiedLayout = layout.copy( + id = UUID.randomUUID().toString(), + name = uniqueName, + buttons = layout.buttons.cloneWithMacroMapping(macroMapping) + ) + + AppLog.d(TAG, "copyLayoutToProfile layoutId=${layout.id} name='$uniqueName' to profileId=$targetProfileId") + updatedTargetProfile = updatedTargetProfile.copy(layouts = updatedTargetProfile.layouts + copiedLayout) + updateProfile(updatedTargetProfile) + } + + /** Copy a single button to a layout in any profile, copying its referenced macro if cross-profile. */ + fun copyButtonToLayout(button: PadButton, sourceProfileId: String, targetProfileId: String, targetLayoutId: String) { + val sourceProfile = _profiles.value.firstOrNull { it.id == sourceProfileId } ?: return + val targetProfile = _profiles.value.firstOrNull { it.id == targetProfileId } ?: return + val targetLayout = targetProfile.layouts.firstOrNull { it.id == targetLayoutId } ?: return + + var updatedTargetProfile = targetProfile + var updatedAction = button.action + + if (button.action is PadAction.Macro && sourceProfileId != targetProfileId) { + val macroId = (button.action as PadAction.Macro).macroId + val sourceMacro = sourceProfile.macros.firstOrNull { it.id == macroId } + if (sourceMacro != null) { + val targetExistingMacroNames = updatedTargetProfile.macros.map { it.name } + val targetMacroName = targetExistingMacroNames.nextUniqueName(sourceMacro.name, "Macro") + val targetMacroId = UUID.randomUUID().toString() + val copiedMacro = sourceMacro.copy( + id = targetMacroId, + name = targetMacroName + ) + updatedAction = PadAction.Macro(targetMacroId) + updatedTargetProfile = updatedTargetProfile.copy(macros = updatedTargetProfile.macros + copiedMacro) + AppLog.d(TAG, "copyButtonToLayout: copied referenced macro sourceId=$macroId targetId=$targetMacroId name='$targetMacroName'") + } + } + + val clonedButton = button.copy( + id = UUID.randomUUID().toString(), + action = updatedAction + ) + + val updatedLayouts = updatedTargetProfile.layouts.map { layout -> + if (layout.id == targetLayoutId) { + layout.copy(buttons = layout.buttons + clonedButton) + } else { + layout + } + } + + AppLog.d(TAG, "copyButtonToLayout buttonId=${button.id} to profileId=$targetProfileId layoutId=$targetLayoutId") + updateProfile(updatedTargetProfile.copy(layouts = updatedLayouts)) + } + + /** Duplicate a button in the current layout, shifting its position slightly to prevent perfect overlap. */ + fun duplicateButtonInLayout(button: PadButton, layoutId: String) { + val profile = activeProfile.value ?: return + val layout = profile.layouts.firstOrNull { it.id == layoutId } ?: return + + val newPosX = (button.posX + DUPLICATE_BUTTON_OFFSET).coerceIn(0f, 1f) + val newPosY = (button.posY + DUPLICATE_BUTTON_OFFSET).coerceIn(0f, 1f) + + val clonedButton = button.copy( + id = UUID.randomUUID().toString(), + posX = newPosX, + posY = newPosY + ) + + val updatedLayouts = profile.layouts.map { lay -> + if (lay.id == layoutId) { + lay.copy(buttons = lay.buttons + clonedButton) + } else { + lay + } + } + + AppLog.d(TAG, "duplicateButtonInLayout buttonId=${button.id} layoutId=$layoutId offset to ($newPosX, $newPosY)") + updateProfile(profile.copy(layouts = updatedLayouts)) + } + // ───────────────────────────────────────────────────────────────────────── // Mirror viewport persistence (per-layout) // ───────────────────────────────────────────────────────────────────────── diff --git a/domain/src/test/java/com/stormpanda/megingiard/macropad/MacroPadStateTest.kt b/domain/src/test/java/com/stormpanda/megingiard/macropad/MacroPadStateTest.kt index 2c323a833..33f3027df 100644 --- a/domain/src/test/java/com/stormpanda/megingiard/macropad/MacroPadStateTest.kt +++ b/domain/src/test/java/com/stormpanda/megingiard/macropad/MacroPadStateTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Before import org.junit.Test @@ -292,4 +293,221 @@ class MacroPadStateTest { assertEquals(true, active.enableMouse) assertEquals(true, active.enableTouch) } + + @Test + fun `copyMacroToProfile clones macro to target profile with new ID and name on collision`() { + val p1Id = UUID.randomUUID().toString() + val p2Id = UUID.randomUUID().toString() + val m1 = Macro(id = "m1", name = "Combo", steps = emptyList()) + val p1 = PadProfile( + id = p1Id, + name = "P1", + layouts = listOf(PadLayout(id = "l1", name = "L1")), + activeLayoutId = "l1", + macros = listOf(m1) + ) + val p2 = PadProfile( + id = p2Id, + name = "P2", + layouts = listOf(PadLayout(id = "l2", name = "L2")), + activeLayoutId = "l2", + macros = listOf(Macro(id = "m2", name = "Combo", steps = emptyList())) + ) + MacroPadState.loadFrom(listOf(p1, p2), p1Id) + + MacroPadState.copyMacroToProfile(m1, p2Id) + + val target = MacroPadState.profiles.value.first { it.id == p2Id } + assertEquals(2, target.macros.size) + val copied = target.macros.first { it.id != "m2" } + assertEquals("Combo (2)", copied.name) + } + + @Test + fun `copyLayoutToProfile duplicates layout and maps referenced macros when cross-profile`() { + val p1Id = UUID.randomUUID().toString() + val p2Id = UUID.randomUUID().toString() + val m1 = Macro(id = "macro-1", name = "Fire", steps = emptyList()) + val btn = PadButton( + id = "btn-1", + label = "B", + posX = 0.5f, + posY = 0.5f, + action = PadAction.Macro("macro-1") + ) + val l1 = PadLayout(id = "layout-1", name = "Lay1", buttons = listOf(btn)) + val p1 = PadProfile( + id = p1Id, + name = "P1", + layouts = listOf(l1), + activeLayoutId = "layout-1", + macros = listOf(m1) + ) + val p2 = PadProfile( + id = p2Id, + name = "P2", + layouts = listOf(PadLayout(id = "layout-2", name = "Lay2")), + activeLayoutId = "layout-2" + ) + MacroPadState.loadFrom(listOf(p1, p2), p1Id) + + MacroPadState.copyLayoutToProfile(l1, p1Id, p2Id) + + val targetProfile = MacroPadState.profiles.value.first { it.id == p2Id } + assertEquals(2, targetProfile.layouts.size) + val copiedLayout = targetProfile.layouts.first { it.id != "layout-2" } + assertEquals("Lay1", copiedLayout.name) + assertEquals(1, copiedLayout.buttons.size) + + assertEquals(1, targetProfile.macros.size) + val copiedMacro = targetProfile.macros.first() + assertEquals("Fire", copiedMacro.name) + + val copiedBtn = copiedLayout.buttons.first() + val copiedBtnAction = copiedBtn.action as PadAction.Macro + assertEquals(copiedMacro.id, copiedBtnAction.macroId) + } + + @Test + fun `copyButtonToLayout duplicates button and copies referenced macro when cross-profile`() { + val p1Id = UUID.randomUUID().toString() + val p2Id = UUID.randomUUID().toString() + val m1 = Macro(id = "macro-1", name = "Punch", steps = emptyList()) + val btn = PadButton( + id = "btn-1", + label = "B", + posX = 0.5f, + posY = 0.5f, + action = PadAction.Macro("macro-1") + ) + val p1 = PadProfile( + id = p1Id, + name = "P1", + layouts = listOf(PadLayout(id = "l1", name = "L1")), + activeLayoutId = "l1", + macros = listOf(m1) + ) + val p2 = PadProfile( + id = p2Id, + name = "P2", + layouts = listOf(PadLayout(id = "l2", name = "L2")), + activeLayoutId = "l2" + ) + MacroPadState.loadFrom(listOf(p1, p2), p1Id) + + MacroPadState.copyButtonToLayout(btn, p1Id, p2Id, "l2") + + val targetProfile = MacroPadState.profiles.value.first { it.id == p2Id } + val targetLayout = targetProfile.layouts.first() + assertEquals(1, targetLayout.buttons.size) + + val copiedBtn = targetLayout.buttons.first() + assertEquals("B", copiedBtn.label) + + assertEquals(1, targetProfile.macros.size) + val copiedMacro = targetProfile.macros.first() + assertEquals("Punch", copiedMacro.name) + assertEquals(copiedMacro.id, (copiedBtn.action as PadAction.Macro).macroId) + } + + @Test + fun `duplicateButtonInLayout duplicates button in place with coordinate offset`() { + val p1Id = UUID.randomUUID().toString() + val btn = PadButton( + id = "btn-1", + label = "B", + posX = 0.5f, + posY = 0.5f, + action = PadAction.KeyboardKey(65, "A") + ) + val p1 = PadProfile( + id = p1Id, + name = "P1", + layouts = listOf(PadLayout(id = "l1", name = "L1", buttons = listOf(btn))), + activeLayoutId = "l1" + ) + MacroPadState.loadFrom(listOf(p1), p1Id) + + MacroPadState.duplicateButtonInLayout(btn, "l1") + + val targetLayout = MacroPadState.activeProfile.value!!.layouts.first() + assertEquals(2, targetLayout.buttons.size) + val copiedBtn = targetLayout.buttons.first { it.id != "btn-1" } + assertEquals(0.55f, copiedBtn.posX, 0.001f) + assertEquals(0.55f, copiedBtn.posY, 0.001f) + } + + @Test + fun `duplicateLayout duplicates active profile layout and resolves name collision`() { + val p1Id = UUID.randomUUID().toString() + val btn = PadButton( + id = "btn-1", + label = "B", + posX = 0.5f, + posY = 0.5f, + action = PadAction.KeyboardKey(65, "A") + ) + val l1 = PadLayout(id = "layout-1", name = "Lay1", buttons = listOf(btn)) + val p1 = PadProfile( + id = p1Id, + name = "P1", + layouts = listOf(l1), + activeLayoutId = "layout-1" + ) + MacroPadState.loadFrom(listOf(p1), p1Id) + + MacroPadState.duplicateLayout("layout-1") + + val profile = MacroPadState.activeProfile.value!! + assertEquals(2, profile.layouts.size) + val duplicated = profile.layouts.first { it.id != "layout-1" } + assertEquals("Lay1 (2)", duplicated.name) + assertEquals(1, duplicated.buttons.size) + val dupBtn = duplicated.buttons.first() + assertEquals("B", dupBtn.label) + assertNotEquals("btn-1", dupBtn.id) + } + + @Test + fun `duplicateProfile deep copies profile, layout buttons and macros`() { + val p1Id = UUID.randomUUID().toString() + val m1 = Macro(id = "macro-1", name = "Slash", steps = emptyList()) + val btn = PadButton( + id = "btn-1", + label = "B", + posX = 0.5f, + posY = 0.5f, + action = PadAction.Macro("macro-1") + ) + val l1 = PadLayout(id = "layout-1", name = "Lay1", buttons = listOf(btn)) + val p1 = PadProfile( + id = p1Id, + name = "P1", + layouts = listOf(l1), + activeLayoutId = "layout-1", + macros = listOf(m1) + ) + MacroPadState.loadFrom(listOf(p1), p1Id) + + MacroPadState.duplicateProfile(p1Id) + + val profiles = MacroPadState.profiles.value + assertEquals(2, profiles.size) + val duplicatedProfile = profiles.first { it.id != p1Id } + assertEquals("P1 (2)", duplicatedProfile.name) + assertEquals(1, duplicatedProfile.layouts.size) + assertEquals(1, duplicatedProfile.macros.size) + + val dupMacro = duplicatedProfile.macros.first() + assertEquals("Slash", dupMacro.name) + assertNotEquals("macro-1", dupMacro.id) + + val dupLayout = duplicatedProfile.layouts.first() + assertEquals("Lay1", dupLayout.name) + assertEquals(1, dupLayout.buttons.size) + + val dupBtn = dupLayout.buttons.first() + assertNotEquals("btn-1", dupBtn.id) + assertEquals(dupMacro.id, (dupBtn.action as PadAction.Macro).macroId) + } }