diff --git a/apps/rebreak-magic-mac/Sources/Views/LoginView.swift b/apps/rebreak-magic-mac/Sources/Views/LoginView.swift index 8abc2bf..6d18a85 100644 --- a/apps/rebreak-magic-mac/Sources/Views/LoginView.swift +++ b/apps/rebreak-magic-mac/Sources/Views/LoginView.swift @@ -2,21 +2,19 @@ import AppKit import SwiftUI struct LoginView: View { - @State private var digits: [String] = Array(repeating: "", count: 6) - @FocusState private var focusedField: Int? + @State private var code: String = "" + @FocusState private var isFocused: Bool @State private var isLoading = false @State private var errorMessage: String? let onSuccess: (AuthSession) -> Void - private var enteredCode: String { digits.joined() } - private var isComplete: Bool { enteredCode.count == 6 && enteredCode.allSatisfy(\.isNumber) } + private var isComplete: Bool { code.count == 6 && code.allSatisfy(\.isNumber) } var body: some View { VStack(spacing: 28) { Spacer().frame(height: 20) - // App-Icon + Header VStack(spacing: 14) { appIconView .frame(width: 84, height: 84) @@ -34,13 +32,30 @@ struct LoginView: View { } } - // 6-stelliger Code-Input + // ─── Code-Input ───────────────────────────────────────────── + // EIN unsichtbares TextField empfängt alle Tasten. Die 6 Boxen + // sind reine Anzeige → kein Focus-Race, kein System-Focus-Ring. VStack(spacing: 14) { - HStack(spacing: 10) { - ForEach(0..<6, id: \.self) { index in - digitField(index: index) + ZStack { + HStack(spacing: 10) { + ForEach(0..<6, id: \.self) { index in + digitBox(index: index) + } } + + TextField("", text: $code) + .textFieldStyle(.plain) + .focused($isFocused) + .focusEffectDisabled() + .frame(width: 380, height: 60) + .opacity(0.01) + .onChange(of: code) { _, newValue in + handleCodeChange(newValue) + } + .onSubmit { handleSubmit() } } + .contentShape(Rectangle()) + .onTapGesture { isFocused = true } if let error = errorMessage { HStack(spacing: 6) { @@ -81,7 +96,11 @@ struct LoginView: View { .padding(.horizontal, 32) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor)) - .onAppear { focusedField = 0 } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + isFocused = true + } + } } // MARK: - Components @@ -95,7 +114,6 @@ struct LoginView: View { .interpolation(.high) .frame(width: 84, height: 84) } else { - // Fallback: gefärbtes RoundedRect (entspricht macOS-Stil) RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(LinearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing)) .overlay( @@ -107,112 +125,58 @@ struct LoginView: View { } @ViewBuilder - private func digitField(index: Int) -> some View { - TextField("", text: Binding( - get: { digits[index] }, - set: { newValue in handleDigitInput(newValue, at: index) } - )) - .textFieldStyle(.plain) - .multilineTextAlignment(.center) - .font(.system(size: 28, weight: .semibold, design: .rounded)) - .frame(width: 48, height: 60) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder( - focusedField == index ? Color.accentColor : Color.gray.opacity(0.3), - lineWidth: focusedField == index ? 2 : 1 - ) - ) - .focused($focusedField, equals: index) + private func digitBox(index: Int) -> some View { + let chars = Array(code) + let digit: String = index < chars.count ? String(chars[index]) : "" + let activeIndex = min(chars.count, 5) + let isActive = isFocused && index == activeIndex + let isFilled = !digit.isEmpty + + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + .frame(width: 48, height: 60) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder( + isActive ? Color.accentColor : + (isFilled ? Color.gray.opacity(0.4) : Color.gray.opacity(0.25)), + lineWidth: isActive ? 2 : 1 + ) + ) + .overlay( + Text(digit) + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .foregroundStyle(.primary) + ) } // MARK: - Logic - private func handleDigitInput(_ raw: String, at index: Int) { - // Erlaubt: 0–9. Mehrere Zeichen → kann Paste sein ODER User tippt in - // ein bereits gefülltes Feld (newValue = "alt+neu"). - let onlyDigits = raw.filter(\.isNumber) - let previous = digits[index] - - // Paste-Heuristik: 2+ Ziffern UND keine davon ist die alte Ziffer am Anfang, - // oder Länge > 2. Sonst: User hat in ein gefülltes Feld eine neue Ziffer - // getippt → letzte Ziffer als neuen Wert nehmen. - let isPaste: Bool = { - if onlyDigits.count >= 3 { return true } - if onlyDigits.count == 2 { - // Wenn beide Ziffern unterschiedlich sind und das erste Zeichen - // dem bisherigen Wert entspricht → Replace-Tipp, kein Paste. - if !previous.isEmpty && onlyDigits.first.map(String.init) == previous { - return false - } - return true - } - return false - }() - - if isPaste { - // Paste / Auto-Fill: über Felder ab `index` verteilen - let chars = Array(onlyDigits.prefix(6 - index)) - for (offset, ch) in chars.enumerated() { - let target = index + offset - if target < 6 { - digits[target] = String(ch) - } - } - let nextFocus = min(index + chars.count, 5) - advanceFocus(to: nextFocus) - if digits.allSatisfy({ !$0.isEmpty }) && !isLoading { - handleSubmit() - } + private func handleCodeChange(_ newValue: String) { + let digits = newValue.filter(\.isNumber) + let clipped = String(digits.prefix(6)) + if clipped != newValue { + code = clipped return } - - if onlyDigits.isEmpty { - // Backspace - digits[index] = "" - if index > 0 { - advanceFocus(to: index - 1) - } - return - } - - // Single-digit Eingabe (oder Replace in gefülltes Feld → letzte Ziffer nehmen) - let newDigit = String(onlyDigits.suffix(1)) - digits[index] = newDigit - if index < 5 { - advanceFocus(to: index + 1) - } else if isComplete && !isLoading { - // Letztes Feld gefüllt → automatisch absenden + if clipped.count == 6 && !isLoading { handleSubmit() } } - /// Focus-Wechsel muss async passieren, sonst kollidiert er mit dem laufenden - /// TextField-Edit-Cycle und der Focus springt nicht zuverlässig. - private func advanceFocus(to target: Int) { - DispatchQueue.main.async { - focusedField = target - } - } - private func handleSubmit() { guard isComplete, !isLoading else { return } - let code = enteredCode + let toSend = code Task { isLoading = true errorMessage = nil do { - let session = try await AuthService.shared.signInWithPairingCode(code) + let session = try await AuthService.shared.signInWithPairingCode(toSend) onSuccess(session) } catch { errorMessage = error.localizedDescription - // Felder leeren bei Fehler - digits = Array(repeating: "", count: 6) - focusedField = 0 + code = "" + isFocused = true } isLoading = false } diff --git a/apps/rebreak-native/CHANGELOG.md b/apps/rebreak-native/CHANGELOG.md index ae57190..a417f35 100644 --- a/apps/rebreak-native/CHANGELOG.md +++ b/apps/rebreak-native/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog All notable changes to rebreak-native will be documented in this file. +## v0.3.13 (Build 69 / versionCode 52) — 2026-06-03\n\n### Fixes + +- DM screen: fixed the last message being half-hidden behind the input bar on initial open. The keyboard-closed clearance now accounts for the input bar's safe-area shift (insets.bottom), matching the comfortable gap of the keyboard-open state + +### Features + +- DM chat background is now per-chat customizable (Instagram-style). Default is a clean solid background (white in light mode, black in dark) instead of the patterned WhatsApp-style background. In the chat's info sheet you can switch each conversation between "Clean" and "Pattern" — stored locally per chat on your device. Incoming bubbles get a subtle grey tint on the clean background so they stay readable. Localized DE/EN/FR/AR\n ## v0.3.13 (Build 68 / versionCode 51) — 2026-06-03\n\n### Fixes - DM screen: bottom gap on initial open tightened — the last message now sits directly above the input bar. The keyboard-closed padding was double-counting the input bar's own layout slot, leaving a large empty gap every time you opened a chat diff --git a/apps/rebreak-native/NEXT_RELEASE.md b/apps/rebreak-native/NEXT_RELEASE.md new file mode 100644 index 0000000..5715db1 --- /dev/null +++ b/apps/rebreak-native/NEXT_RELEASE.md @@ -0,0 +1,11 @@ +### Fixes + +- DM screen: tuned the gap above the input bar so the last message sits at a comfortable middle distance (not too tight, not floating too high) +- DM screen: the keyboard now stays open after sending a message (Instagram/WhatsApp style) — it only dismisses when you tap elsewhere, instead of closing on every send +- DM info sheet: the partner avatar now renders correctly for users with a default/list avatar (not just custom photo uploads), using the same avatar component as the header. The chevron now sits inline right next to the name +- DM info sheet: tapping a shared image now opens the same full-screen viewer as in the chat (rounded corners + save button) instead of doing nothing behind the sheet + +### Changes + +- DM chat background is now always the clean solid style (white in light mode, black in dark) — removed the per-chat background picker again for simplicity +- DM voice notes restyled to Instagram-style waveforms: incoming notes have black bars on a light grey bubble, your own notes have white bars on a mint-green bubble. While playing, the upcoming part dims to grey and fills back in as it progresses diff --git a/apps/rebreak-native/app.config.ts b/apps/rebreak-native/app.config.ts index 131e3d2..87bd346 100644 --- a/apps/rebreak-native/app.config.ts +++ b/apps/rebreak-native/app.config.ts @@ -36,7 +36,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ios: { supportsTablet: true, bundleIdentifier: MAIN_BUNDLE, - buildNumber: "68", + buildNumber: "69", // Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen // signInAsync()-Flow. Ohne flag generiert Expo's prebuild den // com.apple.developer.applesignin-Entitlement nicht in die .entitlements. @@ -59,7 +59,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ android: { package: "org.rebreak.app", - versionCode: 51, + versionCode: 52, adaptiveIcon: { // Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den // Außenring) → adaptive-foreground.png ist das Logo auf transparentem diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx index 0a54d0d..cb9aaff 100644 --- a/apps/rebreak-native/app/_layout.tsx +++ b/apps/rebreak-native/app/_layout.tsx @@ -27,7 +27,6 @@ import { useColors } from '../lib/theme'; import { useLanguageStore } from '../stores/language'; import { useAppLockStore } from '../stores/appLock'; import { useLyraVoiceStore } from '../stores/lyraVoice'; -import { useChatBackgroundStore } from '../stores/chatBackground'; import { AppLockGate } from '../components/AppLockGate'; import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet'; import { DeviceApprovalIncomingSheet } from '../components/DeviceApprovalIncomingSheet'; @@ -66,7 +65,6 @@ function RootLayoutInner() { const initLanguage = useLanguageStore((s) => s.init); const initAppLock = useAppLockStore((s) => s.init); const initLyraVoice = useLyraVoiceStore((s) => s.init); - const initChatBackground = useChatBackgroundStore((s) => s.init); const appLockReady = useAppLockStore((s) => s.ready); const initRealtimeDebug = useRealtimeDebugStore((s) => s.init); const colors = useColors(); @@ -109,7 +107,6 @@ function RootLayoutInner() { initLanguage(); initAppLock(); initLyraVoice(); - initChatBackground(); if (__DEV__) initRealtimeDebug(); }, []); diff --git a/apps/rebreak-native/app/devices.tsx b/apps/rebreak-native/app/devices.tsx index abf7701..47ddaf8 100644 --- a/apps/rebreak-native/app/devices.tsx +++ b/apps/rebreak-native/app/devices.tsx @@ -499,7 +499,27 @@ export default function DevicesScreen() { const TOTAL_DEVICE_SLOTS = 3; const activeProtectedCount = protectedDevices.filter((d) => d.status !== 'revoked').length; - const totalRegistered = mobileDevices.length + activeProtectedCount; + + // Dedupe: wenn ein UserDevice (mobile/desktop) auf der gleichen Plattform + // (mac/ios/android/win) bereits existiert, blende die entsprechende + // ProtectedDevice-Row aus \u2014 sonst erscheint der MacBook doppelt + // (1x als UserDevice via Magic, 1x als ProtectedDevice via altem DNS-Flow). + const normalizePlatform = (p: string | null | undefined): string => { + const n = (p ?? '').toLowerCase(); + if (n.startsWith('mac') || n === 'darwin') return 'mac'; + if (n.startsWith('ios') || n.startsWith('iphone') || n.startsWith('ipad')) return 'ios'; + if (n.startsWith('android')) return 'android'; + if (n.startsWith('win')) return 'win'; + return n; + }; + const mobilePlatformKeys = new Set( + mobileDevices.map((d) => normalizePlatform(d.platform || d.model || '')), + ); + const dedupedProtected = protectedDevices.filter( + (d) => !mobilePlatformKeys.has(normalizePlatform(d.platform)), + ); + + const totalRegistered = mobileDevices.length + dedupedProtected.filter((d) => d.status !== 'revoked').length; const atDeviceLimit = isLegend && totalRegistered >= TOTAL_DEVICE_SLOTS; // Mobile zuerst (current oben), danach Desktop/Protected. @@ -509,7 +529,7 @@ export default function DevicesScreen() { return new Date(b.lastSeenAt).getTime() - new Date(a.lastSeenAt).getTime(); }); const isLoading = mobileLoading || protectedLoading; - const isEmpty = !isLoading && sortedMobile.length === 0 && protectedDevices.length === 0; + const isEmpty = !isLoading && sortedMobile.length === 0 && dedupedProtected.length === 0; const subtitle = isLegend ? t('devices.subtitle_legend') : t('devices.subtitle_free'); async function handleRemoveProtected(id: string) { @@ -582,7 +602,7 @@ export default function DevicesScreen() { <> {sortedMobile.map((device, i) => { const isLast = - i === sortedMobile.length - 1 && protectedDevices.length === 0; + i === sortedMobile.length - 1 && dedupedProtected.length === 0; return ( ); })} - {protectedDevices.map((device, i) => ( + {dedupedProtected.map((device, i) => ( diff --git a/apps/rebreak-native/app/dm.tsx b/apps/rebreak-native/app/dm.tsx index 91e5b6b..742faab 100644 --- a/apps/rebreak-native/app/dm.tsx +++ b/apps/rebreak-native/app/dm.tsx @@ -30,13 +30,10 @@ import * as FileSystem from 'expo-file-system/legacy'; import { apiFetch } from '../lib/api'; import { ChatBubble, type ChatMsg, type MessageReaction } from '../components/chat/ChatBubble'; import { VoiceRecordingBar, formatVoiceDuration } from '../components/chat/VoiceRecordingBar'; -import { DmChatBackground } from '../components/chat/DmChatBackground'; import { FormSheet } from '../components/FormSheet'; import { useDmRealtime } from '../hooks/useChatRealtime'; import { useDmTyping } from '../hooks/useDmTyping'; import { useColors } from '../lib/theme'; -import { useThemeStore } from '../stores/theme'; -import { useChatBackgroundStore, type ChatBgStyle } from '../stores/chatBackground'; import { useAuthStore } from '../stores/auth'; import { supabase } from '../lib/supabase'; import { UserAvatar } from '../components/UserAvatar'; @@ -94,21 +91,10 @@ export default function DmScreen() { const queryClient = useQueryClient(); const myUserId = useAuthStore((s) => s.user?.id); - const colorScheme = useThemeStore((s) => s.colorScheme); - const { userId } = useLocalSearchParams<{ userId: string }>(); - // Pro-Chat-Hintergrund (lokal, gerätegebunden). Default = 'clean' (Insta-Style). - const chatBgStyle = useChatBackgroundStore((s) => (userId && s.backgrounds[userId]) || 'clean'); - const setChatBg = useChatBackgroundStore((s) => s.setBackground); - // 'clean' → solider Theme-BG (weiß / schwarz). 'pattern' → WA-artiger Symbol-BG - // mit warmem/dunklem Tint. - const chatBg = - chatBgStyle === 'pattern' - ? colorScheme === 'dark' - ? '#1a1f1e' - : '#EDE8E1' - : colors.bg; + // Chat-Hintergrund: immer clean (solider Theme-BG, weiß / schwarz). Insta-Style. + const chatBg = colors.bg; const flatListRef = useRef>(null); @@ -759,7 +745,6 @@ export default function DmScreen() { - {chatBgStyle === 'pattern' && } {(isLoading || isFetching) && messages.length === 0 ? ( @@ -785,6 +770,7 @@ export default function DmScreen() { onReact={toggleReaction} onDelete={deleteMessage} onOpenImage={openLightbox} + cleanBg /> )} keyExtractor={(m) => m.id} @@ -794,11 +780,10 @@ export default function DmScreen() { // Tastatur offen: Input-Bar floatet (per transform) über der Tastatur, // der Viewport schrumpft NICHT → Clearance = keyboardHeight + 4 (Gap). // Tastatur zu: die KeyboardStickyView hat offset.closed = -insets.bottom, - // schiebt die Bar also um insets.bottom NACH OBEN über den Content → - // diese Überlappung muss als Clearance abgezogen werden, sonst wird die - // letzte Nachricht halb verdeckt. insets.bottom + 4 hält denselben - // knappen Gap wie im Keyboard-offen-State. - paddingBottom: keyboardVisible ? keyboardHeight + 4 : insets.bottom + 4, + // schiebt die Bar um insets.bottom NACH OBEN über den Content → diese + // Überlappung als Clearance abziehen. +16 = mittlerer Gap (nicht so eng + // wie +4, nicht so hoch wie die alte inputBarHeight-Variante). + paddingBottom: keyboardVisible ? keyboardHeight + 4 : insets.bottom + 16, }} showsVerticalScrollIndicator={false} keyboardDismissMode="interactive" @@ -890,6 +875,9 @@ export default function DmScreen() { maxLength={2000} returnKeyType="send" onSubmitEditing={handleSend} + // Insta/WA-Style: nach dem Senden bleibt die Tastatur offen + // (Fokus bleibt am Input), bis der User woanders hin tippt. + blurOnSubmit={false} editable={!sending && !uploading} /> {(inputText.trim().length > 0 || attachment) ? ( @@ -925,8 +913,14 @@ export default function DmScreen() { visible={infoSheetOpen} onClose={() => setInfoSheetOpen(false)} partner={partner} + partnerUserId={userId ?? null} messages={messages} - onImagePress={openLightbox} + onImagePress={(uri) => { + // Sheet erst schließen, dann Lightbox — sonst läge die Lightbox hinter + // dem FormSheet-Modal und wäre nicht sichtbar. + setInfoSheetOpen(false); + setTimeout(() => openLightbox(uri), 250); + }} onViewProfile={() => { setInfoSheetOpen(false); setTimeout(() => userId && router.push(`/profile/${userId}` as any), 250); @@ -1004,6 +998,7 @@ function DmInfoSheet({ visible, onClose, partner, + partnerUserId, messages, onImagePress, onViewProfile, @@ -1013,6 +1008,7 @@ function DmInfoSheet({ visible: boolean; onClose: () => void; partner: { id: string; nickname: string; avatar?: string | null } | null; + partnerUserId: string | null; messages: ChatMsg[]; onImagePress: (uri: string) => void; onViewProfile: () => void; @@ -1032,39 +1028,30 @@ function DmInfoSheet({ dismissOnBackdrop > - {/* Partner-Karte */} + {/* Partner-Karte — Avatar via UserAvatar (rendert auch Listen-/Default- + Avatare, nicht nur eigene Foto-URLs), Pfeil direkt neben dem Namen. */} - {partner?.avatar ? ( - - ) : ( - - - {partner?.nickname?.[0]?.toUpperCase() ?? '?'} - - - )} + - - {partner?.nickname ?? '…'} - + + + {partner?.nickname ?? '…'} + + + {t('dm.view_profile')} - diff --git a/apps/rebreak-native/app/settings.tsx b/apps/rebreak-native/app/settings.tsx index ec83570..cb8f207 100644 --- a/apps/rebreak-native/app/settings.tsx +++ b/apps/rebreak-native/app/settings.tsx @@ -197,7 +197,7 @@ export default function SettingsScreen() { }, [hydratedVoice]); const subscriptionSheetRef = useRef(null); - const magicSheetRef = useRef(null); + const [magicSheetVisible, setMagicSheetVisible] = useState(false); async function handleVoiceSelect(voiceId: LyraVoiceId) { if (voiceSaving || voiceId === selectedVoice) return; @@ -393,7 +393,7 @@ export default function SettingsScreen() { icon: 'sparkles-outline', label: t('settings.rebreak_magic'), sublabel: t('settings.rebreak_magic_desc'), - onPress: () => magicSheetRef.current?.present(), + onPress: () => setMagicSheetVisible(true), }, { icon: 'star-outline', @@ -754,15 +754,11 @@ export default function SettingsScreen() { - - - + setMagicSheetVisible(false)} + colors={colors} + /> {streakTimePickerVisible ? ( 0 ? Math.max(0, progress * waveWidth - DOT_SIZE / 2) : 0; - const playBtnBg = isOwn ? 'rgba(0,0,0,0.10)' : 'rgba(0,0,0,0.06)'; - const playIconColor = isOwn ? bubbleColors.ownText : colors.text; - // WA-Stil: Bars immer dunkelgrau/schwarz — unabhängig von own/other - const playedBarColor = 'rgba(0,0,0,0.62)'; - const unplayedBarColor = 'rgba(0,0,0,0.18)'; + // Insta-Style Wellenform-Farben: + // - eigene Bubble (Mint-BG): Inhalt weiß, gespielte Bars weiß, ungespielte + // halbtransparent-weiß (grau wirkend). + // - fremde Bubble (graue Clean-Bubble): Inhalt schwarz, gespielte Bars + // schwarz, ungespielte grau. + // Im Ruhezustand (noch nicht gestartet ODER nach komplettem Durchlauf) sind + // ALLE Bars in der Vollfarbe (schwarz / weiß) — die Zwei-Ton-Progress-Ansicht + // erscheint nur während/nach dem Abspielen. + const fullBarColor = isOwn ? '#ffffff' : '#0a0a0a'; + const dimBarColor = isOwn ? 'rgba(255,255,255,0.42)' : 'rgba(0,0,0,0.26)'; + const showFullBars = !isPlaying && progress === 0; + const playBtnBg = isOwn ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.06)'; + const playIconColor = isOwn ? '#ffffff' : colors.text; const dotColor = '#007AFF'; - const durationColor = isOwn ? bubbleColors.ownText + '99' : colors.textMuted; + const durationColor = isOwn ? 'rgba(255,255,255,0.9)' : colors.textMuted; const displayDuration = isPlaying ? fmtSec(currentTime) : (duration || fmtSec(totalSeconds)); const bubbleW = Math.floor(SCREEN_W * 0.60); @@ -145,7 +153,7 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri {barHeights.map((h, i) => ( ))} @@ -218,6 +226,9 @@ type Props = { /** DM-only: eigene Nachricht löschen (Soft-Delete). */ onDelete?: (msg: ChatMsg) => void; onOpenImage: (url: string) => void; + /** Clean-Background-Mode (weißer Chat-BG): eingehende Bubbles bekommen einen + * leichten Grauton statt Weiß, damit sie sich vom Hintergrund abheben (Insta-Style). */ + cleanBg?: boolean; }; function formatTime(ts: string) { @@ -229,7 +240,8 @@ function useBubbleColors() { const isDark = colorScheme === 'dark'; return { ownBg: isDark ? '#1e4d3a' : '#D1F4CC', - ownAudioBg: isDark ? '#1a4430' : '#C2EDBA', + // Eigene Voice-Bubble: kräftiges Mint-Grün (Insta-Style), weißer Inhalt. + ownAudioBg: isDark ? '#1f7a63' : '#34C7A0', ownText: isDark ? '#e8f5e2' : '#0a0a0a', otherBg: isDark ? '#2c2c2e' : '#ffffff', otherAudioBg: isDark ? '#2c2c2e' : '#ffffff', @@ -251,6 +263,7 @@ export function ChatBubble({ onReact, onDelete, onOpenImage, + cleanBg = false, }: Props) { const { t } = useTranslation(); const colors = useColors(); @@ -291,9 +304,17 @@ export function ChatBubble({ borderBottomRightRadius: 14, }; + // Clean-Mode + Light: eingehende Bubbles leicht grau (#f0f0f2) statt reinweiß, + // sonst verschwinden sie auf dem weißen Clean-Background. Dark-Mode braucht das + // nicht (otherBg #2c2c2e hebt sich schon vom schwarzen BG ab). + const colorScheme = useThemeStore((s) => s.colorScheme); + const otherBgEff = + cleanBg && colorScheme !== 'dark' ? '#EFEFF1' : bubbleColors.otherBg; + const otherAudioBgEff = + cleanBg && colorScheme !== 'dark' ? '#EFEFF1' : bubbleColors.otherAudioBg; const bubbleBg = msg.isOwn ? (isAudioMsg ? bubbleColors.ownAudioBg : bubbleColors.ownBg) - : (isAudioMsg ? bubbleColors.otherAudioBg : bubbleColors.otherBg); + : (isAudioMsg ? otherAudioBgEff : otherBgEff); const bubbleText = msg.isOwn ? bubbleColors.ownText : bubbleColors.otherText; function copyContent() { diff --git a/apps/rebreak-native/components/devices/MagicSheet.tsx b/apps/rebreak-native/components/devices/MagicSheet.tsx index b2d04db..a7dae5a 100644 --- a/apps/rebreak-native/components/devices/MagicSheet.tsx +++ b/apps/rebreak-native/components/devices/MagicSheet.tsx @@ -3,7 +3,6 @@ import { ActivityIndicator, Linking, Pressable, - ScrollView, Share, Text, TouchableOpacity, @@ -13,6 +12,7 @@ import { Ionicons } from '@expo/vector-icons'; import * as Clipboard from 'expo-clipboard'; import type { ColorScheme } from '../../lib/theme'; import { apiFetch } from '../../lib/api'; +import { FormSheet } from '../FormSheet'; type PairResponse = { code: string; @@ -36,10 +36,18 @@ type MagicInfo = { }; /** - * MagicSheet — präsentiert die Rebreak-Magic-Pairing-Flow in einem - * TrueSheet (analog SubscriptionSheet). Wird aus settings.tsx getriggert. + * MagicSheet — Rebreak-Magic-Pairing-Flow als geteiltes FormSheet + * (Bottom-Sheet mit Titel-Header). Wird aus settings.tsx getriggert. */ -export function MagicSheet({ colors }: { colors: ColorScheme }) { +export function MagicSheet({ + visible, + onClose, + colors, +}: { + visible: boolean; + onClose: () => void; + colors: ColorScheme; +}) { const [info, setInfo] = useState(null); const [pair, setPair] = useState(null); const [pairLoading, setPairLoading] = useState(false); @@ -118,34 +126,17 @@ export function MagicSheet({ colors }: { colors: ColorScheme }) { const codeExpired = pair !== null && remaining <= 0; return ( - - {/* Header */} - - - - - - - Rebreak Magic - - - iPhone in 30 Sek. binden — ohne Werks-Reset. - - - + + {/* Sub-Header (Tagline) */} + + iPhone in 30 Sek. binden — ohne Werks-Reset. + {/* Step 1 — Download */} @@ -312,7 +303,8 @@ export function MagicSheet({ colors }: { colors: ColorScheme }) { )) )} - + + ); } diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist index 5e6716b..9a8544b 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.13 CFBundleVersion - 68 + 69 NSExtension NSExtensionPointIdentifier diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist index 6d7a147..128c622 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.13 CFBundleVersion - 68 + 69 NSExtension NSExtensionPointIdentifier diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist index 205e74f..8e795be 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.13 CFBundleVersion - 68 + 69 EXAppExtensionAttributes EXExtensionPointIdentifier diff --git a/apps/rebreak-native/stores/chatBackground.ts b/apps/rebreak-native/stores/chatBackground.ts deleted file mode 100644 index 72f816d..0000000 --- a/apps/rebreak-native/stores/chatBackground.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { create } from 'zustand'; -import AsyncStorage from '@react-native-async-storage/async-storage'; - -const STORAGE_KEY = '@rebreak/chat-backgrounds'; - -// Pro-Chat-Hintergrund (lokal, gerätegebunden). Default = 'clean' (Insta-Style, -// solider Theme-BG ohne Muster). 'pattern' = der WhatsApp-artige SVG-Symbol-BG. -export type ChatBgStyle = 'clean' | 'pattern'; -export const DEFAULT_CHAT_BG: ChatBgStyle = 'clean'; - -type ChatBackgroundState = { - // partnerId → Stil. Fehlt der Key → DEFAULT_CHAT_BG. - backgrounds: Record; - init: () => Promise; - setBackground: (partnerId: string, style: ChatBgStyle) => Promise; -}; - -export const useChatBackgroundStore = create((set, get) => ({ - backgrounds: {}, - - init: async () => { - try { - const raw = await AsyncStorage.getItem(STORAGE_KEY); - if (raw) set({ backgrounds: JSON.parse(raw) }); - } catch { - // non-fatal — Default-Clean greift - } - }, - - setBackground: async (partnerId, style) => { - const next = { ...get().backgrounds, [partnerId]: style }; - set({ backgrounds: next }); - try { - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)); - } catch { - // non-fatal - } - }, -})); diff --git a/apps/rebreak-native/tmp/.deploy-runtimes b/apps/rebreak-native/tmp/.deploy-runtimes index dfd9630..1ce4e37 100644 --- a/apps/rebreak-native/tmp/.deploy-runtimes +++ b/apps/rebreak-native/tmp/.deploy-runtimes @@ -38,9 +38,12 @@ Building Release AAB (gradlew bundleRelease)|398 Validating IPA (App-Store Connect)|91 Uploading zu App-Store Connect (TestFlight)|110 Building Release AAB (gradlew bundleRelease)|326 -Building xcarchive|202 -Exporting Ad-Hoc IPA|18 -Exporting App-Store IPA|22 Validating IPA (App-Store Connect)|86 Uploading zu App-Store Connect (TestFlight)|112 Building Release AAB (gradlew bundleRelease)|272 +Building xcarchive|198 +Exporting Ad-Hoc IPA|18 +Exporting App-Store IPA|23 +Validating IPA (App-Store Connect)|117 +Uploading zu App-Store Connect (TestFlight)|138 +Building Release AAB (gradlew bundleRelease)|273 diff --git a/backend/server/api/magic/devices.get.ts b/backend/server/api/magic/devices.get.ts index 5100594..61104b6 100644 --- a/backend/server/api/magic/devices.get.ts +++ b/backend/server/api/magic/devices.get.ts @@ -23,9 +23,9 @@ export default defineEventHandler(async (event) => { db.userDevice.findMany({ where: { userId: user.id, - // Alle bound-Devices (Pro/Legend-Lock). Magic-only rows kommen - // \u00fcber `magic` rein \u2014 wir wollen hier die nicht-magic Lock-Bindings. - boundToPlan: { not: null }, + // Alle Native-App-Geräte des Users \u2014 KEINE magic-only Rows + // (die kommen über `magic`). Lock-Status ist egal: free/legend, alle + // Native-App-Devices sollen im Hub erscheinen. magicEnrolledAt: null, }, orderBy: [{ lastSeenAt: "desc" }, { createdAt: "desc" }],