Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 10 additions & 18 deletions app/creative.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -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 (
Expand Down
29 changes: 9 additions & 20 deletions app/gallery.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down
27 changes: 10 additions & 17 deletions components/game/DrawPhase.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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}
>
Expand Down
4 changes: 2 additions & 2 deletions services/SentryService.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
12 changes: 7 additions & 5 deletions services/SoundManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
76 changes: 76 additions & 0 deletions utils/confirmDestructive.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
33 changes: 32 additions & 1 deletion utils/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
Loading