From 3b9c4790da89e16acb6ea4b83b14ab8597d66cf4 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Fri, 17 Jul 2026 12:30:02 -0500 Subject: [PATCH 1/4] Add haptic feedback for key interactions --- JournalApp/Components/DataPointView.razor | 29 ++++++- .../Components/EditCategoryDialog.razor | 4 + JournalApp/Components/EditNoteDialog.razor | 2 + JournalApp/Components/EmojiPicker.razor | 11 ++- JournalApp/Data/CommonServices.cs | 1 + JournalApp/Data/HapticFeedbackService.cs | 85 +++++++++++++++++++ JournalApp/Pages/ManageCategoriesPage.razor | 7 ++ JournalApp/Pages/SettingsPage.razor | 26 +++++- 8 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 JournalApp/Data/HapticFeedbackService.cs diff --git a/JournalApp/Components/DataPointView.razor b/JournalApp/Components/DataPointView.razor index 1702447..64becb2 100644 --- a/JournalApp/Components/DataPointView.razor +++ b/JournalApp/Components/DataPointView.razor @@ -4,6 +4,7 @@ @inject IDialogService DialogService @inject ISnackbar Snackbar @inject DataPointService DataPointService +@inject HapticFeedbackService HapticFeedback @if (Point.Type == PointType.Mood) { @@ -24,7 +25,7 @@ else if (Point.Type == PointType.Scale) } else if (Point.Type == PointType.LowToHigh) { - + None Low Medium @@ -33,7 +34,7 @@ else if (Point.Type == PointType.LowToHigh) } else if (Point.Type == PointType.MildToSevere) { - + None Mild Moderate @@ -42,7 +43,7 @@ else if (Point.Type == PointType.MildToSevere) } else if (Point.Type == PointType.Bool) { - + No Yes @@ -96,7 +97,11 @@ else if (Point.Type == PointType.Medication) public int ScaleIndexForMudRating { get => DataPointService.GetScaleIndex(Point); - set => DataPointService.SetScaleIndex(Point, value); + set + { + DataPointService.SetScaleIndex(Point, value); + HapticFeedback.Tick(); + } } string NoteLabel @@ -121,12 +126,22 @@ else if (Point.Type == PointType.Medication) { logger.LogDebug("Decrementing sleep"); DataPointService.DecrementSleep(Point); + HapticFeedback.Tick(); } void IncrementSleep() { logger.LogDebug("Incrementing sleep"); DataPointService.IncrementSleep(Point); + HapticFeedback.Tick(); + } + + void OnBoolChanged() + { + if (Point.Bool == true) + HapticFeedback.ToggleOn(); + else + HapticFeedback.ToggleOff(); } async Task EditTextInDialog() @@ -143,6 +158,11 @@ else if (Point.Type == PointType.Medication) // Use service to handle medication dose reset logic DataPointService.HandleMedicationTakenChanged(Point); + if (Point.Bool == true) + HapticFeedback.ToggleOn(); + else + HapticFeedback.ToggleOff(); + await StateChanged.InvokeAsync(); } @@ -159,6 +179,7 @@ else if (Point.Type == PointType.Medication) { logger.LogDebug("Mood selected: {mood}", mood); DataPointService.SetMood(Point, mood); + HapticFeedback.Tick(); // Show a motivational quote when the sob emoji is selected if (mood == DataPoint.Moods[^1]) // Sob emoji (😭) is the last mood in the list diff --git a/JournalApp/Components/EditCategoryDialog.razor b/JournalApp/Components/EditCategoryDialog.razor index eb219f0..f7a8fab 100644 --- a/JournalApp/Components/EditCategoryDialog.razor +++ b/JournalApp/Components/EditCategoryDialog.razor @@ -2,6 +2,7 @@ @inject ILogger logger @inject IDialogService DialogService @inject KeyEventService KeyEventService +@inject HapticFeedbackService HapticFeedback @@ -121,6 +122,7 @@ Category.Deleted = true; logger.LogDebug($"Deleted {Category}"); + HapticFeedback.LongPress(); KeyEventService.CloseDialog(MudDialog, Category); } @@ -133,6 +135,7 @@ if (!_form.IsValid) { logger.LogDebug("Form was not valid"); + HapticFeedback.Reject(); if (Category == null) Cancel(); @@ -151,6 +154,7 @@ logger.LogDebug($"Created new category "); + HapticFeedback.Confirm(); KeyEventService.CloseDialog(MudDialog, Category); return true; } diff --git a/JournalApp/Components/EditNoteDialog.razor b/JournalApp/Components/EditNoteDialog.razor index 6fa9ea3..470238f 100644 --- a/JournalApp/Components/EditNoteDialog.razor +++ b/JournalApp/Components/EditNoteDialog.razor @@ -2,6 +2,7 @@ @inject ILogger logger @inject IDialogService DialogService @inject KeyEventService KeyEventService +@inject HapticFeedbackService HapticFeedback @@ -80,6 +81,7 @@ else { logger.LogInformation("Submitting note"); + HapticFeedback.Confirm(); } KeyEventService.CloseDialog(MudDialog, true); diff --git a/JournalApp/Components/EmojiPicker.razor b/JournalApp/Components/EmojiPicker.razor index 9c5d882..44c3114 100644 --- a/JournalApp/Components/EmojiPicker.razor +++ b/JournalApp/Components/EmojiPicker.razor @@ -1,6 +1,7 @@ @namespace JournalApp +@inject HapticFeedbackService HapticFeedback - @(SelectedMood ?? DataPoint.Moods[0]) @@ -33,6 +34,14 @@ [Parameter] public EventCallback OnMoodSelected { get; set; } + private void TogglePicker() + { + _isOpen = !_isOpen; + + if (_isOpen) + HapticFeedback.Click(); + } + private async Task OnMoodClicked(string mood) { _isOpen = false; diff --git a/JournalApp/Data/CommonServices.cs b/JournalApp/Data/CommonServices.cs index 7bf80da..2e5e8bf 100644 --- a/JournalApp/Data/CommonServices.cs +++ b/JournalApp/Data/CommonServices.cs @@ -22,6 +22,7 @@ public static void AddCommonJournalAppServices(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/JournalApp/Data/HapticFeedbackService.cs b/JournalApp/Data/HapticFeedbackService.cs new file mode 100644 index 0000000..f293615 --- /dev/null +++ b/JournalApp/Data/HapticFeedbackService.cs @@ -0,0 +1,85 @@ +namespace JournalApp; + +/// +/// Plays semantic haptic cues for key interactions. +/// On Android this goes through the decor view's PerformHapticFeedback, which needs no VIBRATE permission and respects the system touch feedback setting; elsewhere it's a no-op. +/// +public sealed class HapticFeedbackService +{ + /// + /// A light tap for opening a picker or similar minor interaction. + /// + public void Click() + { +#if ANDROID + Perform(Android.Views.FeedbackConstants.VirtualKey); +#endif + } + + /// + /// A heavy buzz for destructive actions like a permanent delete. + /// + public void LongPress() + { +#if ANDROID + Perform(Android.Views.FeedbackConstants.LongPress); +#endif + } + + /// + /// Signals an action completed successfully, like submitting a dialog. + /// + public void Confirm() + { +#if ANDROID + // Confirm/Reject exist since API 30. + Perform(OperatingSystem.IsAndroidVersionAtLeast(30) ? Android.Views.FeedbackConstants.Confirm : Android.Views.FeedbackConstants.KeyboardTap); +#endif + } + + /// + /// Signals an action was denied, like failing validation. + /// + public void Reject() + { +#if ANDROID + Perform(OperatingSystem.IsAndroidVersionAtLeast(30) ? Android.Views.FeedbackConstants.Reject : Android.Views.FeedbackConstants.LongPress); +#endif + } + + /// + /// A switch or binary choice turning on. + /// + public void ToggleOn() + { +#if ANDROID + // ToggleOn/ToggleOff and SegmentTick exist since API 34. + Perform(OperatingSystem.IsAndroidVersionAtLeast(34) ? Android.Views.FeedbackConstants.ToggleOn : Android.Views.FeedbackConstants.ClockTick); +#endif + } + + /// + /// A switch or binary choice turning off. + /// + public void ToggleOff() + { +#if ANDROID + Perform(OperatingSystem.IsAndroidVersionAtLeast(34) ? Android.Views.FeedbackConstants.ToggleOff : Android.Views.FeedbackConstants.ClockTick); +#endif + } + + /// + /// A discrete step through segmented values, like picking a mood or nudging sleep hours. + /// + public void Tick() + { +#if ANDROID + Perform(OperatingSystem.IsAndroidVersionAtLeast(34) ? Android.Views.FeedbackConstants.SegmentTick : Android.Views.FeedbackConstants.ClockTick); +#endif + } + +#if ANDROID + private static void Perform(Android.Views.FeedbackConstants constant) => + Platform.CurrentActivity?.Window?.DecorView?.PerformHapticFeedback(constant); +#endif +} diff --git a/JournalApp/Pages/ManageCategoriesPage.razor b/JournalApp/Pages/ManageCategoriesPage.razor index c79d538..5d5593e 100644 --- a/JournalApp/Pages/ManageCategoriesPage.razor +++ b/JournalApp/Pages/ManageCategoriesPage.razor @@ -4,6 +4,7 @@ @implements IDisposable @inject ILogger logger @inject IDbContextFactory DbFactory +@inject HapticFeedbackService HapticFeedback