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:
parent
187a2d8c19
commit
50425a62ee
@ -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,14 +32,31 @@ 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) {
|
||||||
|
ZStack {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
ForEach(0..<6, id: \.self) { index in
|
ForEach(0..<6, id: \.self) { index in
|
||||||
digitField(index: index)
|
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) {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
@ -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))
|
|
||||||
.frame(width: 48, height: 60)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
.fill(Color(nsColor: .controlBackgroundColor))
|
.fill(Color(nsColor: .controlBackgroundColor))
|
||||||
)
|
.frame(width: 48, height: 60)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
.strokeBorder(
|
.strokeBorder(
|
||||||
focusedField == index ? Color.accentColor : Color.gray.opacity(0.3),
|
isActive ? Color.accentColor :
|
||||||
lineWidth: focusedField == index ? 2 : 1
|
(isFilled ? Color.gray.opacity(0.4) : Color.gray.opacity(0.25)),
|
||||||
|
lineWidth: isActive ? 2 : 1
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.focused($focusedField, equals: index)
|
.overlay(
|
||||||
|
Text(digit)
|
||||||
|
.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: 0–9. 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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
11
apps/rebreak-native/NEXT_RELEASE.md
Normal file
11
apps/rebreak-native/NEXT_RELEASE.md
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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 }}>
|
||||||
|
<Text style={{ fontSize: 17, fontFamily: 'Nunito_700Bold', color: colors.text }} numberOfLines={1}>
|
||||||
{partner?.nickname ?? '…'}
|
{partner?.nickname ?? '…'}
|
||||||
</Text>
|
</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 }} />
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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={{
|
|
||||||
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.
|
iPhone in 30 Sek. binden — ohne Werks-Reset.
|
||||||
</Text>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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" }],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user