diff options
Diffstat (limited to 'lib/ui.tsx')
| -rw-r--r-- | lib/ui.tsx | 846 |
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, + }, +}); |