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
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: 09. 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
}

View File

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

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: {
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

View File

@ -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();
}, []);

View File

@ -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 (
<View
key={device.id}
@ -600,11 +620,11 @@ export default function DevicesScreen() {
</View>
);
})}
{protectedDevices.map((device, i) => (
{dedupedProtected.map((device, i) => (
<View
key={device.id}
style={{
borderBottomWidth: i < protectedDevices.length - 1 ? 1 : 0,
borderBottomWidth: i < dedupedProtected.length - 1 ? 1 : 0,
borderBottomColor: colors.border,
}}
>

View File

@ -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<FlatListType<ChatMsg>>(null);
@ -759,7 +745,6 @@ export default function DmScreen() {
</View>
<View style={{ flex: 1, backgroundColor: chatBg }}>
{chatBgStyle === 'pattern' && <DmChatBackground />}
{(isLoading || isFetching) && messages.length === 0 ? (
<View style={styles.loadingBox}>
<ActivityIndicator color={colors.brandOrange} />
@ -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
>
<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
activeOpacity={0.7}
onPress={onViewProfile}
style={{ flexDirection: 'row', alignItems: 'center', padding: 16, gap: 14 }}
>
{partner?.avatar ? (
<Image
source={{ uri: partner.avatar }}
style={{ width: 56, height: 56, borderRadius: 28 }}
contentFit="cover"
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>
)}
<UserAvatar
userId={partnerUserId}
avatar={partner?.avatar ?? null}
nickname={partner?.nickname ?? '?'}
size="lg"
/>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 17, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{partner?.nickname ?? '…'}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<Text style={{ fontSize: 17, fontFamily: 'Nunito_700Bold', color: colors.text }} numberOfLines={1}>
{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 }}>
{t('dm.view_profile')}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textMuted} />
</TouchableOpacity>
<View style={{ height: StyleSheet.hairlineWidth, backgroundColor: colors.border, marginHorizontal: 16 }} />

View File

@ -197,7 +197,7 @@ export default function SettingsScreen() {
}, [hydratedVoice]);
const subscriptionSheetRef = useRef<TrueSheet>(null);
const magicSheetRef = useRef<TrueSheet>(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() {
<SubscriptionSheet plan={plan} colors={colors} t={t} />
</TrueSheet>
<TrueSheet
ref={magicSheetRef}
detents={[0.85]}
cornerRadius={20}
grabber
backgroundColor={colors.surface}
>
<MagicSheet colors={colors} />
</TrueSheet>
<MagicSheet
visible={magicSheetVisible}
onClose={() => setMagicSheetVisible(false)}
colors={colors}
/>
{streakTimePickerVisible ? (
<StreakTimePickerSheet

View File

@ -117,13 +117,21 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri
const DOT_SIZE = 7;
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)';
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) => (
<View
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>
@ -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() {

View File

@ -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<MagicInfo | null>(null);
const [pair, setPair] = useState<PairResponse | null>(null);
const [pairLoading, setPairLoading] = useState(false);
@ -118,34 +126,17 @@ export function MagicSheet({ colors }: { colors: ColorScheme }) {
const codeExpired = pair !== null && remaining <= 0;
return (
<ScrollView
style={{ maxHeight: 640 }}
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 32 }}
showsVerticalScrollIndicator={false}
<FormSheet
visible={visible}
onClose={onClose}
title="Rebreak Magic"
growWithKeyboard={false}
>
{/* Header */}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<View
style={{
width: 44,
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>
<View style={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 24 }}>
{/* Sub-Header (Tagline) */}
<Text style={{ fontSize: 13, color: colors.textMuted, marginBottom: 16, marginLeft: 4 }}>
iPhone in 30 Sek. binden ohne Werks-Reset.
</Text>
{/* Step 1 — Download */}
<SectionTitle text="1. Mac-App herunterladen" colors={colors} />
@ -312,7 +303,8 @@ export function MagicSheet({ colors }: { colors: ColorScheme }) {
))
)}
</View>
</ScrollView>
</View>
</FormSheet>
);
}

View File

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

View File

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

View File

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key>
<string>0.3.13</string>
<key>CFBundleVersion</key>
<string>68</string>
<string>69</string>
<key>EXAppExtensionAttributes</key>
<dict>
<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
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

View File

@ -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" }],