summaryrefslogtreecommitdiff
path: root/lib/ui.tsx
diff options
context:
space:
mode:
authorJustZvan <justzvan@justzvan.xyz>2026-02-06 13:22:33 +0100
committerJustZvan <justzvan@justzvan.xyz>2026-02-06 13:22:33 +0100
commit7eb8ccae48b0cc18a9dcaa9c3626a02df8e6d919 (patch)
tree57b7dd06ac9aa7053c671d916f7183e3b4fa9410 /lib/ui.tsx
feat: initial commit!
Diffstat (limited to 'lib/ui.tsx')
-rw-r--r--lib/ui.tsx846
1 files changed, 846 insertions, 0 deletions
diff --git a/lib/ui.tsx b/lib/ui.tsx
new file mode 100644
index 0000000..e31a10d
--- /dev/null
+++ b/lib/ui.tsx
@@ -0,0 +1,846 @@
+import { Ionicons } from "@expo/vector-icons";
+import React, { ReactNode } from "react";
+import {
+ ActivityIndicator,
+ Modal,
+ Pressable,
+ TextInput as RNTextInput,
+ ScrollView,
+ StyleProp,
+ StyleSheet,
+ Text,
+ TextStyle,
+ TouchableOpacity,
+ View,
+ ViewStyle,
+} from "react-native";
+import { Device } from "../api/types";
+import { colors } from "./theme";
+
+export function Screen({
+ children,
+ contentContainerStyle,
+}: {
+ children: ReactNode;
+ contentContainerStyle?: StyleProp<ViewStyle>;
+}) {
+ return (
+ <ScrollView
+ style={styles.screen}
+ contentContainerStyle={[styles.screenContent, contentContainerStyle]}
+ contentInsetAdjustmentBehavior="automatic"
+ >
+ {children}
+ </ScrollView>
+ );
+}
+
+export function Card({
+ children,
+ style,
+}: {
+ children: ReactNode;
+ style?: StyleProp<ViewStyle>;
+}) {
+ return <View style={[styles.card, style]}>{children}</View>;
+}
+
+export function H1({ children }: { children: ReactNode }) {
+ return <Text style={styles.h1}>{children}</Text>;
+}
+
+export function H2({ children }: { children: ReactNode }) {
+ return <Text style={styles.h2}>{children}</Text>;
+}
+
+export function Body({
+ children,
+ style,
+}: {
+ children: ReactNode;
+ style?: StyleProp<TextStyle>;
+}) {
+ return <Text style={[styles.body, style]}>{children}</Text>;
+}
+
+export function Muted({
+ children,
+ style,
+}: {
+ children: ReactNode;
+ style?: StyleProp<TextStyle>;
+}) {
+ return <Text style={[styles.muted, style]}>{children}</Text>;
+}
+
+export function Pill({
+ label,
+ tone = "neutral",
+}: {
+ label: string;
+ tone?: "neutral" | "good" | "attention";
+}) {
+ const backgroundColor =
+ tone === "good"
+ ? colors.surfaceVariant
+ : tone === "attention"
+ ? colors.primaryContainer
+ : colors.surfaceVariant;
+
+ const borderColor = tone === "attention" ? colors.primary : colors.outline;
+
+ const textColor = tone === "attention" ? colors.onPrimary : colors.onSurface;
+
+ return (
+ <View style={[styles.pill, { backgroundColor, borderColor }]}>
+ <Text style={[styles.pillText, { color: textColor }]}>{label}</Text>
+ </View>
+ );
+}
+
+export function Row({ left, right }: { left: ReactNode; right?: ReactNode }) {
+ return (
+ <View style={styles.row}>
+ <View style={styles.rowLeft}>{left}</View>
+ {right ? <View style={styles.rowRight}>{right}</View> : null}
+ </View>
+ );
+}
+
+export function Divider() {
+ return <View style={styles.divider} />;
+}
+
+export function ActionRow({
+ title,
+ subtitle,
+ onPress,
+ right,
+}: {
+ title: string;
+ subtitle?: string;
+ onPress?: () => void;
+ right?: ReactNode;
+}) {
+ return (
+ <Pressable
+ onPress={onPress}
+ disabled={!onPress}
+ style={({ pressed }) => [
+ styles.actionRow,
+ pressed && onPress ? styles.actionRowPressed : undefined,
+ ]}
+ >
+ <View style={{ flex: 1, gap: 4 }}>
+ <Text style={styles.actionTitle}>{title}</Text>
+ {subtitle ? (
+ <Text style={styles.actionSubtitle}>{subtitle}</Text>
+ ) : null}
+ </View>
+ {right ? (
+ <View style={{ marginLeft: 12, alignItems: "flex-end" }}>{right}</View>
+ ) : null}
+ </Pressable>
+ );
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Dialog / Modal Components
+// ─────────────────────────────────────────────────────────────────────────────
+
+export type DialogOption = {
+ label: string;
+ onPress: () => void;
+ destructive?: boolean;
+};
+
+/**
+ * A selection dialog that shows a list of options to choose from.
+ */
+export function SelectDialog({
+ visible,
+ title,
+ options,
+ onClose,
+}: {
+ visible: boolean;
+ title: string;
+ options: DialogOption[];
+ onClose: () => void;
+}) {
+ return (
+ <Modal
+ visible={visible}
+ transparent
+ animationType="fade"
+ onRequestClose={onClose}
+ >
+ <View style={styles.dialogOverlay}>
+ <View style={styles.dialogCard}>
+ <Text style={styles.dialogTitle}>{title}</Text>
+ {options.map((option, index) => (
+ <TouchableOpacity
+ key={index}
+ onPress={option.onPress}
+ style={styles.dialogOption}
+ >
+ <Text
+ style={[
+ styles.dialogOptionText,
+ option.destructive && styles.dialogDestructiveText,
+ ]}
+ >
+ {option.label}
+ </Text>
+ </TouchableOpacity>
+ ))}
+ <TouchableOpacity onPress={onClose} style={styles.dialogCancel}>
+ <Text style={styles.dialogCancelText}>Cancel</Text>
+ </TouchableOpacity>
+ </View>
+ </View>
+ </Modal>
+ );
+}
+
+/**
+ * A confirmation dialog with a message and OK/Cancel buttons.
+ */
+export function ConfirmDialog({
+ visible,
+ title,
+ message,
+ confirmLabel = "Okay",
+ cancelLabel = "Cancel",
+ onConfirm,
+ onCancel,
+ destructive = false,
+}: {
+ visible: boolean;
+ title: string;
+ message: string;
+ confirmLabel?: string;
+ cancelLabel?: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+ destructive?: boolean;
+}) {
+ return (
+ <Modal
+ visible={visible}
+ transparent
+ animationType="fade"
+ onRequestClose={onCancel}
+ >
+ <View style={styles.dialogOverlay}>
+ <View style={styles.dialogCard}>
+ <Text style={styles.dialogTitle}>{title}</Text>
+ <Text style={styles.dialogBody}>{message}</Text>
+ <View style={styles.dialogButtonRow}>
+ <TouchableOpacity
+ onPress={onCancel}
+ style={styles.dialogInlineButton}
+ >
+ <Text style={styles.dialogButtonText}>{cancelLabel}</Text>
+ </TouchableOpacity>
+ <TouchableOpacity
+ onPress={onConfirm}
+ style={styles.dialogInlineButton}
+ >
+ <Text
+ style={[
+ styles.dialogButtonText,
+ destructive && styles.dialogDestructiveText,
+ ]}
+ >
+ {confirmLabel}
+ </Text>
+ </TouchableOpacity>
+ </View>
+ </View>
+ </View>
+ </Modal>
+ );
+}
+
+/**
+ * A prompt dialog with an input field, message, and Cancel/Submit buttons.
+ */
+export function PromptDialog({
+ visible,
+ title,
+ message,
+ onClose,
+ onSubmit,
+ initialValue = "",
+}: {
+ visible: boolean;
+ title: string;
+ message: string;
+ onClose: () => void;
+ onSubmit: (value: string) => void;
+ initialValue?: string;
+}) {
+ const [value, setValue] = React.useState(initialValue);
+
+ React.useEffect(() => {
+ setValue(initialValue);
+ }, [initialValue, visible]);
+
+ return (
+ <Modal
+ visible={visible}
+ transparent={true}
+ animationType="fade"
+ onRequestClose={onClose}
+ >
+ <View style={styles.modalOverlay}>
+ <View style={styles.modalContent}>
+ <H2>{title}</H2>
+ <Muted>{message}</Muted>
+
+ <View style={{ height: 12 }} />
+
+ <TextInput value={value} onChangeText={setValue} />
+ <View style={styles.modalActions}>
+ <Button title="Cancel" onPress={onClose} variant="secondary" />
+ <Button title="Submit" onPress={() => onSubmit(value)} />
+ </View>
+ </View>
+ </View>
+ </Modal>
+ );
+}
+
+export function AlertDialog({
+ visible,
+ title,
+ message,
+ buttonLabel = "OK",
+ onClose,
+}: {
+ visible: boolean;
+ title: string;
+ message?: string;
+ buttonLabel?: string;
+ onClose: () => void;
+}) {
+ return (
+ <Modal
+ visible={visible}
+ transparent
+ animationType="fade"
+ onRequestClose={onClose}
+ >
+ <View style={styles.dialogOverlay}>
+ <View style={styles.dialogCard}>
+ <Text style={styles.dialogTitle}>{title}</Text>
+ {message && <Text style={styles.dialogBody}>{message}</Text>}
+ <View style={styles.dialogButtonRow}>
+ <TouchableOpacity
+ onPress={onClose}
+ style={styles.dialogInlineButton}
+ >
+ <Text style={styles.dialogButtonText}>{buttonLabel}</Text>
+ </TouchableOpacity>
+ </View>
+ </View>
+ </View>
+ </Modal>
+ );
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Form Components
+// ─────────────────────────────────────────────────────────────────────────────
+
+export function TextInput({
+ label,
+ value,
+ onChangeText,
+ placeholder,
+ secureTextEntry,
+ autoCapitalize = "none",
+ keyboardType = "default",
+ error,
+}: {
+ label?: string;
+ value: string;
+ onChangeText: (text: string) => void;
+ placeholder?: string;
+ secureTextEntry?: boolean;
+ autoCapitalize?: "none" | "sentences" | "words" | "characters";
+ keyboardType?: "default" | "email-address" | "numeric";
+ error?: string;
+}) {
+ return (
+ <View style={styles.inputContainer}>
+ {label && <Text style={styles.inputLabel}>{label}</Text>}
+ <RNTextInput
+ style={[styles.textInput, error && styles.textInputError]}
+ value={value}
+ onChangeText={onChangeText}
+ placeholder={placeholder}
+ placeholderTextColor={colors.onSurfaceVariant}
+ secureTextEntry={secureTextEntry}
+ autoCapitalize={autoCapitalize}
+ keyboardType={keyboardType}
+ />
+ {error && <Text style={styles.inputError}>{error}</Text>}
+ </View>
+ );
+}
+
+export function Button({
+ title,
+ onPress,
+ variant = "primary",
+ disabled,
+ loading,
+}: {
+ title: string;
+ onPress: () => void;
+ variant?: "primary" | "secondary" | "text";
+ disabled?: boolean;
+ loading?: boolean;
+}) {
+ const isPrimary = variant === "primary";
+ const isText = variant === "text";
+
+ return (
+ <TouchableOpacity
+ onPress={onPress}
+ disabled={disabled || loading}
+ style={[
+ styles.button,
+ isPrimary && styles.buttonPrimary,
+ !isPrimary && !isText && styles.buttonSecondary,
+ isText && styles.buttonText,
+ (disabled || loading) && styles.buttonDisabled,
+ ]}
+ >
+ {loading ? (
+ <ActivityIndicator
+ color={isPrimary ? colors.onPrimary : colors.primary}
+ size="small"
+ />
+ ) : (
+ <Text
+ style={[
+ styles.buttonLabel,
+ isPrimary && styles.buttonLabelPrimary,
+ !isPrimary && styles.buttonLabelSecondary,
+ ]}
+ >
+ {title}
+ </Text>
+ )}
+ </TouchableOpacity>
+ );
+}
+
+export function LoadingScreen() {
+ return (
+ <View style={styles.loadingScreen}>
+ <ActivityIndicator size="large" color={colors.primary} />
+ </View>
+ );
+}
+
+/**
+ * Device selector dropdown component for selecting a device.
+ */
+export function DeviceSelector({
+ devices,
+ selectedDevice,
+ onSelectDevice,
+ isLoading,
+}: {
+ devices: Device[];
+ selectedDevice: Device | null;
+ onSelectDevice: (device: Device) => void;
+ isLoading?: boolean;
+}) {
+ const [visible, setVisible] = React.useState(false);
+
+ if (isLoading) {
+ return (
+ <View style={styles.deviceSelectorContainer}>
+ <ActivityIndicator size="small" color={colors.onSurfaceVariant} />
+ </View>
+ );
+ }
+
+ if (devices.length === 0) {
+ return (
+ <View style={styles.deviceSelectorContainer}>
+ <Text style={styles.deviceSelectorText}>No devices linked</Text>
+ </View>
+ );
+ }
+
+ return (
+ <>
+ <Pressable
+ style={styles.deviceSelectorContainer}
+ onPress={() => setVisible(true)}
+ >
+ <View style={styles.deviceSelectorContent}>
+ <View
+ style={[
+ styles.deviceStatusDot,
+ {
+ backgroundColor:
+ selectedDevice?.status === "online"
+ ? "#4CAF50"
+ : colors.onSurfaceVariant,
+ },
+ ]}
+ />
+ <Text style={styles.deviceSelectorText} numberOfLines={1}>
+ {selectedDevice?.name || "Select device"}
+ </Text>
+ <Ionicons
+ name="chevron-down"
+ size={16}
+ color={colors.onSurfaceVariant}
+ />
+ </View>
+ </Pressable>
+
+ <Modal
+ visible={visible}
+ transparent
+ animationType="fade"
+ onRequestClose={() => setVisible(false)}
+ >
+ <View style={styles.dialogOverlay}>
+ <View style={styles.dialogCard}>
+ <Text style={styles.dialogTitle}>Select Device</Text>
+ {devices.map((device) => (
+ <TouchableOpacity
+ key={device.id}
+ onPress={() => {
+ onSelectDevice(device);
+ setVisible(false);
+ }}
+ style={[
+ styles.deviceOption,
+ selectedDevice?.id === device.id &&
+ styles.deviceOptionSelected,
+ ]}
+ >
+ <View style={styles.deviceOptionContent}>
+ <View
+ style={[
+ styles.deviceStatusDot,
+ {
+ backgroundColor:
+ device.status === "online"
+ ? "#4CAF50"
+ : colors.onSurfaceVariant,
+ },
+ ]}
+ />
+ <View style={{ flex: 1 }}>
+ <Text style={styles.deviceOptionName}>{device.name}</Text>
+ <Text style={styles.deviceOptionStatus}>
+ {device.status === "online" ? "Online" : "Offline"} •{" "}
+ {device.lastCheck}
+ </Text>
+ </View>
+ {selectedDevice?.id === device.id && (
+ <Ionicons
+ name="checkmark"
+ size={20}
+ color={colors.primary}
+ />
+ )}
+ </View>
+ </TouchableOpacity>
+ ))}
+ <TouchableOpacity
+ onPress={() => setVisible(false)}
+ style={styles.dialogCancel}
+ >
+ <Text style={styles.dialogCancelText}>Cancel</Text>
+ </TouchableOpacity>
+ </View>
+ </View>
+ </Modal>
+ </>
+ );
+}
+
+const styles = StyleSheet.create({
+ screen: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ screenContent: {
+ padding: 16,
+ gap: 12,
+ },
+ card: {
+ backgroundColor: colors.surface,
+ borderColor: colors.outline,
+ borderWidth: StyleSheet.hairlineWidth,
+ padding: 14,
+ gap: 10,
+ },
+ h1: {
+ color: colors.onBackground,
+ fontSize: 24,
+ fontWeight: "800",
+ },
+ h2: {
+ color: colors.onBackground,
+ fontSize: 16,
+ fontWeight: "700",
+ },
+ body: {
+ color: colors.onBackground,
+ fontSize: 14,
+ lineHeight: 20,
+ },
+ muted: {
+ color: colors.onSurfaceVariant,
+ fontSize: 13,
+ lineHeight: 18,
+ },
+ pill: {
+ alignSelf: "flex-start",
+ borderWidth: StyleSheet.hairlineWidth,
+ paddingHorizontal: 10,
+ paddingVertical: 6,
+ },
+ pillText: {
+ fontSize: 12,
+ fontWeight: "700",
+ letterSpacing: 0.2,
+ },
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: 12,
+ },
+ rowLeft: {
+ flex: 1,
+ },
+ rowRight: {
+ alignItems: "flex-end",
+ },
+ divider: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: colors.outline,
+ opacity: 0.7,
+ },
+ actionRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingVertical: 10,
+ gap: 12,
+ },
+ actionRowPressed: {
+ opacity: 0.85,
+ },
+ actionTitle: {
+ color: colors.onBackground,
+ fontSize: 14,
+ fontWeight: "700",
+ },
+ actionSubtitle: {
+ color: colors.onSurfaceVariant,
+ fontSize: 13,
+ lineHeight: 18,
+ },
+ // Dialog styles
+ dialogOverlay: {
+ flex: 1,
+ backgroundColor: "rgba(0,0,0,0.4)",
+ justifyContent: "center",
+ alignItems: "center",
+ padding: 24,
+ },
+ dialogCard: {
+ width: "100%",
+ backgroundColor: colors.surface,
+ padding: 16,
+ borderColor: colors.outline,
+ borderWidth: StyleSheet.hairlineWidth,
+ gap: 12,
+ },
+ dialogTitle: {
+ fontSize: 16,
+ fontWeight: "700",
+ color: colors.onBackground,
+ },
+ dialogBody: {
+ color: colors.onSurfaceVariant,
+ fontSize: 14,
+ marginBottom: 8,
+ },
+ dialogOption: {
+ paddingVertical: 12,
+ },
+ dialogOptionText: {
+ color: colors.onBackground,
+ fontSize: 15,
+ fontWeight: "700",
+ },
+ dialogDestructiveText: {
+ color: "#b00020",
+ },
+ dialogCancel: {
+ paddingVertical: 12,
+ alignItems: "center",
+ },
+ dialogCancelText: {
+ color: colors.onSurfaceVariant,
+ fontWeight: "700",
+ },
+ dialogButtonRow: {
+ flexDirection: "row",
+ justifyContent: "flex-end",
+ gap: 12,
+ },
+ dialogInlineButton: {
+ paddingVertical: 10,
+ paddingHorizontal: 12,
+ },
+ dialogButtonText: {
+ color: colors.onBackground,
+ fontSize: 15,
+ fontWeight: "700",
+ },
+ // Form styles
+ inputContainer: {
+ gap: 6,
+ },
+ inputLabel: {
+ color: colors.onBackground,
+ fontSize: 14,
+ fontWeight: "600",
+ },
+ textInput: {
+ backgroundColor: colors.surfaceVariant,
+ borderColor: colors.outline,
+ borderWidth: StyleSheet.hairlineWidth,
+ color: colors.onBackground,
+ fontSize: 16,
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ },
+ textInputError: {
+ borderColor: colors.primary,
+ },
+ inputError: {
+ color: colors.primary,
+ fontSize: 12,
+ },
+ button: {
+ alignItems: "center",
+ justifyContent: "center",
+ paddingVertical: 14,
+ paddingHorizontal: 24,
+ },
+ buttonPrimary: {
+ backgroundColor: colors.primary,
+ },
+ buttonSecondary: {
+ backgroundColor: "transparent",
+ borderColor: colors.outline,
+ borderWidth: StyleSheet.hairlineWidth,
+ },
+ buttonText: {
+ backgroundColor: "transparent",
+ },
+ buttonDisabled: {
+ opacity: 0.5,
+ },
+ buttonLabel: {
+ fontSize: 16,
+ fontWeight: "700",
+ },
+ buttonLabelPrimary: {
+ color: colors.onPrimary,
+ },
+ buttonLabelSecondary: {
+ color: colors.onBackground,
+ },
+ loadingScreen: {
+ flex: 1,
+ backgroundColor: colors.background,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ // Device Selector styles
+ deviceSelectorContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ backgroundColor: colors.surfaceVariant,
+ borderColor: colors.outline,
+ borderWidth: StyleSheet.hairlineWidth,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ marginBottom: 4,
+ },
+ deviceSelectorContent: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ flex: 1,
+ },
+ deviceSelectorText: {
+ flex: 1,
+ color: colors.onBackground,
+ fontSize: 14,
+ fontWeight: "600",
+ },
+ deviceStatusDot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ },
+ deviceOption: {
+ paddingVertical: 12,
+ paddingHorizontal: 4,
+ },
+ deviceOptionSelected: {
+ backgroundColor: colors.surfaceVariant,
+ },
+ deviceOptionContent: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 12,
+ },
+ deviceOptionName: {
+ color: colors.onBackground,
+ fontSize: 15,
+ fontWeight: "600",
+ },
+ deviceOptionStatus: {
+ color: colors.onSurfaceVariant,
+ fontSize: 12,
+ marginTop: 2,
+ },
+ modalOverlay: {
+ flex: 1,
+ backgroundColor: "rgba(0, 0, 0, 0.7)",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ modalContent: {
+ backgroundColor: colors.surface,
+ borderRadius: 20,
+ padding: 20,
+ width: "80%",
+ },
+ modalActions: {
+ flexDirection: "row",
+ justifyContent: "flex-end",
+ marginTop: 20,
+ gap: 10,
+ },
+});