From d32161dad77fec587bdea6a9aeb1c853f2926c06 Mon Sep 17 00:00:00 2001 From: Vetle Finstad Date: Fri, 26 Jun 2026 09:17:46 +0200 Subject: [PATCH 1/2] Add StepFlow back navigation and invalid scan cooldown --- CHANGELOG.md | 4 + .../StepFlow/StepFlowSamples.xaml | 16 ++- .../StepFlow/StepFlowSamplesViewModel.cs | 7 ++ .../Overlay/BarcodeScanRectangleOverlay.cs | 27 +++-- .../StepFlow/StepFlow.Properties.cs | 3 +- .../Components/StepFlow/StepFlow.cs | 47 +++++++- .../Components/StepFlow/StepFlowController.cs | 32 +++++- .../StepFlow/StepFlowItem.Properties.cs | 19 ++-- .../Components/StepFlow/StepFlowItem.cs | 72 ++++++++++-- .../StepFlow/StepFlowControllerTests.cs | 103 ++++++++++++++++++ wiki/Components/StepFlow.md | 17 ++- 11 files changed, 316 insertions(+), 31 deletions(-) create mode 100644 src/tests/unittests/Components/StepFlow/StepFlowControllerTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c95bdbd5c..e778813e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [62.0.0] +- [StepFlow] **BREAKING**: Removed `StepFlowItem.LockWhenCompleted`. Use `StepFlowItem.CanGoBack` to allow selected completed steps to be reopened before the flow is fully completed, while resetting that step and following steps for confirmation again. +- [BarcodeScanner] Fixed invalid scan-rectangle validation results restarting detection before the overlay returned to idle by waiting for the reset animation and adding a short cooldown before rescanning. + ## [61.5.1] - [Touch][iOS] Fixed scroll gestures on tappable rows getting stuck after dismissing context menus or picker popovers, and prevented taps behind open overlays from activating touch commands. diff --git a/src/app/Components/ComponentsSamples/StepFlow/StepFlowSamples.xaml b/src/app/Components/ComponentsSamples/StepFlow/StepFlowSamples.xaml index fdcfc1ad2..1b79fd64e 100644 --- a/src/app/Components/ComponentsSamples/StepFlow/StepFlowSamples.xaml +++ b/src/app/Components/ComponentsSamples/StepFlow/StepFlowSamples.xaml @@ -27,6 +27,13 @@ + + + + + @@ -37,7 +44,8 @@ AutoScrollIntoView="{Binding AutoScrollIntoView}"> - + @@ -49,7 +57,8 @@ - + @@ -77,7 +86,8 @@ - + diff --git a/src/app/Components/ComponentsSamples/StepFlow/StepFlowSamplesViewModel.cs b/src/app/Components/ComponentsSamples/StepFlow/StepFlowSamplesViewModel.cs index 1a493dbfe..1cacd0cce 100644 --- a/src/app/Components/ComponentsSamples/StepFlow/StepFlowSamplesViewModel.cs +++ b/src/app/Components/ComponentsSamples/StepFlow/StepFlowSamplesViewModel.cs @@ -12,6 +12,7 @@ public class StepFlowSamplesViewModel : ViewModel private bool m_isScanningDone; private bool m_isFlowFinished; private bool m_autoScrollIntoView = true; + private bool m_canGoBack = true; public StepFlowSamplesViewModel() { @@ -59,6 +60,12 @@ public bool AutoScrollIntoView set => RaiseWhenSet(ref m_autoScrollIntoView, value); } + public bool CanGoBack + { + get => m_canGoBack; + set => RaiseWhenSet(ref m_canGoBack, value); + } + public AsyncCommand ConfirmPatientCommand { get; } public AsyncCommand AddScannedLabelCommand { get; } public AsyncCommand FinishScanningCommand { get; } diff --git a/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/Overlay/BarcodeScanRectangleOverlay.cs b/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/Overlay/BarcodeScanRectangleOverlay.cs index e6a364239..0f4740ab0 100644 --- a/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/Overlay/BarcodeScanRectangleOverlay.cs +++ b/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/Overlay/BarcodeScanRectangleOverlay.cs @@ -42,6 +42,8 @@ internal class BarcodeScanRectangleOverlay : Grid private const float TrackingTargetLargeChangeThreshold = 48f; private const float TrackingTargetSmoothing = .35f; private const float TrackingTargetFastSmoothing = .65f; + private const uint BracketsReturnLength = 280; + private const int FailureRescanCooldownMilliseconds = 500; private const string AnimationKeyCornerBreathing = "CornerBreathing"; private const string AnimationKeyBracketsToBarcode = "BracketsToBarcode"; @@ -631,10 +633,16 @@ internal async Task PlayFailureAndResetAsync(string? errorMessage) await m_cornersGraphicsView.TranslateToAsync(-shakeDistance / 2, 0, 65, Easing.CubicInOut); await m_cornersGraphicsView.TranslateToAsync(0, 0, 80, Easing.CubicOut); - ResetBarcodeDetection(); + await ResetBarcodeDetectionAsync(); + await Task.Delay(FailureRescanCooldownMilliseconds); } internal void ResetBarcodeDetection() + { + _ = ResetBarcodeDetectionAsync(); + } + + private Task ResetBarcodeDetectionAsync() { m_cornersGraphicsView.AbortAnimation(AnimationKeyBracketsToBarcode); m_cornersGraphicsView.AbortAnimation(AnimationKeyBracketsReturn); @@ -652,11 +660,11 @@ internal void ResetBarcodeDetection() if (currentRect is not null) { - // Animate back to scan rectangle var startX = currentRect.Value.X; var startY = currentRect.Value.Y; var startW = currentRect.Value.Width; var startH = currentRect.Value.Height; + var taskCompletionSource = new TaskCompletionSource(); var animation = new Animation(v => { @@ -671,21 +679,24 @@ internal void ResetBarcodeDetection() animation.Commit(m_cornersGraphicsView, AnimationKeyBracketsReturn, rate: 16, - length: 280, + length: BracketsReturnLength, finished: (_, cancelled) => { m_cornersDrawable.OverrideRect = null; if (!cancelled) StartBreathingAnimation(); + + taskCompletionSource.TrySetResult(true); }); - } - else - { - m_cornersDrawable.OverrideRect = null; - StartBreathingAnimation(); + + return taskCompletionSource.Task; } + m_cornersDrawable.OverrideRect = null; + StartBreathingAnimation(); + return Task.CompletedTask; } + private RectF GetScanRectangleForDrawable() { var w = (float)Width; diff --git a/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlow.Properties.cs b/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlow.Properties.cs index 3f4fc15da..c64a40bd9 100644 --- a/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlow.Properties.cs +++ b/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlow.Properties.cs @@ -49,7 +49,8 @@ public bool AutoScrollIntoView nameof(AllowDirectStepActivation), typeof(bool), typeof(StepFlow), - defaultValue: false); + defaultValue: false, + propertyChanged: (b, _, _) => ((StepFlow)b).RefreshItemTapTargets()); public static readonly BindableProperty AutoScrollIntoViewProperty = BindableProperty.Create( nameof(AutoScrollIntoView), diff --git a/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlow.cs b/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlow.cs index f04d911ad..e3a239f05 100644 --- a/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlow.cs +++ b/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlow.cs @@ -102,6 +102,15 @@ private void ReindexAndSyncStack() { m_stack.Children.Insert(Math.Min(i, m_stack.Children.Count), item); } + item.RefreshTapTarget(); + } + } + + private void RefreshItemTapTargets() + { + foreach (var item in m_items) + { + item.RefreshTapTarget(); } } @@ -161,9 +170,16 @@ private void OnControllerStateChanged(object? sender, StepFlowEventArgs e) private void OnControllerFlowCompleted(object? sender, EventArgs e) { + RefreshItemTapTargets(); FlowCompleted?.Invoke(this, EventArgs.Empty); } + internal bool CanGoBackFromCompletedSteps => m_attachedController is { } controller + ? !controller.IsCompleted + : !AreAllItemsCompleted(); + + private bool AreAllItemsCompleted() => m_items.Count > 0 && m_items.All(item => item.State == StepFlowItemState.Completed); + private void OnItemCardTapped(object? sender, EventArgs e) { if (!IsEnabled) return; @@ -173,7 +189,28 @@ private void OnItemCardTapped(object? sender, EventArgs e) if (controller is null) { // Escape hatch (no controller): manually enforce single-active. - if (item.State == StepFlowItemState.Completed && item.LockWhenCompleted) return; + if (item.State == StepFlowItemState.Completed && !item.CanGoBack) return; + if (item.State == StepFlowItemState.Completed && !CanGoBackFromCompletedSteps) return; + if (item.State == StepFlowItemState.Completed && item.CanGoBack) + { + var targetIndex = m_items.IndexOf(item); + if (targetIndex < 0) return; + + for (var i = 0; i < m_items.Count; i++) + { + var other = m_items[i]; + if (ReferenceEquals(other, item)) + { + other.State = StepFlowItemState.Active; + } + else if (i > targetIndex || other.State == StepFlowItemState.Active) + { + other.State = StepFlowItemState.Disabled; + } + } + return; + } + foreach (var other in m_items) { if (!ReferenceEquals(other, item) && other.State == StepFlowItemState.Active) @@ -186,6 +223,12 @@ private void OnItemCardTapped(object? sender, EventArgs e) } if (item.Index < 0) return; + if (item.State == StepFlowItemState.Completed && item.CanGoBack) + { + controller.GoBackTo(item.Index); + return; + } + controller.GoTo(item.Index); } @@ -230,7 +273,7 @@ private void OnParentChangedInvalidateScroller(object? sender, EventArgs e) if (m_scrollerResolved) return m_cachedScroller; m_scrollerResolved = true; - Element? walker = Parent; + var walker = Parent; while (walker is not null) { if (walker is MauiScrollView sv) diff --git a/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlowController.cs b/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlowController.cs index 666265a1c..edfe17a9f 100644 --- a/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlowController.cs +++ b/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlowController.cs @@ -103,7 +103,8 @@ public void Complete(int index) /// /// Activates the step at . No-op if the step is - /// or . + /// . Use to activate a + /// completed step and require it to be confirmed again. /// public void GoTo(int index) { @@ -114,6 +115,35 @@ public void GoTo(int index) ActivateInternal(index); } + /// + /// Activates a completed previous step and resets that step and all following steps so they + /// must be confirmed again. No-op if the step is not completed or the flow is already + /// completed. + /// + public void GoBackTo(int index) + { + if (!IsValidIndex(index)) return; + if (IsCompleted) return; + if (m_states[index] != StepFlowItemState.Completed) return; + + for (var i = 0; i < m_stepCount; i++) + { + if (i == index) + { + if (m_states[i] != StepFlowItemState.Active) + SetStateInternal(i, StepFlowItemState.Active); + } + else if (i > index || m_states[i] == StepFlowItemState.Active) + { + if (m_states[i] != StepFlowItemState.Disabled) + SetStateInternal(i, StepFlowItemState.Disabled); + } + } + + CurrentIndex = index; + StepActivated?.Invoke(this, new StepFlowEventArgs(index)); + } + /// Resets the flow: step 0 becomes , the rest . public void Reset() { diff --git a/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlowItem.Properties.cs b/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlowItem.Properties.cs index 7c2b58f18..d2877b215 100644 --- a/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlowItem.Properties.cs +++ b/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlowItem.Properties.cs @@ -39,11 +39,15 @@ public DataTemplate? IndicatorTemplate set => SetValue(IndicatorTemplateProperty, value); } - /// When true (the default), tapping a completed step does nothing. - public bool LockWhenCompleted + /// + /// When true, tapping this completed step activates it again and resets this and the + /// following steps so they must be confirmed again. Only applies while the flow is in + /// progress. Defaults to false. + /// + public bool CanGoBack { - get => (bool)GetValue(LockWhenCompletedProperty); - set => SetValue(LockWhenCompletedProperty, value); + get => (bool)GetValue(CanGoBackProperty); + set => SetValue(CanGoBackProperty, value); } /// @@ -83,11 +87,12 @@ public StepFlowItemState State typeof(StepFlowItem), propertyChanged: (b, _, _) => ((StepFlowItem)b).OnIndicatorTemplateChanged()); - public static readonly BindableProperty LockWhenCompletedProperty = BindableProperty.Create( - nameof(LockWhenCompleted), + public static readonly BindableProperty CanGoBackProperty = BindableProperty.Create( + nameof(CanGoBack), typeof(bool), typeof(StepFlowItem), - defaultValue: true); + defaultValue: false, + propertyChanged: (b, _, _) => ((StepFlowItem)b).RefreshTapTarget()); public static readonly BindableProperty StateProperty = BindableProperty.Create( nameof(State), diff --git a/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlowItem.cs b/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlowItem.cs index de0ab543b..1d4be8d91 100644 --- a/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlowItem.cs +++ b/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlowItem.cs @@ -52,6 +52,11 @@ public partial class StepFlowItem : ContentView /// Raised when the user taps the card. The container decides whether the tap should activate the step. internal event EventHandler? CardTapped; + internal void RefreshTapTarget() + { + UpdateCardTapTarget(State); + } + public StepFlowItem() { m_animationToken = "stepflow-item-" + Guid.NewGuid().ToString("N"); @@ -233,9 +238,15 @@ private void InvokeCardTapped() return; if (State == StepFlowItemState.Active) return; - if (State == StepFlowItemState.Completed && LockWhenCompleted) - return; - if (State == StepFlowItemState.Disabled && Parent is StepFlow flow && !flow.AllowDirectStepActivation) + if (State == StepFlowItemState.Completed) + { + var flow = FindParentStepFlow(); + if (flow?.CanGoBackFromCompletedSteps != true) + return; + if (!CanGoBack) + return; + } + if (State == StepFlowItemState.Disabled && FindParentStepFlow()?.AllowDirectStepActivation != true) return; CardTapped?.Invoke(this, EventArgs.Empty); @@ -243,13 +254,56 @@ private void InvokeCardTapped() private void UpdateCardTapTarget(StepFlowItemState state) { - var command = state == StepFlowItemState.Active ? null : m_cardTappedCommand; + var command = CanTapCard(state) ? m_cardTappedCommand : null; if (ReferenceEquals(Touch.GetCommand(m_root), command)) return; + if (command is null && Touch.GetCommand(m_root) is not null) + { + Dispatcher.Dispatch(() => + { + if (!CanTapCard(State) && Touch.GetCommand(m_root) is not null) + { + Touch.SetCommand(m_root, null!); + } + }); + return; + } + Touch.SetCommand(m_root, command!); } + private bool CanTapCard(StepFlowItemState state) => state switch + { + StepFlowItemState.Active => false, + StepFlowItemState.Completed => CanTapCompletedCard(), + StepFlowItemState.Disabled => FindParentStepFlow()?.AllowDirectStepActivation == true, + _ => true + }; + + private bool CanTapCompletedCard() + { + var flow = FindParentStepFlow(); + if (flow?.CanGoBackFromCompletedSteps != true) + return false; + + return CanGoBack; + } + + private StepFlow? FindParentStepFlow() + { + var walker = Parent; + while (walker is not null) + { + if (walker is StepFlow flow) + return flow; + + walker = walker.Parent; + } + + return null; + } + private void OnStateChanged(StepFlowItemState oldState, StepFlowItemState newState) { if (oldState == newState) @@ -291,6 +345,7 @@ internal void ApplyStateVisuals(StepFlowItemState state, bool animate) break; case StepFlowItemState.Active: + this.AbortAnimation(m_animationToken + "-opacity"); StopCompletionAnimation(); AnimateIndicator(show: false, animate); LayoutEffect.SetStroke(m_root, Colors.GetColor(ColorName.color_text_default)); @@ -402,9 +457,9 @@ private void ExpandAsync() this.AbortAnimation(m_animationToken + "-body"); parent.Commit(this, m_animationToken + "-body", rate: 16, length: ExpandDurationMs, - easing: Easing.Linear, finished: (_, _) => + easing: Easing.Linear, finished: (_, wasCancelled) => { - if (State != StepFlowItemState.Active || Handler is null) + if (wasCancelled || State != StepFlowItemState.Active || Handler is null) return; // Hand the slot back to auto-sizing so future content changes (async loads, // text wraps) just work without a re-measure dance. @@ -437,8 +492,11 @@ private Task CollapseAsync() this.AbortAnimation(m_animationToken + "-body"); parent.Commit(this, m_animationToken + "-body", rate: 16, length: CollapseDurationMs, - easing: Easing.Linear, finished: (_, _) => + easing: Easing.Linear, finished: (_, wasCancelled) => { + if (wasCancelled || State == StepFlowItemState.Active) + return; + m_bodyContainer.IsVisible = false; m_bodyContainer.TranslationY = 0; }); diff --git a/src/tests/unittests/Components/StepFlow/StepFlowControllerTests.cs b/src/tests/unittests/Components/StepFlow/StepFlowControllerTests.cs new file mode 100644 index 000000000..36a98a62c --- /dev/null +++ b/src/tests/unittests/Components/StepFlow/StepFlowControllerTests.cs @@ -0,0 +1,103 @@ +using DIPS.Mobile.UI.Components.StepFlow; + +namespace DIPS.Mobile.UI.UnitTests.Components.StepFlow; + +public class StepFlowControllerTests +{ + [Fact] + public void GoBackTo_CompletedStep_ActivatesStepAndDisablesFollowingSteps() + { + var controller = CreateControllerWithCompletedSteps(); + + controller.GoBackTo(1); + + controller.CurrentIndex.Should().Be(1); + controller.States.Should().Equal( + StepFlowItemState.Completed, + StepFlowItemState.Active, + StepFlowItemState.Disabled, + StepFlowItemState.Disabled); + } + + [Fact] + public void GoBackTo_FirstCompletedStep_ActivatesFirstStepAndDisablesFollowingSteps() + { + var controller = CreateControllerWithCompletedSteps(); + + controller.GoBackTo(0); + + controller.CurrentIndex.Should().Be(0); + controller.States.Should().Equal( + StepFlowItemState.Active, + StepFlowItemState.Disabled, + StepFlowItemState.Disabled, + StepFlowItemState.Disabled); + } + + [Fact] + public void GoBackTo_NonCompletedStep_DoesNothing() + { + var controller = new StepFlowController { AutoAdvance = false }; + controller.Initialize(3); + + controller.GoBackTo(1); + + controller.GoBackTo(0); + + controller.GoBackTo(-1); + + controller.GoBackTo(3); + + controller.GoBackTo(99); + + controller.CurrentIndex.Should().Be(0); + controller.States.Should().Equal( + StepFlowItemState.Active, + StepFlowItemState.Disabled, + StepFlowItemState.Disabled); + } + + [Fact] + public void GoBackTo_CompletedFlow_DoesNothing() + { + var controller = CreateControllerWithCompletedSteps(); + controller.Complete(3); + + controller.GoBackTo(1); + + controller.CurrentIndex.Should().Be(-1); + controller.States.Should().Equal( + StepFlowItemState.Completed, + StepFlowItemState.Completed, + StepFlowItemState.Completed, + StepFlowItemState.Completed); + } + + [Fact] + public void GoTo_CompletedStep_DoesNotActivateCompletedStep() + { + var controller = CreateControllerWithCompletedSteps(); + + controller.GoTo(1); + + controller.CurrentIndex.Should().Be(3); + controller.States.Should().Equal( + StepFlowItemState.Completed, + StepFlowItemState.Completed, + StepFlowItemState.Completed, + StepFlowItemState.Active); + } + + private static StepFlowController CreateControllerWithCompletedSteps() + { + var controller = new StepFlowController { AutoAdvance = false }; + controller.Initialize(4); + controller.Complete(0); + controller.GoTo(1); + controller.Complete(1); + controller.GoTo(2); + controller.Complete(2); + controller.GoTo(3); + return controller; + } +} diff --git a/wiki/Components/StepFlow.md b/wiki/Components/StepFlow.md index e826e54ba..c5b1eb476 100644 --- a/wiki/Components/StepFlow.md +++ b/wiki/Components/StepFlow.md @@ -78,7 +78,8 @@ public class SamplingViewModel : ViewModel | `AutoAdvanceDelay` | Delay before auto-advance. Defaults to 800 ms. | | `CompleteCurrent()` | Marks `CurrentIndex` `Completed` and (optionally) auto-advances. | | `Complete(int)` | Marks the step at the given index `Completed`. | -| `GoTo(int)` | Activates the step at the given index. No-op if disabled or completed. | +| `GoTo(int)` | Activates the step at the given index. No-op if completed. | +| `GoBackTo(int)` | Activates a completed previous step and resets that step and all following steps so they must be confirmed again. No-op after the full flow is completed. | | `Reset()` | Step 0 → `Active`, all others → `Disabled`. | | `SetState(int, state)` | Explicitly set a step's state. Use sparingly. | | `StepCompleted` | Raised when a step transitions to `Completed`. | @@ -103,7 +104,7 @@ public class SamplingViewModel : ViewModel | `Subtitle` | Optional smaller line below the title. | | `Content` | The body shown when the step is `Active`. XAML default content. | | `IndicatorTemplate` | Optional template for the leading indicator. Defaults to a numbered/check circle. | -| `LockWhenCompleted` | When `true` (default) tapping a completed step does nothing. | +| `CanGoBack` | When `true`, tapping a completed step activates it again and resets this and following steps so they must be confirmed again. No-op after the full flow is completed. | | `State` | Current `StepFlowItemState`. Driven by the container — read-only for advanced scenarios. | ### `StepFlowItemState` @@ -142,6 +143,18 @@ Set `AutoScrollIntoView="False"` on the `StepFlow` to opt out — for example wh If no ancestor `ScrollView` is found, `AutoScrollIntoView` is a silent no-op. Non-MAUI scrollers (`CollectionView` etc.) are not supported. +## Going back + +Set `CanGoBack="True"` on completed steps that people may revisit while the flow is still in progress. When the step is tapped, StepFlow activates that step and resets it plus all following steps to require confirmation again. Once every step is completed, back navigation is disabled. + +```xml + + + + + +``` + ## Escape hatch (binding-only) If you don't want a controller you can omit the `Controller` property and bind `State` two-way on each item. The container still enforces single-active and animates correctly, but you become responsible for orchestration. **Not recommended.** From 3101c69bdc67be27a66e9123e3445e57d803358e Mon Sep 17 00:00:00 2001 From: Vetle Finstad Date: Mon, 29 Jun 2026 15:24:41 +0200 Subject: [PATCH 2/2] comments --- .../Components/StepFlow/StepFlow.cs | 95 ++++++++++++------- .../Components/StepFlow/StepFlowItem.cs | 11 +-- 2 files changed, 65 insertions(+), 41 deletions(-) diff --git a/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlow.cs b/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlow.cs index e3a239f05..faf799cd5 100644 --- a/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlow.cs +++ b/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlow.cs @@ -174,9 +174,15 @@ private void OnControllerFlowCompleted(object? sender, EventArgs e) FlowCompleted?.Invoke(this, EventArgs.Empty); } - internal bool CanGoBackFromCompletedSteps => m_attachedController is { } controller - ? !controller.IsCompleted - : !AreAllItemsCompleted(); + internal bool CanActivateCompletedStep(StepFlowItem item) => + m_items.Contains(item) && + item.State == StepFlowItemState.Completed && + item.CanGoBack && + !IsFlowCompleted(); + + private bool IsFlowCompleted() => m_attachedController is { } controller + ? controller.IsCompleted + : AreAllItemsCompleted(); private bool AreAllItemsCompleted() => m_items.Count > 0 && m_items.All(item => item.State == StepFlowItemState.Completed); @@ -188,44 +194,69 @@ private void OnItemCardTapped(object? sender, EventArgs e) var controller = m_attachedController; if (controller is null) { - // Escape hatch (no controller): manually enforce single-active. - if (item.State == StepFlowItemState.Completed && !item.CanGoBack) return; - if (item.State == StepFlowItemState.Completed && !CanGoBackFromCompletedSteps) return; - if (item.State == StepFlowItemState.Completed && item.CanGoBack) + ActivateTappedItemWithoutController(item); + return; + } + + ActivateTappedItemWithController(controller, item); + } + + private void ActivateTappedItemWithoutController(StepFlowItem item) + { + // Escape hatch (no controller): manually enforce single-active. + if (item.State == StepFlowItemState.Completed) + { + ActivateCompletedItemWithoutController(item); + return; + } + + DisableOtherActiveItems(item); + item.State = StepFlowItemState.Active; + } + + private void ActivateCompletedItemWithoutController(StepFlowItem item) + { + if (!CanActivateCompletedStep(item)) return; + + var targetIndex = m_items.IndexOf(item); + if (targetIndex < 0) return; + + for (var i = 0; i < m_items.Count; i++) + { + var other = m_items[i]; + if (ReferenceEquals(other, item)) { - var targetIndex = m_items.IndexOf(item); - if (targetIndex < 0) return; - - for (var i = 0; i < m_items.Count; i++) - { - var other = m_items[i]; - if (ReferenceEquals(other, item)) - { - other.State = StepFlowItemState.Active; - } - else if (i > targetIndex || other.State == StepFlowItemState.Active) - { - other.State = StepFlowItemState.Disabled; - } - } - return; + other.State = StepFlowItemState.Active; + continue; } - foreach (var other in m_items) + if (i > targetIndex || other.State == StepFlowItemState.Active) { - if (!ReferenceEquals(other, item) && other.State == StepFlowItemState.Active) - { - other.State = StepFlowItemState.Disabled; - } + other.State = StepFlowItemState.Disabled; } - item.State = StepFlowItemState.Active; - return; } + } + private void DisableOtherActiveItems(StepFlowItem item) + { + foreach (var other in m_items) + { + if (!ReferenceEquals(other, item) && other.State == StepFlowItemState.Active) + { + other.State = StepFlowItemState.Disabled; + } + } + } + + private void ActivateTappedItemWithController(StepFlowController controller, StepFlowItem item) + { if (item.Index < 0) return; - if (item.State == StepFlowItemState.Completed && item.CanGoBack) + if (item.State == StepFlowItemState.Completed) { - controller.GoBackTo(item.Index); + if (CanActivateCompletedStep(item)) + { + controller.GoBackTo(item.Index); + } return; } diff --git a/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlowItem.cs b/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlowItem.cs index 1d4be8d91..0de4ce12e 100644 --- a/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlowItem.cs +++ b/src/library/DIPS.Mobile.UI/Components/StepFlow/StepFlowItem.cs @@ -240,10 +240,7 @@ private void InvokeCardTapped() return; if (State == StepFlowItemState.Completed) { - var flow = FindParentStepFlow(); - if (flow?.CanGoBackFromCompletedSteps != true) - return; - if (!CanGoBack) + if (FindParentStepFlow()?.CanActivateCompletedStep(this) != true) return; } if (State == StepFlowItemState.Disabled && FindParentStepFlow()?.AllowDirectStepActivation != true) @@ -283,11 +280,7 @@ private void UpdateCardTapTarget(StepFlowItemState state) private bool CanTapCompletedCard() { - var flow = FindParentStepFlow(); - if (flow?.CanGoBackFromCompletedSteps != true) - return false; - - return CanGoBack; + return FindParentStepFlow()?.CanActivateCompletedStep(this) == true; } private StepFlow? FindParentStepFlow()