fix(devices): Magic-Hub zeigt jetzt alle Native-Geraete, Native dedupliziert Mac

Magic-Mac-Hub (/api/magic/devices):
- Filter boundToPlan war zu eng \u2014 iPhone/iPad ohne aktiven Plan-Lock
  fielen raus. Jetzt: alle UserDevice-Rows des Users ausser den
  magic-enrolled, plus ProtectedDevice mit Dedupe.

Native /devices Page:
- MacBook erschien doppelt: einmal als UserDevice (registriert via
  Magic-Mac, model=Mac14,9) und einmal als ProtectedDevice (alter
  DNS-Flow). Dedupe per platform-key (mac/ios/android/win):
  wenn UserDevice mit gleicher Plattform existiert, blende
  ProtectedDevice aus.
- Slot-Counter zaehlt jetzt nach dedupe (totalRegistered).
This commit is contained in:
chahinebrini 2026-06-03 19:43:33 +02:00
parent 187a2d8c19
commit 50425a62ee
16 changed files with 213 additions and 254 deletions

View File

@ -2,21 +2,19 @@ import AppKit
import SwiftUI import SwiftUI
struct LoginView: View { struct LoginView: View {
@State private var digits: [String] = Array(repeating: "", count: 6) @State private var code: String = ""
@FocusState private var focusedField: Int? @FocusState private var isFocused: Bool
@State private var isLoading = false @State private var isLoading = false
@State private var errorMessage: String? @State private var errorMessage: String?
let onSuccess: (AuthSession) -> Void let onSuccess: (AuthSession) -> Void
private var enteredCode: String { digits.joined() } private var isComplete: Bool { code.count == 6 && code.allSatisfy(\.isNumber) }
private var isComplete: Bool { enteredCode.count == 6 && enteredCode.allSatisfy(\.isNumber) }
var body: some View { var body: some View {
VStack(spacing: 28) { VStack(spacing: 28) {
Spacer().frame(height: 20) Spacer().frame(height: 20)
// App-Icon + Header
VStack(spacing: 14) { VStack(spacing: 14) {
appIconView appIconView
.frame(width: 84, height: 84) .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) { VStack(spacing: 14) {
HStack(spacing: 10) { ZStack {
ForEach(0..<6, id: \.self) { index in HStack(spacing: 10) {
digitField(index: index) 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 { if let error = errorMessage {
HStack(spacing: 6) { HStack(spacing: 6) {
@ -81,7 +96,11 @@ struct LoginView: View {
.padding(.horizontal, 32) .padding(.horizontal, 32)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor)) .background(Color(nsColor: .windowBackgroundColor))
.onAppear { focusedField = 0 } .onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
isFocused = true
}
}
} }
// MARK: - Components // MARK: - Components
@ -95,7 +114,6 @@ struct LoginView: View {
.interpolation(.high) .interpolation(.high)
.frame(width: 84, height: 84) .frame(width: 84, height: 84)
} else { } else {
// Fallback: gefärbtes RoundedRect (entspricht macOS-Stil)
RoundedRectangle(cornerRadius: 18, style: .continuous) RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(LinearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing)) .fill(LinearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing))
.overlay( .overlay(
@ -107,112 +125,58 @@ struct LoginView: View {
} }
@ViewBuilder @ViewBuilder
private func digitField(index: Int) -> some View { private func digitBox(index: Int) -> some View {
TextField("", text: Binding( let chars = Array(code)
get: { digits[index] }, let digit: String = index < chars.count ? String(chars[index]) : ""
set: { newValue in handleDigitInput(newValue, at: index) } let activeIndex = min(chars.count, 5)
)) let isActive = isFocused && index == activeIndex
.textFieldStyle(.plain) let isFilled = !digit.isEmpty
.multilineTextAlignment(.center)
.font(.system(size: 28, weight: .semibold, design: .rounded)) RoundedRectangle(cornerRadius: 10, style: .continuous)
.frame(width: 48, height: 60) .fill(Color(nsColor: .controlBackgroundColor))
.background( .frame(width: 48, height: 60)
RoundedRectangle(cornerRadius: 10, style: .continuous) .overlay(
.fill(Color(nsColor: .controlBackgroundColor)) RoundedRectangle(cornerRadius: 10, style: .continuous)
) .strokeBorder(
.overlay( isActive ? Color.accentColor :
RoundedRectangle(cornerRadius: 10, style: .continuous) (isFilled ? Color.gray.opacity(0.4) : Color.gray.opacity(0.25)),
.strokeBorder( lineWidth: isActive ? 2 : 1
focusedField == index ? Color.accentColor : Color.gray.opacity(0.3), )
lineWidth: focusedField == index ? 2 : 1 )
) .overlay(
) Text(digit)
.focused($focusedField, equals: index) .font(.system(size: 28, weight: .semibold, design: .rounded))
.foregroundStyle(.primary)
)
} }
// MARK: - Logic // MARK: - Logic
private func handleDigitInput(_ raw: String, at index: Int) { private func handleCodeChange(_ newValue: String) {
// Erlaubt: 09. Mehrere Zeichen kann Paste sein ODER User tippt in let digits = newValue.filter(\.isNumber)
// ein bereits gefülltes Feld (newValue = "alt+neu"). let clipped = String(digits.prefix(6))
let onlyDigits = raw.filter(\.isNumber) if clipped != newValue {
let previous = digits[index] code = clipped
// 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()
}
return return
} }
if clipped.count == 6 && !isLoading {
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
handleSubmit() 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() { private func handleSubmit() {
guard isComplete, !isLoading else { return } guard isComplete, !isLoading else { return }
let code = enteredCode let toSend = code
Task { Task {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
do { do {
let session = try await AuthService.shared.signInWithPairingCode(code) let session = try await AuthService.shared.signInWithPairingCode(toSend)
onSuccess(session) onSuccess(session)
} catch { } catch {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
// Felder leeren bei Fehler code = ""
digits = Array(repeating: "", count: 6) isFocused = true
focusedField = 0
} }
isLoading = false isLoading = false
} }

View File

@ -1,6 +1,13 @@
# Changelog # Changelog
All notable changes to rebreak-native will be documented in this file. 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 ## 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 - 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

View File

@ -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

View File

@ -36,7 +36,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
ios: { ios: {
supportsTablet: true, supportsTablet: true,
bundleIdentifier: MAIN_BUNDLE, bundleIdentifier: MAIN_BUNDLE,
buildNumber: "68", buildNumber: "69",
// Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen // Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen
// signInAsync()-Flow. Ohne flag generiert Expo's prebuild den // signInAsync()-Flow. Ohne flag generiert Expo's prebuild den
// com.apple.developer.applesignin-Entitlement nicht in die .entitlements. // com.apple.developer.applesignin-Entitlement nicht in die .entitlements.
@ -59,7 +59,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
android: { android: {
package: "org.rebreak.app", package: "org.rebreak.app",
versionCode: 51, versionCode: 52,
adaptiveIcon: { adaptiveIcon: {
// Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den // Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den
// Außenring) → adaptive-foreground.png ist das Logo auf transparentem // Außenring) → adaptive-foreground.png ist das Logo auf transparentem

View File

@ -27,7 +27,6 @@ import { useColors } from '../lib/theme';
import { useLanguageStore } from '../stores/language'; import { useLanguageStore } from '../stores/language';
import { useAppLockStore } from '../stores/appLock'; import { useAppLockStore } from '../stores/appLock';
import { useLyraVoiceStore } from '../stores/lyraVoice'; import { useLyraVoiceStore } from '../stores/lyraVoice';
import { useChatBackgroundStore } from '../stores/chatBackground';
import { AppLockGate } from '../components/AppLockGate'; import { AppLockGate } from '../components/AppLockGate';
import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet'; import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet';
import { DeviceApprovalIncomingSheet } from '../components/DeviceApprovalIncomingSheet'; import { DeviceApprovalIncomingSheet } from '../components/DeviceApprovalIncomingSheet';
@ -66,7 +65,6 @@ function RootLayoutInner() {
const initLanguage = useLanguageStore((s) => s.init); const initLanguage = useLanguageStore((s) => s.init);
const initAppLock = useAppLockStore((s) => s.init); const initAppLock = useAppLockStore((s) => s.init);
const initLyraVoice = useLyraVoiceStore((s) => s.init); const initLyraVoice = useLyraVoiceStore((s) => s.init);
const initChatBackground = useChatBackgroundStore((s) => s.init);
const appLockReady = useAppLockStore((s) => s.ready); const appLockReady = useAppLockStore((s) => s.ready);
const initRealtimeDebug = useRealtimeDebugStore((s) => s.init); const initRealtimeDebug = useRealtimeDebugStore((s) => s.init);
const colors = useColors(); const colors = useColors();
@ -109,7 +107,6 @@ function RootLayoutInner() {
initLanguage(); initLanguage();
initAppLock(); initAppLock();
initLyraVoice(); initLyraVoice();
initChatBackground();
if (__DEV__) initRealtimeDebug(); if (__DEV__) initRealtimeDebug();
}, []); }, []);

View File

@ -499,7 +499,27 @@ export default function DevicesScreen() {
const TOTAL_DEVICE_SLOTS = 3; const TOTAL_DEVICE_SLOTS = 3;
const activeProtectedCount = protectedDevices.filter((d) => d.status !== 'revoked').length; 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; const atDeviceLimit = isLegend && totalRegistered >= TOTAL_DEVICE_SLOTS;
// Mobile zuerst (current oben), danach Desktop/Protected. // 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(); return new Date(b.lastSeenAt).getTime() - new Date(a.lastSeenAt).getTime();
}); });
const isLoading = mobileLoading || protectedLoading; 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'); const subtitle = isLegend ? t('devices.subtitle_legend') : t('devices.subtitle_free');
async function handleRemoveProtected(id: string) { async function handleRemoveProtected(id: string) {
@ -582,7 +602,7 @@ export default function DevicesScreen() {
<> <>
{sortedMobile.map((device, i) => { {sortedMobile.map((device, i) => {
const isLast = const isLast =
i === sortedMobile.length - 1 && protectedDevices.length === 0; i === sortedMobile.length - 1 && dedupedProtected.length === 0;
return ( return (
<View <View
key={device.id} key={device.id}
@ -600,11 +620,11 @@ export default function DevicesScreen() {
</View> </View>
); );
})} })}
{protectedDevices.map((device, i) => ( {dedupedProtected.map((device, i) => (
<View <View
key={device.id} key={device.id}
style={{ style={{
borderBottomWidth: i < protectedDevices.length - 1 ? 1 : 0, borderBottomWidth: i < dedupedProtected.length - 1 ? 1 : 0,
borderBottomColor: colors.border, borderBottomColor: colors.border,
}} }}
> >

View File

@ -30,13 +30,10 @@ import * as FileSystem from 'expo-file-system/legacy';
import { apiFetch } from '../lib/api'; import { apiFetch } from '../lib/api';
import { ChatBubble, type ChatMsg, type MessageReaction } from '../components/chat/ChatBubble'; import { ChatBubble, type ChatMsg, type MessageReaction } from '../components/chat/ChatBubble';
import { VoiceRecordingBar, formatVoiceDuration } from '../components/chat/VoiceRecordingBar'; import { VoiceRecordingBar, formatVoiceDuration } from '../components/chat/VoiceRecordingBar';
import { DmChatBackground } from '../components/chat/DmChatBackground';
import { FormSheet } from '../components/FormSheet'; import { FormSheet } from '../components/FormSheet';
import { useDmRealtime } from '../hooks/useChatRealtime'; import { useDmRealtime } from '../hooks/useChatRealtime';
import { useDmTyping } from '../hooks/useDmTyping'; import { useDmTyping } from '../hooks/useDmTyping';
import { useColors } from '../lib/theme'; import { useColors } from '../lib/theme';
import { useThemeStore } from '../stores/theme';
import { useChatBackgroundStore, type ChatBgStyle } from '../stores/chatBackground';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { UserAvatar } from '../components/UserAvatar'; import { UserAvatar } from '../components/UserAvatar';
@ -94,21 +91,10 @@ export default function DmScreen() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const myUserId = useAuthStore((s) => s.user?.id); const myUserId = useAuthStore((s) => s.user?.id);
const colorScheme = useThemeStore((s) => s.colorScheme);
const { userId } = useLocalSearchParams<{ userId: string }>(); const { userId } = useLocalSearchParams<{ userId: string }>();
// Pro-Chat-Hintergrund (lokal, gerätegebunden). Default = 'clean' (Insta-Style). // Chat-Hintergrund: immer clean (solider Theme-BG, weiß / schwarz). Insta-Style.
const chatBgStyle = useChatBackgroundStore((s) => (userId && s.backgrounds[userId]) || 'clean'); const chatBg = colors.bg;
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;
const flatListRef = useRef<FlatListType<ChatMsg>>(null); const flatListRef = useRef<FlatListType<ChatMsg>>(null);
@ -759,7 +745,6 @@ export default function DmScreen() {
</View> </View>
<View style={{ flex: 1, backgroundColor: chatBg }}> <View style={{ flex: 1, backgroundColor: chatBg }}>
{chatBgStyle === 'pattern' && <DmChatBackground />}
{(isLoading || isFetching) && messages.length === 0 ? ( {(isLoading || isFetching) && messages.length === 0 ? (
<View style={styles.loadingBox}> <View style={styles.loadingBox}>
<ActivityIndicator color={colors.brandOrange} /> <ActivityIndicator color={colors.brandOrange} />
@ -785,6 +770,7 @@ export default function DmScreen() {
onReact={toggleReaction} onReact={toggleReaction}
onDelete={deleteMessage} onDelete={deleteMessage}
onOpenImage={openLightbox} onOpenImage={openLightbox}
cleanBg
/> />
)} )}
keyExtractor={(m) => m.id} keyExtractor={(m) => m.id}
@ -794,11 +780,10 @@ export default function DmScreen() {
// Tastatur offen: Input-Bar floatet (per transform) über der Tastatur, // Tastatur offen: Input-Bar floatet (per transform) über der Tastatur,
// der Viewport schrumpft NICHT → Clearance = keyboardHeight + 4 (Gap). // der Viewport schrumpft NICHT → Clearance = keyboardHeight + 4 (Gap).
// Tastatur zu: die KeyboardStickyView hat offset.closed = -insets.bottom, // Tastatur zu: die KeyboardStickyView hat offset.closed = -insets.bottom,
// schiebt die Bar also um insets.bottom NACH OBEN über den Content → // schiebt die Bar um insets.bottom NACH OBEN über den Content → diese
// diese Überlappung muss als Clearance abgezogen werden, sonst wird die // Überlappung als Clearance abziehen. +16 = mittlerer Gap (nicht so eng
// letzte Nachricht halb verdeckt. insets.bottom + 4 hält denselben // wie +4, nicht so hoch wie die alte inputBarHeight-Variante).
// knappen Gap wie im Keyboard-offen-State. paddingBottom: keyboardVisible ? keyboardHeight + 4 : insets.bottom + 16,
paddingBottom: keyboardVisible ? keyboardHeight + 4 : insets.bottom + 4,
}} }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardDismissMode="interactive" keyboardDismissMode="interactive"
@ -890,6 +875,9 @@ export default function DmScreen() {
maxLength={2000} maxLength={2000}
returnKeyType="send" returnKeyType="send"
onSubmitEditing={handleSend} 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} editable={!sending && !uploading}
/> />
{(inputText.trim().length > 0 || attachment) ? ( {(inputText.trim().length > 0 || attachment) ? (
@ -925,8 +913,14 @@ export default function DmScreen() {
visible={infoSheetOpen} visible={infoSheetOpen}
onClose={() => setInfoSheetOpen(false)} onClose={() => setInfoSheetOpen(false)}
partner={partner} partner={partner}
partnerUserId={userId ?? null}
messages={messages} 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={() => { onViewProfile={() => {
setInfoSheetOpen(false); setInfoSheetOpen(false);
setTimeout(() => userId && router.push(`/profile/${userId}` as any), 250); setTimeout(() => userId && router.push(`/profile/${userId}` as any), 250);
@ -1004,6 +998,7 @@ function DmInfoSheet({
visible, visible,
onClose, onClose,
partner, partner,
partnerUserId,
messages, messages,
onImagePress, onImagePress,
onViewProfile, onViewProfile,
@ -1013,6 +1008,7 @@ function DmInfoSheet({
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
partner: { id: string; nickname: string; avatar?: string | null } | null; partner: { id: string; nickname: string; avatar?: string | null } | null;
partnerUserId: string | null;
messages: ChatMsg[]; messages: ChatMsg[];
onImagePress: (uri: string) => void; onImagePress: (uri: string) => void;
onViewProfile: () => void; onViewProfile: () => void;
@ -1032,39 +1028,30 @@ function DmInfoSheet({
dismissOnBackdrop dismissOnBackdrop
> >
<ScrollView contentContainerStyle={{ paddingBottom: 40 }}> <ScrollView contentContainerStyle={{ paddingBottom: 40 }}>
{/* Partner-Karte */} {/* Partner-Karte Avatar via UserAvatar (rendert auch Listen-/Default-
Avatare, nicht nur eigene Foto-URLs), Pfeil direkt neben dem Namen. */}
<TouchableOpacity <TouchableOpacity
activeOpacity={0.7} activeOpacity={0.7}
onPress={onViewProfile} onPress={onViewProfile}
style={{ flexDirection: 'row', alignItems: 'center', padding: 16, gap: 14 }} style={{ flexDirection: 'row', alignItems: 'center', padding: 16, gap: 14 }}
> >
{partner?.avatar ? ( <UserAvatar
<Image userId={partnerUserId}
source={{ uri: partner.avatar }} avatar={partner?.avatar ?? null}
style={{ width: 56, height: 56, borderRadius: 28 }} nickname={partner?.nickname ?? '?'}
contentFit="cover" size="lg"
cachePolicy="memory-disk" />
/>
) : (
<View style={{
width: 56, height: 56, borderRadius: 28,
backgroundColor: colors.brandOrange + '30',
alignItems: 'center', justifyContent: 'center',
}}>
<Text style={{ fontSize: 20, fontFamily: 'Nunito_700Bold', color: colors.brandOrange }}>
{partner?.nickname?.[0]?.toUpperCase() ?? '?'}
</Text>
</View>
)}
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={{ fontSize: 17, fontFamily: 'Nunito_700Bold', color: colors.text }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
{partner?.nickname ?? '…'} <Text style={{ fontSize: 17, fontFamily: 'Nunito_700Bold', color: colors.text }} numberOfLines={1}>
</Text> {partner?.nickname ?? '…'}
</Text>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</View>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted, marginTop: 2 }}> <Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted, marginTop: 2 }}>
{t('dm.view_profile')} {t('dm.view_profile')}
</Text> </Text>
</View> </View>
<Ionicons name="chevron-forward" size={18} color={colors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
<View style={{ height: StyleSheet.hairlineWidth, backgroundColor: colors.border, marginHorizontal: 16 }} /> <View style={{ height: StyleSheet.hairlineWidth, backgroundColor: colors.border, marginHorizontal: 16 }} />

View File

@ -197,7 +197,7 @@ export default function SettingsScreen() {
}, [hydratedVoice]); }, [hydratedVoice]);
const subscriptionSheetRef = useRef<TrueSheet>(null); const subscriptionSheetRef = useRef<TrueSheet>(null);
const magicSheetRef = useRef<TrueSheet>(null); const [magicSheetVisible, setMagicSheetVisible] = useState(false);
async function handleVoiceSelect(voiceId: LyraVoiceId) { async function handleVoiceSelect(voiceId: LyraVoiceId) {
if (voiceSaving || voiceId === selectedVoice) return; if (voiceSaving || voiceId === selectedVoice) return;
@ -393,7 +393,7 @@ export default function SettingsScreen() {
icon: 'sparkles-outline', icon: 'sparkles-outline',
label: t('settings.rebreak_magic'), label: t('settings.rebreak_magic'),
sublabel: t('settings.rebreak_magic_desc'), sublabel: t('settings.rebreak_magic_desc'),
onPress: () => magicSheetRef.current?.present(), onPress: () => setMagicSheetVisible(true),
}, },
{ {
icon: 'star-outline', icon: 'star-outline',
@ -754,15 +754,11 @@ export default function SettingsScreen() {
<SubscriptionSheet plan={plan} colors={colors} t={t} /> <SubscriptionSheet plan={plan} colors={colors} t={t} />
</TrueSheet> </TrueSheet>
<TrueSheet <MagicSheet
ref={magicSheetRef} visible={magicSheetVisible}
detents={[0.85]} onClose={() => setMagicSheetVisible(false)}
cornerRadius={20} colors={colors}
grabber />
backgroundColor={colors.surface}
>
<MagicSheet colors={colors} />
</TrueSheet>
{streakTimePickerVisible ? ( {streakTimePickerVisible ? (
<StreakTimePickerSheet <StreakTimePickerSheet

View File

@ -117,13 +117,21 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri
const DOT_SIZE = 7; const DOT_SIZE = 7;
const dotLeft = waveWidth > 0 ? Math.max(0, progress * waveWidth - DOT_SIZE / 2) : 0; const dotLeft = waveWidth > 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)'; // Insta-Style Wellenform-Farben:
const playIconColor = isOwn ? bubbleColors.ownText : colors.text; // - eigene Bubble (Mint-BG): Inhalt weiß, gespielte Bars weiß, ungespielte
// WA-Stil: Bars immer dunkelgrau/schwarz — unabhängig von own/other // halbtransparent-weiß (grau wirkend).
const playedBarColor = 'rgba(0,0,0,0.62)'; // - fremde Bubble (graue Clean-Bubble): Inhalt schwarz, gespielte Bars
const unplayedBarColor = 'rgba(0,0,0,0.18)'; // 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 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 displayDuration = isPlaying ? fmtSec(currentTime) : (duration || fmtSec(totalSeconds));
const bubbleW = Math.floor(SCREEN_W * 0.60); 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) => ( {barHeights.map((h, i) => (
<View <View
key={i} key={i}
style={{ width: 3, height: h, borderRadius: 1.5, backgroundColor: i < playedCount ? playedBarColor : unplayedBarColor }} style={{ width: 3, height: h, borderRadius: 1.5, backgroundColor: showFullBars ? fullBarColor : (i < playedCount ? fullBarColor : dimBarColor) }}
/> />
))} ))}
</View> </View>
@ -218,6 +226,9 @@ type Props = {
/** DM-only: eigene Nachricht löschen (Soft-Delete). */ /** DM-only: eigene Nachricht löschen (Soft-Delete). */
onDelete?: (msg: ChatMsg) => void; onDelete?: (msg: ChatMsg) => void;
onOpenImage: (url: string) => 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) { function formatTime(ts: string) {
@ -229,7 +240,8 @@ function useBubbleColors() {
const isDark = colorScheme === 'dark'; const isDark = colorScheme === 'dark';
return { return {
ownBg: isDark ? '#1e4d3a' : '#D1F4CC', 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', ownText: isDark ? '#e8f5e2' : '#0a0a0a',
otherBg: isDark ? '#2c2c2e' : '#ffffff', otherBg: isDark ? '#2c2c2e' : '#ffffff',
otherAudioBg: isDark ? '#2c2c2e' : '#ffffff', otherAudioBg: isDark ? '#2c2c2e' : '#ffffff',
@ -251,6 +263,7 @@ export function ChatBubble({
onReact, onReact,
onDelete, onDelete,
onOpenImage, onOpenImage,
cleanBg = false,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const colors = useColors(); const colors = useColors();
@ -291,9 +304,17 @@ export function ChatBubble({
borderBottomRightRadius: 14, 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 const bubbleBg = msg.isOwn
? (isAudioMsg ? bubbleColors.ownAudioBg : bubbleColors.ownBg) ? (isAudioMsg ? bubbleColors.ownAudioBg : bubbleColors.ownBg)
: (isAudioMsg ? bubbleColors.otherAudioBg : bubbleColors.otherBg); : (isAudioMsg ? otherAudioBgEff : otherBgEff);
const bubbleText = msg.isOwn ? bubbleColors.ownText : bubbleColors.otherText; const bubbleText = msg.isOwn ? bubbleColors.ownText : bubbleColors.otherText;
function copyContent() { function copyContent() {

View File

@ -3,7 +3,6 @@ import {
ActivityIndicator, ActivityIndicator,
Linking, Linking,
Pressable, Pressable,
ScrollView,
Share, Share,
Text, Text,
TouchableOpacity, TouchableOpacity,
@ -13,6 +12,7 @@ import { Ionicons } from '@expo/vector-icons';
import * as Clipboard from 'expo-clipboard'; import * as Clipboard from 'expo-clipboard';
import type { ColorScheme } from '../../lib/theme'; import type { ColorScheme } from '../../lib/theme';
import { apiFetch } from '../../lib/api'; import { apiFetch } from '../../lib/api';
import { FormSheet } from '../FormSheet';
type PairResponse = { type PairResponse = {
code: string; code: string;
@ -36,10 +36,18 @@ type MagicInfo = {
}; };
/** /**
* MagicSheet präsentiert die Rebreak-Magic-Pairing-Flow in einem * MagicSheet Rebreak-Magic-Pairing-Flow als geteiltes FormSheet
* TrueSheet (analog SubscriptionSheet). Wird aus settings.tsx getriggert. * (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<MagicInfo | null>(null); const [info, setInfo] = useState<MagicInfo | null>(null);
const [pair, setPair] = useState<PairResponse | null>(null); const [pair, setPair] = useState<PairResponse | null>(null);
const [pairLoading, setPairLoading] = useState(false); const [pairLoading, setPairLoading] = useState(false);
@ -118,34 +126,17 @@ export function MagicSheet({ colors }: { colors: ColorScheme }) {
const codeExpired = pair !== null && remaining <= 0; const codeExpired = pair !== null && remaining <= 0;
return ( return (
<ScrollView <FormSheet
style={{ maxHeight: 640 }} visible={visible}
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 32 }} onClose={onClose}
showsVerticalScrollIndicator={false} title="Rebreak Magic"
growWithKeyboard={false}
> >
{/* Header */} <View style={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 24 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 16 }}> {/* Sub-Header (Tagline) */}
<View <Text style={{ fontSize: 13, color: colors.textMuted, marginBottom: 16, marginLeft: 4 }}>
style={{ iPhone in 30 Sek. binden ohne Werks-Reset.
width: 44, </Text>
height: 44,
borderRadius: 12,
backgroundColor: '#007AFF22',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="sparkles" size={22} color="#007AFF" />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 20, fontFamily: 'Nunito_700Bold', color: colors.text }}>
Rebreak Magic
</Text>
<Text style={{ fontSize: 13, color: colors.textMuted, marginTop: 1 }}>
iPhone in 30 Sek. binden ohne Werks-Reset.
</Text>
</View>
</View>
{/* Step 1 — Download */} {/* Step 1 — Download */}
<SectionTitle text="1. Mac-App herunterladen" colors={colors} /> <SectionTitle text="1. Mac-App herunterladen" colors={colors} />
@ -312,7 +303,8 @@ export function MagicSheet({ colors }: { colors: ColorScheme }) {
)) ))
)} )}
</View> </View>
</ScrollView> </View>
</FormSheet>
); );
} }

View File

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.3.13</string> <string>0.3.13</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>68</string> <string>69</string>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>

View File

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.3.13</string> <string>0.3.13</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>68</string> <string>69</string>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>

View File

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.3.13</string> <string>0.3.13</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>68</string> <string>69</string>
<key>EXAppExtensionAttributes</key> <key>EXAppExtensionAttributes</key>
<dict> <dict>
<key>EXExtensionPointIdentifier</key> <key>EXExtensionPointIdentifier</key>

View File

@ -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<string, ChatBgStyle>;
init: () => Promise<void>;
setBackground: (partnerId: string, style: ChatBgStyle) => Promise<void>;
};
export const useChatBackgroundStore = create<ChatBackgroundState>((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
}
},
}));

View File

@ -38,9 +38,12 @@ Building Release AAB (gradlew bundleRelease)|398
Validating IPA (App-Store Connect)|91 Validating IPA (App-Store Connect)|91
Uploading zu App-Store Connect (TestFlight)|110 Uploading zu App-Store Connect (TestFlight)|110
Building Release AAB (gradlew bundleRelease)|326 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 Validating IPA (App-Store Connect)|86
Uploading zu App-Store Connect (TestFlight)|112 Uploading zu App-Store Connect (TestFlight)|112
Building Release AAB (gradlew bundleRelease)|272 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

View File

@ -23,9 +23,9 @@ export default defineEventHandler(async (event) => {
db.userDevice.findMany({ db.userDevice.findMany({
where: { where: {
userId: user.id, userId: user.id,
// Alle bound-Devices (Pro/Legend-Lock). Magic-only rows kommen // Alle Native-App-Geräte des Users \u2014 KEINE magic-only Rows
// \u00fcber `magic` rein \u2014 wir wollen hier die nicht-magic Lock-Bindings. // (die kommen über `magic`). Lock-Status ist egal: free/legend, alle
boundToPlan: { not: null }, // Native-App-Devices sollen im Hub erscheinen.
magicEnrolledAt: null, magicEnrolledAt: null,
}, },
orderBy: [{ lastSeenAt: "desc" }, { createdAt: "desc" }], orderBy: [{ lastSeenAt: "desc" }, { createdAt: "desc" }],