diff --git a/CHANGELOG.md b/CHANGELOG.md index a576f05..611e732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ 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) - Die Logik „Zeichnung in eine Fläche einpassen" (Bounding Box → Skalierung/Offset) liegt jetzt einmal in `utils/drawingTransform.ts` (`computeFitTransform`) statt viermal kopiert in Web-/Native-Zeichenfläche und ShareService web/native; Verhalten unverändert (#359) ### Changed 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