diff options
Diffstat (limited to 'apps/mobile/components')
| -rw-r--r-- | apps/mobile/components/EmptyState.tsx | 53 | ||||
| -rw-r--r-- | apps/mobile/components/LoadingScreen.tsx | 55 | ||||
| -rw-r--r-- | apps/mobile/components/TaskListItem.tsx | 199 | ||||
| -rw-r--r-- | apps/mobile/components/TaskListSkeleton.tsx | 119 | ||||
| -rw-r--r-- | apps/mobile/components/TaskStatusBadge.tsx | 111 |
5 files changed, 537 insertions, 0 deletions
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 ( + <View style={styles.container}> + <Ionicons name={icon} size={64} color={colors.secondaryText} /> + <Text style={[styles.title, { color: colors.text }]}>{title}</Text> + {message && ( + <Text style={[styles.message, { color: colors.secondaryText }]}> + {message} + </Text> + )} + </View> + ); +} + +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 ( + <View style={[styles.container, { backgroundColor: colors.background }]}> + <View style={styles.content}> + <Text style={[styles.title, { color: colors.tint }]}>Makima</Text> + <ActivityIndicator + size="large" + color={colors.tint} + style={styles.spinner} + /> + <Text style={[styles.message, { color: colors.textSecondary }]}> + {message} + </Text> + </View> + </View> + ); +} + +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 ( + <TouchableOpacity + style={[ + styles.container, + { + backgroundColor: colors.card, + borderColor: colors.border, + }, + ]} + onPress={() => onPress(task)} + activeOpacity={0.7} + > + <View style={styles.leftSection}> + <TaskStatusBadge status={task.status} size="medium" /> + </View> + + <View style={styles.content}> + <View style={styles.header}> + <Text + style={[styles.name, { color: colors.text }]} + numberOfLines={1} + > + {task.name} + </Text> + {task.isSupervisor && ( + <View style={styles.supervisorBadge}> + <Ionicons name="star" size={12} color={colors.tint} /> + </View> + )} + </View> + + {task.contractName && ( + <Text + style={[styles.contractName, { color: colors.secondaryText }]} + numberOfLines={1} + > + {task.contractName} + </Text> + )} + + {task.progressSummary && ( + <Text + style={[styles.progressSummary, { color: colors.secondaryText }]} + numberOfLines={2} + > + {truncate(task.progressSummary, 100)} + </Text> + )} + + <View style={styles.footer}> + <Text style={[styles.time, { color: colors.secondaryText }]}> + {isRunning + ? `Started ${formatRelativeTime(task.updatedAt)}` + : isCompleted + ? `Completed ${formatRelativeTime(task.updatedAt)}` + : isFailed + ? `Failed ${formatRelativeTime(task.updatedAt)}` + : `Created ${formatRelativeTime(task.createdAt)}`} + </Text> + + {task.subtaskCount > 0 && ( + <View style={styles.subtaskBadge}> + <Ionicons + name="git-branch-outline" + size={12} + color={colors.secondaryText} + /> + <Text style={[styles.subtaskCount, { color: colors.secondaryText }]}> + {task.subtaskCount} + </Text> + </View> + )} + </View> + </View> + + <View style={styles.rightSection}> + <Ionicons + name="chevron-forward" + size={20} + color={colors.secondaryText} + /> + </View> + </TouchableOpacity> + ); +} + +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 ( + <View + style={[ + styles.row, + { + backgroundColor: colors.card, + borderColor: colors.border, + }, + ]} + > + <Animated.View + style={[ + styles.dot, + { backgroundColor: bgColor, opacity }, + ]} + /> + <View style={styles.content}> + <Animated.View + style={[ + styles.titleBar, + { backgroundColor: bgColor, opacity }, + ]} + /> + <Animated.View + style={[ + styles.subtitleBar, + { backgroundColor: bgColor, opacity }, + ]} + /> + </View> + </View> + ); +} + +export function TaskListSkeleton() { + return ( + <View style={styles.container}> + <SkeletonRow delay={0} /> + <SkeletonRow delay={100} /> + <SkeletonRow delay={200} /> + <SkeletonRow delay={300} /> + <SkeletonRow delay={400} /> + </View> + ); +} + +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<TaskStatus, string> = { + 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 ( + <View + style={[ + styles.badge, + { + backgroundColor: colors.bg, + paddingHorizontal, + paddingVertical, + }, + ]} + > + <View + style={[ + styles.dot, + { + backgroundColor: colors.dot, + width: dotSize, + height: dotSize, + }, + ]} + /> + <Text style={[styles.label, { color: colors.text, fontSize }]}> + {statusLabels[status]} + </Text> + </View> + ); + } + + return ( + <View + style={[ + styles.dot, + { + backgroundColor: colors.dot, + width: dotSize, + height: dotSize, + }, + ]} + /> + ); +} + +const styles = StyleSheet.create({ + badge: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 999, + gap: 6, + }, + dot: { + borderRadius: 999, + }, + label: { + fontWeight: '500', + }, +}); |
