Fix Live Activity lock screen rendering and polish multi-account auth.
Flatten widget layouts and use system colors so banner and expanded regions render on vibrant lock screens; migrate auth sessions per account to prevent double sign-in; scope app lock PIN to accounts; default clock description to "Clock In"; add architecture docs and deferred form validation on auth screens. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { fonts, spacing } from "@/constants/theme";
|
||||
import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { DEFAULT_API_URL } from "@/lib/config";
|
||||
import {
|
||||
formatServerHost,
|
||||
isServerConfigValid,
|
||||
resolveServerMode,
|
||||
resolveServerUrl,
|
||||
SERVER_MODE_OPTIONS,
|
||||
type ServerMode,
|
||||
} from "@/lib/server-mode";
|
||||
|
||||
type AuthServerPickerProps = {
|
||||
onReadyChange?: (ready: boolean) => void;
|
||||
};
|
||||
|
||||
function modeSummary(mode: ServerMode, selfHostedUrl: string) {
|
||||
if (mode === "official") return "Official";
|
||||
const host = formatServerHost(selfHostedUrl);
|
||||
return host || "Self-hosted";
|
||||
}
|
||||
|
||||
export function AuthServerPicker({ onReadyChange }: AuthServerPickerProps) {
|
||||
const { colors } = useAppTheme();
|
||||
const { apiUrl, setInstanceUrl } = useAccounts();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [mode, setMode] = useState<ServerMode>(() => resolveServerMode(apiUrl));
|
||||
const [selfHostedUrl, setSelfHostedUrl] = useState(() =>
|
||||
resolveServerMode(apiUrl) === "self-hosted" ? apiUrl : "",
|
||||
);
|
||||
const [urlError, setUrlError] = useState<string | null>(null);
|
||||
|
||||
const ready = isServerConfigValid(mode, selfHostedUrl);
|
||||
|
||||
useEffect(() => {
|
||||
onReadyChange?.(ready);
|
||||
}, [ready, onReadyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextMode = resolveServerMode(apiUrl);
|
||||
setMode(nextMode);
|
||||
if (nextMode === "self-hosted") {
|
||||
setSelfHostedUrl(apiUrl);
|
||||
}
|
||||
}, [apiUrl]);
|
||||
|
||||
async function applyMode(nextMode: ServerMode) {
|
||||
setMode(nextMode);
|
||||
setUrlError(null);
|
||||
|
||||
if (nextMode === "official") {
|
||||
try {
|
||||
await setInstanceUrl(DEFAULT_API_URL);
|
||||
setExpanded(false);
|
||||
} catch (err) {
|
||||
setUrlError(err instanceof Error ? err.message : "Could not set server");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setExpanded(true);
|
||||
const resolved = resolveServerUrl("self-hosted", selfHostedUrl);
|
||||
if (!resolved) return;
|
||||
|
||||
try {
|
||||
await setInstanceUrl(resolved);
|
||||
} catch (err) {
|
||||
setUrlError(err instanceof Error ? err.message : "Could not set server");
|
||||
}
|
||||
}
|
||||
|
||||
async function commitSelfHostedUrl() {
|
||||
const resolved = resolveServerUrl("self-hosted", selfHostedUrl);
|
||||
if (!resolved) {
|
||||
setUrlError("Enter a valid server URL (e.g. beenvoice.app or localhost:3000)");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = await setInstanceUrl(resolved);
|
||||
setSelfHostedUrl(saved);
|
||||
setUrlError(null);
|
||||
setExpanded(false);
|
||||
} catch (err) {
|
||||
setUrlError(err instanceof Error ? err.message : "Could not save server URL");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded }}
|
||||
onPress={() => setExpanded((open) => !open)}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => [styles.trigger, pressed && styles.pressed]}
|
||||
>
|
||||
<Text style={[styles.triggerText, { color: colors.mutedForeground }]}>
|
||||
Server ·{" "}
|
||||
<Text style={[styles.summary, { color: colors.foreground }]}>
|
||||
{modeSummary(mode, selfHostedUrl)}
|
||||
</Text>
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={expanded ? "chevron-up" : "chevron-down"}
|
||||
size={16}
|
||||
color={colors.mutedForeground}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{expanded ? (
|
||||
<View
|
||||
style={[
|
||||
styles.panel,
|
||||
{ backgroundColor: colors.cardGlass, borderColor: colors.borderGlass },
|
||||
]}
|
||||
>
|
||||
{SERVER_MODE_OPTIONS.map((option) => {
|
||||
const selected = option.value === mode;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.value}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected }}
|
||||
onPress={() => void applyMode(option.value)}
|
||||
style={({ pressed }) => [
|
||||
styles.option,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: selected ? colors.muted : "transparent",
|
||||
},
|
||||
pressed && styles.pressed,
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.optionLabel, { color: colors.foreground }]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{selected ? (
|
||||
<Ionicons name="checkmark" size={18} color={colors.primary} />
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
|
||||
{mode === "self-hosted" ? (
|
||||
<>
|
||||
<Input
|
||||
label="Server URL"
|
||||
value={selfHostedUrl}
|
||||
onChangeText={(value) => {
|
||||
setSelfHostedUrl(value);
|
||||
setUrlError(null);
|
||||
}}
|
||||
onBlur={() => void commitSelfHostedUrl()}
|
||||
onSubmitEditing={() => void commitSelfHostedUrl()}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
placeholder="beenvoice.app or localhost:3000"
|
||||
required
|
||||
error={urlError ?? undefined}
|
||||
/>
|
||||
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
|
||||
Use your Mac's LAN IP on a physical device.
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
trigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: spacing.xs,
|
||||
minHeight: 36,
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
triggerText: {
|
||||
fontSize: 13,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
summary: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 13,
|
||||
},
|
||||
panel: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 14,
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
option: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
minHeight: 44,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
},
|
||||
optionLabel: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 15,
|
||||
},
|
||||
hint: {
|
||||
fontSize: 12,
|
||||
fontFamily: fonts.body,
|
||||
lineHeight: 16,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user