From fcf9e70d54bd737d2dea848d25314120f37db503 Mon Sep 17 00:00:00 2001 From: soryu Date: Sun, 18 Jan 2026 02:58:27 +0000 Subject: [WIP] Heartbeat checkpoint - 2026-01-18 02:58:27 UTC --- apps/mobile/components/EmptyState.tsx | 53 ++++++++ apps/mobile/components/LoadingScreen.tsx | 55 ++++++++ apps/mobile/components/TaskListItem.tsx | 199 ++++++++++++++++++++++++++++ apps/mobile/components/TaskListSkeleton.tsx | 119 +++++++++++++++++ apps/mobile/components/TaskStatusBadge.tsx | 111 ++++++++++++++++ 5 files changed, 537 insertions(+) create mode 100644 apps/mobile/components/EmptyState.tsx create mode 100644 apps/mobile/components/LoadingScreen.tsx create mode 100644 apps/mobile/components/TaskListItem.tsx create mode 100644 apps/mobile/components/TaskListSkeleton.tsx create mode 100644 apps/mobile/components/TaskStatusBadge.tsx (limited to 'apps/mobile/components') diff --git a/apps/mobile/components/EmptyState.tsx b/apps/mobile/components/EmptyState.tsx new file mode 100644 index 0000000..0707f91 --- /dev/null +++ b/apps/mobile/components/EmptyState.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { View, Text, StyleSheet, useColorScheme } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../constants/Colors'; + +interface EmptyStateProps { + icon?: keyof typeof Ionicons.glyphMap; + title: string; + message?: string; +} + +export function EmptyState({ + icon = 'cube-outline', + title, + message, +}: EmptyStateProps) { + const colorScheme = useColorScheme() ?? 'light'; + const colors = Colors[colorScheme]; + + return ( + + + {title} + {message && ( + + {message} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 32, + paddingVertical: 64, + }, + title: { + fontSize: 18, + fontWeight: '600', + marginTop: 16, + textAlign: 'center', + }, + message: { + fontSize: 14, + marginTop: 8, + textAlign: 'center', + lineHeight: 20, + }, +}); diff --git a/apps/mobile/components/LoadingScreen.tsx b/apps/mobile/components/LoadingScreen.tsx new file mode 100644 index 0000000..c64c79d --- /dev/null +++ b/apps/mobile/components/LoadingScreen.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { View, Text, ActivityIndicator, StyleSheet, useColorScheme } from 'react-native'; +import { Colors } from '../constants/Colors'; + +interface LoadingScreenProps { + /** Optional message to display */ + message?: string; +} + +/** + * Loading screen component + * Displayed while checking authentication state + */ +export function LoadingScreen({ message = 'Loading...' }: LoadingScreenProps) { + const colorScheme = useColorScheme() ?? 'dark'; + const colors = Colors[colorScheme]; + + return ( + + + Makima + + + {message} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + content: { + alignItems: 'center', + }, + title: { + fontSize: 42, + fontWeight: 'bold', + marginBottom: 24, + }, + spinner: { + marginBottom: 16, + }, + message: { + fontSize: 14, + }, +}); diff --git a/apps/mobile/components/TaskListItem.tsx b/apps/mobile/components/TaskListItem.tsx new file mode 100644 index 0000000..c21f41a --- /dev/null +++ b/apps/mobile/components/TaskListItem.tsx @@ -0,0 +1,199 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + useColorScheme, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Colors } from '../constants/Colors'; +import { TaskStatusBadge } from './TaskStatusBadge'; +import type { TaskSummary } from '../lib/api'; + +interface TaskListItemProps { + task: TaskSummary; + onPress: (task: TaskSummary) => void; +} + +/** + * Format relative time from a date string + */ +function formatRelativeTime(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + + if (diffSec < 60) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHour < 24) return `${diffHour}h ago`; + if (diffDay < 7) return `${diffDay}d ago`; + + return date.toLocaleDateString(); +} + +/** + * Truncate text with ellipsis + */ +function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return text.slice(0, maxLength - 3) + '...'; +} + +export function TaskListItem({ task, onPress }: TaskListItemProps) { + const colorScheme = useColorScheme() ?? 'light'; + const colors = Colors[colorScheme]; + + const isRunning = ['running', 'initializing', 'starting'].includes(task.status); + const isCompleted = ['done', 'merged'].includes(task.status); + const isFailed = task.status === 'failed'; + + return ( + onPress(task)} + activeOpacity={0.7} + > + + + + + + + + {task.name} + + {task.isSupervisor && ( + + + + )} + + + {task.contractName && ( + + {task.contractName} + + )} + + {task.progressSummary && ( + + {truncate(task.progressSummary, 100)} + + )} + + + + {isRunning + ? `Started ${formatRelativeTime(task.updatedAt)}` + : isCompleted + ? `Completed ${formatRelativeTime(task.updatedAt)}` + : isFailed + ? `Failed ${formatRelativeTime(task.updatedAt)}` + : `Created ${formatRelativeTime(task.createdAt)}`} + + + {task.subtaskCount > 0 && ( + + + + {task.subtaskCount} + + + )} + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + minHeight: 64, + }, + leftSection: { + marginRight: 12, + alignItems: 'center', + justifyContent: 'center', + }, + content: { + flex: 1, + gap: 2, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + name: { + fontSize: 16, + fontWeight: '600', + flex: 1, + }, + supervisorBadge: { + padding: 2, + }, + contractName: { + fontSize: 13, + }, + progressSummary: { + fontSize: 13, + marginTop: 2, + }, + footer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: 4, + }, + time: { + fontSize: 12, + }, + subtaskBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + subtaskCount: { + fontSize: 12, + }, + rightSection: { + marginLeft: 8, + }, +}); diff --git a/apps/mobile/components/TaskListSkeleton.tsx b/apps/mobile/components/TaskListSkeleton.tsx new file mode 100644 index 0000000..60e747d --- /dev/null +++ b/apps/mobile/components/TaskListSkeleton.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useRef } from 'react'; +import { + View, + StyleSheet, + Animated, + useColorScheme, +} from 'react-native'; +import { Colors } from '../constants/Colors'; + +interface SkeletonRowProps { + delay?: number; +} + +function SkeletonRow({ delay = 0 }: SkeletonRowProps) { + const colorScheme = useColorScheme() ?? 'light'; + const colors = Colors[colorScheme]; + const opacity = useRef(new Animated.Value(0.3)).current; + + useEffect(() => { + const animation = Animated.loop( + Animated.sequence([ + Animated.timing(opacity, { + toValue: 0.7, + duration: 800, + useNativeDriver: true, + delay, + }), + Animated.timing(opacity, { + toValue: 0.3, + duration: 800, + useNativeDriver: true, + }), + ]) + ); + animation.start(); + return () => animation.stop(); + }, [opacity, delay]); + + const bgColor = colorScheme === 'dark' ? '#374151' : '#e5e7eb'; + + return ( + + + + + + + + ); +} + +export function TaskListSkeleton() { + return ( + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 16, + paddingHorizontal: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + minHeight: 64, + }, + dot: { + width: 10, + height: 10, + borderRadius: 5, + marginRight: 12, + }, + content: { + flex: 1, + gap: 8, + }, + titleBar: { + height: 16, + width: '70%', + borderRadius: 4, + }, + subtitleBar: { + height: 12, + width: '40%', + borderRadius: 4, + }, +}); diff --git a/apps/mobile/components/TaskStatusBadge.tsx b/apps/mobile/components/TaskStatusBadge.tsx new file mode 100644 index 0000000..a1ba9cb --- /dev/null +++ b/apps/mobile/components/TaskStatusBadge.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { TaskStatusColors } from '../constants/Colors'; +import type { TaskStatus } from '../lib/api'; + +interface TaskStatusBadgeProps { + status: TaskStatus; + showLabel?: boolean; + size?: 'small' | 'medium' | 'large'; +} + +const statusLabels: Record = { + pending: 'Pending', + initializing: 'Initializing', + starting: 'Starting', + running: 'Running', + paused: 'Paused', + blocked: 'Blocked', + done: 'Done', + failed: 'Failed', + merged: 'Merged', +}; + +export function TaskStatusBadge({ + status, + showLabel = false, + size = 'medium', +}: TaskStatusBadgeProps) { + const colors = TaskStatusColors[status] || TaskStatusColors.pending; + + const dotSize = { + small: 8, + medium: 10, + large: 12, + }[size]; + + const fontSize = { + small: 10, + medium: 12, + large: 14, + }[size]; + + const paddingHorizontal = { + small: 6, + medium: 8, + large: 10, + }[size]; + + const paddingVertical = { + small: 2, + medium: 4, + large: 6, + }[size]; + + if (showLabel) { + return ( + + + + {statusLabels[status]} + + + ); + } + + return ( + + ); +} + +const styles = StyleSheet.create({ + badge: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 999, + gap: 6, + }, + dot: { + borderRadius: 999, + }, + label: { + fontWeight: '500', + }, +}); -- cgit v1.2.3