diff options
| author | soryu <soryu@soryu.co> | 2026-01-18 17:44:50 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-18 17:44:50 +0000 |
| commit | 869f21ee2efaefed6a5aa4fbd417c25df8dec02a (patch) | |
| tree | 2a90820ac817173e5b7154e0ba5e4f5d095f9613 /apps/mobile/lib | |
| parent | 219bca168508e1ea5e91e8a9ce98338afeddfbd2 (diff) | |
| download | soryu-869f21ee2efaefed6a5aa4fbd417c25df8dec02a.tar.gz soryu-869f21ee2efaefed6a5aa4fbd417c25df8dec02a.zip | |
Add React Native mobile app for Makima (#3)
* [WIP] Heartbeat checkpoint - 2026-01-18 02:58:27 UTC
* feat(mobile): complete mobile app integration and verification
- Add ThemeColors type export to Colors.ts for type safety
- Export SUPABASE_URL from supabase.ts and use environment variables
- Update .env.example with correct default URLs
- Add comprehensive README.md with setup instructions
Verified:
- TypeScript compiles without errors
- App exports successfully for iOS and Android
- All screens accessible (login, dashboard, tasks, settings, task detail)
- Auth flow working with Zustand store and Supabase
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Task completion checkpoint
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'apps/mobile/lib')
| -rw-r--r-- | apps/mobile/lib/api.ts | 289 | ||||
| -rw-r--r-- | apps/mobile/lib/auth.ts | 210 | ||||
| -rw-r--r-- | apps/mobile/lib/config.ts | 89 | ||||
| -rw-r--r-- | apps/mobile/lib/index.ts | 18 | ||||
| -rw-r--r-- | apps/mobile/lib/supabase.ts | 28 |
5 files changed, 634 insertions, 0 deletions
diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts new file mode 100644 index 0000000..ec848b6 --- /dev/null +++ b/apps/mobile/lib/api.ts @@ -0,0 +1,289 @@ +import { supabase } from './supabase'; + +// ============================================================================= +// API Configuration +// ============================================================================= + +const API_CONFIG = { + local: { + http: 'http://localhost:8080', + ws: 'ws://localhost:8080', + }, + production: { + http: 'https://api.makima.jp', + ws: 'wss://api.makima.jp', + }, +} as const; + +type Environment = 'local' | 'production'; + +// Detect environment based on __DEV__ flag +const env: Environment = __DEV__ ? 'local' : 'production'; + +export const API_BASE = API_CONFIG[env].http; +export const WS_BASE = API_CONFIG[env].ws; + +export function getEnvironment(): Environment { + return env; +} + +// ============================================================================= +// Authentication Helper +// ============================================================================= + +/** + * Fetch wrapper that automatically adds authentication headers + * Gets the access token from Supabase session + */ +async function authFetch(url: string, options: RequestInit = {}): Promise<Response> { + const { data: { session } } = await supabase.auth.getSession(); + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + }; + + if (session?.access_token) { + headers['Authorization'] = `Bearer ${session.access_token}`; + } + + return fetch(`${API_BASE}${url}`, { + ...options, + headers: { ...headers, ...options.headers }, + }); +} + +// ============================================================================= +// Mesh/Task Types +// ============================================================================= + +export type TaskStatus = + | 'pending' + | 'initializing' + | 'starting' + | 'running' + | 'paused' + | 'blocked' + | 'done' + | 'failed' + | 'merged'; + +export type MergeMode = 'pr' | 'auto' | 'manual'; +export type CompletionAction = 'none' | 'branch' | 'merge' | 'pr'; +export type ContractPhase = 'research' | 'specify' | 'plan' | 'execute' | 'review'; + +export interface TaskSummary { + id: string; + contractId: string | null; + contractName: string | null; + contractPhase: ContractPhase | null; + parentTaskId: string | null; + depth: number; + name: string; + status: TaskStatus; + priority: number; + progressSummary: string | null; + subtaskCount: number; + isSupervisor: boolean; + version: number; + createdAt: string; + updatedAt: string; +} + +export interface Task { + id: string; + ownerId: string; + contractId: string | null; + parentTaskId: string | null; + depth: number; + name: string; + description: string | null; + status: TaskStatus; + priority: number; + plan: string; + daemonId: string | null; + containerId: string | null; + overlayPath: string | null; + repositoryUrl: string | null; + baseBranch: string | null; + targetBranch: string | null; + mergeMode: MergeMode | null; + prUrl: string | null; + targetRepoPath: string | null; + completionAction: CompletionAction | null; + progressSummary: string | null; + lastOutput: string | null; + errorMessage: string | null; + startedAt: string | null; + completedAt: string | null; + version: number; + createdAt: string; + updatedAt: string; + isSupervisor: boolean; +} + +export interface TaskWithSubtasks extends Task { + subtasks: TaskSummary[]; +} + +export interface TaskListResponse { + tasks: TaskSummary[]; + total: number; +} + +export interface TaskOutputEntry { + id: string; + taskId: string; + messageType: string; + content: string; + toolName?: string; + toolInput?: Record<string, unknown>; + isError?: boolean; + costUsd?: number; + durationMs?: number; + createdAt: string; +} + +export interface TaskOutputResponse { + entries: TaskOutputEntry[]; + total: number; + taskId: string; +} + +export interface SendMessageResponse { + success: boolean; + taskId: string; + messageLength: number; +} + +// ============================================================================= +// Pending Question Types +// ============================================================================= + +export interface PendingQuestion { + questionId: string; + taskId: string; + contractId: string; + question: string; + choices: string[]; + context: string | null; + createdAt: string; +} + +export interface AnswerQuestionResponse { + success: boolean; +} + +// ============================================================================= +// Task API Functions +// ============================================================================= + +/** + * List all tasks for the current user + */ +export async function listTasks(): Promise<TaskListResponse> { + const res = await authFetch('/api/v1/mesh/tasks'); + if (!res.ok) { + throw new Error(`Failed to list tasks: ${res.statusText}`); + } + return res.json(); +} + +/** + * Get a specific task with its subtasks + */ +export async function getTask(id: string): Promise<TaskWithSubtasks> { + const res = await authFetch(`/api/v1/mesh/tasks/${id}`); + if (!res.ok) { + throw new Error(`Failed to get task: ${res.statusText}`); + } + return res.json(); +} + +/** + * Start a pending task + */ +export async function startTask(id: string): Promise<Task> { + const res = await authFetch(`/api/v1/mesh/tasks/${id}/start`, { + method: 'POST', + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to start task: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** + * Stop a running task + */ +export async function stopTask(id: string): Promise<Task> { + const res = await authFetch(`/api/v1/mesh/tasks/${id}/stop`, { + method: 'POST', + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to stop task: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** + * Send a message to a running task's stdin + */ +export async function sendTaskMessage( + taskId: string, + message: string +): Promise<SendMessageResponse> { + const res = await authFetch(`/api/v1/mesh/tasks/${taskId}/message`, { + method: 'POST', + body: JSON.stringify({ message }), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to send message: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** + * Get task output history + */ +export async function getTaskOutput(taskId: string): Promise<TaskOutputResponse> { + const res = await authFetch(`/api/v1/mesh/tasks/${taskId}/output`); + if (!res.ok) { + throw new Error(`Failed to get task output: ${res.statusText}`); + } + return res.json(); +} + +// ============================================================================= +// Question API Functions +// ============================================================================= + +/** + * Get all pending supervisor questions for the current user + */ +export async function listPendingQuestions(): Promise<PendingQuestion[]> { + const res = await authFetch('/api/v1/mesh/questions'); + if (!res.ok) { + throw new Error(`Failed to list questions: ${res.statusText}`); + } + return res.json(); +} + +/** + * Answer a pending supervisor question + */ +export async function answerQuestion( + questionId: string, + response: string +): Promise<AnswerQuestionResponse> { + const res = await authFetch(`/api/v1/mesh/questions/${questionId}/answer`, { + method: 'POST', + body: JSON.stringify({ response }), + }); + if (!res.ok) { + throw new Error(`Failed to answer question: ${res.statusText}`); + } + return res.json(); +} diff --git a/apps/mobile/lib/auth.ts b/apps/mobile/lib/auth.ts new file mode 100644 index 0000000..0005949 --- /dev/null +++ b/apps/mobile/lib/auth.ts @@ -0,0 +1,210 @@ +import type { Session, User, AuthError } from '@supabase/supabase-js'; +import { supabase } from './supabase'; + +/** + * Auth response types + */ +export interface AuthResult { + success: boolean; + error?: string; +} + +export interface SignInResult extends AuthResult { + user?: User; + session?: Session; +} + +export interface SessionResult { + session: Session | null; + error?: string; +} + +export interface UserResult { + user: User | null; + error?: string; +} + +/** + * Helper function to extract error message from AuthError + */ +function getErrorMessage(error: AuthError | null): string | undefined { + if (!error) return undefined; + return error.message || 'An unknown error occurred'; +} + +/** + * Sign in with email and password + * @param email - User's email address + * @param password - User's password + * @returns SignInResult with user and session on success, or error on failure + */ +export async function signIn(email: string, password: string): Promise<SignInResult> { + try { + const { data, error } = await supabase.auth.signInWithPassword({ + email: email.trim().toLowerCase(), + password, + }); + + if (error) { + return { + success: false, + error: getErrorMessage(error), + }; + } + + return { + success: true, + user: data.user ?? undefined, + session: data.session ?? undefined, + }; + } catch (error) { + console.error('Sign in error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to sign in', + }; + } +} + +/** + * Sign out the current user and clear the session + * @returns AuthResult indicating success or failure + */ +export async function signOut(): Promise<AuthResult> { + try { + const { error } = await supabase.auth.signOut(); + + if (error) { + return { + success: false, + error: getErrorMessage(error), + }; + } + + return { success: true }; + } catch (error) { + console.error('Sign out error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to sign out', + }; + } +} + +/** + * Get the current session + * @returns SessionResult with the current session or null + */ +export async function getSession(): Promise<SessionResult> { + try { + const { data, error } = await supabase.auth.getSession(); + + if (error) { + return { + session: null, + error: getErrorMessage(error), + }; + } + + return { session: data.session }; + } catch (error) { + console.error('Get session error:', error); + return { + session: null, + error: error instanceof Error ? error.message : 'Failed to get session', + }; + } +} + +/** + * Get the current user + * @returns UserResult with the current user or null + */ +export async function getCurrentUser(): Promise<UserResult> { + try { + const { data, error } = await supabase.auth.getUser(); + + if (error) { + return { + user: null, + error: getErrorMessage(error), + }; + } + + return { user: data.user }; + } catch (error) { + console.error('Get current user error:', error); + return { + user: null, + error: error instanceof Error ? error.message : 'Failed to get current user', + }; + } +} + +/** + * Refresh the current session + * @returns SessionResult with the refreshed session + */ +export async function refreshSession(): Promise<SessionResult> { + try { + const { data, error } = await supabase.auth.refreshSession(); + + if (error) { + return { + session: null, + error: getErrorMessage(error), + }; + } + + return { session: data.session }; + } catch (error) { + console.error('Refresh session error:', error); + return { + session: null, + error: error instanceof Error ? error.message : 'Failed to refresh session', + }; + } +} + +/** + * Check if the user is currently authenticated + * @returns boolean indicating if user is authenticated + */ +export async function isAuthenticated(): Promise<boolean> { + const { session } = await getSession(); + return session !== null; +} + +/** + * Sign up with email and password (for future use) + * @param email - User's email address + * @param password - User's password + * @returns SignInResult with user on success + */ +export async function signUp(email: string, password: string): Promise<SignInResult> { + try { + const { data, error } = await supabase.auth.signUp({ + email: email.trim().toLowerCase(), + password, + }); + + if (error) { + return { + success: false, + error: getErrorMessage(error), + }; + } + + return { + success: true, + user: data.user ?? undefined, + session: data.session ?? undefined, + }; + } catch (error) { + console.error('Sign up error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to sign up', + }; + } +} diff --git a/apps/mobile/lib/config.ts b/apps/mobile/lib/config.ts new file mode 100644 index 0000000..70fce52 --- /dev/null +++ b/apps/mobile/lib/config.ts @@ -0,0 +1,89 @@ +import { Platform } from 'react-native'; + +/** + * API configuration for Makima mobile app + * Supports different environments and platforms + */ + +// Environment detection +const isDevelopment = __DEV__; + +/** + * API base URLs for different environments + */ +export const API_CONFIG = { + local: 'http://localhost:8080', + // Use 10.0.2.2 for Android emulator to access host machine's localhost + localAndroid: 'http://10.0.2.2:8080', + production: 'https://api.makima.jp', +} as const; + +/** + * Get the appropriate API base URL based on environment and platform + */ +export function getApiBaseUrl(): string { + // Check for environment variable override + const envUrl = process.env.EXPO_PUBLIC_API_URL; + if (envUrl) { + return envUrl; + } + + // In production, use the production API + if (!isDevelopment) { + return API_CONFIG.production; + } + + // In development, use local API with platform-specific URL + if (Platform.OS === 'android') { + return API_CONFIG.localAndroid; + } + + return API_CONFIG.local; +} + +/** + * App configuration object + */ +export const config = { + /** + * API base URL for making requests + */ + apiBaseUrl: getApiBaseUrl(), + + /** + * Whether the app is running in development mode + */ + isDevelopment, + + /** + * Supabase URL (from environment or default) + */ + supabaseUrl: process.env.EXPO_PUBLIC_SUPABASE_URL || 'https://ynxyjytytmfwxjqxljzm.supabase.co', + + /** + * App version + */ + version: '1.0.0', + + /** + * Support/help URL + */ + supportUrl: 'https://makima.jp/support', + + /** + * Privacy policy URL + */ + privacyPolicyUrl: 'https://makima.jp/privacy', + + /** + * Terms of service URL + */ + termsOfServiceUrl: 'https://makima.jp/terms', + + /** + * Forgot password URL (opens in browser) + */ + forgotPasswordUrl: 'https://makima.jp/forgot-password', +} as const; + +export type Config = typeof config; diff --git a/apps/mobile/lib/index.ts b/apps/mobile/lib/index.ts new file mode 100644 index 0000000..8213fb0 --- /dev/null +++ b/apps/mobile/lib/index.ts @@ -0,0 +1,18 @@ +/** + * Library exports + */ +export { supabase, SUPABASE_URL } from './supabase'; +export { config, getApiBaseUrl, API_CONFIG } from './config'; +export { + signIn, + signOut, + getSession, + getCurrentUser, + refreshSession, + isAuthenticated, + signUp, + type AuthResult, + type SignInResult, + type SessionResult, + type UserResult, +} from './auth'; diff --git a/apps/mobile/lib/supabase.ts b/apps/mobile/lib/supabase.ts new file mode 100644 index 0000000..f7d005a --- /dev/null +++ b/apps/mobile/lib/supabase.ts @@ -0,0 +1,28 @@ +import { createClient } from '@supabase/supabase-js'; +import * as SecureStore from 'expo-secure-store'; + +// Supabase configuration from environment variables +export const SUPABASE_URL = process.env.EXPO_PUBLIC_SUPABASE_URL || 'https://ynxyjytytmfwxjqxljzm.supabase.co'; +const SUPABASE_ANON_KEY = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || 'your-anon-key'; + +// Custom storage using expo-secure-store for React Native +const ExpoSecureStoreAdapter = { + getItem: (key: string) => { + return SecureStore.getItemAsync(key); + }, + setItem: (key: string, value: string) => { + return SecureStore.setItemAsync(key, value); + }, + removeItem: (key: string) => { + return SecureStore.deleteItemAsync(key); + }, +}; + +export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + auth: { + storage: ExpoSecureStoreAdapter, + autoRefreshToken: true, + persistSession: true, + detectSessionInUrl: false, + }, +}); |
