From 10208dec0078a0325d6cd308ddaa6193c83ef606 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 20:56:30 +0000 Subject: [PATCH] refactor: confirmDestructive()-Helper statt dreifach kopiertem Web/Native-Dialog (#360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - utils/platform.ts: confirmDestructive({ title, message, confirmLabel, cancelLabel, onConfirm }) — window.confirm im Web, Alert.alert mit destruktivem Button nativ - creative.tsx, gallery.tsx, DrawPhase.tsx: eigene Web/Native-Verzweigungen, // platform-safe-Kommentare und ungenutzte Platform/Alert-Imports entfernt - SoundManager, SentryService: isWeb statt Platform.OS; redundanter Web-Check in den Haptics-Guards entfällt (Haptics wird nur nativ geladen) - Tests: Web- und Native-Zweig des Helpers (isoliert pro Plattform) ReviewManager und ParentalGate bleiben bewusst bei Platform.OS (Laufzeit- Check, ihre Tests setzen Platform.OS dynamisch). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_017fxbsNALrmwMj7LGMDPLw2 --- CHANGELOG.md | 4 ++ app/creative.tsx | 28 +++++------- app/gallery.tsx | 29 ++++-------- components/game/DrawPhase.tsx | 27 +++++------- services/SentryService.ts | 4 +- services/SoundManager.ts | 12 ++--- utils/confirmDestructive.test.ts | 76 ++++++++++++++++++++++++++++++++ utils/platform.ts | 33 +++++++++++++- 8 files changed, 150 insertions(+), 63 deletions(-) create mode 100644 utils/confirmDestructive.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b2b2d4b..27c9c56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Bestätigungsdialog vor destruktiven Aktionen als Helper `confirmDestructive()` in `utils/platform.ts` (Web: `window.confirm`, nativ: `Alert.alert` mit destruktivem Button); die drei kopierten Web/Native-Verzweigungen in `creative.tsx`, `gallery.tsx` und `DrawPhase.tsx` entfallen. `SoundManager` und `SentryService` nutzen `isWeb` statt direkter `Platform.OS`-Checks (#360) + +### Changed + - Replay: Die Skalierung der Zeichnung wird aus der **gesamten** Zeichnung berechnet, nicht aus den gerade sichtbaren Pfaden — die Zeichnung erscheint von Anfang an in Endgröße und wandert nicht mehr mit. Dadurch können fertige Pfade und Fills in Web und Android zwischen den Frames wiederverwendet werden (pro Frame höchstens 2 statt N Flood-Fills) (#366) - Web-Zeichenfläche: Beim Malen wird nur noch der Live-Strich über einen gecachten Snapshot der fertigen Zeichnung gelegt. Vorher liefen bei jeder Mausbewegung alle Striche und alle Flood-Fills (volle `getImageData`/`putImageData`-Durchläufe) erneut (#358) diff --git a/app/creative.tsx b/app/creative.tsx index 5a004b8..7335a1b 100644 --- a/app/creative.tsx +++ b/app/creative.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, Alert, Platform, FlatList } from 'react-native'; +import { View, Text, StyleSheet, TouchableOpacity, Alert, FlatList } from 'react-native'; import { useRouter } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import DrawingCanvas, { useDrawingCanvas } from '@components/DrawingCanvas'; @@ -12,6 +12,7 @@ import { useTheme } from '@services/ThemeContext'; import storageManager from '@services/StorageManager'; import { shareDrawing } from '@services/ShareService'; import SoundManager from '@services/SoundManager'; +import { confirmDestructive } from '@utils/platform'; export default function CreativeScreen() { const { t } = useTranslation(); @@ -46,25 +47,16 @@ export default function CreativeScreen() { const handleClear = () => { if (drawing.paths.length === 0) return; - if (Platform.OS === 'web') { - if (window.confirm(t('game.draw.clearConfirm'))) { - // platform-safe + confirmDestructive({ + title: t('game.draw.clear'), + message: t('game.draw.clearConfirm'), + confirmLabel: t('common.yes'), + cancelLabel: t('common.cancel'), + onConfirm: () => { drawing.setPaths([]); setSavedToGallery(false); - } - } else { - Alert.alert(t('game.draw.clear'), t('game.draw.clearConfirm'), [ - { text: t('common.cancel'), style: 'cancel' }, - { - text: t('common.yes'), - style: 'destructive', - onPress: () => { - drawing.setPaths([]); - setSavedToGallery(false); - }, - }, - ]); - } + }, + }); }; return ( diff --git a/app/gallery.tsx b/app/gallery.tsx index 3b9a4f3..b1842c9 100644 --- a/app/gallery.tsx +++ b/app/gallery.tsx @@ -1,18 +1,11 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { - View, - Text, - StyleSheet, - TouchableOpacity, - ScrollView, - Alert, - Platform, -} from 'react-native'; +import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Alert } from 'react-native'; import { useRouter } from 'expo-router'; import { useTranslation } from '@services/i18n'; import { useTheme } from '@services/ThemeContext'; import storageManager, { GalleryEntry } from '@services/StorageManager'; import { shareDrawing } from '@services/ShareService'; +import { confirmDestructive } from '@utils/platform'; import DrawingCanvas from '@components/DrawingCanvas'; import { GallerySkeleton } from '@components/SkeletonLoader'; import { GlassCard } from '@components/AnimatedPrimitives'; @@ -53,17 +46,13 @@ export default function GalleryScreen() { setEntries(prev => prev.filter(e => e.id !== entry.id)); }; - if (Platform.OS === 'web') { - if (window.confirm(t('gallery.deleteConfirm'))) { - // platform-safe - doDelete(); - } - } else { - Alert.alert(t('gallery.deleteTitle'), t('gallery.deleteConfirm'), [ - { text: t('common.cancel'), style: 'cancel' }, - { text: t('common.yes'), style: 'destructive', onPress: doDelete }, - ]); - } + confirmDestructive({ + title: t('gallery.deleteTitle'), + message: t('gallery.deleteConfirm'), + confirmLabel: t('common.yes'), + cancelLabel: t('common.cancel'), + onConfirm: doDelete, + }); }; const formatDate = (isoString: string) => { diff --git a/components/game/DrawPhase.tsx b/components/game/DrawPhase.tsx index 0bc9146..6fa4782 100644 --- a/components/game/DrawPhase.tsx +++ b/components/game/DrawPhase.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, Alert, Platform, FlatList } from 'react-native'; +import { View, Text, StyleSheet, TouchableOpacity, FlatList } from 'react-native'; import DrawingCanvas from '@components/DrawingCanvas'; import { ErrorBoundary } from '@components/ErrorBoundary'; import { PressableScale } from '@components/AnimatedPrimitives'; @@ -10,6 +10,7 @@ import { Spacing, FontSize, FontWeight, BorderRadius } from '../../constants/Lay import type { DrawPhaseProps } from './game.shared'; import { useTranslation } from '@services/i18n'; import { useTheme } from '@services/ThemeContext'; +import { confirmDestructive } from '@utils/platform'; export default function DrawPhase({ currentImage, @@ -177,22 +178,14 @@ export default function DrawPhase({ accessibilityLabel={t('game.draw.clear')} accessibilityRole="button" onPress={() => { - if (Platform.OS === 'web') { - if (drawing.paths.length === 0) return; - if (window.confirm(t('game.draw.clearConfirm'))) drawing.setPaths([]); // platform-safe - } else { - if (drawing.paths.length === 0) return; - Alert.alert(t('game.draw.clear'), t('game.draw.clearConfirm'), [ - { text: t('common.cancel'), style: 'cancel' }, - { - text: t('common.yes'), - style: 'destructive', - onPress: () => { - drawing.setPaths([]); - }, - }, - ]); - } + if (drawing.paths.length === 0) return; + confirmDestructive({ + title: t('game.draw.clear'), + message: t('game.draw.clearConfirm'), + confirmLabel: t('common.yes'), + cancelLabel: t('common.cancel'), + onConfirm: () => drawing.setPaths([]), + }); }} disabled={drawing.paths.length === 0} > diff --git a/services/SentryService.ts b/services/SentryService.ts index dbca238..05c34c2 100644 --- a/services/SentryService.ts +++ b/services/SentryService.ts @@ -1,10 +1,10 @@ -import { Platform } from 'react-native'; +import { isWeb } from '@utils/platform'; const DSN = process.env.EXPO_PUBLIC_SENTRY_DSN ?? ''; // Dynamic import: web bundle never imports the native-only Sentry SDK let Sentry: typeof import('@sentry/react-native') | null = null; -if (Platform.OS !== 'web') { +if (!isWeb) { try { Sentry = require('@sentry/react-native'); } catch { diff --git a/services/SoundManager.ts b/services/SoundManager.ts index b816b50..8a58f56 100644 --- a/services/SoundManager.ts +++ b/services/SoundManager.ts @@ -4,12 +4,12 @@ * Native: expo-haptics für Vibration */ -import { Platform } from 'react-native'; +import { isWeb } from '@utils/platform'; import storageManager from './StorageManager'; // Dynamic import for haptics (native only) let Haptics: typeof import('expo-haptics') | null = null; -if (Platform.OS !== 'web') { +if (!isWeb) { try { Haptics = require('expo-haptics'); } catch { @@ -23,7 +23,7 @@ class SoundManager { private initialized: boolean = false; private getAudioContext(): AudioContext | null { - if (Platform.OS !== 'web') return null; + if (!isWeb) return null; if (!this.audioContext) { try { this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); // platform-safe @@ -126,7 +126,8 @@ class SoundManager { // Haptic feedback (native only) private async hapticLight() { - if (Platform.OS === 'web' || !Haptics) return; + // Haptics wird nur auf Native geladen (siehe oben) — `!Haptics` deckt Web mit ab + if (!Haptics) return; try { await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } catch { @@ -135,7 +136,8 @@ class SoundManager { } private async hapticSuccess() { - if (Platform.OS === 'web' || !Haptics) return; + // Haptics wird nur auf Native geladen (siehe oben) — `!Haptics` deckt Web mit ab + if (!Haptics) return; try { await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } catch { diff --git a/utils/confirmDestructive.test.ts b/utils/confirmDestructive.test.ts new file mode 100644 index 0000000..f62bb86 --- /dev/null +++ b/utils/confirmDestructive.test.ts @@ -0,0 +1,76 @@ +/** + * Tests für confirmDestructive (platform.ts): window.confirm im Web, Alert.alert nativ. + * `isWeb` ist beim Import fest, daher wird das Modul pro Plattform isoliert geladen. + */ + +describe('confirmDestructive', () => { + const options = (onConfirm: () => void) => ({ + title: 'Titel', + message: 'Wirklich löschen?', + confirmLabel: 'Ja', + cancelLabel: 'Abbrechen', + onConfirm, + }); + + describe('web', () => { + const load = () => { + let mod: typeof import('./platform'); + jest.isolateModules(() => { + jest.doMock('react-native', () => ({ + Platform: { OS: 'web' }, + Alert: { alert: jest.fn() }, + })); + mod = require('./platform'); + }); + return mod!; + }; + + afterEach(() => jest.restoreAllMocks()); + + it('ruft onConfirm, wenn window.confirm bestätigt wird', () => { + const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true); // platform-safe + const onConfirm = jest.fn(); + load().confirmDestructive(options(onConfirm)); + expect(confirmSpy).toHaveBeenCalledWith('Wirklich löschen?'); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('ruft onConfirm nicht, wenn abgebrochen wird', () => { + jest.spyOn(window, 'confirm').mockReturnValue(false); // platform-safe + const onConfirm = jest.fn(); + load().confirmDestructive(options(onConfirm)); + expect(onConfirm).not.toHaveBeenCalled(); + }); + }); + + describe('native', () => { + const load = () => { + const alert = jest.fn(); + let mod: typeof import('./platform'); + jest.isolateModules(() => { + jest.doMock('react-native', () => ({ Platform: { OS: 'android' }, Alert: { alert } })); + mod = require('./platform'); + }); + return { mod: mod!, alert }; + }; + + it('zeigt Alert.alert mit Abbrechen- und destruktivem Bestätigen-Button', () => { + const { mod, alert } = load(); + const onConfirm = jest.fn(); + mod.confirmDestructive(options(onConfirm)); + + expect(alert).toHaveBeenCalledTimes(1); + const [title, message, buttons] = alert.mock.calls[0]; + expect(title).toBe('Titel'); + expect(message).toBe('Wirklich löschen?'); + expect(buttons).toEqual([ + { text: 'Abbrechen', style: 'cancel' }, + { text: 'Ja', style: 'destructive', onPress: onConfirm }, + ]); + // onConfirm läuft erst beim Tippen auf den Bestätigen-Button + expect(onConfirm).not.toHaveBeenCalled(); + buttons[1].onPress(); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/utils/platform.ts b/utils/platform.ts index 7190337..d51d986 100644 --- a/utils/platform.ts +++ b/utils/platform.ts @@ -5,7 +5,7 @@ * Verhindert direkte Verwendung von Web APIs ohne Platform-Check. */ -import { Platform } from 'react-native'; +import { Alert, Platform } from 'react-native'; // Platform Detection export const isWeb = Platform.OS === 'web'; @@ -101,6 +101,37 @@ export const Storage = { }, }; +export interface ConfirmDestructiveOptions { + /** Titel des nativen Dialogs (Web zeigt nur die Nachricht) */ + title: string; + message: string; + confirmLabel: string; + cancelLabel: string; + onConfirm: () => void; +} + +/** + * Bestätigungsdialog vor einer destruktiven Aktion (löschen, zurücksetzen): + * `window.confirm` im Web, `Alert.alert` mit destruktivem Button auf Android/iOS. + * Zentral, damit die Screens keine eigenen Web/Native-Verzweigungen mehr brauchen. + */ +export function confirmDestructive({ + title, + message, + confirmLabel, + cancelLabel, + onConfirm, +}: ConfirmDestructiveOptions): void { + if (isWeb) { + if (window.confirm(message)) onConfirm(); // platform-safe + return; + } + Alert.alert(title, message, [ + { text: cancelLabel, style: 'cancel' }, + { text: confirmLabel, style: 'destructive', onPress: onConfirm }, + ]); +} + /** * Prüft ob eine Web API sicher verwendet werden kann * Wirft einen Fehler wenn die API auf der aktuellen Plattform nicht verfügbar ist