summaryrefslogtreecommitdiff
path: root/apps/mobile/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/mobile/components')
-rw-r--r--apps/mobile/components/EmptyState.tsx53
-rw-r--r--apps/mobile/components/LoadingScreen.tsx55
-rw-r--r--apps/mobile/components/TaskListItem.tsx199
-rw-r--r--apps/mobile/components/TaskListSkeleton.tsx119
-rw-r--r--apps/mobile/components/TaskStatusBadge.tsx111
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',
+ },
+});