From 30b14e0f761c06911ad732bdfe4b5d95867d7909 Mon Sep 17 00:00:00 2001 From: Nimatstar Date: Sat, 25 Jul 2026 18:07:50 +0100 Subject: [PATCH] feat: add haptic feedback for quiz answers and achievement unlocks Add a small quizHaptics helper that fires a medium impact on quiz answer selection and a success notification on achievement unlock, gated on the OS Reduce Motion accessibility setting. Wire it into quizStore.selectAnswer and achievementStore.unlockAchievement. --- src/store/achievementStore.ts | 3 +++ src/store/quizStore.ts | 4 ++++ src/utils/quizHaptics.ts | 39 +++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 src/utils/quizHaptics.ts diff --git a/src/store/achievementStore.ts b/src/store/achievementStore.ts index 5e73c71b..ae629ac2 100644 --- a/src/store/achievementStore.ts +++ b/src/store/achievementStore.ts @@ -11,6 +11,7 @@ import { useReviewStore } from './reviewStore'; import apiService from '../services/api'; import { inAppReviewService, ReviewTrigger } from '../services/inAppReview'; import { appLogger } from '../utils/logger'; +import { achievementHaptic } from '../utils/quizHaptics'; const triggerAchievementReview = () => { const { incrementAchievementsUnlocked, getMetrics, recordReviewRequest } = useReviewStore.getState(); @@ -317,6 +318,8 @@ export const useAchievementStore = create()( achievementProgress: snapshotAchievementProgress(updatedAchievements), unlockedCount: updatedAchievements.filter(a => !a.isLocked).length, }); + // #835: success haptic when an achievement unlocks. + void achievementHaptic(); setTimeout(triggerAchievementReview, 500); try { diff --git a/src/store/quizStore.ts b/src/store/quizStore.ts index 4174d6e9..2f8dad78 100644 --- a/src/store/quizStore.ts +++ b/src/store/quizStore.ts @@ -4,6 +4,7 @@ import { create } from 'zustand'; import { isRecord } from './persistence'; import { Quiz, QuizProgress } from '../types/course'; import logger from '../utils/logger'; +import { quizAnswerHaptic } from '../utils/quizHaptics'; const QUIZ_SESSION_KEY = '@teachlink_quiz_session'; const QUIZ_PROGRESS_KEY = '@teachlink_quiz_progress'; @@ -177,6 +178,9 @@ export const useQuizStore = create((set, get) => ({ }, selectAnswer: (questionId: string, answer: string | number, isMultiSelect = false) => { + // #835: tactile confirmation on every answer selection. + void quizAnswerHaptic(); + const { session } = get(); let updatedAnswer: string | number | (string | number)[]; diff --git a/src/utils/quizHaptics.ts b/src/utils/quizHaptics.ts new file mode 100644 index 00000000..cd3d8231 --- /dev/null +++ b/src/utils/quizHaptics.ts @@ -0,0 +1,39 @@ +import { + ImpactFeedbackStyle, + NotificationFeedbackType, + impactAsync, + notificationAsync, +} from 'expo-haptics'; +import { AccessibilityInfo } from 'react-native'; + +/** + * #835: haptic feedback for quiz answers and achievement unlocks. + * + * Both helpers respect the OS "Reduce Motion" accessibility setting — when it + * is enabled, no haptic is triggered. Failures are swallowed so haptics never + * interrupt the learning flow. + */ + +async function reduceMotionEnabled(): Promise { + try { + return await AccessibilityInfo.isReduceMotionEnabled(); + } catch { + return false; + } +} + +/** Medium impact when a quiz answer is selected. */ +export async function quizAnswerHaptic(): Promise { + if (await reduceMotionEnabled()) return; + impactAsync(ImpactFeedbackStyle.Medium).catch(() => { + // Ignore haptic failures silently. + }); +} + +/** Success notification when an achievement is unlocked. */ +export async function achievementHaptic(): Promise { + if (await reduceMotionEnabled()) return; + notificationAsync(NotificationFeedbackType.Success).catch(() => { + // Ignore haptic failures silently. + }); +}