Files
soconnor 06bc91ac13 Redesign mobile time clock, add shortcuts, and improve account management.
Add iOS Shortcuts/Siri intents, local send-reminder notifications, stable
client picker with last-client defaults, account refresh/remove, and softer
session handling on unauthorized API responses.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:06:17 -04:00

339 lines
10 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import {
ActivityIndicator,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { startAdditionalAccountSignIn } from "@/lib/add-account";
import { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatServerHost } from "@/lib/server-mode";
function initials(name: string, email: string) {
const source = name.trim() || email.trim();
const parts = source.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0]![0] ?? ""}${parts[1]![0] ?? ""}`.toUpperCase();
}
return (source[0] ?? "?").toUpperCase();
}
function displayName(name: string, email: string) {
const trimmed = name.trim();
if (trimmed) return trimmed.split(/\s+/)[0] ?? trimmed;
return email.split("@")[0] ?? email;
}
/** Header control to switch signed-in accounts or add another. */
export function AccountSwitcher() {
const { colors } = useAppTheme();
const authClient = useAuthClient();
const { data: session } = useSession();
const {
accounts,
activeAccount,
activeAccountId,
switchAccount,
removeAccount,
refreshAccounts,
clearActiveAccount,
} = useAccounts();
const [open, setOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const label = displayName(
activeAccount?.name ?? session?.user.name ?? "",
activeAccount?.email ?? session?.user.email ?? "",
);
const avatar = initials(
activeAccount?.name ?? session?.user.name ?? "",
activeAccount?.email ?? session?.user.email ?? "",
);
async function handleAddAccount() {
setOpen(false);
await startAdditionalAccountSignIn(clearActiveAccount);
}
async function handleSwitch(accountId: string) {
if (accountId === activeAccountId) {
setOpen(false);
return;
}
setOpen(false);
await switchAccount(accountId);
}
async function handleRefresh() {
setRefreshing(true);
try {
await refreshAccounts();
} finally {
setRefreshing(false);
}
}
function handleRemove(accountId: string, label: string) {
confirmRemoveAccount(
label,
() => removeAccount(accountId),
async (result) => {
if (result.remainingCount === 0) {
setOpen(false);
}
await finishAccountRemoval({
result,
clearActiveAccount,
signOut: () => authClient.signOut(),
});
},
);
}
return (
<>
<Pressable
accessibilityRole="button"
accessibilityLabel="Switch account"
hitSlop={8}
onPress={() => setOpen(true)}
style={styles.hit}
>
<View style={[styles.row, { backgroundColor: colors.muted }]}>
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
<Text style={[styles.avatarText, { color: colors.primaryForeground }]}>
{avatar}
</Text>
</View>
<Text
style={[styles.name, { color: colors.foreground }]}
numberOfLines={1}
>
{label}
</Text>
<Ionicons name="chevron-down" size={14} color={colors.mutedForeground} />
</View>
</Pressable>
<Modal animationType="fade" onRequestClose={() => setOpen(false)} transparent visible={open}>
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
<Pressable
style={[styles.sheet, { backgroundColor: colors.background }]}
onPress={(event) => event.stopPropagation()}
>
<View style={[styles.sheetHeader, { borderBottomColor: colors.border }]}>
<Text style={[styles.sheetTitle, { color: colors.foreground }]}>Accounts</Text>
<View style={styles.sheetActions}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Refresh accounts"
disabled={refreshing}
hitSlop={8}
onPress={() => void handleRefresh()}
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
>
{refreshing ? (
<ActivityIndicator color={colors.primary} size="small" />
) : (
<Ionicons name="refresh" size={20} color={colors.primary} />
)}
</Pressable>
<Pressable accessibilityRole="button" onPress={() => setOpen(false)}>
<Text style={[styles.done, { color: colors.primary }]}>Done</Text>
</Pressable>
</View>
</View>
<ScrollView keyboardShouldPersistTaps="handled">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
return (
<Pressable
key={account.id}
accessibilityRole="button"
onPress={() => void handleSwitch(account.id)}
style={({ pressed }) => [
styles.accountRow,
{
borderBottomColor: colors.border,
backgroundColor: isActive ? colors.muted : "transparent",
},
pressed && styles.pressed,
]}
>
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
<Text style={[styles.avatarText, { color: colors.primaryForeground }]}>
{initials(account.name, account.email)}
</Text>
</View>
<View style={styles.accountMeta}>
<Text style={[styles.accountName, { color: colors.foreground }]}>
{account.name || account.email}
</Text>
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
{account.email}
</Text>
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
{formatServerHost(account.instanceUrl)}
</Text>
</View>
<View style={styles.accountActions}>
{isActive ? (
<Ionicons name="checkmark" size={18} color={colors.primary} />
) : null}
<Pressable
accessibilityRole="button"
accessibilityLabel={`Remove ${account.name || account.email}`}
hitSlop={8}
onPress={() =>
handleRemove(account.id, account.name || account.email)
}
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
>
<Ionicons name="trash-outline" size={18} color={colors.destructive} />
</Pressable>
</View>
</Pressable>
);
})}
<Pressable
accessibilityRole="button"
onPress={() => void handleAddAccount()}
style={({ pressed }) => [
styles.addRow,
{ borderTopColor: colors.border },
pressed && styles.pressed,
]}
>
<Ionicons name="add-circle-outline" size={22} color={colors.primary} />
<Text style={[styles.addLabel, { color: colors.primary }]}>Add account</Text>
</Pressable>
</ScrollView>
</Pressable>
</Pressable>
</Modal>
</>
);
}
const styles = StyleSheet.create({
hit: {
flexShrink: 1,
maxWidth: "58%",
},
row: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingLeft: 4,
paddingRight: 8,
minHeight: 32,
borderRadius: radii.pill,
},
avatar: {
width: 24,
height: 24,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
},
avatarText: {
fontFamily: fonts.bodySemiBold,
fontSize: 11,
},
name: {
flexShrink: 1,
fontFamily: fonts.bodyMedium,
fontSize: 14,
lineHeight: 18,
},
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.45)",
},
sheet: {
borderTopLeftRadius: radii.xl,
borderTopRightRadius: radii.xl,
maxHeight: "70%",
},
sheetHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: 1,
},
sheetTitle: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
sheetActions: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
},
iconButton: {
alignItems: "center",
justifyContent: "center",
minWidth: 28,
minHeight: 28,
},
done: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
accountRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: StyleSheet.hairlineWidth,
},
accountMeta: {
flex: 1,
gap: 2,
},
accountActions: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
accountName: {
fontFamily: fonts.bodyMedium,
fontSize: 15,
},
accountSub: {
fontFamily: fonts.body,
fontSize: 12,
lineHeight: 16,
},
addRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.sm,
paddingVertical: spacing.lg,
borderTopWidth: StyleSheet.hairlineWidth,
},
addLabel: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
pressed: {
opacity: 0.75,
},
});