mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 18:06:24 +00:00
Implement Handler mobile app (React Native + Expo iOS)
Build the committed 2a interactive prototype from Handler Mobile.dc.html as a real Expo app: six wired screens (Fleet, Agent detail, Answer, Spawn, Log, Settings) with the exact state logic ported from the design's renderVals(). - Port Leeworks tokens (colors light/dark, typography, spacing, radii, shadows) to typed RN values in src/theme. - Reimplement the design-system components used by the screens (Button, Badge, Icon, Switch, Select, TextField, segmented control, chip, tab bar). - Shared store mirrors the prototype's single-screen navigation and the Waiting -> Running flip when agt-7a1d is answered. - Real OS status bar / home indicator (safe-area insets) replace the mock phone chrome; dark mode follows system appearance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdF2hHVQ4UUBFPtpeMQ81o
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { fonts, radius } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import type { BadgeTone } from "../state/AppState";
|
||||
|
||||
/** Leeworks Badge, ported from components/display/Badge.jsx. */
|
||||
export function Badge({ tone, children }: { tone: BadgeTone; children: string }) {
|
||||
const { colors } = useTheme();
|
||||
const tones: Record<BadgeTone, { bg: string; fg: string }> = {
|
||||
neutral: { bg: colors.ink1, fg: colors.ink7 },
|
||||
positive: { bg: colors.positiveTint, fg: colors.positive },
|
||||
warning: { bg: colors.warningTint, fg: colors.warning },
|
||||
danger: { bg: colors.dangerTint, fg: colors.danger },
|
||||
};
|
||||
const t = tones[tone];
|
||||
return (
|
||||
<View style={[styles.badge, { backgroundColor: t.bg }]}>
|
||||
<Text style={[styles.text, { color: t.fg }]}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
badge: {
|
||||
alignSelf: "flex-start",
|
||||
paddingVertical: 3,
|
||||
paddingHorizontal: 10,
|
||||
borderRadius: radius.pill,
|
||||
},
|
||||
text: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { fonts, radius } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
|
||||
/**
|
||||
* Leeworks Button, ported from components/forms/Button.jsx. Hover collapses
|
||||
* into press on touch; the primary/secondary/danger press tints are kept.
|
||||
*/
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
interface ButtonProps {
|
||||
children: string;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
onPress?: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const HEIGHT: Record<ButtonSize, number> = { sm: 32, md: 40, lg: 48 };
|
||||
const PAD_X: Record<ButtonSize, number> = { sm: 12, md: 16, lg: 20 };
|
||||
const FONT: Record<ButtonSize, number> = { sm: 13, md: 14, lg: 15 };
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
onPress,
|
||||
style,
|
||||
}: ButtonProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => {
|
||||
const base: ViewStyle = {
|
||||
height: HEIGHT[size],
|
||||
paddingHorizontal: PAD_X[size],
|
||||
borderRadius: radius.md,
|
||||
};
|
||||
const byVariant: Record<ButtonVariant, ViewStyle> = {
|
||||
primary: {
|
||||
backgroundColor: pressed
|
||||
? colors.interactivePress
|
||||
: colors.interactive,
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: pressed ? "rgba(20,20,19,0.05)" : colors.surfaceCard,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderDefault,
|
||||
},
|
||||
ghost: {
|
||||
backgroundColor: pressed ? "rgba(20,20,19,0.05)" : "transparent",
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: pressed ? "#8c2f25" : colors.danger,
|
||||
},
|
||||
};
|
||||
return [styles.base, base, byVariant[variant], style];
|
||||
}}
|
||||
>
|
||||
<View style={styles.inner}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.label,
|
||||
{
|
||||
fontSize: FONT[size],
|
||||
color:
|
||||
variant === "primary"
|
||||
? colors.textInverse
|
||||
: variant === "danger"
|
||||
? "#ffffff"
|
||||
: variant === "secondary"
|
||||
? colors.textHeading
|
||||
: colors.ink9,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
inner: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
},
|
||||
label: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import { Pressable, StyleSheet, Text } from "react-native";
|
||||
import { fonts, radius } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
|
||||
/**
|
||||
* Pill chip used for both the answer quick-replies and the log filters.
|
||||
* Ported from the `chip()` style helper in the DC script.
|
||||
*/
|
||||
export function Chip({
|
||||
label,
|
||||
selected,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.chip,
|
||||
{
|
||||
backgroundColor: selected ? colors.interactive : colors.surfacePage,
|
||||
borderColor: selected ? colors.borderStrong : colors.borderDefault,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
{ color: selected ? "#ffffff" : colors.textBody },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
chip: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: radius.pill,
|
||||
borderWidth: 1,
|
||||
},
|
||||
label: {
|
||||
fontFamily: fonts.bodyMedium,
|
||||
fontSize: 13,
|
||||
lineHeight: 13,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
import Svg, { Circle, Path } from "react-native-svg";
|
||||
|
||||
/**
|
||||
* Leeworks Icon — the Lucide subset used by the Handler screens, ported from
|
||||
* components/display/Icon.jsx. Monochrome, 1.75px stroke, `currentColor`
|
||||
* becomes the required `color` prop since RN SVG doesn't inherit it.
|
||||
*/
|
||||
|
||||
export type IconName =
|
||||
| "chevronRight"
|
||||
| "chevronDown"
|
||||
| "home"
|
||||
| "file"
|
||||
| "settings"
|
||||
| "x";
|
||||
|
||||
const paths: Record<IconName, React.ReactNode> = {
|
||||
chevronRight: <Path d="m9 18 6-6-6-6" />,
|
||||
chevronDown: <Path d="m6 9 6 6 6-6" />,
|
||||
home: (
|
||||
<>
|
||||
<Path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<Path d="M9 22V12h6v10" />
|
||||
</>
|
||||
),
|
||||
file: (
|
||||
<>
|
||||
<Path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<Path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</>
|
||||
),
|
||||
settings: (
|
||||
<>
|
||||
<Path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||
<Circle cx="12" cy="12" r="3" />
|
||||
</>
|
||||
),
|
||||
x: <Path d="M18 6 6 18M6 6l12 12" />,
|
||||
};
|
||||
|
||||
interface IconProps {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
color: string;
|
||||
strokeWidth?: number;
|
||||
/** Degrees; the detail/answer back arrows reuse chevronRight rotated 180. */
|
||||
rotate?: number;
|
||||
}
|
||||
|
||||
export function Icon({
|
||||
name,
|
||||
size = 18,
|
||||
color,
|
||||
strokeWidth = 1.75,
|
||||
rotate,
|
||||
}: IconProps) {
|
||||
return (
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={rotate ? { transform: [{ rotate: `${rotate}deg` }] } : undefined}
|
||||
>
|
||||
{paths[name]}
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { fonts } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import { Badge } from "./Badge";
|
||||
import { Icon } from "./Icon";
|
||||
import { Mono } from "./primitives";
|
||||
import type { BadgeTone } from "../state/AppState";
|
||||
|
||||
/**
|
||||
* Header for pushed screens (detail / answer / spawn): a leading nav control
|
||||
* plus either an agent id + status badge, or a plain title.
|
||||
*/
|
||||
export function PageHeader({
|
||||
leading,
|
||||
onLeadingPress,
|
||||
agentId,
|
||||
badge,
|
||||
title,
|
||||
}: {
|
||||
leading: "back" | "close";
|
||||
onLeadingPress: () => void;
|
||||
agentId?: string;
|
||||
badge?: { tone: BadgeTone; label: string };
|
||||
title?: string;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Pressable
|
||||
onPress={onLeadingPress}
|
||||
hitSlop={8}
|
||||
style={styles.control}
|
||||
>
|
||||
<Icon
|
||||
name={leading === "back" ? "chevronRight" : "x"}
|
||||
size={20}
|
||||
color={colors.textHeading}
|
||||
rotate={leading === "back" ? 180 : undefined}
|
||||
/>
|
||||
</Pressable>
|
||||
{agentId ? (
|
||||
<Mono style={[styles.agentId, { color: colors.textMuted }]}>
|
||||
{agentId}
|
||||
</Mono>
|
||||
) : (
|
||||
<Text style={[styles.title, { color: colors.textHeading }]}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{badge ? <Badge tone={badge.tone}>{badge.label}</Badge> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
control: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
marginLeft: -8,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
agentId: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 13,
|
||||
lineHeight: 16,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { fonts, radius, shadows } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
|
||||
/**
|
||||
* Segmented control (the Checkmark / Log toggle on agent detail).
|
||||
* Ported from the `seg()` style helper in the DC script.
|
||||
*/
|
||||
export function SegmentedControl<T extends string>({
|
||||
segments,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
segments: { value: T; label: string }[];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View style={[styles.track, { backgroundColor: colors.surfaceSunken }]}>
|
||||
{segments.map((s) => {
|
||||
const on = s.value === value;
|
||||
return (
|
||||
<Pressable
|
||||
key={s.value}
|
||||
onPress={() => onChange(s.value)}
|
||||
style={[
|
||||
styles.segment,
|
||||
on && {
|
||||
backgroundColor: colors.surfacePage,
|
||||
...shadows.card,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
{ color: on ? colors.textHeading : colors.textMuted },
|
||||
]}
|
||||
>
|
||||
{s.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
track: {
|
||||
flexDirection: "row",
|
||||
borderRadius: radius.md,
|
||||
padding: 3,
|
||||
},
|
||||
segment: {
|
||||
flex: 1,
|
||||
paddingVertical: 8,
|
||||
borderRadius: radius.sm,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
label: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 13,
|
||||
lineHeight: 13,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { fonts, radius, shadows, text } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
/**
|
||||
* Leeworks Select, ported from components/forms/Select.jsx. Native <select>
|
||||
* has no cross-platform styling in RN, so the field opens a bottom sheet of
|
||||
* options — same visual field (value + chevron), real picking behavior.
|
||||
*/
|
||||
export function Select({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
options: string[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<View style={{ gap: 6 }}>
|
||||
<Text style={[styles.label, { color: colors.textHeading }]}>{label}</Text>
|
||||
<Pressable
|
||||
onPress={() => setOpen(true)}
|
||||
style={[
|
||||
styles.field,
|
||||
{ backgroundColor: colors.surfaceCard, borderColor: colors.borderDefault },
|
||||
]}
|
||||
>
|
||||
<Text style={[text.body, { color: colors.textHeading, flex: 1 }]}>
|
||||
{value}
|
||||
</Text>
|
||||
<Icon name="chevronDown" size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
|
||||
<Modal visible={open} transparent animationType="fade" onRequestClose={() => setOpen(false)}>
|
||||
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ backgroundColor: colors.surfaceCard, borderColor: colors.borderSubtle },
|
||||
shadows.raised,
|
||||
]}
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const on = opt === value;
|
||||
return (
|
||||
<Pressable
|
||||
key={opt}
|
||||
onPress={() => {
|
||||
onChange(opt);
|
||||
setOpen(false);
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
styles.option,
|
||||
pressed && { backgroundColor: colors.surfaceSunken },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
text.body,
|
||||
{
|
||||
color: colors.textHeading,
|
||||
fontFamily: on ? fonts.bodySemiBold : fonts.bodyRegular,
|
||||
flex: 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{opt}
|
||||
</Text>
|
||||
{on && <Icon name="chevronRight" size={16} color={colors.textMuted} />}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
label: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 13,
|
||||
lineHeight: 16,
|
||||
},
|
||||
field: {
|
||||
height: 48,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderRadius: radius.md,
|
||||
paddingHorizontal: 12,
|
||||
gap: 8,
|
||||
},
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(20,20,19,0.4)",
|
||||
justifyContent: "flex-end",
|
||||
padding: 20,
|
||||
},
|
||||
sheet: {
|
||||
borderWidth: 1,
|
||||
borderRadius: radius.xl,
|
||||
overflow: "hidden",
|
||||
marginBottom: 24,
|
||||
},
|
||||
option: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
minHeight: 44,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Animated, Pressable, StyleSheet } from "react-native";
|
||||
import { radius } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
|
||||
/**
|
||||
* Leeworks Switch, ported from components/forms/Switch.jsx.
|
||||
* 36×22 track, 18px thumb, 14px travel, 180ms ease.
|
||||
*/
|
||||
export function Switch({
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
value: boolean;
|
||||
onValueChange: (v: boolean) => void;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
const anim = useRef(new Animated.Value(value ? 1 : 0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(anim, {
|
||||
toValue: value ? 1 : 0,
|
||||
duration: 180,
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
}, [value, anim]);
|
||||
|
||||
const trackColor = anim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [colors.ink3, colors.interactive],
|
||||
});
|
||||
const translateX = anim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, 14],
|
||||
});
|
||||
|
||||
return (
|
||||
<Pressable onPress={() => onValueChange(!value)} hitSlop={8}>
|
||||
<Animated.View style={[styles.track, { backgroundColor: trackColor }]}>
|
||||
<Animated.View
|
||||
style={[styles.thumb, { transform: [{ translateX }] }]}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
track: {
|
||||
width: 36,
|
||||
height: 22,
|
||||
borderRadius: radius.pill,
|
||||
padding: 2,
|
||||
},
|
||||
thumb: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
backgroundColor: "#ffffff",
|
||||
shadowColor: "#141413",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { fonts } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
import { useAppState, type Screen } from "../state/AppState";
|
||||
|
||||
/**
|
||||
* Bottom tab bar shown on the three primary screens (Fleet / Log / Settings).
|
||||
* The design's fixed 24px bottom inset is replaced by the real home-indicator
|
||||
* safe-area inset.
|
||||
*/
|
||||
|
||||
const TABS: { key: Screen; label: string; icon: IconName }[] = [
|
||||
{ key: "fleet", label: "Fleet", icon: "home" },
|
||||
{ key: "log", label: "Log", icon: "file" },
|
||||
{ key: "settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
||||
export function TabBar({ active }: { active: Screen }) {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { go } = useAppState();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.bar,
|
||||
{
|
||||
backgroundColor: colors.surfacePage,
|
||||
borderTopColor: colors.borderSubtle,
|
||||
paddingBottom: Math.max(insets.bottom, 12),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{TABS.map((t) => {
|
||||
const on = t.key === active;
|
||||
const tint = on ? colors.textHeading : colors.ink4;
|
||||
return (
|
||||
<Pressable
|
||||
key={t.key}
|
||||
style={styles.tab}
|
||||
onPress={() => go(t.key)}
|
||||
>
|
||||
<Icon
|
||||
name={t.icon}
|
||||
size={22}
|
||||
color={tint}
|
||||
strokeWidth={on ? 2 : 1.75}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
{
|
||||
color: tint,
|
||||
fontFamily: on ? fonts.bodySemiBold : fonts.bodyMedium,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{t.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bar: {
|
||||
flexDirection: "row",
|
||||
borderTopWidth: 1,
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
lineHeight: 14,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
View,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { fonts, radius } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
|
||||
/**
|
||||
* Text field covering both the Leeworks single-line Input (answer reply) and
|
||||
* the multiline task textarea on the spawn screen. Focus swaps the border to
|
||||
* `--border-strong` per the Input component.
|
||||
*/
|
||||
export function TextField({
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
multiline = false,
|
||||
height = 48,
|
||||
style,
|
||||
}: {
|
||||
value: string;
|
||||
onChangeText: (t: string) => void;
|
||||
placeholder?: string;
|
||||
multiline?: boolean;
|
||||
/** For single-line this is the control height; for multiline, the box height. */
|
||||
height?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
const [focus, setFocus] = useState(false);
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.wrap,
|
||||
{
|
||||
height,
|
||||
backgroundColor: colors.surfaceCard,
|
||||
borderColor: focus ? colors.borderStrong : colors.borderDefault,
|
||||
alignItems: multiline ? "stretch" : "center",
|
||||
paddingVertical: multiline ? 12 : 0,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
multiline={multiline}
|
||||
onFocus={() => setFocus(true)}
|
||||
onBlur={() => setFocus(false)}
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
color: colors.textHeading,
|
||||
textAlignVertical: multiline ? "top" : "center",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: {
|
||||
flexDirection: "row",
|
||||
borderWidth: 1,
|
||||
borderRadius: radius.md,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
alignSelf: "stretch",
|
||||
fontFamily: fonts.bodyRegular,
|
||||
fontSize: 15,
|
||||
padding: 0,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { text } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import { Switch } from "./Switch";
|
||||
|
||||
/**
|
||||
* A labelled settings row with a trailing Switch — used for the spawn options
|
||||
* and the notification settings. Matches the `min-height:44px` tap target.
|
||||
*/
|
||||
export function ToggleRow({
|
||||
title,
|
||||
subtitle,
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
value: boolean;
|
||||
onValueChange: (v: boolean) => void;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1, paddingRight: 12 }}>
|
||||
<Text style={[text.label, { color: colors.textHeading }]}>{title}</Text>
|
||||
<Text style={[text.caption, { color: colors.textMuted, marginTop: 2 }]}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch value={value} onValueChange={onValueChange} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
minHeight: 44,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { fonts, radius, shadows, text } from "../theme/tokens";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
|
||||
/** White card: 1px subtle border, 10px radius, whisper-quiet shadow. */
|
||||
export function Card({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
backgroundColor: colors.surfaceCard,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radius.lg,
|
||||
},
|
||||
shadows.card,
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** Uppercase, letter-spaced overline label (Outfit 600, 11px). */
|
||||
export function SectionLabel({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children: string;
|
||||
style?: StyleProp<TextStyle>;
|
||||
}) {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<Text style={[text.overline, { color: colors.textMuted }, style]}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/** Hairline divider used between card rows. */
|
||||
export function Divider() {
|
||||
const { colors } = useTheme();
|
||||
return <View style={{ height: 1, backgroundColor: colors.borderSubtle }} />;
|
||||
}
|
||||
|
||||
/** 8px status dot. */
|
||||
export function StatusDot({ color, size = 8 }: { color: string; size?: number }) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Monospace text helper (Spline Sans Mono). */
|
||||
export function Mono({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
style?: StyleProp<TextStyle>;
|
||||
}) {
|
||||
return <Text style={[monoStyles.mono, style]}>{children}</Text>;
|
||||
}
|
||||
|
||||
const monoStyles = StyleSheet.create({
|
||||
mono: { fontFamily: fonts.monoRegular },
|
||||
});
|
||||
Reference in New Issue
Block a user