diff --git a/CHANGELOG.md b/CHANGELOG.md
index 91b100684..10e5b19c1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,6 @@
+## [60.2.15]
+- [BarcodeScanner] Shows validation failure messages above the scan rectangle when rejected barcode results include an error message, adds success/error haptics, and adds automatic camera zoom tips with `BarcodeScannerStartOptions.Hint`.
+
## [60.2.14]
- [CollectionView][iOS] Fixed item template root corner radius and stroke being cleared when `AutoCornerRadius` is disabled.
diff --git a/src/app/Components/ComponentsSamples/BarcodeScanning/BarcodeFeedbackSample.xaml b/src/app/Components/ComponentsSamples/BarcodeScanning/BarcodeFeedbackSample.xaml
new file mode 100644
index 000000000..b6fff1d8c
--- /dev/null
+++ b/src/app/Components/ComponentsSamples/BarcodeScanning/BarcodeFeedbackSample.xaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/app/Components/ComponentsSamples/BarcodeScanning/BarcodeFeedbackSample.xaml.cs b/src/app/Components/ComponentsSamples/BarcodeScanning/BarcodeFeedbackSample.xaml.cs
new file mode 100644
index 000000000..725695581
--- /dev/null
+++ b/src/app/Components/ComponentsSamples/BarcodeScanning/BarcodeFeedbackSample.xaml.cs
@@ -0,0 +1,124 @@
+using Components.Resources.LocalizedStrings;
+using DIPS.Mobile.UI.API.Camera;
+using DIPS.Mobile.UI.API.Camera.BarcodeScanning;
+using DIPS.Mobile.UI.Resources.Colors;
+using DIPS.Mobile.UI.Resources.Styles;
+using DIPS.Mobile.UI.Resources.Styles.Label;
+using DuiColors = DIPS.Mobile.UI.Resources.Colors.Colors;
+using DuiLabel = DIPS.Mobile.UI.Components.Labels.Label;
+
+namespace Components.ComponentsSamples.BarcodeScanning;
+
+public partial class BarcodeFeedbackSample
+{
+ private readonly BarcodeScanner m_barcodeScanner;
+ private View? m_tooltipView;
+ private int m_validationAttempt;
+
+ public BarcodeFeedbackSample()
+ {
+ InitializeComponent();
+ m_barcodeScanner = new BarcodeScanner();
+ }
+
+ private async Task Start()
+ {
+ try
+ {
+ await m_barcodeScanner.Start(new BarcodeScannerStartOptions
+ {
+ Preview = CameraPreview,
+ OnCameraFailed = CameraFailed,
+ OnBarcodeAcceptedAsync = HandleBarcodeAcceptedAsync,
+ ValidateBarcodeAsync = ValidateBarcodeAsync,
+ OnBarcodeRejectedAsync = HandleBarcodeRejectedAsync,
+ Strategy = new ScanRectangleBarcodeScanStrategy
+ {
+ WidthFraction = 0.8f,
+ HeightFraction = 0.3f
+ },
+ Hint = new BarcodeScannerHintOptions
+ {
+ ShowAutomaticHint = true,
+ HintText = LocalizedStrings.BarcodeScannerFeedbackFocusHint,
+ Delay = TimeSpan.FromSeconds(5)
+ }
+ });
+
+ m_barcodeScanner.SetTooltipView(GetTooltipView());
+ }
+ catch (Exception exception)
+ {
+ await Application.Current?.MainPage?.DisplayAlert("Failed, check console!", exception.Message, "Ok")!;
+ Console.WriteLine(exception);
+ }
+ }
+
+ private View GetTooltipView()
+ {
+ if (m_tooltipView is not null)
+ return m_tooltipView;
+
+ m_tooltipView = new DuiLabel
+ {
+ Text = LocalizedStrings.BarcodeScannerFeedbackTooltipText,
+ Style = Styles.GetLabelStyle(LabelStyle.UI200),
+ TextColor = DuiColors.GetColor(ColorName.color_text_on_button),
+ HorizontalTextAlignment = TextAlignment.Center,
+ LineBreakMode = LineBreakMode.WordWrap
+ };
+
+ return m_tooltipView;
+ }
+
+ private void CameraFailed(CameraException e)
+ {
+ App.Current.MainPage.DisplayAlert("Something failed!", e.Message, "Ok");
+ }
+
+ private Task HandleBarcodeAcceptedAsync(BarcodeScanResult barcodeScanResult)
+ {
+ Console.WriteLine($"Accepted barcode: {barcodeScanResult.Barcode.RawValue}");
+ return Task.CompletedTask;
+ }
+
+ private async Task ValidateBarcodeAsync(string barcode)
+ {
+ await Task.Delay(150);
+
+ var feedback = (m_validationAttempt++ % 4) switch
+ {
+ 0 => (Message: LocalizedStrings.BarcodeScannerFeedbackAlreadyScanned, ReasonCode: "already-scanned"),
+ 1 => (Message: LocalizedStrings.BarcodeScannerFeedbackWrongPatient, ReasonCode: "wrong-patient"),
+ 2 => (Message: LocalizedStrings.BarcodeScannerFeedbackNotLabel, ReasonCode: "not-label"),
+ _ => default
+ };
+
+ return feedback.Message is null
+ ? BarcodeScanValidationResult.Valid()
+ : BarcodeScanValidationResult.Invalid(feedback.Message, feedback.ReasonCode);
+ }
+
+ private Task HandleBarcodeRejectedAsync(BarcodeScanResult barcodeScanResult, BarcodeScanValidationResult validationResult)
+ {
+ Console.WriteLine($"Rejected barcode: {barcodeScanResult.Barcode.RawValue}. {validationResult.ReasonCode}");
+ return Task.CompletedTask;
+ }
+
+ protected override void OnAppearing()
+ {
+ _ = Start();
+ base.OnAppearing();
+ }
+
+ protected override void OnDisappearing()
+ {
+ m_barcodeScanner.StopAndDispose();
+ base.OnDisappearing();
+ }
+
+ private void Close(object? sender, EventArgs e)
+ {
+ Shell.Current.Navigation.PopModalAsync();
+ }
+}
\ No newline at end of file
diff --git a/src/app/Components/ComponentsSamples/BarcodeScanning/BarcodeScanningHubSamples.xaml b/src/app/Components/ComponentsSamples/BarcodeScanning/BarcodeScanningHubSamples.xaml
index e6b9f5cb8..23f4c931a 100644
--- a/src/app/Components/ComponentsSamples/BarcodeScanning/BarcodeScanningHubSamples.xaml
+++ b/src/app/Components/ComponentsSamples/BarcodeScanning/BarcodeScanningHubSamples.xaml
@@ -5,6 +5,7 @@
+
+
diff --git a/src/app/Components/ComponentsSamples/BarcodeScanning/BarcodeScanningHubSamples.xaml.cs b/src/app/Components/ComponentsSamples/BarcodeScanning/BarcodeScanningHubSamples.xaml.cs
index 57b0704f9..d22f4efcf 100644
--- a/src/app/Components/ComponentsSamples/BarcodeScanning/BarcodeScanningHubSamples.xaml.cs
+++ b/src/app/Components/ComponentsSamples/BarcodeScanning/BarcodeScanningHubSamples.xaml.cs
@@ -8,6 +8,7 @@ public BarcodeScanningHubSamples()
BasicScannerItem.Command = new Command(() => OpenModal(new BarcodeScanningSample()));
OverlayScannerItem.Command = new Command(() => OpenModal(new BarcodeOverlaySample()));
TooltipScannerItem.Command = new Command(() => OpenModal(new BarcodeTooltipSample()));
+ FeedbackScannerItem.Command = new Command(() => OpenModal(new BarcodeFeedbackSample()));
CounterScannerItem.Command = new Command(() => OpenModal(new BarcodeCounterSample()));
}
diff --git a/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.Designer.cs b/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.Designer.cs
index 52fc8aeae..195875424 100644
--- a/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.Designer.cs
+++ b/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.Designer.cs
@@ -644,6 +644,48 @@ internal static string BarcodeScanning {
return ResourceManager.GetString("BarcodeScanning", resourceCulture);
}
}
+
+ internal static string BarcodeScannerFeedbackTitle {
+ get {
+ return ResourceManager.GetString("BarcodeScannerFeedbackTitle", resourceCulture);
+ }
+ }
+
+ internal static string BarcodeScannerFeedbackSubtitle {
+ get {
+ return ResourceManager.GetString("BarcodeScannerFeedbackSubtitle", resourceCulture);
+ }
+ }
+
+ internal static string BarcodeScannerFeedbackTooltipText {
+ get {
+ return ResourceManager.GetString("BarcodeScannerFeedbackTooltipText", resourceCulture);
+ }
+ }
+
+ internal static string BarcodeScannerFeedbackFocusHint {
+ get {
+ return ResourceManager.GetString("BarcodeScannerFeedbackFocusHint", resourceCulture);
+ }
+ }
+
+ internal static string BarcodeScannerFeedbackAlreadyScanned {
+ get {
+ return ResourceManager.GetString("BarcodeScannerFeedbackAlreadyScanned", resourceCulture);
+ }
+ }
+
+ internal static string BarcodeScannerFeedbackWrongPatient {
+ get {
+ return ResourceManager.GetString("BarcodeScannerFeedbackWrongPatient", resourceCulture);
+ }
+ }
+
+ internal static string BarcodeScannerFeedbackNotLabel {
+ get {
+ return ResourceManager.GetString("BarcodeScannerFeedbackNotLabel", resourceCulture);
+ }
+ }
internal static string Barcode {
get {
diff --git a/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.en.resx b/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.en.resx
index e1251925e..c7d36d178 100644
--- a/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.en.resx
+++ b/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.en.resx
@@ -318,6 +318,27 @@
Barcode Scanning
+
+ Barcode scanner feedback
+
+
+ Shows rejection reasons above the scan area
+
+
+ Place the label inside the frame and hold the camera steady.
+
+
+ Hold the label farther away to focus.
+
+
+ The label has already been scanned.
+
+
+ The label does not belong to this patient.
+
+
+ The code you scanned is not a label.
+
Barcode
diff --git a/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.resx b/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.resx
index 2062872d9..4f8979d33 100644
--- a/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.resx
+++ b/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.resx
@@ -323,6 +323,27 @@
Strekkode skanning
+
+ Strekkode skanning med tilbakemelding
+
+
+ Viser avvisningsårsaker over skanneområdet
+
+
+ Plasser etiketten innenfor rammen og hold kameraet rolig.
+
+
+ Hold etiketten lenger unna for å fokusere.
+
+
+ Etiketten er allerede skannet.
+
+
+ Etiketten hører ikke til denne pasienten.
+
+
+ Koden du skannet er ikke en etikett.
+
Strekkode
diff --git a/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/BarcodeScanSession.cs b/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/BarcodeScanSession.cs
index 1385bdfdd..d5416a887 100644
--- a/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/BarcodeScanSession.cs
+++ b/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/BarcodeScanSession.cs
@@ -1,4 +1,5 @@
using DIPS.Mobile.UI.API.Camera.Preview;
+using DIPS.Mobile.UI.API.Vibration;
using DIPS.Mobile.UI.Internal.Logging;
namespace DIPS.Mobile.UI.API.Camera.BarcodeScanning;
@@ -110,6 +111,7 @@ private async Task ValidateBarcodeScanResultAsync(B
private async Task AcceptBarcodeScanResultAsync(BarcodeScanResult barcodeScanResult, BarcodeScanRectangleOverlay? scanRectangleOverlay)
{
State = BarcodeScannerState.SuccessAnimating;
+ RunFeedbackVibration(VibrationService.Success);
var successAnimationTask = scanRectangleOverlay?.PlaySuccessAndResetAsync() ?? Task.CompletedTask;
if (ShouldShowProgressCounter && scanRectangleOverlay is not null)
@@ -150,6 +152,7 @@ private async Task AcceptBarcodeScanResultAsync(BarcodeScanResult barcodeS
private async Task RejectBarcodeScanResultAsync(BarcodeScanResult barcodeScanResult, BarcodeScanValidationResult validationResult, BarcodeScanRectangleOverlay? scanRectangleOverlay)
{
State = BarcodeScannerState.FailureAnimating;
+ RunFeedbackVibration(VibrationService.Error);
if (scanRectangleOverlay is not null)
{
@@ -163,6 +166,21 @@ private async Task RejectBarcodeScanResultAsync(BarcodeScanResult barcodeS
return !m_isDisposed && State != BarcodeScannerState.Paused;
}
+ private static void RunFeedbackVibration(Action vibration)
+ {
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ try
+ {
+ vibration.Invoke();
+ }
+ catch (Exception exception)
+ {
+ DUILogService.LogError($"Barcode scanner feedback vibration failed: {exception.Message}");
+ }
+ });
+ }
+
private async Task NotifyBarcodeAcceptedAsync(BarcodeScanResult barcodeScanResult)
{
try
diff --git a/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/BarcodeScanner.cs b/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/BarcodeScanner.cs
index 816a8ba52..a58d311cb 100644
--- a/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/BarcodeScanner.cs
+++ b/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/BarcodeScanner.cs
@@ -23,6 +23,8 @@ public partial class BarcodeScanner : ICameraUseCase
private readonly HashSet m_confirmedBarcodeValues = new(StringComparer.Ordinal);
private bool m_isCoolingDown;
private Timer? m_cooldownTimer;
+ private CancellationTokenSource? m_automaticHintCts;
+ private bool m_hasCompletedAutomaticHint;
// Confirmation handler & overlay
private IBarcodeConfirmationHandler? m_confirmationHandler;
@@ -60,6 +62,7 @@ public async Task Start(BarcodeScannerStartOptions startOptions)
var ct = m_sessionCts.Token;
BeginNewScanRun();
await StopPlatformIfNeededAsync();
+ StopAutomaticHint();
RemoveOverlayViews();
m_currentStartOptions = startOptions;
m_cameraPreview = cameraPreview;
@@ -68,6 +71,7 @@ public async Task Start(BarcodeScannerStartOptions startOptions)
m_confirmationHandler = null;
ResetScanState();
DisposeCooldownTimer();
+ m_hasCompletedAutomaticHint = false;
m_confirmedBarcodeValues.Clear();
m_scanSession?.Dispose();
@@ -104,6 +108,7 @@ public async Task Start(BarcodeScannerStartOptions startOptions)
m_isPlatformStarted = true;
m_scanSession.Start();
+ RestartAutomaticHintTimer();
if (m_cameraPreview?.CameraZoomView is not null)
{
@@ -178,6 +183,7 @@ public void PauseScanning(bool resetOverlay = true)
m_scanSession?.PauseScanning();
ResetBarcodeConfirmationState(resetOverlay);
DisposeCooldownTimer();
+ StopAutomaticHint();
m_confirmedBarcodeValues.Clear();
}
catch (Exception e)
@@ -201,7 +207,8 @@ public void ResumeScanning()
m_confirmationHandler?.Reset();
DisposeCooldownTimer();
m_confirmedBarcodeValues.Clear();
- m_scanSession!.ResumeScanning();
+ m_scanSession?.ResumeScanning();
+ RestartAutomaticHintTimer();
}
catch (Exception e)
{
@@ -231,6 +238,7 @@ private void StopScanningCore(bool resetOverlay)
StopPlatformIfNeeded();
ResetBarcodeConfirmationState(resetOverlay);
DisposeCooldownTimer();
+ StopAutomaticHint();
m_confirmationHandler?.Dispose();
m_confirmationHandler = null;
m_scanSession?.Dispose();
@@ -257,6 +265,8 @@ internal void InvokeBarcodeFound(Barcode barcode, RectF? overlayBounds = null, i
if (string.IsNullOrWhiteSpace(barcodeKey))
return;
+ StopAutomaticHint();
+
if (m_confirmedBarcodeValues.Contains(barcodeKey))
{
m_confirmationHandler?.OnConfirmedBarcodeRedetected();
@@ -368,6 +378,7 @@ private void OnBarcodeLostByHandler()
m_confirmedBarcodeValues.Clear();
ResetScanState();
m_scanRectangleOverlay?.ResetBarcodeDetection();
+ RestartAutomaticHintTimer();
}
private void StartCooldown()
@@ -400,6 +411,127 @@ private void DisposeCooldownTimer()
m_isCoolingDown = false;
}
+ private void RestartAutomaticHintTimer()
+ {
+ StopAutomaticHint();
+
+ if (!TryGetAutomaticHint(out var hintText, out var delay))
+ return;
+
+ var scanRunId = CurrentScanRunId;
+ m_automaticHintCts = new CancellationTokenSource();
+ _ = ShowAutomaticHintAfterDelayAsync(hintText, delay, scanRunId, m_automaticHintCts.Token);
+ }
+
+ private async Task ShowAutomaticHintAfterDelayAsync(string hintText, TimeSpan delay, int scanRunId, CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (delay > TimeSpan.Zero)
+ {
+ await Task.Delay(delay, cancellationToken);
+ }
+
+ if (cancellationToken.IsCancellationRequested || scanRunId != CurrentScanRunId || !IsSessionActive)
+ return;
+
+ await MainThread.InvokeOnMainThreadAsync(() => TryShowAutomaticHintAsync(hintText, scanRunId, cancellationToken));
+ }
+ catch (TaskCanceledException)
+ {
+ }
+ catch (Exception exception)
+ {
+ DUILogService.LogError($"Barcode scanner automatic hint failed: {exception.Message}");
+ }
+ }
+
+ private bool TryGetAutomaticHint(out string hintText, out TimeSpan delay)
+ {
+ hintText = string.Empty;
+ delay = TimeSpan.Zero;
+
+ var hint = m_currentStartOptions.Hint;
+ if (m_cameraPreview is null || m_hasCompletedAutomaticHint || !hint.ShowAutomaticHint || string.IsNullOrWhiteSpace(hint.HintText) || m_cameraPreview.HasZoomed)
+ return false;
+
+ hintText = hint.HintText;
+ delay = hint.Delay < TimeSpan.Zero ? TimeSpan.Zero : hint.Delay;
+ return true;
+ }
+
+ private async Task TryShowAutomaticHintAsync(string hintText, int scanRunId, CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested || scanRunId != CurrentScanRunId || !IsSessionActive)
+ return;
+
+ if (m_hasCompletedAutomaticHint || m_cameraPreview?.HasZoomed is not false)
+ return;
+
+ m_hasCompletedAutomaticHint = true;
+
+ if (!await CanShowAutomaticHintAsync())
+ return;
+
+ if (cancellationToken.IsCancellationRequested || scanRunId != CurrentScanRunId || !IsSessionActive || m_cameraPreview?.HasZoomed is not false)
+ return;
+
+ try
+ {
+ m_cameraPreview.ShowZoomSliderTip(hintText);
+ }
+ catch (Exception exception)
+ {
+ DUILogService.LogError($"Barcode scanner zoom tip failed: {exception.Message}");
+ return;
+ }
+
+ await NotifyAutomaticHintShownAsync();
+ }
+
+ private async Task CanShowAutomaticHintAsync()
+ {
+ if (m_cameraPreview?.HasZoomed is not false)
+ return false;
+
+ try
+ {
+ return m_currentStartOptions.Hint.CanShowHintAsync is null || await m_currentStartOptions.Hint.CanShowHintAsync.Invoke();
+ }
+ catch (Exception exception)
+ {
+ DUILogService.LogError($"Barcode scanner hint visibility callback failed: {exception.Message}");
+ return false;
+ }
+ }
+
+ private async Task NotifyAutomaticHintShownAsync()
+ {
+ try
+ {
+ if (m_currentStartOptions.Hint.OnHintShownAsync is not null)
+ {
+ await m_currentStartOptions.Hint.OnHintShownAsync.Invoke();
+ }
+ }
+ catch (Exception exception)
+ {
+ DUILogService.LogError($"Barcode scanner hint shown callback failed: {exception.Message}");
+ }
+ }
+
+ private void StopAutomaticHint()
+ {
+ DisposeAutomaticHintTimer();
+ }
+
+ private void DisposeAutomaticHintTimer()
+ {
+ m_automaticHintCts?.Cancel();
+ m_automaticHintCts?.Dispose();
+ m_automaticHintCts = null;
+ }
+
private void ResetBarcodeConfirmationState(bool resetOverlay = true)
{
m_confirmationHandler?.Reset();
@@ -422,7 +554,8 @@ private void ResumeScanningAfterAnimation()
return;
StartCooldown();
- m_scanSession!.ResumeScanning();
+ m_scanSession?.ResumeScanning();
+ RestartAutomaticHintTimer();
}
private int BeginNewScanRun() => Interlocked.Increment(ref m_scanRunId);
diff --git a/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/BarcodeScannerHintOptions.cs b/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/BarcodeScannerHintOptions.cs
new file mode 100644
index 000000000..ac6051010
--- /dev/null
+++ b/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/BarcodeScannerHintOptions.cs
@@ -0,0 +1,35 @@
+namespace DIPS.Mobile.UI.API.Camera.BarcodeScanning;
+
+///
+/// Defines automatic barcode scanner zoom-tip behavior.
+///
+public class BarcodeScannerHintOptions
+{
+ ///
+ /// Gets or sets a value indicating whether the scanner should automatically show using the camera zoom tip when scanning takes longer than .
+ ///
+ /// The scanner attempts the automatic hint once per scanner session and does not show it after people have used camera zoom.
+ public bool ShowAutomaticHint { get; set; }
+
+ ///
+ /// Gets or sets the hint text shown by the camera zoom tip when is enabled.
+ ///
+ public string? HintText { get; set; }
+
+ ///
+ /// Gets or sets how long the scanner waits before showing .
+ ///
+ public TimeSpan Delay { get; set; } = TimeSpan.FromSeconds(15);
+
+ ///
+ /// Gets or sets an optional callback that decides whether the automatic hint can be shown.
+ ///
+ /// The scanner invokes this callback on the main thread.
+ public Func>? CanShowHintAsync { get; set; }
+
+ ///
+ /// Gets or sets an optional callback invoked after the automatic hint has been shown.
+ ///
+ /// The scanner invokes this callback on the main thread.
+ public Func? OnHintShownAsync { get; set; }
+}
\ No newline at end of file
diff --git a/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/BarcodeScannerStartOptions.cs b/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/BarcodeScannerStartOptions.cs
index 3e3b1d352..0fd70d1db 100644
--- a/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/BarcodeScannerStartOptions.cs
+++ b/src/library/DIPS.Mobile.UI/API/Camera/BarcodeScanning/BarcodeScannerStartOptions.cs
@@ -46,6 +46,11 @@ public class BarcodeScannerStartOptions
///
public BarcodeScanCompletionOptions? Completion { get; set; }
+ ///
+ /// Gets or sets automatic hint options for scan rectangle barcode scanner sessions.
+ ///
+ public BarcodeScannerHintOptions Hint { get; set; } = new();
+
///
/// Gets or sets the cooldown applied after a scan has been accepted or rejected.
///
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 150bcde01..e6a364239 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
@@ -1,10 +1,13 @@
using DIPS.Mobile.UI.API.Camera.Preview;
+using DIPS.Mobile.UI.Resources.Styles;
+using DIPS.Mobile.UI.Resources.Styles.Label;
using DIPS.Mobile.UI.Resources.Colors;
using DIPS.Mobile.UI.Resources.Icons;
using DIPS.Mobile.UI.Resources.Sizes;
using Microsoft.Maui.Controls.Shapes;
using DuiColors = DIPS.Mobile.UI.Resources.Colors.Colors;
using DuiImage = DIPS.Mobile.UI.Components.Images.Image.Image;
+using LayoutEffect = DIPS.Mobile.UI.Effects.Layout.Layout;
namespace DIPS.Mobile.UI.API.Camera.BarcodeScanning;
@@ -20,7 +23,11 @@ internal class BarcodeScanRectangleOverlay : Grid
private readonly GraphicsView m_dimGraphicsView;
private readonly GraphicsView m_cornersGraphicsView;
private readonly Border m_collectionToken;
+ private readonly Grid m_feedbackMessageContainer;
+ private readonly Label m_feedbackMessageLabel;
private View? m_tooltipView;
+ private bool m_wasTooltipVisibleBeforeFeedback;
+ private int m_feedbackMessageVersion;
private readonly float m_widthFraction;
private readonly float m_heightFraction;
private readonly uint m_bracketsTravelLength;
@@ -43,6 +50,7 @@ internal class BarcodeScanRectangleOverlay : Grid
private const string AnimationKeyBracketsSuccess = "BracketsSuccess";
private const string AnimationKeyBracketsFailure = "BracketsFailure";
private const string AnimationKeyCollectionToken = "CollectionToken";
+ private const string AnimationKeyFeedbackMessage = "FeedbackMessage";
public BarcodeScanRectangleOverlay(float widthFraction, float heightFraction, TimeSpan bracketsTravelDuration, TimeSpan formingDuration)
{
@@ -103,18 +111,53 @@ public BarcodeScanRectangleOverlay(float widthFraction, float heightFraction, Ti
Scale = .65
};
+ (m_feedbackMessageContainer, m_feedbackMessageLabel) = CreateFeedbackMessageView();
+
InputTransparent = true;
BackgroundColor = Microsoft.Maui.Graphics.Colors.Transparent;
Children.Add(m_dimGraphicsView);
Children.Add(m_cornersGraphicsView);
Children.Add(m_collectionToken);
+ Children.Add(m_feedbackMessageContainer);
SizeChanged += OnSizeChanged;
+ m_feedbackMessageContainer.SizeChanged += OnFeedbackMessageSizeChanged;
StartBreathingAnimation();
}
+ private static (Grid Container, Label MessageLabel) CreateFeedbackMessageView()
+ {
+ var messageLabel = new Label
+ {
+ Style = Styles.GetLabelStyle(LabelStyle.UI200),
+ TextColor = DuiColors.GetColor(ColorName.color_text_on_button),
+ HorizontalTextAlignment = TextAlignment.Center,
+ LineBreakMode = LineBreakMode.WordWrap
+ };
+
+ var container = new Grid
+ {
+ InputTransparent = true,
+ HorizontalOptions = LayoutOptions.Center,
+ VerticalOptions = LayoutOptions.Start,
+ Padding = new Thickness(
+ Sizes.GetSize(SizeName.content_margin_medium),
+ Sizes.GetSize(SizeName.content_margin_small)),
+ BackgroundColor = DuiColors.GetColor(ColorName.color_fill_danger),
+ IsVisible = false,
+ Opacity = 0,
+ Scale = .96,
+ AnchorX = .5,
+ AnchorY = 1,
+ Children = { messageLabel }
+ };
+
+ LayoutEffect.SetCornerRadius(container, new CornerRadius(Sizes.GetSize(SizeName.radius_medium)));
+ return (container, messageLabel);
+ }
+
private void StartBreathingAnimation()
{
var animation = new Animation
@@ -144,14 +187,26 @@ private void StopBreathingAnimation()
internal void SetTooltipView(View tooltipView)
{
+ if (ReferenceEquals(m_tooltipView, tooltipView))
+ return;
+
+ if (m_tooltipView is not null)
+ {
+ m_tooltipView.SizeChanged -= OnTooltipSizeChanged;
+ Children.Remove(m_tooltipView);
+ m_tooltipView.DisconnectHandlers();
+ }
+
m_tooltipView = tooltipView;
m_tooltipView.InputTransparent = true;
m_tooltipView.HorizontalOptions = LayoutOptions.Center;
m_tooltipView.VerticalOptions = LayoutOptions.Start;
+ m_tooltipView.IsVisible = true;
Children.Add(m_tooltipView);
m_tooltipView.SizeChanged += OnTooltipSizeChanged;
+ PositionTooltip();
}
private void OnTooltipSizeChanged(object? sender, EventArgs e)
@@ -162,6 +217,12 @@ private void OnTooltipSizeChanged(object? sender, EventArgs e)
private void OnSizeChanged(object? sender, EventArgs e)
{
PositionTooltip();
+ PositionFeedbackMessage();
+ }
+
+ private void OnFeedbackMessageSizeChanged(object? sender, EventArgs e)
+ {
+ PositionFeedbackMessage();
}
private void PositionTooltip()
@@ -176,10 +237,27 @@ private void PositionTooltip()
m_tooltipView.TranslationY = rectY - m_tooltipView.Height - spacing;
}
+ private void PositionFeedbackMessage()
+ {
+ if (Height <= 0 || Width <= 0 || m_feedbackMessageContainer.Height <= 0)
+ return;
+
+ var rectHeight = Height * m_heightFraction;
+ var rectY = GetCameraFeedCenterY((float)Width, (float)Height) - rectHeight / 2f;
+ var spacing = Sizes.GetSize(SizeName.content_margin_small);
+ var horizontalMargin = Sizes.GetSize(SizeName.page_margin_medium);
+
+ m_feedbackMessageContainer.MaximumWidthRequest = Math.Max(0, Width - horizontalMargin * 2);
+ m_feedbackMessageContainer.TranslationY = Math.Max(
+ Sizes.GetSize(SizeName.content_margin_medium),
+ rectY - m_feedbackMessageContainer.Height - spacing);
+ }
+
internal void Cleanup()
{
StopAllAnimations();
SizeChanged -= OnSizeChanged;
+ m_feedbackMessageContainer.SizeChanged -= OnFeedbackMessageSizeChanged;
if (m_tooltipView is not null)
{
@@ -197,13 +275,77 @@ private void StopAllAnimations()
m_cornersGraphicsView.AbortAnimation(AnimationKeyBracketsSuccess);
m_cornersGraphicsView.AbortAnimation(AnimationKeyBracketsFailure);
m_collectionToken.AbortAnimation(AnimationKeyCollectionToken);
+ HideFeedbackMessage();
+ }
+
+ private void ShowFeedbackMessage(string? message)
+ {
+ if (string.IsNullOrWhiteSpace(message))
+ return;
+
+ var messageVersion = ++m_feedbackMessageVersion;
+ m_feedbackMessageLabel.Text = message;
+ m_wasTooltipVisibleBeforeFeedback = m_tooltipView?.IsVisible == true;
+
+ if (m_tooltipView is not null)
+ {
+ m_tooltipView.IsVisible = false;
+ }
+
+ PositionFeedbackMessage();
+ m_feedbackMessageContainer.AbortAnimation(AnimationKeyFeedbackMessage);
+ m_feedbackMessageContainer.IsVisible = true;
+ m_feedbackMessageContainer.Opacity = 0;
+ m_feedbackMessageContainer.Scale = .96;
+
+ var animation = new Animation
+ {
+ { 0.0, 0.16, new Animation(v =>
+ {
+ m_feedbackMessageContainer.Opacity = v;
+ m_feedbackMessageContainer.Scale = .96 + .04 * v;
+ }, 0, 1, Easing.CubicOut) },
+ { 0.16, 0.26, new Animation(v => m_feedbackMessageContainer.Scale = v, 1, 1.03, Easing.CubicOut) },
+ { 0.26, 0.38, new Animation(v => m_feedbackMessageContainer.Scale = v, 1.03, 1, Easing.CubicInOut) },
+ { 0.38, 0.78, new Animation(_ => { }, 0, 1) },
+ { 0.78, 1.0, new Animation(v =>
+ {
+ m_feedbackMessageContainer.Opacity = v;
+ m_feedbackMessageContainer.Scale = .98 + .02 * v;
+ }, 1, 0, Easing.CubicIn) }
+ };
+
+ animation.Commit(m_feedbackMessageContainer, AnimationKeyFeedbackMessage,
+ rate: 16,
+ length: 1800,
+ finished: (_, cancelled) =>
+ {
+ if (cancelled || messageVersion != m_feedbackMessageVersion)
+ return;
+
+ HideFeedbackMessage(restoreTooltip: true);
+ });
+ }
+
+ private void HideFeedbackMessage(bool restoreTooltip = false)
+ {
+ ++m_feedbackMessageVersion;
+ m_feedbackMessageContainer.AbortAnimation(AnimationKeyFeedbackMessage);
+ m_feedbackMessageContainer.IsVisible = false;
+ m_feedbackMessageContainer.Opacity = 0;
+ m_feedbackMessageContainer.Scale = .96;
+
+ if (restoreTooltip && m_tooltipView is not null)
+ {
+ m_tooltipView.IsVisible = m_wasTooltipVisibleBeforeFeedback;
+ }
}
internal void SetBarcodeDetected()
{
StopBreathingAnimation();
m_cornersDrawable.Inset = 0;
- m_cornersDrawable.BracketColor = CornerBracketsDrawable.DetectedBracketColor;
+ m_cornersDrawable.BracketColor = CornerBracketsDrawable.s_detectedBracketColor;
m_cornersDrawable.MaxStrokeWidth = CornerBracketsDrawable.DetectedStrokeWidth;
m_cornersGraphicsView.Invalidate();
}
@@ -369,7 +511,7 @@ internal Task PlaySuccessAndResetAsync()
{
m_cornersGraphicsView.AbortAnimation(AnimationKeyBracketsForming);
- m_cornersDrawable.BracketColor = CornerBracketsDrawable.SuccessBracketColor;
+ m_cornersDrawable.BracketColor = CornerBracketsDrawable.s_successBracketColor;
m_cornersDrawable.MaxStrokeWidth = CornerBracketsDrawable.SuccessStrokeWidth;
m_cornersGraphicsView.Invalidate();
@@ -476,16 +618,18 @@ internal Task PlayCollectionTokenAsync(Point? targetCenter = null)
internal async Task PlayFailureAndResetAsync(string? errorMessage)
{
+ ShowFeedbackMessage(errorMessage);
+
m_cornersGraphicsView.AbortAnimation(AnimationKeyBracketsForming);
- m_cornersDrawable.BracketColor = CornerBracketsDrawable.FailureBracketColor;
+ m_cornersDrawable.BracketColor = CornerBracketsDrawable.s_failureBracketColor;
m_cornersDrawable.MaxStrokeWidth = CornerBracketsDrawable.FailureStrokeWidth;
m_cornersGraphicsView.Invalidate();
var shakeDistance = Sizes.GetSize(SizeName.size_2);
- await m_cornersGraphicsView.TranslateTo(-shakeDistance, 0, 55, Easing.CubicOut);
- await m_cornersGraphicsView.TranslateTo(shakeDistance, 0, 80, Easing.CubicInOut);
- await m_cornersGraphicsView.TranslateTo(-shakeDistance / 2, 0, 65, Easing.CubicInOut);
- await m_cornersGraphicsView.TranslateTo(0, 0, 80, Easing.CubicOut);
+ await m_cornersGraphicsView.TranslateToAsync(-shakeDistance, 0, 55, Easing.CubicOut);
+ await m_cornersGraphicsView.TranslateToAsync(shakeDistance, 0, 80, Easing.CubicInOut);
+ await m_cornersGraphicsView.TranslateToAsync(-shakeDistance / 2, 0, 65, Easing.CubicInOut);
+ await m_cornersGraphicsView.TranslateToAsync(0, 0, 80, Easing.CubicOut);
ResetBarcodeDetection();
}
@@ -500,7 +644,7 @@ internal void ResetBarcodeDetection()
m_lastTrackingTargetUpdateMilliseconds = 0;
m_cornersGraphicsView.TranslationX = 0;
m_cornersDrawable.FormingProgress = 0;
- m_cornersDrawable.BracketColor = CornerBracketsDrawable.DefaultBracketColor;
+ m_cornersDrawable.BracketColor = CornerBracketsDrawable.s_defaultBracketColor;
m_cornersDrawable.MaxStrokeWidth = CornerBracketsDrawable.DefaultStrokeWidth;
var currentRect = m_cornersDrawable.OverrideRect;
@@ -656,10 +800,10 @@ private class CornerBracketsDrawable : IDrawable
private const float BracketLength = 30f;
internal const float DefaultStrokeWidth = 4f;
- internal static readonly Color DefaultBracketColor = Microsoft.Maui.Graphics.Colors.White;
- internal static readonly Color DetectedBracketColor = DuiColors.GetColor(ColorName.color_border_warning);
- internal static readonly Color SuccessBracketColor = DuiColors.GetColor(ColorName.color_border_success);
- internal static readonly Color FailureBracketColor = DuiColors.GetColor(ColorName.color_border_danger);
+ internal static readonly Color s_defaultBracketColor = Microsoft.Maui.Graphics.Colors.White;
+ internal static readonly Color s_detectedBracketColor = DuiColors.GetColor(ColorName.color_border_warning);
+ internal static readonly Color s_successBracketColor = DuiColors.GetColor(ColorName.color_border_success);
+ internal static readonly Color s_failureBracketColor = DuiColors.GetColor(ColorName.color_border_danger);
internal const float DetectedStrokeWidth = 2f;
internal const float SuccessStrokeWidth = 3f;
internal const float FailureStrokeWidth = 3f;
@@ -689,7 +833,7 @@ private class CornerBracketsDrawable : IDrawable
///
/// The color used for the corner bracket strokes.
///
- internal Color BracketColor { get; set; } = DefaultBracketColor;
+ internal Color BracketColor { get; set; } = s_defaultBracketColor;
public CornerBracketsDrawable(float widthFraction, float heightFraction)
{
diff --git a/wiki/Media/Camera.md b/wiki/Media/Camera.md
index e1b3d79ca..6febd1bfd 100644
--- a/wiki/Media/Camera.md
+++ b/wiki/Media/Camera.md
@@ -132,6 +132,51 @@ When `Completion.RequiredCount` is greater than zero, the scanner displays a sim
If `ValidateBarcodeAsync` throws, the scanner treats the barcode as invalid, plays the failure animation, logs the exception, and resumes scanning.
+## Show scanner feedback above the scan rectangle
+When you use `ScanRectangleBarcodeScanStrategy`, rejected validation results can show a short message above the scan rectangle while the failure animation plays. Return `BarcodeScanValidationResult.Invalid(errorMessage, reasonCode)` from `ValidateBarcodeAsync` to explain why the scan was rejected.
+
+```csharp
+private Task ValidateBarcodeAsync(string barcode)
+{
+ return barcode switch
+ {
+ _ when ViewModel.HasAlreadyScanned(barcode) => Task.FromResult(
+ BarcodeScanValidationResult.Invalid("The label has already been scanned.", "already-scanned")),
+ _ when !ViewModel.BelongsToCurrentPatient(barcode) => Task.FromResult(
+ BarcodeScanValidationResult.Invalid("The label does not belong to this patient.", "wrong-patient")),
+ _ when !ViewModel.IsLabel(barcode) => Task.FromResult(
+ BarcodeScanValidationResult.Invalid("The code you scanned is not a label.", "not-label")),
+ _ => Task.FromResult(BarcodeScanValidationResult.Valid())
+ };
+}
+```
+
+Use `Hint` when the scanner should automatically show the camera zoom tip if scanning takes longer than expected, for example “Hold the label farther away to focus.” The scanner attempts the automatic hint once per scanner session and does not show it after people have already used zoom. You can also provide callbacks to decide whether the hint can be shown and to record that it was shown.
+
+```csharp
+await m_scanner.Start(new BarcodeScannerStartOptions
+{
+ Preview = CameraPreview,
+ OnCameraFailed = CameraFailed,
+ Strategy = new ScanRectangleBarcodeScanStrategy(),
+ ValidateBarcodeAsync = ValidateBarcodeAsync,
+ Hint = new BarcodeScannerHintOptions
+ {
+ ShowAutomaticHint = true,
+ HintText = "Hold the label farther away to focus.",
+ Delay = TimeSpan.FromSeconds(15),
+ CanShowHintAsync = () => TipService.CanShow(BarcodeScanningZoomTip),
+ OnHintShownAsync = () =>
+ {
+ TipService.DidShow(BarcodeScanningZoomTip);
+ return Task.CompletedTask;
+ }
+ }
+});
+```
+
+Use `SetTooltipView` after `Start()` only when you need fully custom tooltip content above the scan rectangle.
+
## Stop scanning
Remember to release resources to make sure your page does not leak memory.