From f29b1cd4a70b6339c21a836a33fa8d49dfbe1e37 Mon Sep 17 00:00:00 2001 From: SamAmco Date: Mon, 16 Mar 2026 23:05:31 +0000 Subject: [PATCH 1/3] Add dialog for symlinks in component context menu --- .../graphstatview/ui/GraphStatCardView.kt | 12 +- .../com/samco/trackandgraph/group/Function.kt | 18 ++- .../com/samco/trackandgraph/group/Group.kt | 18 ++- .../samco/trackandgraph/group/GroupScreen.kt | 33 +++-- .../trackandgraph/group/SymlinksDialog.kt | 116 ++++++++++++++++++ .../group/SymlinksDialogViewModel.kt | 67 ++++++++++ .../com/samco/trackandgraph/group/Tracker.kt | 20 ++- .../ui/compose/ui/PreviewHeroCardButton.kt | 1 + .../util/ComponentPathProvider.kt | 87 +++++++++++++ app/app/src/main/res/values/strings.xml | 2 + docs/knowledge-base/component-types.md | 3 +- docs/knowledge-base/group-hierarchy.md | 19 ++- docs/knowledge-base/index.yaml | 8 +- 13 files changed, 381 insertions(+), 23 deletions(-) create mode 100644 app/app/src/main/java/com/samco/trackandgraph/group/SymlinksDialog.kt create mode 100644 app/app/src/main/java/com/samco/trackandgraph/group/SymlinksDialogViewModel.kt create mode 100644 app/app/src/main/java/com/samco/trackandgraph/util/ComponentPathProvider.kt diff --git a/app/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/GraphStatCardView.kt b/app/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/GraphStatCardView.kt index 66caf7cbd..ab5916d2a 100644 --- a/app/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/GraphStatCardView.kt +++ b/app/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/GraphStatCardView.kt @@ -35,7 +35,8 @@ class GraphStatClickListener( val onEdit: (graphStat: IGraphStatViewData) -> Unit, val onClick: (graphStat: IGraphStatViewData) -> Unit, val onMove: (graphStat: IGraphStatViewData) -> Unit, - val onDuplicate: (graphStat: IGraphStatViewData) -> Unit + val onDuplicate: (graphStat: IGraphStatViewData) -> Unit, + val onSymlinks: (graphStat: IGraphStatViewData) -> Unit = {}, ) @Composable @@ -140,5 +141,14 @@ private fun MenuSection( expanded = false } ) + if (!graphStatViewData.graphOrStat.unique) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.symlinks)) }, + onClick = { + clickListener.onSymlinks(graphStatViewData) + expanded = false + } + ) + } } } \ No newline at end of file diff --git a/app/app/src/main/java/com/samco/trackandgraph/group/Function.kt b/app/app/src/main/java/com/samco/trackandgraph/group/Function.kt index ac4b2f8d8..db3df91bb 100644 --- a/app/app/src/main/java/com/samco/trackandgraph/group/Function.kt +++ b/app/app/src/main/java/com/samco/trackandgraph/group/Function.kt @@ -86,6 +86,7 @@ fun Function( onDelete: (DisplayFunction) -> Unit, onMoveTo: (DisplayFunction) -> Unit, onDuplicate: (DisplayFunction) -> Unit, + onSymlinks: (DisplayFunction) -> Unit, onClick: (DisplayFunction) -> Unit, ) = Box(modifier = modifier.fillMaxWidth()) { var showContextMenu by remember { mutableStateOf(false) } @@ -140,7 +141,8 @@ fun Function( onEdit = onEdit, onDelete = onDelete, onMoveTo = onMoveTo, - onDuplicate = onDuplicate + onDuplicate = onDuplicate, + onSymlinks = onSymlinks, ) // Function name FunctionNameText(functionName = displayFunction.name) @@ -168,7 +170,8 @@ private fun FunctionMenuButton( onEdit: (DisplayFunction) -> Unit, onDelete: (DisplayFunction) -> Unit, onMoveTo: (DisplayFunction) -> Unit, - onDuplicate: (DisplayFunction) -> Unit + onDuplicate: (DisplayFunction) -> Unit, + onSymlinks: (DisplayFunction) -> Unit, ) { Box(modifier = modifier) { IconButton( @@ -217,6 +220,15 @@ private fun FunctionMenuButton( onDuplicate(displayFunction) } ) + if (!displayFunction.unique) { + DropdownMenuItem( + text = { Text(stringResource(R.string.symlinks)) }, + onClick = { + onShowContextMenu(false) + onSymlinks(displayFunction) + } + ) + } } } } @@ -258,6 +270,7 @@ fun FunctionPreview() { onDelete = {}, onMoveTo = {}, onDuplicate = {}, + onSymlinks = {}, onClick = {} ) @@ -274,6 +287,7 @@ fun FunctionPreview() { onDelete = {}, onMoveTo = {}, onDuplicate = {}, + onSymlinks = {}, onClick = {} ) } diff --git a/app/app/src/main/java/com/samco/trackandgraph/group/Group.kt b/app/app/src/main/java/com/samco/trackandgraph/group/Group.kt index 4614cd897..ab38c1d53 100644 --- a/app/app/src/main/java/com/samco/trackandgraph/group/Group.kt +++ b/app/app/src/main/java/com/samco/trackandgraph/group/Group.kt @@ -70,6 +70,7 @@ fun Group( onEdit: (Group) -> Unit, onDelete: (Group) -> Unit, onMoveTo: (Group) -> Unit, + onSymlinks: (Group) -> Unit, onClick: (Group) -> Unit, ) = Box(modifier = modifier.fillMaxWidth()) { var showContextMenu by remember { mutableStateOf(false) } @@ -113,7 +114,8 @@ fun Group( group = group, onEdit = onEdit, onDelete = onDelete, - onMoveTo = onMoveTo + onMoveTo = onMoveTo, + onSymlinks = onSymlinks, ) // Group name text @@ -138,7 +140,8 @@ private fun GroupMenuButton( group: Group, onEdit: (Group) -> Unit, onDelete: (Group) -> Unit, - onMoveTo: (Group) -> Unit + onMoveTo: (Group) -> Unit, + onSymlinks: (Group) -> Unit, ) { Box( modifier = modifier.size(buttonSize) @@ -180,6 +183,15 @@ private fun GroupMenuButton( onMoveTo(group) } ) + if (!group.unique) { + DropdownMenuItem( + text = { Text(stringResource(R.string.symlinks)) }, + onClick = { + onShowContextMenu(false) + onSymlinks(group) + } + ) + } } } } @@ -198,6 +210,7 @@ private fun GroupPreview() { onEdit = {}, onDelete = {}, onMoveTo = {}, + onSymlinks = {}, onClick = {} ) } @@ -218,6 +231,7 @@ private fun GroupElevatedPreview() { onEdit = {}, onDelete = {}, onMoveTo = {}, + onSymlinks = {}, onClick = {} ) } diff --git a/app/app/src/main/java/com/samco/trackandgraph/group/GroupScreen.kt b/app/app/src/main/java/com/samco/trackandgraph/group/GroupScreen.kt index a99c21098..e56b24865 100644 --- a/app/app/src/main/java/com/samco/trackandgraph/group/GroupScreen.kt +++ b/app/app/src/main/java/com/samco/trackandgraph/group/GroupScreen.kt @@ -72,6 +72,7 @@ import com.samco.trackandgraph.addgroup.AddGroupDialog import com.samco.trackandgraph.addgroup.AddGroupDialogViewModelImpl import com.samco.trackandgraph.data.database.dto.DisplayTracker import com.samco.trackandgraph.data.database.dto.Group +import com.samco.trackandgraph.data.database.dto.GroupChildType import com.samco.trackandgraph.graphstatview.factories.viewdto.IGraphStatViewData import com.samco.trackandgraph.graphstatview.ui.GraphStatCardView import com.samco.trackandgraph.graphstatview.ui.GraphStatClickListener @@ -128,6 +129,7 @@ fun GroupScreen( val addGroupDialogViewModel: AddGroupDialogViewModelImpl = hiltViewModel() val addSymlinkViewModel: AddSymlinkViewModel = hiltViewModel() val releaseNotesViewModel: ReleaseNotesViewModel = hiltViewModel() + val symlinksDialogViewModel: SymlinksDialogViewModel = hiltViewModel() LaunchedEffect(navArgs.groupId) { groupViewModel.setGroup(navArgs.groupId) @@ -154,6 +156,7 @@ fun GroupScreen( addGroupDialogViewModel = addGroupDialogViewModel, addSymlinkViewModel = addSymlinkViewModel, releaseNotesViewModel = releaseNotesViewModel, + symlinksDialogViewModel = symlinksDialogViewModel, groupId = navArgs.groupId, groupName = navArgs.groupName, onTrackerEdit = onTrackerEdit, @@ -173,6 +176,7 @@ data class TrackerClickListeners( val onDelete: (DisplayTracker) -> Unit = {}, val onMoveTo: (DisplayTracker) -> Unit = {}, val onDescription: (DisplayTracker) -> Unit = {}, + val onSymlinks: (DisplayTracker) -> Unit = {}, val onAdd: (DisplayTracker, Boolean) -> Unit = { _, _ -> }, val onHistory: (DisplayTracker) -> Unit = {}, val onPlayTimer: (DisplayTracker) -> Unit = {}, @@ -184,14 +188,16 @@ data class GraphStatClickListeners( val onEdit: (IGraphStatViewData) -> Unit = {}, val onClick: (IGraphStatViewData) -> Unit = {}, val onMove: (IGraphStatViewData) -> Unit = {}, - val onDuplicate: (IGraphStatViewData) -> Unit = {} + val onDuplicate: (IGraphStatViewData) -> Unit = {}, + val onSymlinks: (IGraphStatViewData) -> Unit = {}, ) data class GroupClickListeners( val onClick: (Group) -> Unit = {}, val onEdit: (Group) -> Unit = {}, val onDelete: (Group) -> Unit = {}, - val onMove: (Group) -> Unit = {} + val onMove: (Group) -> Unit = {}, + val onSymlinks: (Group) -> Unit = {}, ) data class FunctionClickListeners( @@ -199,7 +205,8 @@ data class FunctionClickListeners( val onEdit: (DisplayFunction) -> Unit = {}, val onDelete: (DisplayFunction) -> Unit = {}, val onMove: (DisplayFunction) -> Unit = {}, - val onDuplicate: (DisplayFunction) -> Unit = {} + val onDuplicate: (DisplayFunction) -> Unit = {}, + val onSymlinks: (DisplayFunction) -> Unit = {}, ) /** Content component for GroupScreen with navigation callbacks */ @@ -210,6 +217,7 @@ private fun GroupScreenContent( addGroupDialogViewModel: AddGroupDialogViewModelImpl, addSymlinkViewModel: AddSymlinkViewModel, releaseNotesViewModel: ReleaseNotesViewModel, + symlinksDialogViewModel: SymlinksDialogViewModel, groupId: Long, groupName: String?, onTrackerEdit: (DisplayTracker) -> Unit = {}, @@ -266,6 +274,7 @@ private fun GroupScreenContent( onDelete = { groupDialogsViewModel.showDeleteTrackerDialog(it, groupId) }, onMoveTo = { moveItemViewModel.showMoveTrackerDialog(fromGroupId = groupId, tracker = it) }, onDescription = { groupDialogsViewModel.showFeatureDescriptionDialog(it) }, + onSymlinks = { symlinksDialogViewModel.showSymlinks(it.id, GroupChildType.TRACKER, it.name) }, onAdd = { tracker, useDefault -> if (tracker.hasDefaultValue && useDefault) { context.performTrackVibrate() @@ -286,7 +295,8 @@ private fun GroupScreenContent( onEdit = onGraphStatEdit, onClick = onGraphStatClick, onMove = { moveItemViewModel.showMoveGraphDialog(groupId, it) }, - onDuplicate = { groupViewModel.duplicateGraphOrStat(it) } + onDuplicate = { groupViewModel.duplicateGraphOrStat(it) }, + onSymlinks = { symlinksDialogViewModel.showSymlinks(it.graphOrStat.id, GroupChildType.GRAPH, it.graphOrStat.name) }, ), groupClickListeners = GroupClickListeners( onClick = onGroupClick, @@ -294,14 +304,16 @@ private fun GroupScreenContent( addGroupDialogViewModel.showForEdit(groupId = group.id) }, onDelete = { groupDialogsViewModel.showDeleteGroupDialog(it, groupId) }, - onMove = { moveItemViewModel.showMoveGroupDialog(groupId, it) } + onMove = { moveItemViewModel.showMoveGroupDialog(groupId, it) }, + onSymlinks = { symlinksDialogViewModel.showSymlinks(it.id, GroupChildType.GROUP, it.name) }, ), functionClickListeners = FunctionClickListeners( onClick = onFunctionClick, onEdit = onFunctionEdit, onDelete = { groupDialogsViewModel.showDeleteFunctionDialog(it) }, onMove = { moveItemViewModel.showMoveFunctionDialog(fromGroupId = groupId, displayFunction = it) }, - onDuplicate = { groupViewModel.duplicateFunction(it) } + onDuplicate = { groupViewModel.duplicateFunction(it) }, + onSymlinks = { symlinksDialogViewModel.showSymlinks(it.id, GroupChildType.FUNCTION, it.name) }, ), onDragStart = groupViewModel::onDragStart, onDragSwap = groupViewModel::onDragSwap, @@ -362,6 +374,9 @@ private fun GroupScreenContent( ) } + // Symlinks dialog + SymlinksDialog(viewModel = symlinksDialogViewModel) + // Confirmation dialogs GroupDeleteDialog(groupDialogsViewModel, groupViewModel) @@ -639,6 +654,7 @@ private fun ReorderableCollectionItemScope.TrackerItem( onDelete = { clickListeners.onDelete(it) }, onMoveTo = { clickListeners.onMoveTo(it) }, onDescription = { clickListeners.onDescription(it) }, + onSymlinks = { clickListeners.onSymlinks(it) }, onAdd = { t, useDefault -> clickListeners.onAdd(t, useDefault) }, onHistory = { clickListeners.onHistory(it) }, onPlayTimer = { clickListeners.onPlayTimer(it) }, @@ -657,6 +673,7 @@ private fun ReorderableCollectionItemScope.GroupItem( onEdit = { clickListeners.onEdit(it) }, onDelete = { clickListeners.onDelete(it) }, onMoveTo = { clickListeners.onMove(it) }, + onSymlinks = { clickListeners.onSymlinks(it) }, onClick = { clickListeners.onClick(it) } ) @@ -675,6 +692,7 @@ private fun ReorderableCollectionItemScope.GraphStatItem( onClick = { clickListeners.onClick(it) }, onMove = { clickListeners.onMove(it) }, onDuplicate = { clickListeners.onDuplicate(it) }, + onSymlinks = { clickListeners.onSymlinks(it) }, ) ) @@ -691,7 +709,8 @@ private fun ReorderableCollectionItemScope.FunctionItem( onEdit = { clickListeners.onEdit(displayFunction) }, onDelete = { clickListeners.onDelete(displayFunction) }, onMoveTo = { clickListeners.onMove(displayFunction) }, - onDuplicate = { clickListeners.onDuplicate(displayFunction) } + onDuplicate = { clickListeners.onDuplicate(displayFunction) }, + onSymlinks = { clickListeners.onSymlinks(displayFunction) } ) @Preview(showBackground = true) diff --git a/app/app/src/main/java/com/samco/trackandgraph/group/SymlinksDialog.kt b/app/app/src/main/java/com/samco/trackandgraph/group/SymlinksDialog.kt new file mode 100644 index 000000000..04ccab12a --- /dev/null +++ b/app/app/src/main/java/com/samco/trackandgraph/group/SymlinksDialog.kt @@ -0,0 +1,116 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.group + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.samco.trackandgraph.R +import com.samco.trackandgraph.ui.compose.theming.TnGComposeTheme +import com.samco.trackandgraph.ui.compose.ui.CustomDialog +import com.samco.trackandgraph.ui.compose.ui.DialogInputSpacing +import com.samco.trackandgraph.ui.compose.ui.SmallTextButton +import com.samco.trackandgraph.ui.compose.ui.halfDialogInputSpacing +import com.samco.trackandgraph.ui.compose.ui.inputSpacingLarge + +@Composable +internal fun SymlinksDialog( + viewModel: SymlinksDialogViewModel, +) { + val data = viewModel.dialogData.collectAsStateWithLifecycle().value ?: return + + SymlinksDialogContent( + data = data, + onDismiss = { viewModel.dismiss() }, + ) +} + +@Composable +private fun SymlinksDialogContent( + data: SymlinksDialogData, + onDismiss: () -> Unit, +) { + CustomDialog( + onDismissRequest = onDismiss, + paddingValues = PaddingValues( + start = inputSpacingLarge, + end = inputSpacingLarge, + bottom = halfDialogInputSpacing, + top = inputSpacingLarge, + ) + ) { + Text( + text = stringResource(R.string.symlink_locations_title, data.componentName), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + DialogInputSpacing() + Column(modifier = Modifier.fillMaxWidth()) { + data.paths.forEachIndexed { index, path -> + Text( + text = path, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = halfDialogInputSpacing), + ) + if (index < data.paths.size - 1) { + HorizontalDivider() + } + } + } + DialogInputSpacing() + SmallTextButton( + stringRes = R.string.ok, + onClick = onDismiss, + modifier = Modifier.align(Alignment.End), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.primary + ) + ) + } +} + +@Preview +@Composable +private fun SymlinksDialogPreview() { + TnGComposeTheme { + SymlinksDialogContent( + data = SymlinksDialogData( + componentName = "Steps", + paths = listOf( + "/Health/Exercise/Steps", + "/Dashboard/Exercise/Steps", + "/Favorites/Steps", + ), + ), + onDismiss = {}, + ) + } +} diff --git a/app/app/src/main/java/com/samco/trackandgraph/group/SymlinksDialogViewModel.kt b/app/app/src/main/java/com/samco/trackandgraph/group/SymlinksDialogViewModel.kt new file mode 100644 index 000000000..9cb6f9e49 --- /dev/null +++ b/app/app/src/main/java/com/samco/trackandgraph/group/SymlinksDialogViewModel.kt @@ -0,0 +1,67 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.group + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.samco.trackandgraph.data.database.dto.GroupChildType +import com.samco.trackandgraph.data.interactor.DataInteractor +import com.samco.trackandgraph.util.ComponentPathProvider +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class SymlinksDialogData( + val componentName: String, + val paths: List, +) + +@HiltViewModel +class SymlinksDialogViewModel @Inject constructor( + private val dataInteractor: DataInteractor +) : ViewModel() { + + private val _dialogData = MutableStateFlow(null) + val dialogData: StateFlow = _dialogData.asStateFlow() + + fun showSymlinks(componentId: Long, componentType: GroupChildType, componentName: String) { + viewModelScope.launch { + val groupGraph = dataInteractor.getGroupGraphSync() + val pathProvider = ComponentPathProvider(groupGraph) + + val paths = when (componentType) { + GroupChildType.GROUP -> pathProvider.getAllPathsForGroup(componentId) + GroupChildType.TRACKER -> pathProvider.getAllPathsForTracker(componentId) + GroupChildType.FUNCTION -> pathProvider.getAllPathsForFunction(componentId) + GroupChildType.GRAPH -> pathProvider.getAllPathsForGraph(componentId) + GroupChildType.REMINDER -> emptyList() + } + + _dialogData.value = SymlinksDialogData( + componentName = componentName, + paths = paths, + ) + } + } + + fun dismiss() { + _dialogData.value = null + } +} diff --git a/app/app/src/main/java/com/samco/trackandgraph/group/Tracker.kt b/app/app/src/main/java/com/samco/trackandgraph/group/Tracker.kt index 19b3f5ec8..a75cc3e91 100644 --- a/app/app/src/main/java/com/samco/trackandgraph/group/Tracker.kt +++ b/app/app/src/main/java/com/samco/trackandgraph/group/Tracker.kt @@ -90,6 +90,7 @@ fun Tracker( onDelete: (DisplayTracker) -> Unit, onMoveTo: (DisplayTracker) -> Unit, onDescription: (DisplayTracker) -> Unit, + onSymlinks: (DisplayTracker) -> Unit, onAdd: (DisplayTracker, useDefault: Boolean) -> Unit, onHistory: (DisplayTracker) -> Unit, onPlayTimer: (DisplayTracker) -> Unit, @@ -177,7 +178,8 @@ fun Tracker( onEdit = onEdit, onDelete = onDelete, onMoveTo = onMoveTo, - onDescription = onDescription + onDescription = onDescription, + onSymlinks = onSymlinks, ) TrackerNameText(trackerName = tracker.name) @@ -210,7 +212,8 @@ private fun TrackerMenuButton( onEdit: (DisplayTracker) -> Unit, onDelete: (DisplayTracker) -> Unit, onMoveTo: (DisplayTracker) -> Unit, - onDescription: (DisplayTracker) -> Unit + onDescription: (DisplayTracker) -> Unit, + onSymlinks: (DisplayTracker) -> Unit, ) { Box( modifier = modifier.size(buttonSize) @@ -259,6 +262,15 @@ private fun TrackerMenuButton( onDescription(tracker) } ) + if (!tracker.unique) { + DropdownMenuItem( + text = { Text(stringResource(R.string.symlinks)) }, + onClick = { + onShowContextMenu(false) + onSymlinks(tracker) + } + ) + } } } } @@ -420,6 +432,7 @@ fun TrackerPreview() { onDelete = {}, onMoveTo = {}, onDescription = {}, + onSymlinks = {}, onAdd = { _, _ -> }, onHistory = {}, onPlayTimer = {}, @@ -445,6 +458,7 @@ fun TrackerPreview() { onDelete = {}, onMoveTo = {}, onDescription = {}, + onSymlinks = {}, onAdd = { _, _ -> }, onHistory = {}, onPlayTimer = {}, @@ -470,6 +484,7 @@ fun TrackerPreview() { onDelete = {}, onMoveTo = {}, onDescription = {}, + onSymlinks = {}, onAdd = { _, _ -> }, onHistory = {}, onPlayTimer = {}, @@ -495,6 +510,7 @@ fun TrackerPreview() { onDelete = {}, onMoveTo = {}, onDescription = {}, + onSymlinks = {}, onAdd = { _, _ -> }, onHistory = {}, onPlayTimer = {}, diff --git a/app/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/PreviewHeroCardButton.kt b/app/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/PreviewHeroCardButton.kt index 3c61c007f..c59c8608a 100644 --- a/app/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/PreviewHeroCardButton.kt +++ b/app/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/PreviewHeroCardButton.kt @@ -167,6 +167,7 @@ private fun PreviewCardButtonPreviewHero() { onDelete = {}, onMoveTo = {}, onDescription = {}, + onSymlinks = {}, onAdd = { _, _ -> }, onHistory = {}, onPlayTimer = {}, diff --git a/app/app/src/main/java/com/samco/trackandgraph/util/ComponentPathProvider.kt b/app/app/src/main/java/com/samco/trackandgraph/util/ComponentPathProvider.kt new file mode 100644 index 000000000..1f96637c7 --- /dev/null +++ b/app/app/src/main/java/com/samco/trackandgraph/util/ComponentPathProvider.kt @@ -0,0 +1,87 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.util + +import com.samco.trackandgraph.data.database.dto.GroupGraph +import com.samco.trackandgraph.data.database.dto.GroupGraphItem + +class ComponentPathProvider(groupGraph: GroupGraph) { + + private val separator = "/" + + private val groupPathsById = mutableMapOf>>() + private val trackerPathsById = mutableMapOf>>() + private val functionPathsById = mutableMapOf>>() + private val graphPathsById = mutableMapOf>>() + + init { + groupPathsById[groupGraph.group.id] = mutableListOf(emptyList()) + + fun walk(graph: GroupGraph, currentPath: List) { + for (child in graph.children) { + when (child) { + is GroupGraphItem.GroupNode -> { + val childGroup = child.groupGraph.group + val childPath = currentPath + childGroup.name + groupPathsById.getOrPut(childGroup.id) { mutableListOf() } + .add(childPath) + walk(child.groupGraph, childPath) + } + is GroupGraphItem.TrackerNode -> { + trackerPathsById.getOrPut(child.tracker.id) { mutableListOf() } + .add(currentPath + child.tracker.name) + } + is GroupGraphItem.FunctionNode -> { + functionPathsById.getOrPut(child.function.id) { mutableListOf() } + .add(currentPath + child.function.name) + } + is GroupGraphItem.GraphNode -> { + graphPathsById.getOrPut(child.graph.id) { mutableListOf() } + .add(currentPath + child.graph.name) + } + } + } + } + + walk(groupGraph, emptyList()) + } + + fun getAllPathsForGroup(groupId: Long): List { + return formatPaths(groupPathsById[groupId]) + } + + fun getAllPathsForTracker(trackerId: Long): List { + return formatPaths(trackerPathsById[trackerId]) + } + + fun getAllPathsForFunction(functionId: Long): List { + return formatPaths(functionPathsById[functionId]) + } + + fun getAllPathsForGraph(graphId: Long): List { + return formatPaths(graphPathsById[graphId]) + } + + private fun formatPaths(paths: List>?): List { + if (paths == null) return emptyList() + return paths.map { segments -> + if (segments.isEmpty()) separator + else separator + segments.joinToString(separator) + } + } +} diff --git a/app/app/src/main/res/values/strings.xml b/app/app/src/main/res/values/strings.xml index a55ade5c5..c83862c1c 100644 --- a/app/app/src/main/res/values/strings.xml +++ b/app/app/src/main/res/values/strings.xml @@ -23,6 +23,8 @@ Function Graph or Statistic Symlink + Symlinks + Locations of \"%1$s\" Track something: Add e.g. Daily Activities diff --git a/docs/knowledge-base/component-types.md b/docs/knowledge-base/component-types.md index 99a7dadfc..1fcdbe6bd 100644 --- a/docs/knowledge-base/component-types.md +++ b/docs/knowledge-base/component-types.md @@ -41,9 +41,10 @@ All component DTOs that can appear in the group screen carry a `unique: Boolean` **Design decisions:** - **No default value** — intentionally forces the compiler to catch any call site that forgets to compute it. For UI previews or tests unrelated to uniqueness, pass `unique = true` explicitly. -- **UI uses** (both triggered when `unique == false`): +- **UI uses** (all triggered when `unique == false`): - **Symlink icon**: A link icon (`SymlinkIcon` composable in the group package) is shown in the top-left corner of each card. Each card composable (Tracker, Function, Group, GraphStatCardView) checks `!unique` and overlays the icon at `Alignment.TopStart`. - **Delete dialog**: non-unique → "delete everywhere or just here?" dialog. "Remove from this group" passes the current `groupId` to the data layer (removes only that GroupItem link); "delete everywhere" passes `null` (full delete). See [helper-classes.md](helper-classes.md) for the data layer delete pattern. + - **Symlinks context menu item**: non-unique → a "Symlinks" item appears in the context menu of all component types. Opens `SymlinksDialog` which lists every full path where the component exists. Uses `SymlinksDialogViewModel` (fetches `GroupGraph`, builds `ComponentPathProvider`) — see [group-hierarchy.md](group-hierarchy.md) for path provider details. - **`DisplayFunction`** is a UI-layer DTO (search `Function.kt` in the group package), unlike other group screen DTOs which come from the data layer. Its `unique` field is populated from the data-layer `Function` DTO. - For GROUP deletion, the parameter is named `parentGroupId` (in both `GroupDeleteRequest` and `GroupViewModel.onDeleteGroup`). - For graphs/stats, `unique` lives on the `GraphOrStat` DTO (accessed via `graphStatViewData.graphOrStat.unique`), not on `IGraphStatViewData` itself. diff --git a/docs/knowledge-base/group-hierarchy.md b/docs/knowledge-base/group-hierarchy.md index 1db246852..e040da82e 100644 --- a/docs/knowledge-base/group-hierarchy.md +++ b/docs/knowledge-base/group-hierarchy.md @@ -7,8 +7,9 @@ topics: - Deletion: remove only GroupItem if group exists in other parents; recursive delete if deleting entirely - GroupGraph: tree built from DAG by DataInteractorImpl.buildGroupGraph(); same node may appear at multiple positions - FeaturePathProvider: resolves display paths; collapses multi-path items with "..." + - ComponentPathProvider: returns all full paths for a component; used by symlinks dialog - DTOs do NOT carry hierarchy info (no groupId/parentGroupIds/displayIndex fields) -keywords: [group, DAG, symlink, hierarchy, acyclic, parent, child, GroupGraph, FeaturePathProvider, deletion, GroupHelperImpl, path-resolution] +keywords: [group, DAG, symlink, hierarchy, acyclic, parent, child, GroupGraph, FeaturePathProvider, ComponentPathProvider, deletion, GroupHelperImpl, path-resolution, SymlinksDialog] --- # Group Hierarchy @@ -58,14 +59,24 @@ See `GroupHelperImpl.deleteGroup()` for the implementation: `GroupGraph` is a tree built from the DAG by `DataInteractorImpl.buildGroupGraph()`. Since a group/feature can have multiple parents, the same node may appear at multiple positions in the tree. -`FeaturePathProvider` (in `app/app/.../util/`) handles path display for **both** groups and features: -- Takes a `GroupGraph`, walks it to collect all paths for each group ID and feature ID +There are two path provider classes (both in `app/app/.../util/`), each walking the `GroupGraph` but serving different purposes: + +**`FeaturePathProvider`** — for display in dropdowns and selectors where a single string per item is needed: +- Collects all paths for each group ID and feature ID (trackers/functions only, not graphs) - Single-path items get a full path like `/Health/Exercise/Steps` - Multi-path items get a collapsed path with `...` showing common prefix/suffix: `/Health/.../Steps` - Results are lazily cached per ID +- Used by: graph config screens, function editor, notes, select-item dialogs + +**`ComponentPathProvider`** — for listing all locations of a symlinked component: +- Collects all paths for all component types including graphs (by primary key ID, not featureId) +- Returns every path individually as a `List` — no collapsing +- Used by: `SymlinksDialogViewModel` to show the symlinks dialog from context menus + +The separation exists because `FeaturePathProvider` intentionally deduplicates/collapses paths (desirable for selectors) while `ComponentPathProvider` must show every location (the whole point of the symlinks dialog). `FeaturePathProvider` also indexes by featureId (for trackers/functions), while `ComponentPathProvider` indexes by primary key ID (matching `group_items_table.child_id`). **Important**: Group and Feature DTOs do **not** carry hierarchy information (`groupId`, `parentGroupIds`, `displayIndex`). All hierarchy is expressed through `group_items_table` and `GroupGraph`. Do not add hierarchy fields to DTOs. ## Finding Code -Search for `GroupHelperImpl` (CRUD + recursive deletion), `GroupItemDao` (relationship queries), and `FeaturePathProvider` (path resolution). +Search for `GroupHelperImpl` (CRUD + recursive deletion), `GroupItemDao` (relationship queries), `FeaturePathProvider` (collapsed path resolution), and `ComponentPathProvider` (full path listing). diff --git a/docs/knowledge-base/index.yaml b/docs/knowledge-base/index.yaml index b0a3e1aa6..ab94173b9 100644 --- a/docs/knowledge-base/index.yaml +++ b/docs/knowledge-base/index.yaml @@ -25,8 +25,8 @@ docs: - file: component-types.md title: Component types — Trackers, Functions, Graphs, Reminders, Groups - description: The 5 component types, their dual-ID pattern (primary key vs featureId), the unique field on DTOs for symlink detection (no default, must always be computed by querying group_items_table), and GroupItemType vs GroupChildType enums. - keywords: [tracker, function, graph, reminder, group, featureId, primaryKey, component, GroupItemType, GroupChildType, unique, symlink, dual-id] + description: The 5 component types, their dual-ID pattern (primary key vs featureId), the unique field on DTOs for symlink detection (no default, must always be computed by querying group_items_table), GroupItemType vs GroupChildType enums, and all three UI behaviors triggered by unique==false (icon, delete dialog, symlinks menu). + keywords: [tracker, function, graph, reminder, group, featureId, primaryKey, component, GroupItemType, GroupChildType, unique, symlink, dual-id, SymlinksDialog, context-menu] - file: database-schema.md title: Database tables and relationships @@ -40,8 +40,8 @@ docs: - file: group-hierarchy.md title: Group DAG structure and symlinks - description: Groups form a DAG (not a tree) — a group can appear in multiple parent groups via symlinks; deletion handles shared membership; GroupGraph and FeaturePathProvider resolve display paths; DTOs never carry hierarchy info. - keywords: [group, DAG, symlink, hierarchy, acyclic, parent, child, GroupGraph, FeaturePathProvider, deletion, path-resolution] + description: Groups form a DAG (not a tree) — a group can appear in multiple parent groups via symlinks; deletion handles shared membership; GroupGraph, FeaturePathProvider, and ComponentPathProvider resolve display paths; DTOs never carry hierarchy info. + keywords: [group, DAG, symlink, hierarchy, acyclic, parent, child, GroupGraph, FeaturePathProvider, ComponentPathProvider, deletion, path-resolution, SymlinksDialog] - file: group-items.md title: group_items_table — junction table and display ordering From 9f69f08819281b510b64beb7896b187564e4f2f4 Mon Sep 17 00:00:00 2001 From: SamAmco Date: Mon, 16 Mar 2026 23:51:10 +0000 Subject: [PATCH 2/3] Link lua script info button to developer guide in node selection dialog --- .../node_editor/NodeDescriptionDialog.kt | 17 ------- .../node_selector/NodeSelectionDialog.kt | 46 +++++++------------ .../node_selector/NodeSelectionViewModel.kt | 10 ++++ .../remoteconfig/RemoteConfigurationData.kt | 2 + .../remoteconfig/UrlNavigator.kt | 1 + .../remoteconfig/UrlNavigatorImpl.kt | 1 + .../src/main/res/values-de-rDE/strings.xml | 2 +- app/app/src/main/res/values-es/strings.xml | 2 +- app/app/src/main/res/values-fr/strings.xml | 2 +- app/app/src/main/res/values/strings.xml | 2 +- .../RemoteConfigurationFixtures.kt | 1 + configuration/remote-configuration.json | 1 + .../remote-configuration.schema.json | 6 +++ docs/knowledge-base/index.yaml | 5 ++ docs/knowledge-base/remote-config-and-urls.md | 43 +++++++++++++++++ 15 files changed, 90 insertions(+), 51 deletions(-) create mode 100644 docs/knowledge-base/remote-config-and-urls.md diff --git a/app/app/src/main/java/com/samco/trackandgraph/functions/node_editor/NodeDescriptionDialog.kt b/app/app/src/main/java/com/samco/trackandgraph/functions/node_editor/NodeDescriptionDialog.kt index f76669949..d0a432fa1 100644 --- a/app/app/src/main/java/com/samco/trackandgraph/functions/node_editor/NodeDescriptionDialog.kt +++ b/app/app/src/main/java/com/samco/trackandgraph/functions/node_editor/NodeDescriptionDialog.kt @@ -51,7 +51,6 @@ import io.github.z4kn4fein.semver.toVersion sealed class InfoDisplay { data object DataSource : InfoDisplay() - data object LuaScript : InfoDisplay() data class Function(val metadata: LuaFunctionMetadata) : InfoDisplay() } @@ -86,7 +85,6 @@ fun NodeDescriptionDialog( // Header when (infoDisplay) { is InfoDisplay.Function -> FunctionHeader(infoDisplay.metadata) - is InfoDisplay.LuaScript -> DefaultHeader(stringResource(R.string.lua_script)) is InfoDisplay.DataSource -> DefaultHeader(stringResource(R.string.data_source)) } @@ -101,10 +99,6 @@ fun NodeDescriptionDialog( stringResource(R.string.data_source_description) } - is InfoDisplay.LuaScript -> { - stringResource(R.string.lua_script_description) - } - is InfoDisplay.Function -> { infoDisplay.metadata.description.resolve()?.trim() ?: "" } @@ -150,17 +144,6 @@ private fun InfoDisplayDialogDataSourcePreview() { } } -@Preview(showBackground = true) -@Composable -private fun InfoDisplayDialogLuaScriptPreview() { - TnGComposeTheme { - NodeDescriptionDialog( - infoDisplay = InfoDisplay.LuaScript, - onDismiss = {} - ) - } -} - @Preview(showBackground = true) @Composable private fun InfoDisplayDialogFunctionPreview() { diff --git a/app/app/src/main/java/com/samco/trackandgraph/functions/node_selector/NodeSelectionDialog.kt b/app/app/src/main/java/com/samco/trackandgraph/functions/node_selector/NodeSelectionDialog.kt index 64193bd23..f51ad18e7 100644 --- a/app/app/src/main/java/com/samco/trackandgraph/functions/node_selector/NodeSelectionDialog.kt +++ b/app/app/src/main/java/com/samco/trackandgraph/functions/node_selector/NodeSelectionDialog.kt @@ -101,7 +101,8 @@ fun NodeSelectionDialog( isLandscape = isLandscape, infoDisplay = infoDisplay, onShowInfo = { infoDisplay = it }, - onCloseInfo = { infoDisplay = null } + onCloseInfo = { infoDisplay = null }, + onLuaScriptInfoClick = viewModel::navigateToLuaCustomFunctionsDocs ) } @@ -116,6 +117,7 @@ private fun NodeSelectionDialogUi( infoDisplay: InfoDisplay? = null, onShowInfo: (InfoDisplay) -> Unit = {}, onCloseInfo: () -> Unit = {}, + onLuaScriptInfoClick: () -> Unit = {}, ) { val usePlatformDefaultWidth = when { state !is NodeSelectionUiState.Ready -> true @@ -143,7 +145,8 @@ private fun NodeSelectionDialogUi( }, onRetry = onRetry, onSelectCategory = onSelectCategory, - onShowInfo = onShowInfo + onShowInfo = onShowInfo, + onLuaScriptInfoClick = onLuaScriptInfoClick ) } } @@ -165,6 +168,7 @@ private fun SelectionView( onRetry: () -> Unit, onSelectCategory: (String?) -> Unit, onShowInfo: (InfoDisplay) -> Unit, + onLuaScriptInfoClick: () -> Unit, ) { Row( modifier = Modifier.sizeIn(minHeight = buttonSize), @@ -207,14 +211,16 @@ private fun SelectionView( state = state, onSelect = onSelect, onSelectCategory = onSelectCategory, - onShowInfo = onShowInfo + onShowInfo = onShowInfo, + onLuaScriptInfoClick = onLuaScriptInfoClick ) } else { PortraitReadyState( state = state, onSelect = onSelect, onSelectCategory = onSelectCategory, - onShowInfo = onShowInfo + onShowInfo = onShowInfo, + onLuaScriptInfoClick = onLuaScriptInfoClick ) } } @@ -234,6 +240,7 @@ private fun PortraitReadyState( onSelect: (AddNodeData) -> Unit, onSelectCategory: (String?) -> Unit, onShowInfo: (InfoDisplay) -> Unit, + onLuaScriptInfoClick: () -> Unit, ) = AnimatedContent(state.selectedCategory) { if (it == null) { // Show category list first @@ -242,6 +249,7 @@ private fun PortraitReadyState( onSelect = onSelect, onSelectCategory = onSelectCategory, onShowInfo = onShowInfo, + onLuaScriptInfoClick = onLuaScriptInfoClick, modifier = Modifier.fillMaxWidth() ) } else { @@ -276,6 +284,7 @@ private fun CategoryList( onSelect: (AddNodeData) -> Unit, onSelectCategory: (String?) -> Unit, onShowInfo: (InfoDisplay) -> Unit, + onLuaScriptInfoClick: () -> Unit, modifier: Modifier = Modifier ) { FadingScrollColumn(modifier = modifier) { @@ -320,9 +329,7 @@ private fun CategoryList( onSelect(AddNodeData.LuaScriptNode) }, showInfoIcon = true, - onInfoClick = { - onShowInfo(InfoDisplay.LuaScript) - } + onInfoClick = onLuaScriptInfoClick ) Divider() @@ -370,6 +377,7 @@ private fun LandscapeReadyState( onSelect: (AddNodeData) -> Unit, onSelectCategory: (String?) -> Unit, onShowInfo: (InfoDisplay) -> Unit, + onLuaScriptInfoClick: () -> Unit, ) = Row( modifier = Modifier.fillMaxWidth() ) { @@ -379,6 +387,7 @@ private fun LandscapeReadyState( onSelect = onSelect, onSelectCategory = onSelectCategory, onShowInfo = onShowInfo, + onLuaScriptInfoClick = onLuaScriptInfoClick, modifier = Modifier.weight(1f) ) @@ -602,29 +611,6 @@ private fun NodeSelectionDialogDataSourceInfoPreview() { } } -@Preview(showBackground = true) -@Composable -private fun NodeSelectionDialogLuaScriptInfoPreview() { - TnGComposeTheme { - NodeSelectionDialogUi( - state = NodeSelectionUiState.Ready( - allFunctions = emptyList(), - displayedFunctions = emptyList(), - selectedCategory = null, - allCategories = emptyMap() - ), - onDismiss = {}, - onSelect = {}, - onRetry = {}, - onSelectCategory = {}, - isLandscape = true, - infoDisplay = InfoDisplay.LuaScript, - onShowInfo = {}, - onCloseInfo = {} - ) - } -} - @Preview(showBackground = true) @Composable private fun NodeSelectionDialogFunctionInfoPreview() { diff --git a/app/app/src/main/java/com/samco/trackandgraph/functions/node_selector/NodeSelectionViewModel.kt b/app/app/src/main/java/com/samco/trackandgraph/functions/node_selector/NodeSelectionViewModel.kt index 2121d557c..303b05868 100644 --- a/app/app/src/main/java/com/samco/trackandgraph/functions/node_selector/NodeSelectionViewModel.kt +++ b/app/app/src/main/java/com/samco/trackandgraph/functions/node_selector/NodeSelectionViewModel.kt @@ -16,13 +16,16 @@ */ package com.samco.trackandgraph.functions.node_selector +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.samco.trackandgraph.data.lua.dto.LuaFunctionMetadata import com.samco.trackandgraph.data.localisation.TranslatedString import com.samco.trackandgraph.functions.repository.FunctionsRepository import com.samco.trackandgraph.functions.repository.SignatureVerificationException +import com.samco.trackandgraph.remoteconfig.UrlNavigator import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -52,11 +55,14 @@ interface NodeSelectionViewModel { fun selectCategory(categoryId: String?) fun clearSelection() fun retry() + fun navigateToLuaCustomFunctionsDocs() } @HiltViewModel class NodeSelectionViewModelImpl @Inject constructor( + @ApplicationContext private val context: Context, private val repository: FunctionsRepository, + private val urlNavigator: UrlNavigator, ) : ViewModel(), NodeSelectionViewModel { private val _state = MutableStateFlow(NodeSelectionUiState.Loading) @@ -85,6 +91,10 @@ class NodeSelectionViewModelImpl @Inject constructor( fetchFunctions() } + override fun navigateToLuaCustomFunctionsDocs() { + urlNavigator.triggerNavigation(context, UrlNavigator.Location.LUA_CUSTOM_FUNCTIONS_DOCS) + } + private fun updateReadyState() { if (_state.value !is NodeSelectionUiState.Ready) return diff --git a/app/app/src/main/java/com/samco/trackandgraph/remoteconfig/RemoteConfigurationData.kt b/app/app/src/main/java/com/samco/trackandgraph/remoteconfig/RemoteConfigurationData.kt index 2a579f658..198419277 100644 --- a/app/app/src/main/java/com/samco/trackandgraph/remoteconfig/RemoteConfigurationData.kt +++ b/app/app/src/main/java/com/samco/trackandgraph/remoteconfig/RemoteConfigurationData.kt @@ -52,6 +52,8 @@ data class Endpoints( val functionCatalogueSignature: String, @SerialName("functions-docs") val functionsDocs: String, + @SerialName("lua-custom-functions-docs") + val luaCustomFunctionsDocs: String, @SerialName("donate-url") val donateUrl: String ) diff --git a/app/app/src/main/java/com/samco/trackandgraph/remoteconfig/UrlNavigator.kt b/app/app/src/main/java/com/samco/trackandgraph/remoteconfig/UrlNavigator.kt index 441067b5b..503ca6c2f 100644 --- a/app/app/src/main/java/com/samco/trackandgraph/remoteconfig/UrlNavigator.kt +++ b/app/app/src/main/java/com/samco/trackandgraph/remoteconfig/UrlNavigator.kt @@ -29,6 +29,7 @@ interface UrlNavigator { LUA_COMMUNITY_SCRIPTS_ROOT, PLAY_STORE_PAGE, FUNCTIONS_DOCS, + LUA_CUSTOM_FUNCTIONS_DOCS, DONATE } diff --git a/app/app/src/main/java/com/samco/trackandgraph/remoteconfig/UrlNavigatorImpl.kt b/app/app/src/main/java/com/samco/trackandgraph/remoteconfig/UrlNavigatorImpl.kt index 152059b7d..c1907f7f2 100644 --- a/app/app/src/main/java/com/samco/trackandgraph/remoteconfig/UrlNavigatorImpl.kt +++ b/app/app/src/main/java/com/samco/trackandgraph/remoteconfig/UrlNavigatorImpl.kt @@ -71,6 +71,7 @@ class UrlNavigatorImpl @Inject constructor( UrlNavigator.Location.LUA_COMMUNITY_SCRIPTS_ROOT -> config.endpoints.luaCommunityScriptsRoot UrlNavigator.Location.PLAY_STORE_PAGE -> config.endpoints.playStorePage UrlNavigator.Location.FUNCTIONS_DOCS -> config.endpoints.functionsDocs + UrlNavigator.Location.LUA_CUSTOM_FUNCTIONS_DOCS -> config.endpoints.luaCustomFunctionsDocs UrlNavigator.Location.DONATE -> config.endpoints.donateUrl } } diff --git a/app/app/src/main/res/values-de-rDE/strings.xml b/app/app/src/main/res/values-de-rDE/strings.xml index 44a151a76..82f8474d8 100644 --- a/app/app/src/main/res/values-de-rDE/strings.xml +++ b/app/app/src/main/res/values-de-rDE/strings.xml @@ -431,7 +431,7 @@ Datenquelle Ein Datenquellen-Knoten ermöglicht es Ihnen, einen Tracker oder eine Funktion als Eingabe auszuwählen. Lua-Skript - Ein Lua-Skript-Knoten ermöglicht es Ihnen, benutzerdefinierten Lua-Code zur Datentransformation einzufügen. + Geben Sie ein Lua-Skript ein... Knotentyp auswählen Datei diff --git a/app/app/src/main/res/values-es/strings.xml b/app/app/src/main/res/values-es/strings.xml index d58751a1b..485efd1ed 100644 --- a/app/app/src/main/res/values-es/strings.xml +++ b/app/app/src/main/res/values-es/strings.xml @@ -430,7 +430,7 @@ Fuente de Datos Un nodo de Fuente de Datos te permite seleccionar un rastreador o función para usar como entrada. Script Lua - Un nodo de Script Lua te permite insertar código Lua personalizado para transformar datos. + Introduce un script Lua… Seleccionar Tipo de Nodo Archivo diff --git a/app/app/src/main/res/values-fr/strings.xml b/app/app/src/main/res/values-fr/strings.xml index a6c022011..9e8ac19bf 100644 --- a/app/app/src/main/res/values-fr/strings.xml +++ b/app/app/src/main/res/values-fr/strings.xml @@ -451,7 +451,7 @@ Source de Données Un nœud Source de Données vous permet de sélectionner un tracker ou une fonction à utiliser comme entrée. Script Lua - Un nœud Script Lua vous permet d\'insérer du code Lua personnalisé pour transformer les données. + Entrez un script Lua… Sélectionner le Type de Nœud Fichier diff --git a/app/app/src/main/res/values/strings.xml b/app/app/src/main/res/values/strings.xml index c83862c1c..493da5ddb 100644 --- a/app/app/src/main/res/values/strings.xml +++ b/app/app/src/main/res/values/strings.xml @@ -502,7 +502,7 @@ Data Source Lua Script A Data Source node allows you to select a tracker or function to use as an input. - A Lua Script node allows you to insert custom Lua code to transform data. + Enter a Lua script… Select Node Type File diff --git a/app/app/src/test/java/com/samco/trackandgraph/remoteconfig/RemoteConfigurationFixtures.kt b/app/app/src/test/java/com/samco/trackandgraph/remoteconfig/RemoteConfigurationFixtures.kt index 220859c5e..295dc9bb7 100644 --- a/app/app/src/test/java/com/samco/trackandgraph/remoteconfig/RemoteConfigurationFixtures.kt +++ b/app/app/src/test/java/com/samco/trackandgraph/remoteconfig/RemoteConfigurationFixtures.kt @@ -27,6 +27,7 @@ val testEndpoints = Endpoints( functionCatalogueLocation = "https://functions.com/catalog", functionCatalogueSignature = "https://functions.com/signature", functionsDocs = "https://functions.com/docs", + luaCustomFunctionsDocs = "https://functions.com/custom-docs", donateUrl = "https://buymeacoffee.com/trackandgraph", tutorialFunctionsReminders = "https://tutorial.com/functions/reminders" ) diff --git a/configuration/remote-configuration.json b/configuration/remote-configuration.json index 9e464da41..8dc8c5e38 100644 --- a/configuration/remote-configuration.json +++ b/configuration/remote-configuration.json @@ -11,6 +11,7 @@ "function-catalogue-location": "https://raw.githubusercontent.com/SamAmco/track-and-graph/refs/heads/master/lua/catalog/community-functions.lua", "function-catalogue-signature": "https://raw.githubusercontent.com/SamAmco/track-and-graph/refs/heads/master/lua/catalog/community-functions.sig.json", "functions-docs": "https://samamco.github.io/track-and-graph/tutorial/functions/", + "lua-custom-functions-docs": "https://samamco.github.io/track-and-graph/lua/developer-guide/writing-custom-functions/", "donate-url": "https://buymeacoffee.com/trackandgraph" }, "trusted-lua-script-sources": [ diff --git a/configuration/remote-configuration.schema.json b/configuration/remote-configuration.schema.json index b2ceb9b35..630c2090a 100644 --- a/configuration/remote-configuration.schema.json +++ b/configuration/remote-configuration.schema.json @@ -20,6 +20,7 @@ "function-catalogue-location", "function-catalogue-signature", "functions-docs", + "lua-custom-functions-docs", "donate-url" ], "properties": { @@ -71,6 +72,11 @@ "format": "uri", "description": "URL to the functions documentation" }, + "lua-custom-functions-docs": { + "type": "string", + "format": "uri", + "description": "URL to documentation for writing custom Lua function nodes" + }, "donate-url": { "type": "string", "format": "uri", diff --git a/docs/knowledge-base/index.yaml b/docs/knowledge-base/index.yaml index ab94173b9..1555f5466 100644 --- a/docs/knowledge-base/index.yaml +++ b/docs/knowledge-base/index.yaml @@ -78,6 +78,11 @@ docs: description: Directory structure for lua/, building the function catalog (pack-functions.lua), validation and inspection tools, debug publishing, and the 7 implementation + 5 test files to update when adding a new configuration type. keywords: [lua, tooling, build, pack-functions, catalog, luarocks, serpent, validation, config-types, debug-publish, api-specs] + - file: remote-config-and-urls.md + title: Remote configuration and URL navigation + description: How external URLs are managed via remote config and opened from the app — UrlNavigator pattern, adding new URLs, and the multi-file update checklist. + keywords: [RemoteConfiguration, UrlNavigator, Endpoints, remote-config, URL, navigate, triggerNavigation, browser, InfoDisplay] + plans: - file: plans/group-view-model-testing-plan.md title: GroupViewModel testing plan (3 phases) diff --git a/docs/knowledge-base/remote-config-and-urls.md b/docs/knowledge-base/remote-config-and-urls.md new file mode 100644 index 000000000..a965f5e26 --- /dev/null +++ b/docs/knowledge-base/remote-config-and-urls.md @@ -0,0 +1,43 @@ +--- +title: Remote configuration and URL navigation +description: How external URLs are managed via remote config and opened from the app — UrlNavigator pattern, adding new URLs, and the multi-file update checklist. +topics: [remote-config, url-navigation, endpoints] +keywords: [RemoteConfiguration, UrlNavigator, Endpoints, remote-config, URL, navigate, triggerNavigation, browser] +--- + +# Remote Configuration and URL Navigation + +## Overview + +External URLs (docs site, GitHub, Play Store, etc.) are **never hard-coded in UI code**. They are defined in a remote configuration JSON and accessed through `UrlNavigator`. + +## Architecture + +- **`configuration/remote-configuration.json`** — the source of truth for all URLs, deployed remotely +- **`configuration/remote-configuration.schema.json`** — JSON Schema validating the config +- **`RemoteConfigurationData.kt`** — Kotlin data classes (`RemoteConfiguration`, `Endpoints`) matching the JSON structure, uses `kotlinx.serialization` +- **`UrlNavigator`** — interface with a `Location` enum mapping logical destinations to URLs +- **`UrlNavigatorImpl`** — loads remote config via `RemoteConfigProvider`, resolves `Location` to URL, opens via `Intent.ACTION_VIEW`. Falls back to GitHub homepage on failure. + +## Adding a New URL Endpoint + +Update these files (all required): + +1. **`configuration/remote-configuration.json`** — add the URL value +2. **`configuration/remote-configuration.schema.json`** — add to `required` array and `properties` +3. **`RemoteConfigurationData.kt`** — add field to `Endpoints` with `@SerialName` +4. **`UrlNavigator.kt`** — add entry to `Location` enum +5. **`UrlNavigatorImpl.kt`** — add mapping in `getUrlForLocation()` +6. **`RemoteConfigurationFixtures.kt`** (test) — add field to `testEndpoints` + +## Using UrlNavigator from UI + +Two access patterns exist: + +1. **From a ViewModel** (preferred): Inject `UrlNavigator` and `@ApplicationContext Context` into the ViewModel. Expose a method that calls `urlNavigator.triggerNavigation(context, location)` (fire-and-forget) or `urlNavigator.navigateTo(context, location)` (suspend, returns success boolean). Wire to the composable via a callback/lambda. + +2. **From Activity-level code** (e.g. drawer menu): Inject `UrlNavigator` into the Activity and call `triggerNavigation` directly. + +## Design Decisions + +- **No `InfoDisplay` for external links**: When an info button should open an external URL rather than show an in-app dialog, use a separate lambda (e.g. `onLuaScriptInfoClick`) threaded through the composable chain rather than routing through the `InfoDisplay` sealed class. `InfoDisplay` is only for in-app description dialogs. From a3ca54b46ce576a2e7059627fdce6f0f1915e494 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:48:27 +0000 Subject: [PATCH 3/3] Bump json from 2.18.0 to 2.19.2 Bumps [json](https://github.com/ruby/json) from 2.18.0 to 2.19.2. - [Release notes](https://github.com/ruby/json/releases) - [Changelog](https://github.com/ruby/json/blob/master/CHANGES.md) - [Commits](https://github.com/ruby/json/compare/v2.18.0...v2.19.2) --- updated-dependencies: - dependency-name: json dependency-version: 2.19.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index c957a9f78..9bb6f7177 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -167,7 +167,7 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.18.0) + json (2.19.2) jwt (2.10.2) base64 logger (1.7.0)