From 7eb8ccae48b0cc18a9dcaa9c3626a02df8e6d919 Mon Sep 17 00:00:00 2001
From: JustZvan <justzvan@justzvan.xyz>
Date: Fri, 6 Feb 2026 13:22:33 +0100
Subject: feat: initial commit!

---
 lib/ui.tsx | 846 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 846 insertions(+)
 create mode 100644 lib/ui.tsx

(limited to 'lib/ui.tsx')

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,
+  },
+});
-- 
cgit v1.2.3

