From 1c5ee27a511f75ed3e7a649b3867f203849deed0 Mon Sep 17 00:00:00 2001 From: stormpanda Date: Sat, 13 Jun 2026 17:41:32 +0200 Subject: [PATCH 1/6] feat: support copying and duplicating macros, layouts, and buttons - Implement state mutations in MacroPadState (copyMacroToProfile, copyLayoutToProfile, copyButtonToLayout, duplicateButtonInLayout) - Create CopyDialogs.kt containing InlineProfileSelectionOverlay and InlineLayoutSelectionOverlay - Integrate copy and duplicate options into MacroListEditor, EditorInlineOverlays, and PadButtonEditDialog - Wire up overlays backstack in MacroPadEditor - Add new unit tests covering copying behaviors in MacroPadStateTest - Localize copy strings in English and German - Update MacroPad FEATURE.md documentation --- .../megingiard/macropad/CopyDialogs.kt | 185 ++++++++++++++++++ .../macropad/EditorInlineOverlays.kt | 16 ++ .../megingiard/macropad/MacroListEditor.kt | 25 +++ .../megingiard/macropad/MacroPadEditor.kt | 54 ++++- .../macropad/PadButtonEditDialog.kt | 23 +++ app/src/main/res/values-de/strings.xml | 7 + app/src/main/res/values/strings.xml | 7 + docs/features/macropad/FEATURE.md | 7 + .../megingiard/macropad/MacroPadState.kt | 140 +++++++++++++ .../megingiard/macropad/MacroPadStateTest.kt | 143 ++++++++++++++ 10 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/stormpanda/megingiard/macropad/CopyDialogs.kt 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..21ce04bb6 --- /dev/null +++ b/app/src/main/java/com/stormpanda/megingiard/macropad/CopyDialogs.kt @@ -0,0 +1,185 @@ +package com.stormpanda.megingiard.macropad + +import androidx.compose.foundation.background +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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 +import com.stormpanda.megingiard.ui.blockPointerEvents + +@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 } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .clickable(onClick = onDismiss) + .blockPointerEvents(), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .fillMaxWidth(0.85f) + .background(colors.surface, RoundedCornerShape(12.dp)) + .clickable(enabled = true, onClick = {}) + .padding(16.dp), + ) { + Text( + text = title, + color = colors.onSurface, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(12.dp)) + + if (filteredProfiles.isEmpty()) { + Text( + text = "No other 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 + ) + } + } + } + } + + 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) + } + } + } + } +} + +@Composable +internal fun InlineLayoutSelectionOverlay( + title: String, + profiles: List, + excludeLayoutId: String?, + onSelect: (targetProfileId: String, targetLayoutId: String) -> Unit, + onDismiss: () -> Unit, +) { + val colors = LocalAppColors.current + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .clickable(onClick = onDismiss) + .blockPointerEvents(), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .fillMaxWidth(0.85f) + .background(colors.surface, RoundedCornerShape(12.dp)) + .clickable(enabled = true, onClick = {}) + .padding(16.dp), + ) { + Text( + text = title, + color = colors.onSurface, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(12.dp)) + + 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 + ) + } + } + } + } + } + + 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) + } + } + } + } +} 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..fb3582e17 100644 --- a/app/src/main/java/com/stormpanda/megingiard/macropad/EditorInlineOverlays.kt +++ b/app/src/main/java/com/stormpanda/megingiard/macropad/EditorInlineOverlays.kt @@ -537,6 +537,7 @@ internal fun InlineLayoutSettingsOverlay( showDelete: Boolean, canDelete: Boolean, onDelete: () -> Unit, + onCopyToProfile: () -> Unit, onConfirm: (String, Boolean, ButtonColorStyle, ButtonColorStyle) -> Unit, onDismiss: () -> Unit, ) { @@ -648,6 +649,21 @@ internal fun InlineLayoutSettingsOverlay( onSelect = { mirrorStyle = it } ) + Spacer(Modifier.height(12.dp)) + AppDivider() + Spacer(Modifier.height(12.dp)) + + TextButton( + onClick = onCopyToProfile, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.macropad_editor_copy_to_profile), + color = accentColor, + style = MaterialTheme.typography.labelLarge + ) + } + Spacer(Modifier.height(16.dp)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { TextButton(onClick = onDismiss) { 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..8d0fc88e1 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 } } @@ -231,6 +236,17 @@ fun MacroPadEditor(onDone: () -> Unit) { button = editingButton, accentColor = colors.accent, onEditMacro = { macro -> pendingMacroEditId = macro.id; showMacroListEditor = true }, + onDuplicate = { + val layout = MacroPadState.activeLayout.value + if (layout != null && editingButton != null) { + MacroPadState.duplicateButtonInLayout(editingButton!!, layout.id) + } + editingButtonActive = false + editingButton = null + }, + onCopyToLayout = { + showCopyButtonLayoutDialog = true + }, onConfirm = { updated -> val layout = MacroPadState.activeLayout.value ?: return@ButtonEditDialog MacroPadState.updateLayout( @@ -373,6 +389,10 @@ fun MacroPadEditor(onDone: () -> Unit) { showEditLayoutDialog = false layoutPendingDelete = curLayout }, + onCopyToProfile = { + showEditLayoutDialog = false + showCopyLayoutProfileDialog = true + }, onConfirm = { name, enabled, noMirrorStyle, mirrorStyle -> MacroPadState.updateLayout( curLayout.copy( @@ -445,6 +465,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 } 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..8deb7f17a 100644 --- a/app/src/main/java/com/stormpanda/megingiard/macropad/PadButtonEditDialog.kt +++ b/app/src/main/java/com/stormpanda/megingiard/macropad/PadButtonEditDialog.kt @@ -113,6 +113,8 @@ internal fun ButtonEditDialog( enableMouse: Boolean = true, initialAction: PadAction? = null, // pre-set action for new buttons; ignored if button != null onEditMacro: ((Macro) -> Unit)? = null, + onDuplicate: (() -> Unit)? = null, + onCopyToLayout: (() -> Unit)? = null, onConfirm: (PadButton) -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, @@ -653,6 +655,27 @@ internal fun ButtonEditDialog( }, onChange = ::onActionChanged, ) + + if (button != null) { + Spacer(Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + TextButton( + onClick = { onDuplicate?.invoke() }, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.macropad_editor_copy_button_duplicate), color = accentColor) + } + TextButton( + onClick = { onCopyToLayout?.invoke() }, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.macropad_editor_copy_to_layout), color = accentColor) + } + } + } } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 68c533534..53d1a399e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -653,4 +653,11 @@ 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e1728def1..4bb07b14a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -646,4 +646,11 @@ 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 diff --git a/docs/features/macropad/FEATURE.md b/docs/features/macropad/FEATURE.md index ca234f1d0..847fc738a 100644 --- a/docs/features/macropad/FEATURE.md +++ b/docs/features/macropad/FEATURE.md @@ -291,6 +291,13 @@ 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 + +- **Macro Copying**: The Macro Library context menu (3-dots) MUST provide a **"Copy to Profile…"** option. Tapping it shows an inline selection overlay with all other profiles. Selecting a profile clones the macro into it (generating a new UUID, and appending `(Copy)` or increments to the name on collision). +- **Layout Copying**: The Layout Settings overlay MUST provide a **"Copy to Profile…"** option. Selecting a target profile clones the layout (including background settings) and all its buttons into it. If copying cross-profile, any buttons referencing macros MUST cause those macros to be copied into the target profile (mapping unique macro IDs to avoid duplicates), and the copied buttons are updated to point to the new macro UUIDs. +- **Button Duplication**: The Button Settings dialog for an existing button MUST provide a **"Duplicate Button"** option. This clones the button in the current layout and offsets its `posX` and `posY` coordinates slightly by `+0.05` (clamped to `[0f, 1f]`) so that the duplicated button is clearly visible and does not perfectly overlap the original button. +- **Button Copying**: The Button Settings dialog for an existing button MUST provide a **"Copy to Layout…"** option. Tapping it shows a hierarchical profile-layout selection overlay. Selecting a target layout clones the button into it (generating a new UUID, same position, and copying the macro if copying to a different profile, updating the macro ID). + --- ## Technical Implementation 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..4e451cca4 100644 --- a/domain/src/main/java/com/stormpanda/megingiard/macropad/MacroPadState.kt +++ b/domain/src/main/java/com/stormpanda/megingiard/macropad/MacroPadState.kt @@ -386,6 +386,146 @@ object MacroPadState { 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} (Copy)" + val uniqueName = existingNames.nextUniqueName(desiredName, "Macro (Copy)") + 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} (Copy)" + val uniqueName = existingNames.nextUniqueName(desiredName, "Layout (Copy)") + + 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} (Copy)", "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 copiedButtons = layout.buttons.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 + ) + } + + val copiedLayout = layout.copy( + id = UUID.randomUUID().toString(), + name = uniqueName, + buttons = copiedButtons + ) + + 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} (Copy)", "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 + 0.05f).coerceIn(0f, 1f) + val newPosY = (button.posY + 0.05f).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..7fa5f9e1a 100644 --- a/domain/src/test/java/com/stormpanda/megingiard/macropad/MacroPadStateTest.kt +++ b/domain/src/test/java/com/stormpanda/megingiard/macropad/MacroPadStateTest.kt @@ -292,4 +292,147 @@ 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 (Copy)", 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 (Copy) (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 (Copy)", copiedLayout.name) + assertEquals(1, copiedLayout.buttons.size) + + assertEquals(1, targetProfile.macros.size) + val copiedMacro = targetProfile.macros.first() + assertEquals("Fire (Copy)", 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 (Copy)", 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) + } } From 417859b0f1001886cb22f9f89ade706113430539 Mon Sep 17 00:00:00 2001 From: stormpanda Date: Sat, 13 Jun 2026 18:11:55 +0200 Subject: [PATCH 2/6] feat(macropad): refactor copy operations and add profile/layout duplication - Remove " (Copy)" suffix from copy operations in favor of standard collision numbering - Implement duplicateProfile and duplicateLayout deep-copy state mutations in MacroPadState - Merge individual edit/reorder buttons in profile and layout chips bars into unified "..." dropdown menus - Replace delete button in button list items with a "..." dropdown containing edit, duplicate, copy to layout, and delete options - Remove redundant copy/duplicate button actions from PadButtonEditDialog and InlineLayoutSettingsOverlay - Add localized string resources for profile/layout duplication in English and German - Update assertions and add new duplication test coverage in MacroPadStateTest - Update macropad FEATURE.md documentation to reflect the new UI and behavior specifications --- .../megingiard/macropad/ButtonListItem.kt | 39 +++++- .../macropad/EditorInlineOverlays.kt | 16 --- .../macropad/EditorLayoutComponents.kt | 122 +++++++++++------- .../megingiard/macropad/MacroPadEditor.kt | 24 ++-- .../macropad/PadButtonEditDialog.kt | 22 ---- app/src/main/res/values-de/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + docs/features/macropad/FEATURE.md | 10 +- .../megingiard/macropad/MacroPadState.kt | 96 ++++++++++++-- .../megingiard/macropad/MacroPadStateTest.kt | 84 +++++++++++- 10 files changed, 298 insertions(+), 119 deletions(-) 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/EditorInlineOverlays.kt b/app/src/main/java/com/stormpanda/megingiard/macropad/EditorInlineOverlays.kt index fb3582e17..54a41d514 100644 --- a/app/src/main/java/com/stormpanda/megingiard/macropad/EditorInlineOverlays.kt +++ b/app/src/main/java/com/stormpanda/megingiard/macropad/EditorInlineOverlays.kt @@ -537,7 +537,6 @@ internal fun InlineLayoutSettingsOverlay( showDelete: Boolean, canDelete: Boolean, onDelete: () -> Unit, - onCopyToProfile: () -> Unit, onConfirm: (String, Boolean, ButtonColorStyle, ButtonColorStyle) -> Unit, onDismiss: () -> Unit, ) { @@ -649,21 +648,6 @@ internal fun InlineLayoutSettingsOverlay( onSelect = { mirrorStyle = it } ) - Spacer(Modifier.height(12.dp)) - AppDivider() - Spacer(Modifier.height(12.dp)) - - TextButton( - onClick = onCopyToProfile, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(R.string.macropad_editor_copy_to_profile), - color = accentColor, - style = MaterialTheme.typography.labelLarge - ) - } - Spacer(Modifier.height(16.dp)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { TextButton(onClick = onDismiss) { 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..578e5a041 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,12 @@ internal fun EditorProfileChipsBar( activeProfile: PadProfile?, onSelectProfile: (String) -> Unit, onEditProfile: () -> Unit, + onDuplicateProfile: () -> Unit, onReorderProfiles: () -> Unit, modifier: Modifier = Modifier, ) { val colors = LocalAppColors.current + var menuExpanded by remember { mutableStateOf(false) } Row( modifier = modifier.fillMaxWidth(), @@ -69,28 +76,36 @@ 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() } + ) + } } } } @@ -101,11 +116,14 @@ internal fun EditorLayoutChipsBar( activeLayout: PadLayout?, onSelectLayout: (String) -> Unit, onEditLayout: () -> Unit, + onDuplicateLayout: () -> Unit, + onCopyToProfile: () -> Unit, onReorderLayouts: () -> Unit, modifier: Modifier = Modifier, ) { val colors = LocalAppColors.current val latestLayouts by rememberUpdatedState(layouts) + var menuExpanded by remember { mutableStateOf(false) } val lazyRowState = rememberLazyListState() val reorderState = rememberReorderableLazyListState(lazyRowState) { from, to -> @@ -146,28 +164,40 @@ 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() } + ) + } } } } 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 8d0fc88e1..b16c7ad24 100644 --- a/app/src/main/java/com/stormpanda/megingiard/macropad/MacroPadEditor.kt +++ b/app/src/main/java/com/stormpanda/megingiard/macropad/MacroPadEditor.kt @@ -191,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 }, @@ -236,17 +238,6 @@ fun MacroPadEditor(onDone: () -> Unit) { button = editingButton, accentColor = colors.accent, onEditMacro = { macro -> pendingMacroEditId = macro.id; showMacroListEditor = true }, - onDuplicate = { - val layout = MacroPadState.activeLayout.value - if (layout != null && editingButton != null) { - MacroPadState.duplicateButtonInLayout(editingButton!!, layout.id) - } - editingButtonActive = false - editingButton = null - }, - onCopyToLayout = { - showCopyButtonLayoutDialog = true - }, onConfirm = { updated -> val layout = MacroPadState.activeLayout.value ?: return@ButtonEditDialog MacroPadState.updateLayout( @@ -389,10 +380,6 @@ fun MacroPadEditor(onDone: () -> Unit) { showEditLayoutDialog = false layoutPendingDelete = curLayout }, - onCopyToProfile = { - showEditLayoutDialog = false - showCopyLayoutProfileDialog = true - }, onConfirm = { name, enabled, noMirrorStyle, mirrorStyle -> MacroPadState.updateLayout( curLayout.copy( @@ -553,6 +540,8 @@ private fun EditorBody( onManageMacros: () -> Unit, onAddButton: () -> Unit, onEditButton: (PadButton) -> Unit, + onCopyToProfile: () -> Unit, + onCopyToLayout: (PadButton) -> Unit, onDeleteRequested: (PadButton) -> Unit, onReorderProfiles: () -> Unit, onReorderLayouts: () -> Unit, @@ -601,6 +590,7 @@ private fun EditorBody( activeProfile = profile, onSelectProfile = onSelectProfile, onEditProfile = onEditProfile, + onDuplicateProfile = { profile?.id?.let { MacroPadState.duplicateProfile(it) } }, onReorderProfiles = onReorderProfiles, modifier = Modifier .background(colors.surface) @@ -626,6 +616,8 @@ private fun EditorBody( activeLayout = layout, onSelectLayout = onSelectLayout, onEditLayout = onEditLayout, + onDuplicateLayout = { layout?.id?.let { MacroPadState.duplicateLayout(it) } }, + onCopyToProfile = onCopyToProfile, onReorderLayouts = onReorderLayouts, modifier = Modifier .background(colors.surface) @@ -697,6 +689,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 8deb7f17a..ff83e99ca 100644 --- a/app/src/main/java/com/stormpanda/megingiard/macropad/PadButtonEditDialog.kt +++ b/app/src/main/java/com/stormpanda/megingiard/macropad/PadButtonEditDialog.kt @@ -113,8 +113,6 @@ internal fun ButtonEditDialog( enableMouse: Boolean = true, initialAction: PadAction? = null, // pre-set action for new buttons; ignored if button != null onEditMacro: ((Macro) -> Unit)? = null, - onDuplicate: (() -> Unit)? = null, - onCopyToLayout: (() -> Unit)? = null, onConfirm: (PadButton) -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, @@ -656,26 +654,6 @@ internal fun ButtonEditDialog( onChange = ::onActionChanged, ) - if (button != null) { - Spacer(Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - TextButton( - onClick = { onDuplicate?.invoke() }, - modifier = Modifier.weight(1f) - ) { - Text(stringResource(R.string.macropad_editor_copy_button_duplicate), color = accentColor) - } - TextButton( - onClick = { onCopyToLayout?.invoke() }, - modifier = Modifier.weight(1f) - ) { - Text(stringResource(R.string.macropad_editor_copy_to_layout), color = accentColor) - } - } - } } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 53d1a399e..61e8c49f2 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -660,4 +660,6 @@ Einstellungen öffnen, um ein Layout zu erstellen. Zielprofil auswählen Ziellayout auswählen Taste duplizieren + Profil duplizieren + Layout duplizieren diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4bb07b14a..72f940d99 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -653,4 +653,6 @@ Open Settings to create a layout. Select Target Profile Select Target Layout Duplicate Button + Duplicate Profile + Duplicate Layout diff --git a/docs/features/macropad/FEATURE.md b/docs/features/macropad/FEATURE.md index 847fc738a..274211c3c 100644 --- a/docs/features/macropad/FEATURE.md +++ b/docs/features/macropad/FEATURE.md @@ -293,10 +293,12 @@ Each button supports one of the following actions: ### FR-P16: Entity Duplication and Copying -- **Macro Copying**: The Macro Library context menu (3-dots) MUST provide a **"Copy to Profile…"** option. Tapping it shows an inline selection overlay with all other profiles. Selecting a profile clones the macro into it (generating a new UUID, and appending `(Copy)` or increments to the name on collision). -- **Layout Copying**: The Layout Settings overlay MUST provide a **"Copy to Profile…"** option. Selecting a target profile clones the layout (including background settings) and all its buttons into it. If copying cross-profile, any buttons referencing macros MUST cause those macros to be copied into the target profile (mapping unique macro IDs to avoid duplicates), and the copied buttons are updated to point to the new macro UUIDs. -- **Button Duplication**: The Button Settings dialog for an existing button MUST provide a **"Duplicate Button"** option. This clones the button in the current layout and offsets its `posX` and `posY` coordinates slightly by `+0.05` (clamped to `[0f, 1f]`) so that the duplicated button is clearly visible and does not perfectly overlap the original button. -- **Button Copying**: The Button Settings dialog for an existing button MUST provide a **"Copy to Layout…"** option. Tapping it shows a hierarchical profile-layout selection overlay. Selecting a target layout clones the button into it (generating a new UUID, same position, and copying the macro if copying to a different profile, updating the macro ID). +- **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/metadata settings editing, without copy or duplicate options. --- 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 4e451cca4..9de80bbe2 100644 --- a/domain/src/main/java/com/stormpanda/megingiard/macropad/MacroPadState.kt +++ b/domain/src/main/java/com/stormpanda/megingiard/macropad/MacroPadState.kt @@ -250,6 +250,61 @@ 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 -> + val copiedButtons = layout.buttons.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 + ) + } + layout.copy( + id = UUID.randomUUID().toString(), + buttons = copiedButtons + ) + } + + 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 +368,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,11 +456,13 @@ 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)) } @@ -390,8 +470,8 @@ object MacroPadState { 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} (Copy)" - val uniqueName = existingNames.nextUniqueName(desiredName, "Macro (Copy)") + val desiredName = macro.name + val uniqueName = existingNames.nextUniqueName(desiredName, "Macro") val copied = macro.copy( id = UUID.randomUUID().toString(), name = uniqueName, @@ -407,8 +487,8 @@ object MacroPadState { val targetProfile = _profiles.value.firstOrNull { it.id == targetProfileId } ?: return val existingNames = targetProfile.layouts.map { it.name } - val desiredName = "${layout.name} (Copy)" - val uniqueName = existingNames.nextUniqueName(desiredName, "Layout (Copy)") + val desiredName = layout.name + val uniqueName = existingNames.nextUniqueName(desiredName, "Layout") val macroMapping = mutableMapOf() // source macro ID -> target macro ID var updatedTargetProfile = targetProfile @@ -419,7 +499,7 @@ object MacroPadState { val sourceMacro = sourceProfile.macros.firstOrNull { it.id == macroId } if (sourceMacro != null) { val targetExistingMacroNames = updatedTargetProfile.macros.map { it.name } - val targetMacroName = targetExistingMacroNames.nextUniqueName("${sourceMacro.name} (Copy)", "Macro") + val targetMacroName = targetExistingMacroNames.nextUniqueName(sourceMacro.name, "Macro") val targetMacroId = UUID.randomUUID().toString() val copiedMacro = sourceMacro.copy( id = targetMacroId, @@ -471,7 +551,7 @@ object MacroPadState { val sourceMacro = sourceProfile.macros.firstOrNull { it.id == macroId } if (sourceMacro != null) { val targetExistingMacroNames = updatedTargetProfile.macros.map { it.name } - val targetMacroName = targetExistingMacroNames.nextUniqueName("${sourceMacro.name} (Copy)", "Macro") + val targetMacroName = targetExistingMacroNames.nextUniqueName(sourceMacro.name, "Macro") val targetMacroId = UUID.randomUUID().toString() val copiedMacro = sourceMacro.copy( id = targetMacroId, 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 7fa5f9e1a..5edb38b6e 100644 --- a/domain/src/test/java/com/stormpanda/megingiard/macropad/MacroPadStateTest.kt +++ b/domain/src/test/java/com/stormpanda/megingiard/macropad/MacroPadStateTest.kt @@ -310,7 +310,7 @@ class MacroPadStateTest { name = "P2", layouts = listOf(PadLayout(id = "l2", name = "L2")), activeLayoutId = "l2", - macros = listOf(Macro(id = "m2", name = "Combo (Copy)", steps = emptyList())) + macros = listOf(Macro(id = "m2", name = "Combo", steps = emptyList())) ) MacroPadState.loadFrom(listOf(p1, p2), p1Id) @@ -319,7 +319,7 @@ class MacroPadStateTest { val target = MacroPadState.profiles.value.first { it.id == p2Id } assertEquals(2, target.macros.size) val copied = target.macros.first { it.id != "m2" } - assertEquals("Combo (Copy) (2)", copied.name) + assertEquals("Combo (2)", copied.name) } @Test @@ -355,12 +355,12 @@ class MacroPadStateTest { 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 (Copy)", copiedLayout.name) + assertEquals("Lay1", copiedLayout.name) assertEquals(1, copiedLayout.buttons.size) assertEquals(1, targetProfile.macros.size) val copiedMacro = targetProfile.macros.first() - assertEquals("Fire (Copy)", copiedMacro.name) + assertEquals("Fire", copiedMacro.name) val copiedBtn = copiedLayout.buttons.first() val copiedBtnAction = copiedBtn.action as PadAction.Macro @@ -405,7 +405,7 @@ class MacroPadStateTest { assertEquals(1, targetProfile.macros.size) val copiedMacro = targetProfile.macros.first() - assertEquals("Punch (Copy)", copiedMacro.name) + assertEquals("Punch", copiedMacro.name) assertEquals(copiedMacro.id, (copiedBtn.action as PadAction.Macro).macroId) } @@ -435,4 +435,78 @@ class MacroPadStateTest { 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) + org.junit.Assert.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) + org.junit.Assert.assertNotEquals("macro-1", dupMacro.id) + + val dupLayout = duplicatedProfile.layouts.first() + assertEquals("Lay1", dupLayout.name) + assertEquals(1, dupLayout.buttons.size) + + val dupBtn = dupLayout.buttons.first() + org.junit.Assert.assertNotEquals("btn-1", dupBtn.id) + assertEquals(dupMacro.id, (dupBtn.action as PadAction.Macro).macroId) + } } From 8fbca4e6072f6894d21c6b5cabac7a7f675d4c68 Mon Sep 17 00:00:00 2001 From: stormpanda Date: Sat, 13 Jun 2026 18:17:25 +0200 Subject: [PATCH 3/6] refactor(macropad): abstract and simplify dialog overlays and domain logic - Extract `cloneWithMacroMapping` extension function in `MacroPadState.kt` to deduplicate button cloning logic in profile duplication and layout copying. - Implement reusable `InlineDialogOverlay` container component in `EditorInlineOverlays.kt` to encapsulate scrim, blockPointerEvents, layout, header, and cancel behavior. - Refactor confirm delete, name input, profile settings, layout settings, profile selection, and layout selection overlays to consume `InlineDialogOverlay`, eliminating significant UI code duplication. - Clean up unused imports in `CopyDialogs.kt`. - Update `FEATURE.md` documentation to reflect the new overlay component structure and "..." context menus. --- .../megingiard/macropad/CopyDialogs.kt | 180 ++--- .../macropad/EditorInlineOverlays.kt | 715 +++++++++--------- docs/features/macropad/FEATURE.md | 2 +- .../megingiard/macropad/MacroPadState.kt | 47 +- 4 files changed, 427 insertions(+), 517 deletions(-) diff --git a/app/src/main/java/com/stormpanda/megingiard/macropad/CopyDialogs.kt b/app/src/main/java/com/stormpanda/megingiard/macropad/CopyDialogs.kt index 21ce04bb6..f3739658f 100644 --- a/app/src/main/java/com/stormpanda/megingiard/macropad/CopyDialogs.kt +++ b/app/src/main/java/com/stormpanda/megingiard/macropad/CopyDialogs.kt @@ -1,33 +1,21 @@ package com.stormpanda.megingiard.macropad -import androidx.compose.foundation.background 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.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -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 -import com.stormpanda.megingiard.ui.blockPointerEvents @Composable internal fun InlineProfileSelectionOverlay( @@ -40,66 +28,39 @@ internal fun InlineProfileSelectionOverlay( val colors = LocalAppColors.current val filteredProfiles = profiles.filter { it.id != excludeProfileId } - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.5f)) - .clickable(onClick = onDismiss) - .blockPointerEvents(), - contentAlignment = Alignment.Center, + InlineDialogOverlay( + title = title, + onDismiss = onDismiss, ) { - Column( - modifier = Modifier - .fillMaxWidth(0.85f) - .background(colors.surface, RoundedCornerShape(12.dp)) - .clickable(enabled = true, onClick = {}) - .padding(16.dp), - ) { + if (filteredProfiles.isEmpty()) { Text( - text = title, - color = colors.onSurface, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold + text = "No other profiles available.", + color = colors.onSurfaceSecondary, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 16.dp) ) - Spacer(Modifier.height(12.dp)) - - if (filteredProfiles.isEmpty()) { - Text( - text = "No other 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 - ) - } + } 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 + ) } } } - - 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) - } - } } } } @@ -114,72 +75,45 @@ internal fun InlineLayoutSelectionOverlay( ) { val colors = LocalAppColors.current - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.5f)) - .clickable(onClick = onDismiss) - .blockPointerEvents(), - contentAlignment = Alignment.Center, + InlineDialogOverlay( + title = title, + onDismiss = onDismiss, ) { - Column( + LazyColumn( modifier = Modifier - .fillMaxWidth(0.85f) - .background(colors.surface, RoundedCornerShape(12.dp)) - .clickable(enabled = true, onClick = {}) - .padding(16.dp), + .fillMaxWidth() + .heightIn(max = 300.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - text = title, - color = colors.onSurface, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold - ) - Spacer(Modifier.height(12.dp)) - - 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}") { + 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 = profile.name, - color = colors.accent, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(top = 8.dp, bottom = 4.dp, start = 8.dp) + text = layout.name, + color = colors.onSurface, + style = MaterialTheme.typography.bodyLarge ) } - 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 - ) - } - } } } } - - 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) - } - } } } } 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..21e4aa28c 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)) + } + }, + ) } } @@ -229,248 +273,216 @@ 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, + titleAccessory = { + if (!showAppList && showDelete) { + IconButton( + onClick = onDelete, + enabled = canDelete, + modifier = Modifier.size(24.dp) + ) { + 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) + ) + } + } + }, + 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, @@ -549,121 +561,96 @@ 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, + titleAccessory = { + 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) + ) + } + } + }, + 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/docs/features/macropad/FEATURE.md b/docs/features/macropad/FEATURE.md index 274211c3c..ad8d47bf1 100644 --- a/docs/features/macropad/FEATURE.md +++ b/docs/features/macropad/FEATURE.md @@ -591,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, and initiating full-screen drag-reordering (`ReorderProfilesOverlay` or `ReorderLayoutsOverlay`). 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. 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 9de80bbe2..dd25544be 100644 --- a/domain/src/main/java/com/stormpanda/megingiard/macropad/MacroPadState.kt +++ b/domain/src/main/java/com/stormpanda/megingiard/macropad/MacroPadState.kt @@ -55,6 +55,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. * @@ -267,22 +283,9 @@ object MacroPadState { // Copy layouts val copiedLayouts = sourceProfile.layouts.map { layout -> - val copiedButtons = layout.buttons.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 - ) - } layout.copy( id = UUID.randomUUID().toString(), - buttons = copiedButtons + buttons = layout.buttons.cloneWithMacroMapping(macroMapping) ) } @@ -512,24 +515,10 @@ object MacroPadState { } } - val copiedButtons = layout.buttons.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 - ) - } - val copiedLayout = layout.copy( id = UUID.randomUUID().toString(), name = uniqueName, - buttons = copiedButtons + buttons = layout.buttons.cloneWithMacroMapping(macroMapping) ) AppLog.d(TAG, "copyLayoutToProfile layoutId=${layout.id} name='$uniqueName' to profileId=$targetProfileId") From af8ff94fba71717eaef83ead78056f23f462c025 Mon Sep 17 00:00:00 2001 From: stormpanda Date: Sat, 13 Jun 2026 18:19:58 +0200 Subject: [PATCH 4/6] refactor(macropad): move profile and layout delete options to contextual menus - Add "Delete Profile" option to the profiles contextual dropdown menu and disable it if only one profile exists. - Add "Delete Layout" option to the layouts contextual dropdown menu and disable it if only one layout exists. - Remove onDelete, canDelete, and showDelete parameters and header delete icon buttons from InlineProfileSettingsOverlay and InlineLayoutSettingsOverlay dialogs. - Wire up onDeleteProfile and onDeleteLayout callbacks in MacroPadEditor.kt. - Update FEATURE.md to reflect that profile and layout deletion resides in contextual dropdowns. --- .../macropad/EditorInlineOverlays.kt | 38 ------------------- .../macropad/EditorLayoutComponents.kt | 26 +++++++++++++ .../megingiard/macropad/MacroPadEditor.kt | 17 +-------- docs/features/macropad/FEATURE.md | 2 +- 4 files changed, 29 insertions(+), 54 deletions(-) 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 21e4aa28c..8cf258b95 100644 --- a/app/src/main/java/com/stormpanda/megingiard/macropad/EditorInlineOverlays.kt +++ b/app/src/main/java/com/stormpanda/megingiard/macropad/EditorInlineOverlays.kt @@ -210,9 +210,6 @@ internal fun InlineProfileSettingsOverlay( initialPackage: String?, accentColor: Color, existingNames: List, - showDelete: Boolean, - canDelete: Boolean, - onDelete: () -> Unit, onConfirm: (String, String?) -> Unit, onDismiss: () -> Unit, ) { @@ -276,22 +273,6 @@ internal fun InlineProfileSettingsOverlay( InlineDialogOverlay( title = if (showAppList) stringResource(R.string.profile_settings_app_mapping) else title, onDismiss = onDismiss, - titleAccessory = { - if (!showAppList && showDelete) { - IconButton( - onClick = onDelete, - enabled = canDelete, - modifier = Modifier.size(24.dp) - ) { - 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) - ) - } - } - }, buttonsArrangement = if (showAppList) Arrangement.Start else Arrangement.End, buttonsRow = { if (showAppList) { @@ -546,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, ) { @@ -564,22 +542,6 @@ internal fun InlineLayoutSettingsOverlay( InlineDialogOverlay( title = title, onDismiss = onDismiss, - titleAccessory = { - 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) - ) - } - } - }, buttonsRow = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.macropad_editor_cancel), color = colors.onSurfaceSecondary) 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 578e5a041..361d1c8cc 100644 --- a/app/src/main/java/com/stormpanda/megingiard/macropad/EditorLayoutComponents.kt +++ b/app/src/main/java/com/stormpanda/megingiard/macropad/EditorLayoutComponents.kt @@ -51,10 +51,12 @@ internal fun EditorProfileChipsBar( 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(), @@ -105,6 +107,17 @@ internal fun EditorProfileChipsBar( 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() } + ) } } } @@ -119,11 +132,13 @@ internal fun EditorLayoutChipsBar( 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 -> @@ -197,6 +212,17 @@ internal fun EditorLayoutChipsBar( 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/MacroPadEditor.kt b/app/src/main/java/com/stormpanda/megingiard/macropad/MacroPadEditor.kt index b16c7ad24..a3c245a77 100644 --- a/app/src/main/java/com/stormpanda/megingiard/macropad/MacroPadEditor.kt +++ b/app/src/main/java/com/stormpanda/megingiard/macropad/MacroPadEditor.kt @@ -315,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) @@ -335,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 @@ -374,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( @@ -592,6 +577,7 @@ private fun EditorBody( onEditProfile = onEditProfile, onDuplicateProfile = { profile?.id?.let { MacroPadState.duplicateProfile(it) } }, onReorderProfiles = onReorderProfiles, + onDeleteProfile = onDeleteProfile, modifier = Modifier .background(colors.surface) .padding(horizontal = MPE_PADDING) @@ -619,6 +605,7 @@ private fun EditorBody( 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) diff --git a/docs/features/macropad/FEATURE.md b/docs/features/macropad/FEATURE.md index ad8d47bf1..f45bac28e 100644 --- a/docs/features/macropad/FEATURE.md +++ b/docs/features/macropad/FEATURE.md @@ -591,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, 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, and initiating full-screen drag-reordering (`ReorderProfilesOverlay` or `ReorderLayoutsOverlay`). 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. From 6d580e7004c0ac9a9b5886b640bc0aa0dbbc540d Mon Sep 17 00:00:00 2001 From: stormpanda Date: Sat, 13 Jun 2026 21:49:59 +0200 Subject: [PATCH 5/6] fix(macropad): address PR review comments for CopyDialogs localization and layout empty states - Add `private const val TAG = "CopyDialogs"` constant to `CopyDialogs.kt` per AGENTS.md guidelines. - Localize empty-state text "No other profiles available." to `macropad_copy_no_profiles_available` in English and German. - Implement selectable layout checks and empty-state message `macropad_copy_no_layouts_available` in `InlineLayoutSelectionOverlay` to prevent blank broken-looking dialogs. --- .../megingiard/macropad/CopyDialogs.kt | 76 +++++++++++-------- app/src/main/res/values-de/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 3 files changed, 50 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/stormpanda/megingiard/macropad/CopyDialogs.kt b/app/src/main/java/com/stormpanda/megingiard/macropad/CopyDialogs.kt index f3739658f..120c5673c 100644 --- a/app/src/main/java/com/stormpanda/megingiard/macropad/CopyDialogs.kt +++ b/app/src/main/java/com/stormpanda/megingiard/macropad/CopyDialogs.kt @@ -13,10 +13,14 @@ 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, @@ -34,7 +38,7 @@ internal fun InlineProfileSelectionOverlay( ) { if (filteredProfiles.isEmpty()) { Text( - text = "No other profiles available.", + text = stringResource(R.string.macropad_copy_no_profiles_available), color = colors.onSurfaceSecondary, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 16.dp) @@ -74,43 +78,55 @@ internal fun InlineLayoutSelectionOverlay( onDismiss: () -> Unit, ) { val colors = LocalAppColors.current + val hasSelectableLayouts = profiles.any { profile -> + profile.layouts.any { it.id != excludeLayoutId } + } InlineDialogOverlay( title = title, onDismiss = onDismiss, ) { - 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 - ) { + 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 = layout.name, - color = colors.onSurface, - style = MaterialTheme.typography.bodyLarge + 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/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 61e8c49f2..0c06ba5f4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -662,4 +662,6 @@ Einstellungen öffnen, um ein Layout zu erstellen. 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 72f940d99..3e440e29f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -655,4 +655,6 @@ Open Settings to create a layout. Duplicate Button Duplicate Profile Duplicate Layout + No other profiles available. + No other layouts available. From c8507af681d5987145e6697995e3cddc108e3d75 Mon Sep 17 00:00:00 2001 From: stormpanda Date: Sat, 13 Jun 2026 22:05:47 +0200 Subject: [PATCH 6/6] fix(macropad): address additional review comments for magic numbers, test imports, and feature docs typo - Extract `duplicateButtonInLayout` magic number `0.05f` into the file-scoped constant `DUPLICATE_BUTTON_OFFSET` in `MacroPadState.kt`. - Clean up fully-qualified `Assert.assertNotEquals` usages in `MacroPadStateTest.kt` and add it as an explicit import. - Correct the "metadata/metadata settings editing" typo in `FEATURE.md`. --- docs/features/macropad/FEATURE.md | 2 +- .../com/stormpanda/megingiard/macropad/MacroPadState.kt | 5 +++-- .../stormpanda/megingiard/macropad/MacroPadStateTest.kt | 7 ++++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/features/macropad/FEATURE.md b/docs/features/macropad/FEATURE.md index f45bac28e..5a389c3d9 100644 --- a/docs/features/macropad/FEATURE.md +++ b/docs/features/macropad/FEATURE.md @@ -298,7 +298,7 @@ Each button supports one of the following actions: - **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/metadata settings editing, without copy or duplicate options. + - **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. --- 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 dd25544be..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 } @@ -574,8 +575,8 @@ object MacroPadState { val profile = activeProfile.value ?: return val layout = profile.layouts.firstOrNull { it.id == layoutId } ?: return - val newPosX = (button.posX + 0.05f).coerceIn(0f, 1f) - val newPosY = (button.posY + 0.05f).coerceIn(0f, 1f) + 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(), 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 5edb38b6e..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 @@ -464,7 +465,7 @@ class MacroPadStateTest { assertEquals(1, duplicated.buttons.size) val dupBtn = duplicated.buttons.first() assertEquals("B", dupBtn.label) - org.junit.Assert.assertNotEquals("btn-1", dupBtn.id) + assertNotEquals("btn-1", dupBtn.id) } @Test @@ -499,14 +500,14 @@ class MacroPadStateTest { val dupMacro = duplicatedProfile.macros.first() assertEquals("Slash", dupMacro.name) - org.junit.Assert.assertNotEquals("macro-1", dupMacro.id) + assertNotEquals("macro-1", dupMacro.id) val dupLayout = duplicatedProfile.layouts.first() assertEquals("Lay1", dupLayout.name) assertEquals(1, dupLayout.buttons.size) val dupBtn = dupLayout.buttons.first() - org.junit.Assert.assertNotEquals("btn-1", dupBtn.id) + assertNotEquals("btn-1", dupBtn.id) assertEquals(dupMacro.id, (dupBtn.action as PadAction.Macro).macroId) } }