Change PIN to 1451

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ARCHITECT
2026-01-17 23:31:52 +00:00
parent c152cacb90
commit f199daf4ba
171 changed files with 10492 additions and 2 deletions

BIN
- Normal file

Binary file not shown.

1
LLMChat Submodule

Submodule LLMChat added at 01f53de663

307
PLAN_CAPTAIN_MOBILE_V2.md Normal file
View File

@@ -0,0 +1,307 @@
# PLAN: Captain Claude Mobile v2
## Objetivo
App móvil nativa de chat con Claude que ejecuta comandos en el servidor y muestra resultados formateados.
---
## 1. ARQUITECTURA
```
┌──────────────────┐ HTTPS/WSS ┌──────────────────┐
│ │ ◄────────────────► │ │
│ Flutter App │ │ FastAPI │
│ (Android/iOS) │ │ Backend │
│ │ │ │
└──────────────────┘ └────────┬─────────┘
│ subprocess
┌──────────────────┐
│ │
│ Claude CLI │
│ (claude -p) │
│ │
└──────────────────┘
```
### Backend (FastAPI)
- Recibe mensajes del usuario via WebSocket
- Ejecuta `claude -p "mensaje" --output-format stream-json`
- Parsea el output JSON de Claude
- Envía respuesta formateada al frontend
- Guarda historial en PostgreSQL
### Frontend (Flutter)
- UI de chat nativa (burbujas, input, etc.)
- Renderiza Markdown en respuestas
- Syntax highlighting en code blocks
- Muestra progreso de ejecución
- Historial de conversaciones
---
## 2. DISEÑO UI/UX
### Pantalla Principal (Chat)
```
┌─────────────────────────────────┐
│ ☰ Captain Claude [●] Online│ ← AppBar con estado conexión
├─────────────────────────────────┤
│ │
│ ┌─ User ────────────────────┐ │
│ │ Muéstrame el uso de disco │ │ ← Burbuja usuario (derecha)
│ └───────────────────────────┘ │
│ │
│ ┌─ Claude ──────────────────┐ │ ← Burbuja Claude (izquierda)
│ │ Ejecutando `df -h`... │ │
│ │ │ │
│ │ ``` │ │ ← Code block con resultado
│ │ Filesystem Size Used │ │
│ │ /dev/sda1 100G 45G │ │
│ │ ``` │ │
│ │ │ │
│ │ El disco principal tiene │ │ ← Explicación en texto
│ │ 55% libre (55GB). │ │
│ └───────────────────────────┘ │
│ │
├─────────────────────────────────┤
│ ┌─────────────────────────┐ 📎│ ← Input con attach
│ │ Escribe un mensaje... │ ➤ │ ← Botón enviar
│ └─────────────────────────────┘│
└─────────────────────────────────┘
```
### Pantalla Historial
```
┌─────────────────────────────────┐
│ ← Conversaciones │
├─────────────────────────────────┤
│ ┌─────────────────────────────┐│
│ │ 📁 Backup de base de datos ││ ← Título auto-generado
│ │ Hace 2 horas • 5 mensajes ││
│ └─────────────────────────────┘│
│ ┌─────────────────────────────┐│
│ │ 🔧 Fix error en nginx ││
│ │ Ayer • 12 mensajes ││
│ └─────────────────────────────┘│
│ ┌─────────────────────────────┐│
│ │ 📊 Análisis de logs ││
│ │ 15 Ene • 8 mensajes ││
│ └─────────────────────────────┘│
└─────────────────────────────────┘
```
### Estados de Mensaje Claude
1. **Pensando**: Spinner + "Claude está pensando..."
2. **Ejecutando**: Muestra comando siendo ejecutado
3. **Streaming**: Texto aparece progresivamente
4. **Completado**: Mensaje completo con formato
5. **Error**: Mensaje rojo con opción de reintentar
---
## 3. API BACKEND
### Endpoints REST
| Método | Endpoint | Descripción |
|--------|----------|-------------|
| POST | /auth/login | Login, retorna JWT |
| GET | /conversations | Lista conversaciones |
| GET | /conversations/{id} | Mensajes de una conversación |
| DELETE | /conversations/{id} | Eliminar conversación |
| POST | /upload | Subir archivo para contexto |
### WebSocket /ws/chat
**Cliente → Servidor:**
```json
{
"type": "message",
"content": "Muéstrame el uso de disco",
"conversation_id": "uuid-opcional",
"files": ["/path/to/file"]
}
```
**Servidor → Cliente (streaming):**
```json
{"type": "thinking"}
{"type": "tool_use", "tool": "Bash", "input": "df -h"}
{"type": "tool_result", "output": "Filesystem Size..."}
{"type": "text", "content": "El disco principal..."}
{"type": "done", "conversation_id": "uuid"}
```
---
## 4. MODELO DE DATOS
### PostgreSQL
```sql
-- Conversaciones
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR(255) NOT NULL,
title VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Mensajes
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID REFERENCES conversations(id),
role VARCHAR(50) NOT NULL, -- 'user' | 'assistant'
content TEXT NOT NULL,
tool_uses JSONB, -- [{tool, input, output}]
created_at TIMESTAMP DEFAULT NOW()
);
```
---
## 5. FLUTTER - ESTRUCTURA
```
lib/
├── main.dart
├── config/
│ └── api_config.dart
├── models/
│ ├── conversation.dart
│ ├── message.dart
│ └── tool_use.dart
├── services/
│ ├── api_service.dart
│ ├── chat_service.dart # WebSocket
│ └── auth_service.dart
├── providers/
│ ├── auth_provider.dart
│ └── chat_provider.dart
├── screens/
│ ├── login_screen.dart
│ ├── chat_screen.dart
│ └── history_screen.dart
└── widgets/
├── message_bubble.dart # Burbuja de mensaje
├── code_block.dart # Syntax highlighting
├── tool_use_card.dart # Muestra ejecución de tool
├── thinking_indicator.dart
└── chat_input.dart # Input con attach
```
---
## 6. COMPONENTES CLAVE
### MessageBubble (Flutter)
```dart
class MessageBubble extends StatelessWidget {
final Message message;
// Renderiza según tipo:
// - Texto normal → Markdown
// - Code blocks → Syntax highlighting
// - Tool uses → Cards expandibles
// - Errores → Estilo rojo
}
```
### ToolUseCard (Flutter)
```dart
// Muestra:
// ┌─ Bash ──────────────────────┐
// │ ▶ df -h │ ← Comando ejecutado
// ├─────────────────────────────┤
// │ Filesystem Size Used ... │ ← Output (colapsable)
// └─────────────────────────────┘
```
### ChatInput (Flutter)
```dart
// - TextField multilínea
// - Botón adjuntar archivo
// - Botón enviar (disabled si vacío o desconectado)
// - Indicador de conexión
```
---
## 7. PROCESO DE DESARROLLO
### Fase 1: Backend básico
1. FastAPI con WebSocket
2. Integración con Claude CLI
3. Parsing de output JSON
4. Base de datos PostgreSQL
### Fase 2: Frontend básico
1. Chat UI con burbujas
2. Conexión WebSocket
3. Streaming de mensajes
4. Markdown rendering
### Fase 3: Features completas
1. Historial de conversaciones
2. Syntax highlighting
3. Tool use cards
4. Upload de archivos
5. Estados de conexión
### Fase 4: Pulido
1. Animaciones
2. Error handling robusto
3. Offline mode básico
4. Notificaciones
---
## 8. PROCESO DE AUDITORÍA (post-código)
### Ronda 1: Agentes paralelos
- **Arquitecto**: Revisa estructura del código
- **QA**: Busca bugs y edge cases
- **UX**: Evalúa usabilidad
### Ronda 2: Tests reales
- Probar cada endpoint con curl
- Instalar APK y probar flujos
- Documentar problemas
### Ronda 3: Fixes + re-test
- Aplicar correcciones
- Verificar que funcionan
- Compilar versión final
---
## 9. DIFERENCIAS CON v1
| Aspecto | v1 (Terminal) | v2 (Chat) |
|---------|---------------|-----------|
| UI | Emulador xterm | Chat nativo |
| Input | Teclado terminal | Texto natural |
| Output | Raw ANSI | Markdown formateado |
| Comandos | Ctrl+C manual | Claude decide |
| UX | Técnico | Amigable |
---
## 10. CRITERIOS DE ÉXITO
1. ✅ Usuario puede chatear con Claude en lenguaje natural
2. ✅ Claude ejecuta comandos y muestra resultados formateados
3. ✅ Code blocks tienen syntax highlighting
4. ✅ Conversaciones se guardan y se pueden retomar
5. ✅ UI es responsiva y agradable en móvil
6. ✅ Errores se muestran claramente con opción de reintentar
7. ✅ Conexión se reconecta automáticamente
---
## PRÓXIMO PASO
¿Apruebas este plan para empezar a implementar?

View File

@@ -0,0 +1,164 @@
# Captain Claude Mobile v2 - Estado del Proyecto
**Fecha:** 2026-01-17
**Estado:** BACKEND ACTUALIZADO / PENDIENTE COMPILAR APK
---
## Cambios Realizados (17 Ene 2026)
### Backend reescrito con patrón LLMChat
Se aplicó el patrón de streaming de [LLMChat](https://github.com/c0sogi/LLMChat):
1. **Queue para desacoplar receiver/sender**
- `asyncio.Queue` para mensajes entrantes
- `ws_receiver()` y `ws_sender()` corren en paralelo con `asyncio.gather()`
2. **ChatBuffer** - Contexto por conexión
- Mantiene estado: `websocket`, `username`, `queue`, `done`, `conversation_id`
- `done` event para interrumpir streaming
3. **Mensaje `init` al conectar**
- Envía lista de conversaciones al conectar
- Frontend recibe estado inicial sin llamada REST adicional
4. **Soporte para interrumpir streaming**
- Cliente envía `{"type": "stop"}`
- Backend termina proceso Claude y envía `{"type": "interrupted"}`
5. **Nuevos tipos de mensaje**
- `init` - Estado inicial con conversaciones
- `text_start` - Inicio de bloque de texto
- `tool_input` - Input completo del tool
- `interrupted` - Generación interrumpida
- `conversation_loaded` - Conversación cargada via WS
---
## Configuración Actual
| Componente | Puerto | Notas |
|------------|--------|-------|
| **captain-api-v2** | **3030** | Backend con patrón LLMChat |
**Credenciales:** `admin` / `admin`
**Servicio:** `captain-api-v2.service` (systemd)
---
## Archivos Modificados
```
apps/captain-mobile-v2/
├── backend/
│ ├── captain_api_v2.py # Backend reescrito
│ └── captain-api-v2.service # Puerto 3031
├── flutter/lib/
│ ├── config/api_config.dart # Puerto 3031
│ ├── services/chat_service.dart # Nuevos eventos
│ └── providers/chat_provider.dart # Manejo de init, interrupted, etc.
└── ESTADO_PROYECTO.md # Este archivo
```
---
## Próximos Pasos
### 1. Instalar servicio systemd (requiere sudo)
```bash
sudo cp captain-api-v2.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable captain-api-v2
sudo systemctl start captain-api-v2
```
### 2. Compilar APK
```bash
cd /home/architect/captain-claude/apps/captain-mobile-v2/flutter
/home/architect/flutter/bin/flutter build apk --release
```
### 3. Subir a Nextcloud
```bash
scp -i ~/.ssh/tzzr build/app/outputs/flutter-apk/app-release.apk \
root@72.62.1.113:"/var/www/nextcloud/data/tzzrdeck/files/documentos adjuntos/captain-mobile-v2.apk"
ssh -i ~/.ssh/tzzr root@72.62.1.113 \
"chown www-data:www-data '/var/www/nextcloud/data/tzzrdeck/files/documentos adjuntos/captain-mobile-v2.apk' && \
cd /var/www/nextcloud && sudo -u www-data php occ files:scan tzzrdeck"
```
---
## Probar Backend Manualmente
```bash
# Health check
curl http://localhost:3031/health
# Login
TOKEN=$(curl -s -X POST http://localhost:3031/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}' | jq -r '.token')
# Listar conversaciones
curl -H "Authorization: Bearer $TOKEN" http://localhost:3031/conversations
```
---
## Arquitectura WebSocket (LLMChat pattern)
```
┌─────────────────┐ WebSocket ┌─────────────────┐
│ Flutter Client │ ◄───────────────► │ FastAPI Server │
│ │ /ws/chat │ │
└─────────────────┘ └────────┬────────┘
┌──────────────────────────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
ws_receiver() ws_sender() ChatBuffer
- Recibe JSON - Procesa queue - websocket
- stop → buffer.done.set() - Llama Claude - username
- ping → pong - Stream chunks - queue
- Otros → queue.put() - Guarda en DB - done event
```
---
## Flujo de Mensajes
### Conexión
```
Cliente Servidor
│ │
│──── {"token": "xxx"} ─────────►│
│ │
│◄─── {"type": "init", │
│ "user": "admin", │
│ "conversations": [...]} │
```
### Chat
```
Cliente Servidor
│ │
│──── {"type": "message", │
│ "content": "Hola"} ──────►│
│ │
│◄─── {"type": "start"} ─────────│
│◄─── {"type": "thinking"} ──────│
│◄─── {"type": "delta", ...} ────│ (múltiples)
│◄─── {"type": "done", ...} ─────│
```
### Interrumpir
```
Cliente Servidor
│ │
│──── {"type": "stop"} ─────────►│
│ │
│◄─── {"type": "interrupted", │
│ "content": "..."} ────────│
```

View File

@@ -0,0 +1,17 @@
[Unit]
Description=Captain Claude Mobile v2 API
After=network.target
[Service]
Type=simple
User=architect
Group=architect
WorkingDirectory=/home/architect/captain-claude/apps/captain-mobile-v2/backend
Environment="PATH=/home/architect/captain-claude/apps/captain-mobile-v2/backend/venv/bin:/home/architect/.npm-global/bin:/usr/local/bin:/usr/bin:/bin"
# Password from /data/.admin_password (default: admin)
ExecStart=/home/architect/captain-claude/apps/captain-mobile-v2/backend/venv/bin/uvicorn captain_api_v2:app --host 0.0.0.0 --port 3030
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Captain Claude API v3
After=network.target
[Service]
Type=simple
User=architect
WorkingDirectory=/home/architect/captain-claude/apps/captain-mobile-v2/backend
Environment=PATH=/home/architect/captain-claude/apps/captain-mobile-v2/backend/venv/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/home/architect/captain-claude/apps/captain-mobile-v2/backend/venv/bin/uvicorn captain_api_v3:app --host 0.0.0.0 --port 3030
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,620 @@
#!/usr/bin/env python3
"""
Captain Claude Mobile v3 - Backend API
Architecture: Uses claude CLI with --output-format stream-json
No more screen/hardcopy bullshit - clean JSON output
"""
import sys
import logging
import os
import asyncio
import secrets
import subprocess
import json
import re
from datetime import datetime, timedelta
from typing import Optional, Dict, Set
from contextlib import asynccontextmanager
from pathlib import Path
from dataclasses import dataclass, field
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
import jwt
# Configure logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler(sys.stderr)]
)
logger = logging.getLogger(__name__)
# ============================================================================
# Configuration
# ============================================================================
DATA_DIR = Path("/home/architect/captain-claude/apps/captain-mobile-v2/data")
CLAUDE_CMD = "/home/architect/.npm-global/bin/claude"
WORKING_DIR = "/home/architect/captain-claude"
JWT_SECRET_FILE = DATA_DIR / ".jwt_secret"
JWT_ALGORITHM = "HS256"
JWT_EXPIRY_DAYS = 7
def get_jwt_secret():
DATA_DIR.mkdir(parents=True, exist_ok=True)
if os.environ.get("JWT_SECRET"):
return os.environ.get("JWT_SECRET")
if JWT_SECRET_FILE.exists():
return JWT_SECRET_FILE.read_text().strip()
secret = secrets.token_hex(32)
JWT_SECRET_FILE.write_text(secret)
JWT_SECRET_FILE.chmod(0o600)
return secret
JWT_SECRET = get_jwt_secret()
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD")
if not ADMIN_PASSWORD:
_pass_file = DATA_DIR / ".admin_password"
DATA_DIR.mkdir(parents=True, exist_ok=True)
if _pass_file.exists():
ADMIN_PASSWORD = _pass_file.read_text().strip()
else:
ADMIN_PASSWORD = "admin"
_pass_file.write_text(ADMIN_PASSWORD)
_pass_file.chmod(0o600)
VALID_USERS = {"admin": ADMIN_PASSWORD}
ALLOWED_ORIGINS = [
"http://localhost:3000",
"http://localhost:8080",
"http://127.0.0.1:3000",
"http://127.0.0.1:8080",
"https://captain.tzzrarchitect.me",
"capacitor://localhost",
"ionic://localhost"
]
security = HTTPBearer(auto_error=False)
# ============================================================================
# Pydantic Models
# ============================================================================
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
token: str
expires_at: str
class SessionInfo(BaseModel):
session_id: str
name: str
created_at: str
class CreateSessionRequest(BaseModel):
name: str
# ============================================================================
# Auth Helpers
# ============================================================================
def create_token(username: str) -> tuple[str, datetime]:
expires = datetime.utcnow() + timedelta(days=JWT_EXPIRY_DAYS)
payload = {
"sub": username,
"exp": expires,
"iat": datetime.utcnow()
}
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
return token, expires
def verify_token(token: str) -> Optional[str]:
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return payload.get("sub")
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
if not credentials:
raise HTTPException(status_code=401, detail="Not authenticated")
username = verify_token(credentials.credentials)
if not username:
raise HTTPException(status_code=401, detail="Invalid or expired token")
return username
# ============================================================================
# Claude Session Manager
# ============================================================================
@dataclass
class ClaudeSession:
"""Represents a Claude conversation session"""
session_id: str # Claude's session UUID
name: str # User-friendly name
created_at: datetime
subscribers: Set[asyncio.Queue] = field(default_factory=set)
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
is_processing: bool = False
class SessionManager:
"""
Manages Claude CLI sessions using -p mode with stream-json output.
Each session maintains conversation history via Claude's session_id.
"""
def __init__(self):
self._sessions: Dict[str, ClaudeSession] = {}
self._lock = asyncio.Lock()
async def create_session(self, name: str) -> ClaudeSession:
"""Create a new Claude session"""
# Generate initial session by making a simple call
session_id = secrets.token_hex(16) # We'll get real session_id from first call
session = ClaudeSession(
session_id=session_id,
name=name,
created_at=datetime.utcnow()
)
async with self._lock:
self._sessions[session_id] = session
logger.info(f"Created session: {name} ({session_id})")
return session
async def get_session(self, session_id: str) -> Optional[ClaudeSession]:
"""Get a session by ID"""
return self._sessions.get(session_id)
async def list_sessions(self) -> list[SessionInfo]:
"""List all sessions"""
return [
SessionInfo(
session_id=s.session_id,
name=s.name,
created_at=s.created_at.isoformat()
)
for s in self._sessions.values()
]
async def subscribe(self, session_id: str) -> asyncio.Queue:
"""Subscribe to a session's output"""
session = self._sessions.get(session_id)
if not session:
raise ValueError(f"Session {session_id} not found")
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
async with session.lock:
session.subscribers.add(queue)
logger.debug(f"Subscribed to session {session_id}, {len(session.subscribers)} subscribers")
return queue
async def unsubscribe(self, session_id: str, queue: asyncio.Queue):
"""Unsubscribe from a session's output"""
session = self._sessions.get(session_id)
if session:
async with session.lock:
session.subscribers.discard(queue)
logger.debug(f"Unsubscribed from session {session_id}")
async def send_message(self, session_id: str, content: str) -> bool:
"""
Send a message to Claude and stream the response.
Uses claude -p with stream-json for clean output.
"""
session = self._sessions.get(session_id)
if not session:
logger.error(f"Session {session_id} not found")
return False
if session.is_processing:
logger.warning(f"Session {session_id} is already processing")
await self._broadcast(session, {"type": "error", "content": "Ya hay un mensaje en proceso"})
return False
session.is_processing = True
try:
# Build command
cmd = [
CLAUDE_CMD,
"-p", content,
"--output-format", "stream-json",
"--verbose",
"--dangerously-skip-permissions"
]
# If we have a Claude session UUID, resume it to maintain conversation
# Claude UUIDs are in format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (36 chars with dashes)
if session.session_id and '-' in session.session_id and len(session.session_id) == 36:
cmd.extend(["--resume", session.session_id])
logger.debug(f"Resuming Claude session: {session.session_id}")
logger.debug(f"Executing: {' '.join(cmd)}")
# Run claude process
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=WORKING_DIR
)
# Process output as it arrives
full_response = ""
tool_count = 0
try:
while True:
# Read line with timeout to avoid hanging
try:
line = await asyncio.wait_for(
process.stdout.readline(),
timeout=60.0
)
except asyncio.TimeoutError:
logger.warning("Timeout reading from Claude process")
break
if not line:
break
line_str = line.decode('utf-8').strip()
if not line_str:
continue
logger.debug(f"Claude output: {line_str[:100]}...")
try:
data = json.loads(line_str)
msg_type = data.get("type", "")
if msg_type == "assistant":
message = data.get("message", {})
content_list = message.get("content", [])
for item in content_list:
if item.get("type") == "text":
text = item.get("text", "")
if text:
full_response = text
# Send immediately!
await self._broadcast(session, {
"type": "output",
"content": text
})
elif item.get("type") == "tool_use":
tool_count += 1
await self._broadcast(session, {
"type": "output",
"content": f"procesando{'.' * tool_count}"
})
elif msg_type == "result":
result = data.get("result", "")
claude_session = data.get("session_id")
if claude_session:
session.session_id = claude_session
# Send done signal
await self._broadcast(session, {
"type": "done",
"session_id": session.session_id
})
except json.JSONDecodeError:
pass
except Exception as e:
logger.error(f"Error processing line: {e}")
finally:
# Ensure process is cleaned up
try:
process.terminate()
await asyncio.wait_for(process.wait(), timeout=5.0)
except:
process.kill()
return True
except Exception as e:
logger.error(f"Error sending message: {e}")
if str(e): # Only broadcast if there's an actual error message
await self._broadcast(session, {
"type": "error",
"content": str(e)
})
return False
finally:
session.is_processing = False
async def _broadcast(self, session: ClaudeSession, message: dict):
"""Broadcast message to all subscribers"""
async with session.lock:
subscribers = list(session.subscribers)
logger.debug(f"Broadcasting to {len(subscribers)} subscribers: {message}")
for queue in subscribers:
try:
queue.put_nowait(message)
logger.debug(f"Message queued successfully")
except asyncio.QueueFull:
try:
queue.get_nowait()
queue.put_nowait(message)
except:
pass
except Exception as e:
logger.error(f"Error queuing message: {e}")
# Global session manager
session_manager = SessionManager()
# ============================================================================
# App Setup
# ============================================================================
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Captain Claude v3 starting...")
yield
logger.info("Captain Claude v3 shutting down...")
app = FastAPI(
title="Captain Claude Mobile v3",
description="Chat API using Claude CLI with stream-json",
version="3.0.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ============================================================================
# REST Endpoints
# ============================================================================
@app.get("/health")
async def health():
return {"status": "ok", "version": "3.0.0"}
@app.post("/auth/login", response_model=LoginResponse)
async def login(request: LoginRequest):
if request.username not in VALID_USERS:
raise HTTPException(status_code=401, detail="Invalid credentials")
if VALID_USERS[request.username] != request.password:
raise HTTPException(status_code=401, detail="Invalid credentials")
token, expires = create_token(request.username)
return LoginResponse(token=token, expires_at=expires.isoformat())
@app.get("/sessions")
async def get_sessions(user: str = Depends(get_current_user)):
return await session_manager.list_sessions()
@app.post("/sessions")
async def create_session(request: CreateSessionRequest, user: str = Depends(get_current_user)):
try:
session = await session_manager.create_session(request.name)
return SessionInfo(
session_id=session.session_id,
name=session.name,
created_at=session.created_at.isoformat()
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# WebSocket Chat
# ============================================================================
@app.websocket("/ws/chat")
async def websocket_chat(websocket: WebSocket):
"""WebSocket endpoint for chat"""
await websocket.accept()
# Auth handshake
try:
auth_msg = await asyncio.wait_for(websocket.receive_json(), timeout=10.0)
token = auth_msg.get("token", "")
username = verify_token(token)
if not username:
await websocket.send_json({"type": "error", "message": "Invalid token"})
await websocket.close(code=4001)
return
except asyncio.TimeoutError:
await websocket.send_json({"type": "error", "message": "Auth timeout"})
await websocket.close(code=4001)
return
except Exception as e:
await websocket.send_json({"type": "error", "message": f"Auth error: {str(e)}"})
await websocket.close(code=4001)
return
# Send init
sessions = await session_manager.list_sessions()
await websocket.send_json({
"type": "init",
"user": username,
"sessions": [{"session_id": s.session_id, "name": s.name} for s in sessions]
})
# Current subscription state
current_session_id: Optional[str] = None
current_queue: Optional[asyncio.Queue] = None
output_task: Optional[asyncio.Task] = None
async def output_forwarder(queue: asyncio.Queue):
"""Forward output from queue to websocket"""
while True:
try:
message = await queue.get()
# Message already has type, just forward it
await websocket.send_json(message)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Output forwarder error: {e}")
break
try:
while True:
data = await websocket.receive_json()
msg_type = data.get("type", "")
if msg_type == "create_session":
# Create a new session and auto-connect
name = data.get("name", f"session_{datetime.now().strftime('%H%M%S')}")
try:
session = await session_manager.create_session(name)
await websocket.send_json({
"type": "session_created",
"session_id": session.session_id,
"name": session.name
})
# Auto-connect to the new session
if current_session_id and current_queue:
if output_task:
output_task.cancel()
try:
await output_task
except asyncio.CancelledError:
pass
await session_manager.unsubscribe(current_session_id, current_queue)
current_queue = await session_manager.subscribe(session.session_id)
current_session_id = session.session_id
output_task = asyncio.create_task(output_forwarder(current_queue))
await websocket.send_json({
"type": "session_connected",
"session_id": session.session_id,
"name": session.name
})
logger.info(f"Auto-connected to new session {session.session_id}")
except Exception as e:
await websocket.send_json({"type": "error", "message": str(e)})
elif msg_type == "connect_session":
session_id = data.get("session_id", "")
# Unsubscribe from previous session
if current_session_id and current_queue:
if output_task:
output_task.cancel()
try:
await output_task
except asyncio.CancelledError:
pass
await session_manager.unsubscribe(current_session_id, current_queue)
# Subscribe to new session
try:
current_queue = await session_manager.subscribe(session_id)
current_session_id = session_id
output_task = asyncio.create_task(output_forwarder(current_queue))
session = await session_manager.get_session(session_id)
await websocket.send_json({
"type": "session_connected",
"session_id": session_id,
"name": session.name if session else "unknown"
})
except ValueError as e:
await websocket.send_json({"type": "error", "message": str(e)})
current_session_id = None
current_queue = None
elif msg_type == "message":
content = data.get("content", "")
if current_session_id and content:
# Send message in background so we can receive more commands
asyncio.create_task(
session_manager.send_message(current_session_id, content)
)
elif not current_session_id:
await websocket.send_json({
"type": "error",
"message": "No hay sesión conectada"
})
elif msg_type == "list_sessions":
sessions = await session_manager.list_sessions()
await websocket.send_json({
"type": "sessions_list",
"sessions": [{"session_id": s.session_id, "name": s.name} for s in sessions]
})
elif msg_type == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
pass
except Exception as e:
logger.error(f"WebSocket error: {e}")
try:
await websocket.send_json({"type": "error", "message": str(e)})
except:
pass
finally:
if output_task:
output_task.cancel()
try:
await output_task
except asyncio.CancelledError:
pass
if current_session_id and current_queue:
await session_manager.unsubscribe(current_session_id, current_queue)
# ============================================================================
# Main
# ============================================================================
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=3030)

View File

@@ -0,0 +1,5 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
websockets==12.0
pyjwt==2.8.0
python-multipart==0.0.6

View File

@@ -0,0 +1,45 @@
import asyncio
import json
from websockets import connect
import httpx
async def test():
print("=== Test Auto-Connect ===", flush=True)
async with httpx.AsyncClient() as client:
r = await client.post('http://localhost:3030/auth/login',
json={'username': 'admin', 'password': 'admin'})
token = r.json()['token']
async with connect('ws://localhost:3030/ws/chat') as ws:
await ws.send(json.dumps({'token': token}))
init = json.loads(await ws.recv())
print(f"1. Init: {init.get('type')}, sessions: {len(init.get('sessions', []))}", flush=True)
# Create session
print("2. Creating session...", flush=True)
await ws.send(json.dumps({'type': 'create_session', 'name': 'mi_sesion'}))
# Should receive session_created AND session_connected
for i in range(3):
msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=5))
print(f" Received: {msg.get('type')} - {msg}", flush=True)
if msg.get('type') == 'session_connected':
print("3. AUTO-CONNECT OK!", flush=True)
# Now send a message
print("4. Sending message...", flush=True)
await ws.send(json.dumps({'type': 'message', 'content': 'di hola'}))
for j in range(30):
try:
resp = json.loads(await asyncio.wait_for(ws.recv(), timeout=2))
print(f" Response: {resp}", flush=True)
if resp.get('type') == 'done':
print("\n=== SUCCESS ===", flush=True)
return
except asyncio.TimeoutError:
pass
return
asyncio.run(test())

View File

@@ -0,0 +1,65 @@
import asyncio
import json
from websockets import connect
import httpx
async def test():
print("=== Test Connect Session ===")
# Login
async with httpx.AsyncClient() as client:
r = await client.post('http://localhost:3030/auth/login',
json={'username': 'admin', 'password': 'admin'})
token = r.json()['token']
# Connect and create session
async with connect('ws://localhost:3030/ws/chat') as ws:
await ws.send(json.dumps({'token': token}))
init = json.loads(await ws.recv())
print(f"1. Init sessions: {len(init.get('sessions', []))}")
for s in init.get('sessions', []):
print(f" - {s['name']} ({s['session_id'][:8]}...)")
# Create a new session
await ws.send(json.dumps({'type': 'create_session', 'name': 'test_session'}))
created = json.loads(await ws.recv())
print(f"2. Created: {created}")
sid = created.get('session_id')
# Should auto-connect - check for session_connected
connected = json.loads(await ws.recv())
print(f"3. Connected msg: {connected}")
print("\n--- Reconnecting ---\n")
# Reconnect
async with connect('ws://localhost:3030/ws/chat') as ws:
await ws.send(json.dumps({'token': token}))
init = json.loads(await ws.recv())
print(f"4. Init sessions: {len(init.get('sessions', []))}")
for s in init.get('sessions', []):
print(f" - {s['name']} ({s['session_id'][:8]}...)")
# Try to connect to the session we created
print(f"\n5. Connecting to session {sid[:8]}...")
await ws.send(json.dumps({'type': 'connect_session', 'session_id': sid}))
result = json.loads(await ws.recv())
print(f"6. Result: {result}")
if result.get('type') == 'session_connected':
print("\n=== SUCCESS - Connected to existing session ===")
# Try sending a message
await ws.send(json.dumps({'type': 'message', 'content': 'test'}))
for i in range(30):
try:
m = await asyncio.wait_for(ws.recv(), timeout=1.0)
print(f" RECV: {json.loads(m)}")
if json.loads(m).get('type') == 'done':
break
except asyncio.TimeoutError:
pass
else:
print(f"\n=== FAILED: {result} ===")
asyncio.run(test())

View File

@@ -0,0 +1,35 @@
import asyncio
import json
from websockets import connect
import httpx
async def test():
print("=== Test Connect Session ===", flush=True)
# Login
async with httpx.AsyncClient() as client:
r = await client.post('http://localhost:3030/auth/login',
json={'username': 'admin', 'password': 'admin'})
token = r.json()['token']
print(f"Got token", flush=True)
# Connect and list sessions
async with connect('ws://localhost:3030/ws/chat') as ws:
await ws.send(json.dumps({'token': token}))
init = json.loads(await ws.recv())
print(f"Init: {init.get('type')}", flush=True)
sessions = init.get('sessions', [])
print(f"Sessions: {len(sessions)}", flush=True)
for s in sessions:
print(f" - {s['name']} ({s['session_id']})", flush=True)
if sessions:
sid = sessions[0]['session_id']
print(f"\nConnecting to: {sid}", flush=True)
await ws.send(json.dumps({'type': 'connect_session', 'session_id': sid}))
result = json.loads(await ws.recv())
print(f"Result: {result}", flush=True)
else:
print("No sessions to connect to", flush=True)
asyncio.run(test())

View File

@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""
Test final de Captain Mobile - Valida flujo completo
"""
import asyncio
import json
import httpx
from websockets import connect
SESSION = "655551.qa_test"
async def test():
print("=" * 60)
print("TEST FINAL - Captain Mobile")
print(f"Sesión: {SESSION}")
print("=" * 60)
# 1. Login
print("\n[1] Obteniendo token...")
async with httpx.AsyncClient() as client:
r = await client.post('http://localhost:3030/auth/login',
json={'username': 'admin', 'password': 'admin'})
if r.status_code != 200:
print(f"ERROR: Login failed {r.status_code}")
return
token = r.json()['token']
print(f" Token OK: {token[:20]}...")
# 2. WebSocket
print("\n[2] Conectando WebSocket...")
async with connect('ws://localhost:3030/ws/chat') as ws:
await ws.send(json.dumps({'token': token}))
init = json.loads(await ws.recv())
print(f" Init: {init['type']}")
# 3. Connect to session
print(f"\n[3] Conectando a sesión {SESSION}...")
await ws.send(json.dumps({'type': 'connect_session', 'full_name': SESSION}))
conn = json.loads(await ws.recv())
print(f" Resultado: {conn['type']}")
if conn['type'] != 'session_connected':
print(f" ERROR: No se pudo conectar a la sesión")
return
# 4. Esperar un poco para que se establezca la conexión
print("\n[4] Esperando 2s para estabilizar conexión...")
await asyncio.sleep(2)
# 5. Enviar mensaje simple
mensaje = "responde EXACTAMENTE: TEST_OK_12345"
print(f"\n[5] Enviando mensaje: '{mensaje}'")
await ws.send(json.dumps({'type': 'message', 'content': mensaje}))
# 6. Recibir respuestas
print("\n[6] Esperando respuestas (max 45s)...")
outputs = []
start = asyncio.get_event_loop().time()
try:
while (asyncio.get_event_loop().time() - start) < 45:
try:
msg = await asyncio.wait_for(ws.recv(), timeout=10.0)
data = json.loads(msg)
if data['type'] == 'output':
content = data['content']
outputs.append(content)
# Mostrar preview
preview = content[:80].replace('\n', '\\n')
print(f" Output #{len(outputs)} ({len(content)} chars): {preview}...")
# Buscar la respuesta esperada
if 'TEST_OK_12345' in content:
print(f" *** RESPUESTA ENCONTRADA! ***")
break
except asyncio.TimeoutError:
print(" (timeout 10s sin output)")
if outputs:
break
except Exception as e:
print(f" Error: {e}")
# 7. Validación
full_output = ''.join(outputs)
print("\n" + "=" * 60)
print("RESULTADOS")
print("=" * 60)
print(f"\nOutputs recibidos: {len(outputs)}")
print(f"Total caracteres: {len(full_output)}")
# Check 1: Respuesta encontrada
has_response = 'TEST_OK_12345' in full_output
print(f"\n[CHECK 1] Respuesta 'TEST_OK_12345': {'PASS' if has_response else 'FAIL'}")
# Check 2: Sin duplicados excesivos
no_duplicates = len(full_output) < 2000 # Respuesta simple debería ser corta
print(f"[CHECK 2] Sin duplicados excesivos (<2000 chars): {'PASS' if no_duplicates else 'FAIL'}")
# Check 3: UI filtrada
has_ui = 'bypass permissions' in full_output or '───' in full_output
no_ui = not has_ui
print(f"[CHECK 3] UI de Claude filtrada: {'PASS' if no_ui else 'FAIL'}")
# Resultado final
all_pass = has_response and no_duplicates and no_ui
print("\n" + "=" * 60)
print(f"RESULTADO FINAL: {'PASS' if all_pass else 'FAIL'}")
print("=" * 60)
if len(full_output) < 500:
print(f"\nRespuesta completa:\n{full_output}")
else:
print(f"\nRespuesta (primeros 500 chars):\n{full_output[:500]}...")
if __name__ == '__main__':
asyncio.run(test())

View File

@@ -0,0 +1,40 @@
import asyncio
import json
from websockets import connect
import httpx
async def test():
print("=== Test Simple ===")
async with httpx.AsyncClient() as client:
r = await client.post('http://localhost:3030/auth/login',
json={'username': 'admin', 'password': 'admin'})
token = r.json()['token']
async with connect('ws://localhost:3030/ws/chat') as ws:
await ws.send(json.dumps({'token': token}))
await ws.recv()
await ws.send(json.dumps({'type': 'create_session', 'name': 's'}))
created = json.loads(await ws.recv())
sid = created['session_id']
await ws.send(json.dumps({'type': 'connect_session', 'session_id': sid}))
await ws.recv()
print("Enviando: di hola")
await ws.send(json.dumps({'type': 'message', 'content': 'di hola'}))
print("Esperando respuesta (60s max)...")
for i in range(60):
try:
m = await asyncio.wait_for(ws.recv(), timeout=1.0)
data = json.loads(m)
print(f" RECIBIDO: {data}")
if data.get('type') == 'done':
print("\n=== COMPLETADO ===")
break
except asyncio.TimeoutError:
print(f" {i+1}s...", end="\r")
asyncio.run(test())

View File

@@ -0,0 +1,40 @@
import asyncio
import json
from websockets import connect
import httpx
async def test():
print("=== Test v3 Simple ===")
async with httpx.AsyncClient() as client:
r = await client.post('http://localhost:3030/auth/login',
json={'username': 'admin', 'password': 'admin'})
token = r.json()['token']
async with connect('ws://localhost:3030/ws/chat') as ws:
await ws.send(json.dumps({'token': token}))
await ws.recv() # init
await ws.send(json.dumps({'type': 'create_session', 'name': 't'}))
created = json.loads(await ws.recv())
sid = created['session_id']
await ws.send(json.dumps({'type': 'connect_session', 'session_id': sid}))
await ws.recv() # connected
print("Enviando mensaje...")
await ws.send(json.dumps({'type': 'message', 'content': 'di OK'}))
# Esperar respuesta con mucho timeout
print("Esperando (30s)...")
try:
for i in range(30):
try:
msg = await asyncio.wait_for(ws.recv(), timeout=1.0)
print(f"RECIBIDO: {msg}")
except asyncio.TimeoutError:
print(f" {i+1}s...", end="\r")
except Exception as e:
print(f"Error: {e}")
asyncio.run(test())

View File

@@ -0,0 +1,46 @@
import asyncio
import json
from websockets import connect
import httpx
async def test():
print("=== Test v3 Conversación ===\n")
async with httpx.AsyncClient() as client:
r = await client.post('http://localhost:3030/auth/login',
json={'username': 'admin', 'password': 'admin'})
token = r.json()['token']
async with connect('ws://localhost:3030/ws/chat') as ws:
await ws.send(json.dumps({'token': token}))
await ws.recv()
await ws.send(json.dumps({'type': 'create_session', 'name': 'conversacion'}))
created = json.loads(await ws.recv())
sid = created['session_id']
await ws.send(json.dumps({'type': 'connect_session', 'session_id': sid}))
await ws.recv()
async def send_and_wait(msg):
print(f"YO: {msg}")
await ws.send(json.dumps({'type': 'message', 'content': msg}))
response = ""
for _ in range(20):
try:
m = await asyncio.wait_for(ws.recv(), timeout=1.0)
data = json.loads(m)
if data.get('type') == 'output':
response = data.get('content', '')
if data.get('type') == 'done':
break
except asyncio.TimeoutError:
pass
print(f"CLAUDE: {response}\n")
return response
await send_and_wait("mi nombre es Pablo")
await send_and_wait("cuál es mi nombre?")
await send_and_wait("di hola")
asyncio.run(test())

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""
Test WebSocket - Simula comportamiento de app Flutter
Valida conexión, autenticación, mensajes y respuestas
"""
import asyncio
import json
import httpx
from websockets import connect
async def test():
print("=" * 60)
print("TEST WEBSOCKET - Simulador App Flutter")
print("=" * 60)
# 1. Login
print("\n[1] Obteniendo token de autenticación...")
async with httpx.AsyncClient() as client:
r = await client.post('http://localhost:3030/auth/login',
json={'username': 'admin', 'password': 'admin'})
if r.status_code != 200:
print(f"ERROR: Login failed with status {r.status_code}")
return
token = r.json()['token']
print(f" Token obtenido: {token[:20]}...")
# 2. WebSocket
print("\n[2] Conectando a WebSocket...")
async with connect('ws://localhost:3030/ws/chat') as ws:
# Auth
await ws.send(json.dumps({'token': token}))
init = json.loads(await ws.recv())
print(f" Init response: {init['type']}")
# Connect to session
print("\n[3] Conectando a sesión 648211.captain_test...")
await ws.send(json.dumps({'type': 'connect_session', 'full_name': '648211.captain_test'}))
conn = json.loads(await ws.recv())
print(f" Session response: {conn}")
# Send message
print("\n[4] Enviando mensaje: 'di solo: OK'")
await ws.send(json.dumps({'type': 'message', 'content': 'di solo: OK'}))
# Receive responses - longer timeout for Claude response
print("\n[5] Esperando respuestas (timeout 30s)...")
outputs = []
output_count = 0
start_time = asyncio.get_event_loop().time()
max_wait = 30.0 # Max 30 seconds total
idle_timeout = 8.0 # 8 seconds without output = done
try:
while (asyncio.get_event_loop().time() - start_time) < max_wait:
try:
msg = await asyncio.wait_for(ws.recv(), timeout=idle_timeout)
data = json.loads(msg)
if data['type'] == 'output':
output_count += 1
content = data['content']
outputs.append(content)
preview = content[:100].replace('\n', '\\n')
print(f" Output #{output_count} ({len(content)} chars): {preview}...")
elif data['type'] == 'status':
print(f" Status: {data.get('status', data)}")
elif data['type'] == 'error':
print(f" ERROR: {data.get('message', data)}")
except asyncio.TimeoutError:
print(" (idle timeout - fin de respuestas)")
break
except Exception as e:
print(f" Error: {e}")
# Validate
full_output = ''.join(outputs)
print("\n" + "=" * 60)
print("VALIDACION DE RESULTADOS")
print("=" * 60)
# Validación 1: Contiene OK
contains_ok = 'OK' in full_output or 'ok' in full_output.lower()
print(f"\n[CHECK 1] Contiene 'OK': {'PASS' if contains_ok else 'FAIL'}")
# Validación 2: No duplicados
# Detectar si hay contenido repetido
has_duplicates = False
if len(outputs) > 1:
# Verificar si chunks consecutivos son idénticos
for i in range(len(outputs) - 1):
if outputs[i] == outputs[i+1] and len(outputs[i]) > 10:
has_duplicates = True
break
# Verificar si el contenido total tiene patrones repetidos
if len(full_output) > 100:
half = len(full_output) // 2
first_half = full_output[:half]
second_half = full_output[half:half*2]
if first_half == second_half:
has_duplicates = True
print(f"[CHECK 2] Sin duplicados: {'PASS' if not has_duplicates else 'FAIL'}")
if has_duplicates:
print(" WARNING: Se detectó contenido duplicado!")
# Validación 3: Longitud razonable
is_short = len(full_output) <= 500
print(f"[CHECK 3] Longitud <= 500 chars: {'PASS' if is_short else 'FAIL'}")
print(f" Longitud actual: {len(full_output)} caracteres")
# Resumen
all_passed = contains_ok and not has_duplicates and is_short
print("\n" + "=" * 60)
if all_passed:
print("RESULTADO FINAL: PASS - Todas las validaciones correctas")
else:
print("RESULTADO FINAL: FAIL - Hay validaciones fallidas")
print("=" * 60)
# Mostrar respuesta completa si es corta
if len(full_output) <= 500:
print(f"\nRespuesta completa:\n{full_output}")
else:
print(f"\nRespuesta (primeros 500 chars):\n{full_output[:500]}...")
print(f"\n... (truncado, total: {len(full_output)} chars)")
if __name__ == '__main__':
asyncio.run(test())

View File

@@ -0,0 +1 @@
admin

View File

@@ -0,0 +1 @@
9bb9be71305495d244b9d4966699190ab607163867e52513375575496010238f

Binary file not shown.

View File

@@ -0,0 +1,43 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

View File

@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668
base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668
- platform: android
create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668
base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668
- platform: ios
create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668
base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668
- platform: linux
create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668
base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668
- platform: macos
create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668
base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668
- platform: web
create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668
base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668
- platform: windows
create_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668
base_revision: dec2ee5c1f98f8e84a7d5380c05eb8a3d0a81668
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View File

@@ -0,0 +1,16 @@
# captain_mobile_v2
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View File

@@ -0,0 +1,13 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,44 @@
plugins {
id "com.android.application"
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
android {
namespace = "com.tzzr.captain_mobile_v2"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.tzzr.captain_mobile_v2"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.debug
}
}
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,47 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="Captain Claude"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package com.tzzr.captain_mobile_v2
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,18 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = "../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip

View File

@@ -0,0 +1,25 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.1.0" apply false
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
}
include ":app"

View File

@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1,616 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.tzzr.captainMobileV2;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tzzr.captainMobileV2.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tzzr.captainMobileV2.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.tzzr.captainMobileV2.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.tzzr.captainMobileV2;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.tzzr.captainMobileV2;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,13 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Captain Mobile V2</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>captain_mobile_v2</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

View File

@@ -0,0 +1,13 @@
class ApiConfig {
// Captain Mobile v2 API
static const String baseUrl = 'http://69.62.126.110:3030';
static const String wsUrl = 'ws://69.62.126.110:3030';
// For local development
// static const String baseUrl = 'http://localhost:3030';
// static const String wsUrl = 'ws://localhost:3030';
static const Duration connectionTimeout = Duration(seconds: 30);
static const Duration reconnectDelay = Duration(seconds: 3);
static const int maxReconnectAttempts = 5;
}

View File

@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'providers/auth_provider.dart';
import 'providers/chat_provider.dart';
import 'screens/login_screen.dart';
import 'screens/chat_screen.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
),
);
runApp(const CaptainClaudeApp());
}
class CaptainClaudeApp extends StatelessWidget {
const CaptainClaudeApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthProvider()..init()),
ChangeNotifierProvider(create: (_) => ChatProvider()),
],
child: MaterialApp(
title: 'Captain Claude',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
primaryColor: Colors.orange.shade700,
scaffoldBackgroundColor: const Color(0xFF1A1A1A),
colorScheme: ColorScheme.dark(
primary: Colors.orange.shade700,
secondary: Colors.orange.shade400,
surface: const Color(0xFF2D2D2D),
),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF2D2D2D),
elevation: 0,
),
fontFamily: 'Roboto',
),
home: const AuthWrapper(),
),
);
}
}
class AuthWrapper extends StatelessWidget {
const AuthWrapper({super.key});
@override
Widget build(BuildContext context) {
return Consumer<AuthProvider>(
builder: (context, auth, _) {
if (auth.isLoading) {
return const Scaffold(
backgroundColor: Color(0xFF1A1A1A),
body: Center(
child: CircularProgressIndicator(color: Colors.orange),
),
);
}
if (auth.isAuthenticated) {
return const ChatScreen();
}
return const LoginScreen();
},
);
}
}

View File

@@ -0,0 +1,38 @@
class Conversation {
final String id;
final String? title;
final DateTime createdAt;
final DateTime updatedAt;
final int messageCount;
Conversation({
required this.id,
this.title,
required this.createdAt,
required this.updatedAt,
this.messageCount = 0,
});
factory Conversation.fromJson(Map<String, dynamic> json) {
return Conversation(
id: json['id'],
title: json['title'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
messageCount: json['message_count'] ?? 0,
);
}
String get displayTitle => title ?? 'New Conversation';
String get timeAgo {
final now = DateTime.now();
final diff = now.difference(updatedAt);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return '${updatedAt.day}/${updatedAt.month}/${updatedAt.year}';
}
}

View File

@@ -0,0 +1,85 @@
class ToolUse {
final String tool;
final dynamic input;
final String? output;
ToolUse({
required this.tool,
this.input,
this.output,
});
factory ToolUse.fromJson(Map<String, dynamic> json) {
return ToolUse(
tool: json['tool'] ?? 'unknown',
input: json['input'],
output: json['output'],
);
}
Map<String, dynamic> toJson() => {
'tool': tool,
'input': input,
'output': output,
};
}
class Message {
final String id;
final String role;
final String content;
final List<ToolUse>? toolUses;
final bool isStreaming;
final bool isThinking;
final DateTime createdAt;
Message({
String? id,
required this.role,
required this.content,
this.toolUses,
this.isStreaming = false,
this.isThinking = false,
DateTime? createdAt,
}) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(),
createdAt = createdAt ?? DateTime.now();
Message copyWith({
String? id,
String? role,
String? content,
List<ToolUse>? toolUses,
bool? isStreaming,
bool? isThinking,
DateTime? createdAt,
}) {
return Message(
id: id ?? this.id,
role: role ?? this.role,
content: content ?? this.content,
toolUses: toolUses ?? this.toolUses,
isStreaming: isStreaming ?? this.isStreaming,
isThinking: isThinking ?? this.isThinking,
createdAt: createdAt ?? this.createdAt,
);
}
factory Message.fromJson(Map<String, dynamic> json) {
return Message(
id: json['id'],
role: json['role'],
content: json['content'],
toolUses: json['tool_uses'] != null
? (json['tool_uses'] as List)
.map((e) => ToolUse.fromJson(e))
.toList()
: null,
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'])
: DateTime.now(),
);
}
bool get isUser => role == 'user';
bool get isAssistant => role == 'assistant';
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/foundation.dart';
import '../services/auth_service.dart';
class AuthProvider with ChangeNotifier {
final AuthService _authService = AuthService();
bool _isLoading = false;
String? _error;
bool get isLoading => _isLoading;
bool get isAuthenticated => _authService.isAuthenticated;
String? get token => _authService.token;
String? get username => _authService.username;
String? get error => _error;
Future<void> init() async {
_isLoading = true;
notifyListeners();
await _authService.loadStoredAuth();
_isLoading = false;
notifyListeners();
}
Future<bool> login(String username, String password) async {
_isLoading = true;
_error = null;
notifyListeners();
final success = await _authService.login(username, password);
if (!success) {
_error = _authService.lastError ?? 'Login failed';
}
_isLoading = false;
notifyListeners();
return success;
}
Future<void> logout() async {
await _authService.logout();
notifyListeners();
}
void clearError() {
_error = null;
notifyListeners();
}
}

View File

@@ -0,0 +1,295 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/message.dart';
import '../services/chat_service.dart';
import '../providers/auth_provider.dart';
/// Chat provider for v3 API (dynamic Claude sessions)
class ChatProvider with ChangeNotifier {
final ChatService _chatService = ChatService();
final List<Message> _messages = [];
static const int _maxMessages = 500;
AuthProvider? _authProvider;
StreamSubscription? _messageSubscription;
StreamSubscription? _stateSubscription;
StreamSubscription? _errorSubscription;
List<ChatSession> _sessions = [];
ChatSession? _currentSession;
bool _isProcessing = false;
String? _error;
List<Message> get messages => List.unmodifiable(_messages);
List<ChatSession> get sessions => List.unmodifiable(_sessions);
ChatSession? get currentSession => _currentSession;
bool get isProcessing => _isProcessing;
bool get isConnected => _chatService.currentState == ChatConnectionState.connected;
bool get isSessionConnected => _currentSession != null;
ChatConnectionState get connectionState => _chatService.currentState;
String? get error => _error;
void updateAuth(AuthProvider auth) {
_authProvider = auth;
_chatService.setToken(auth.token);
if (auth.isAuthenticated && !isConnected) {
connect();
} else if (!auth.isAuthenticated && isConnected) {
disconnect();
}
}
Future<void> connect() async {
if (_authProvider?.token == null) return;
_chatService.setToken(_authProvider!.token);
_stateSubscription?.cancel();
_stateSubscription = _chatService.connectionState.listen((state) {
notifyListeners();
});
_errorSubscription?.cancel();
_errorSubscription = _chatService.errors.listen((error) {
_error = error;
notifyListeners();
});
_messageSubscription?.cancel();
_messageSubscription = _chatService.messages.listen(_handleMessage);
await _chatService.connect();
}
void _handleMessage(Map<String, dynamic> data) {
final type = data['type'];
debugPrint('ChatProvider: type=$type');
switch (type) {
// Initial state with sessions
case 'init':
final sessionsList = data['sessions'] as List<dynamic>?;
if (sessionsList != null) {
_sessions = sessionsList
.map((s) => ChatSession.fromJson(s))
.toList();
}
notifyListeners();
break;
// Sessions list update
case 'sessions_list':
final sessionsList = data['sessions'] as List<dynamic>?;
if (sessionsList != null) {
_sessions = sessionsList
.map((s) => ChatSession.fromJson(s))
.toList();
}
notifyListeners();
break;
// Session created (v3)
case 'session_created':
final sessionId = data['session_id'] as String?;
final name = data['name'] as String?;
if (sessionId != null) {
final newSession = ChatSession(
sessionId: sessionId,
name: name ?? 'New Session',
);
_sessions.add(newSession);
_messages.add(Message(
role: 'system',
content: 'Sesión creada: ${newSession.name}',
));
// Auto-connect to new session
connectToSession(sessionId);
}
notifyListeners();
break;
// Connected to session (v3)
case 'session_connected':
final sessionId = data['session_id'] as String?;
final name = data['name'] as String?;
if (sessionId != null) {
_currentSession = _sessions.firstWhere(
(s) => s.sessionId == sessionId,
orElse: () => ChatSession(
sessionId: sessionId,
name: name ?? 'Session',
),
);
_messages.clear();
_messages.add(Message(
role: 'system',
content: 'Conectado a: ${_currentSession!.name}',
));
}
notifyListeners();
break;
// Output from Claude (v3)
case 'output':
final content = data['content'] as String? ?? '';
if (content.isEmpty) break;
debugPrint('ChatProvider: OUTPUT "${content.substring(0, content.length > 50 ? 50 : content.length)}"');
_isProcessing = true;
// Check if it's a progress indicator
if (content.startsWith('procesando')) {
// Update or create progress message
if (_messages.isNotEmpty && _messages.last.role == 'assistant' && _messages.last.isStreaming == true) {
final lastIndex = _messages.length - 1;
_messages[lastIndex] = Message(
role: 'assistant',
content: content,
isStreaming: true,
);
} else {
_messages.add(Message(
role: 'assistant',
content: content,
isStreaming: true,
));
}
} else {
// Real content - replace progress or add new
if (_messages.isNotEmpty && _messages.last.role == 'assistant' && _messages.last.isStreaming == true) {
final lastIndex = _messages.length - 1;
final lastContent = _messages[lastIndex].content;
// If last message was progress, replace it. Otherwise append.
if (lastContent.startsWith('procesando')) {
_messages[lastIndex] = Message(
role: 'assistant',
content: content,
isStreaming: true,
);
} else {
// Append to existing response
_messages[lastIndex] = Message(
role: 'assistant',
content: lastContent + content,
isStreaming: true,
);
}
} else {
_messages.add(Message(
role: 'assistant',
content: content,
isStreaming: true,
));
}
}
_trimMessages();
notifyListeners();
break;
// Response complete (v3)
case 'done':
_isProcessing = false;
// Mark last assistant message as complete
if (_messages.isNotEmpty && _messages.last.role == 'assistant') {
final lastIndex = _messages.length - 1;
_messages[lastIndex] = Message(
role: 'assistant',
content: _messages[lastIndex].content,
isStreaming: false,
);
}
notifyListeners();
break;
case 'error':
_isProcessing = false;
final errorMsg = data['message'] ?? data['content'] ?? 'Error';
if (errorMsg.toString().isNotEmpty) {
_error = errorMsg;
_messages.add(Message(
role: 'system',
content: 'Error: $errorMsg',
));
}
notifyListeners();
break;
}
}
void _trimMessages() {
while (_messages.length > _maxMessages) {
_messages.removeAt(0);
}
}
/// Create and connect to a new session
void createSession(String name) {
_chatService.createSession(name);
}
/// Connect to an existing session
void connectToSession(String sessionId) {
_chatService.connectToSession(sessionId);
}
/// Refresh sessions list
void refreshSessions() {
_chatService.listSessions();
}
/// Send message to current session
void sendMessage(String content) {
if (content.trim().isEmpty) return;
if (!isConnected) {
_error = 'No conectado al servidor';
notifyListeners();
return;
}
if (_currentSession == null) {
_error = 'No hay sesión activa';
notifyListeners();
return;
}
if (_isProcessing) {
_error = 'Espera a que termine la respuesta anterior';
notifyListeners();
return;
}
_messages.add(Message(
role: 'user',
content: content,
));
_trimMessages();
_isProcessing = true;
notifyListeners();
_chatService.sendMessage(content);
}
void clearMessages() {
_messages.clear();
notifyListeners();
}
void clearError() {
_error = null;
notifyListeners();
}
void disconnect() {
_chatService.disconnect();
_messageSubscription?.cancel();
_stateSubscription?.cancel();
_errorSubscription?.cancel();
_currentSession = null;
}
@override
void dispose() {
disconnect();
_chatService.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,358 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import '../providers/chat_provider.dart';
import '../services/chat_service.dart';
import '../widgets/message_bubble.dart';
import '../widgets/chat_input.dart';
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
final auth = context.read<AuthProvider>();
final chat = context.read<ChatProvider>();
chat.updateAuth(auth);
chat.addListener(_onChatUpdate);
});
}
String? _lastError;
void _onChatUpdate() {
final chat = context.read<ChatProvider>();
if (chat.error != null && chat.error != _lastError) {
_lastError = chat.error;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(chat.error!),
backgroundColor: Colors.red.shade700,
action: SnackBarAction(
label: 'OK',
textColor: Colors.white,
onPressed: () => chat.clearError(),
),
),
);
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_scrollController.dispose();
try {
final chat = context.read<ChatProvider>();
chat.removeListener(_onChatUpdate);
} catch (_) {}
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
final chat = context.read<ChatProvider>();
if (!chat.isConnected) {
chat.connect();
}
}
}
void _scrollToBottom() {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
void _sendMessage(String message) {
final chat = context.read<ChatProvider>();
chat.sendMessage(message);
Future.delayed(const Duration(milliseconds: 100), _scrollToBottom);
}
void _newChat() {
final chat = context.read<ChatProvider>();
final name = 'Chat ${DateTime.now().hour}:${DateTime.now().minute.toString().padLeft(2, '0')}';
chat.createSession(name);
}
void _logout() {
final auth = context.read<AuthProvider>();
final chat = context.read<ChatProvider>();
chat.disconnect();
auth.logout();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF1A1A1A),
appBar: AppBar(
backgroundColor: const Color(0xFF2D2D2D),
elevation: 0,
title: Consumer<ChatProvider>(
builder: (context, chat, _) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.auto_awesome, color: Colors.orange.shade400, size: 24),
const SizedBox(width: 8),
Text(
chat.currentSession?.name ?? 'Captain Claude',
style: const TextStyle(fontSize: 18),
),
],
);
},
),
actions: [
Consumer<ChatProvider>(
builder: (context, chat, _) {
return IconButton(
icon: const Icon(Icons.add),
onPressed: _newChat,
tooltip: 'Nuevo Chat',
);
},
),
IconButton(
icon: const Icon(Icons.logout),
onPressed: _logout,
tooltip: 'Salir',
),
],
),
body: Column(
children: [
// Connection status bar
Consumer<ChatProvider>(
builder: (context, chat, _) {
return _buildConnectionBar(chat);
},
),
// Messages list
Expanded(
child: Consumer<ChatProvider>(
builder: (context, chat, _) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (chat.isProcessing) _scrollToBottom();
});
if (!chat.isSessionConnected) {
return _buildStartState();
}
if (chat.messages.isEmpty) {
return _buildEmptyState();
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.only(top: 8, bottom: 8),
itemCount: chat.messages.length,
itemBuilder: (context, index) {
return MessageBubble(message: chat.messages[index]);
},
);
},
),
),
// Input area
Consumer<ChatProvider>(
builder: (context, chat, _) {
return ChatInput(
onSend: _sendMessage,
isConnected: chat.isConnected && chat.isSessionConnected,
isLoading: chat.isProcessing,
);
},
),
],
),
);
}
Widget _buildConnectionBar(ChatProvider chat) {
if (chat.connectionState == ChatConnectionState.connected) {
return const SizedBox.shrink();
}
Color backgroundColor;
String text;
IconData icon;
switch (chat.connectionState) {
case ChatConnectionState.connecting:
backgroundColor = Colors.blue.shade700;
text = 'Conectando...';
icon = Icons.sync;
break;
case ChatConnectionState.reconnecting:
backgroundColor = Colors.orange.shade700;
text = 'Reconectando...';
icon = Icons.sync;
break;
case ChatConnectionState.error:
backgroundColor = Colors.red.shade700;
text = 'Error de conexión';
icon = Icons.error_outline;
break;
default:
backgroundColor = Colors.grey.shade700;
text = 'Desconectado';
icon = Icons.cloud_off;
}
final showRetry = chat.connectionState == ChatConnectionState.error ||
chat.connectionState == ChatConnectionState.disconnected;
return GestureDetector(
onTap: showRetry ? () => chat.connect() : null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: backgroundColor,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 16, color: Colors.white),
const SizedBox(width: 8),
Text(
showRetry ? '$text - Toca para reintentar' : text,
style: const TextStyle(color: Colors.white, fontSize: 13),
),
if (chat.connectionState == ChatConnectionState.connecting ||
chat.connectionState == ChatConnectionState.reconnecting) ...[
const SizedBox(width: 8),
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
),
],
],
),
),
);
}
Widget _buildStartState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.chat_bubble_outline,
size: 80,
color: Colors.orange.shade400.withOpacity(0.5),
),
const SizedBox(height: 24),
Text(
'Captain Claude',
style: TextStyle(
color: Colors.grey.shade300,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Text(
'Toca el botón + para iniciar un chat',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.grey.shade500,
fontSize: 15,
),
),
const SizedBox(height: 32),
ElevatedButton.icon(
onPressed: _newChat,
icon: const Icon(Icons.add),
label: const Text('Nuevo Chat'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
),
),
],
),
),
);
}
Widget _buildEmptyState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.auto_awesome,
size: 80,
color: Colors.orange.shade400.withOpacity(0.5),
),
const SizedBox(height: 24),
Text(
'Chat Conectado',
style: TextStyle(
color: Colors.grey.shade300,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Text(
'Escribe un mensaje para hablar con Claude',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.grey.shade500,
fontSize: 15,
),
),
const SizedBox(height: 32),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
_buildSuggestionChip('Hola'),
_buildSuggestionChip('Estado del sistema'),
_buildSuggestionChip('Ayuda'),
],
),
],
),
),
);
}
Widget _buildSuggestionChip(String text) {
return ActionChip(
label: Text(text),
backgroundColor: const Color(0xFF2D2D2D),
labelStyle: TextStyle(color: Colors.grey.shade300, fontSize: 13),
side: BorderSide(color: Colors.grey.shade700),
onPressed: () => _sendMessage(text),
);
}
}

View File

@@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import '../services/api_service.dart';
import '../models/conversation.dart';
class HistoryScreen extends StatefulWidget {
final Function(String) onSelectConversation;
const HistoryScreen({
super.key,
required this.onSelectConversation,
});
@override
State<HistoryScreen> createState() => _HistoryScreenState();
}
class _HistoryScreenState extends State<HistoryScreen> {
final ApiService _apiService = ApiService();
List<Conversation> _conversations = [];
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_loadConversations();
}
Future<void> _loadConversations() async {
final auth = context.read<AuthProvider>();
_apiService.setToken(auth.token);
setState(() {
_isLoading = true;
_error = null;
});
try {
final conversations = await _apiService.getConversations();
setState(() {
_conversations = conversations;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _deleteConversation(String id) async {
try {
await _apiService.deleteConversation(id);
setState(() {
_conversations.removeWhere((c) => c.id == id);
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to delete: $e'),
backgroundColor: Colors.red.shade700,
),
);
}
}
}
IconData _getTitleIcon(String title) {
final lower = title.toLowerCase();
if (lower.contains('error') || lower.contains('fix') || lower.contains('bug')) {
return Icons.bug_report;
}
if (lower.contains('test')) {
return Icons.science;
}
if (lower.contains('backup') || lower.contains('restore')) {
return Icons.backup;
}
if (lower.contains('deploy') || lower.contains('build')) {
return Icons.rocket_launch;
}
if (lower.contains('install') || lower.contains('setup')) {
return Icons.download;
}
return Icons.chat_bubble_outline;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF1A1A1A),
appBar: AppBar(
backgroundColor: const Color(0xFF2D2D2D),
title: const Text('Conversations'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadConversations,
),
],
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(color: Colors.orange),
);
}
if (_error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: Colors.red.shade400),
const SizedBox(height: 16),
Text(
_error!,
style: TextStyle(color: Colors.grey.shade400),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadConversations,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange.shade700,
),
child: const Text('Retry'),
),
],
),
);
}
if (_conversations.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey.shade700),
const SizedBox(height: 16),
Text(
'No conversations yet',
style: TextStyle(
color: Colors.grey.shade400,
fontSize: 18,
),
),
const SizedBox(height: 8),
Text(
'Start a new chat to get started',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 14,
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: _loadConversations,
color: Colors.orange,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _conversations.length,
itemBuilder: (context, index) {
final conv = _conversations[index];
return Dismissible(
key: Key(conv.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
color: Colors.red.shade700,
child: const Icon(Icons.delete, color: Colors.white),
),
confirmDismiss: (_) async {
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF2D2D2D),
title: const Text(
'Delete Conversation?',
style: TextStyle(color: Colors.white),
),
content: const Text(
'This action cannot be undone.',
style: TextStyle(color: Colors.white70),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(
foregroundColor: Colors.red.shade400,
),
child: const Text('Delete'),
),
],
),
);
},
onDismissed: (_) => _deleteConversation(conv.id),
child: ListTile(
leading: CircleAvatar(
backgroundColor: const Color(0xFF3D3D3D),
child: Icon(
_getTitleIcon(conv.displayTitle),
color: Colors.orange.shade400,
size: 20,
),
),
title: Text(
conv.displayTitle,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${conv.timeAgo}${conv.messageCount} messages',
style: TextStyle(
color: Colors.grey.shade500,
fontSize: 12,
),
),
trailing: Icon(
Icons.chevron_right,
color: Colors.grey.shade600,
),
onTap: () {
widget.onSelectConversation(conv.id);
Navigator.pop(context);
},
),
);
},
),
);
}
}

View File

@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _login() async {
final username = _usernameController.text.trim();
final password = _passwordController.text;
if (username.isEmpty || password.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please enter username and password'),
backgroundColor: Colors.orange,
),
);
return;
}
final auth = context.read<AuthProvider>();
await auth.login(username, password);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF1A1A1A),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 60),
// Logo
Icon(
Icons.auto_awesome,
size: 80,
color: Colors.orange.shade400,
),
const SizedBox(height: 24),
// Title
const Text(
'Captain Claude',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Your AI Command Center',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.grey.shade400,
fontSize: 16,
),
),
const SizedBox(height: 60),
// Username field
TextField(
controller: _usernameController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Username',
labelStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: Icon(Icons.person, color: Colors.grey.shade400),
filled: true,
fillColor: const Color(0xFF2D2D2D),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.orange.shade400),
),
),
),
const SizedBox(height: 16),
// Password field
TextField(
controller: _passwordController,
obscureText: _obscurePassword,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Password',
labelStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: Icon(Icons.lock, color: Colors.grey.shade400),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off
: Icons.visibility,
color: Colors.grey.shade400,
),
onPressed: () {
setState(() => _obscurePassword = !_obscurePassword);
},
),
filled: true,
fillColor: const Color(0xFF2D2D2D),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.orange.shade400),
),
),
onSubmitted: (_) => _login(),
),
const SizedBox(height: 24),
// Error message
Consumer<AuthProvider>(
builder: (context, auth, _) {
if (auth.error != null) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
auth.error!,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.red.shade400,
fontSize: 14,
),
),
);
}
return const SizedBox.shrink();
},
),
// Login button
Consumer<AuthProvider>(
builder: (context, auth, _) {
return ElevatedButton(
onPressed: auth.isLoading ? null : _login,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
disabledBackgroundColor: Colors.grey.shade800,
),
child: auth.isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Sign In',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
);
},
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,55 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../config/api_config.dart';
import '../models/conversation.dart';
import '../models/message.dart';
class ApiService {
String? _token;
void setToken(String? token) {
_token = token;
}
Map<String, String> get _headers => {
'Content-Type': 'application/json',
if (_token != null) 'Authorization': 'Bearer $_token',
};
Future<List<Conversation>> getConversations() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}/conversations'),
headers: _headers,
).timeout(ApiConfig.connectionTimeout);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body);
return data.map((e) => Conversation.fromJson(e)).toList();
}
throw Exception('Failed to load conversations');
}
Future<List<Message>> getConversationMessages(String conversationId) async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}/conversations/$conversationId'),
headers: _headers,
).timeout(ApiConfig.connectionTimeout);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body);
return data.map((e) => Message.fromJson(e)).toList();
}
throw Exception('Failed to load messages');
}
Future<void> deleteConversation(String conversationId) async {
final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}/conversations/$conversationId'),
headers: _headers,
).timeout(ApiConfig.connectionTimeout);
if (response.statusCode != 200) {
throw Exception('Failed to delete conversation');
}
}
}

View File

@@ -0,0 +1,74 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../config/api_config.dart';
class AuthService {
static const String _tokenKey = 'auth_token';
static const String _expiresKey = 'token_expires';
static const String _usernameKey = 'username';
String? _token;
DateTime? _expiresAt;
String? _username;
String? get token => _token;
String? get username => _username;
bool get isAuthenticated => _token != null && !isExpired;
bool get isExpired =>
_expiresAt != null && DateTime.now().isAfter(_expiresAt!);
Future<void> loadStoredAuth() async {
final prefs = await SharedPreferences.getInstance();
_token = prefs.getString(_tokenKey);
_username = prefs.getString(_usernameKey);
final expiresStr = prefs.getString(_expiresKey);
if (expiresStr != null) {
_expiresAt = DateTime.tryParse(expiresStr);
}
}
String? lastError;
Future<bool> login(String username, String password) async {
lastError = null;
try {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}/auth/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'username': username, 'password': password}),
).timeout(ApiConfig.connectionTimeout);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
_token = data['token'];
_expiresAt = DateTime.parse(data['expires_at']);
_username = username;
// Save to storage
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, _token!);
await prefs.setString(_expiresKey, _expiresAt!.toIso8601String());
await prefs.setString(_usernameKey, _username!);
return true;
}
lastError = 'Invalid credentials (${response.statusCode})';
return false;
} catch (e) {
lastError = 'Connection error: $e';
return false;
}
}
Future<void> logout() async {
_token = null;
_expiresAt = null;
_username = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
await prefs.remove(_expiresKey);
await prefs.remove(_usernameKey);
}
}

View File

@@ -0,0 +1,223 @@
import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../config/api_config.dart';
enum ChatConnectionState {
disconnected,
connecting,
connected,
reconnecting,
error,
}
/// Chat session info (v3 - dynamic sessions)
class ChatSession {
final String sessionId;
final String name;
final String? createdAt;
ChatSession({
required this.sessionId,
required this.name,
this.createdAt,
});
factory ChatSession.fromJson(Map<String, dynamic> json) {
return ChatSession(
sessionId: json['session_id'] ?? '',
name: json['name'] ?? 'Session',
createdAt: json['created_at'],
);
}
}
/// Chat service that connects to Claude sessions (v3)
class ChatService {
WebSocketChannel? _channel;
String? _token;
final _messagesController = StreamController<Map<String, dynamic>>.broadcast();
final _stateController = StreamController<ChatConnectionState>.broadcast();
final _errorController = StreamController<String>.broadcast();
ChatConnectionState _currentState = ChatConnectionState.disconnected;
int _reconnectAttempts = 0;
Timer? _reconnectTimer;
Timer? _pingTimer;
bool _intentionalDisconnect = false;
Stream<Map<String, dynamic>> get messages => _messagesController.stream;
Stream<ChatConnectionState> get connectionState => _stateController.stream;
Stream<String> get errors => _errorController.stream;
ChatConnectionState get currentState => _currentState;
void setToken(String? token) {
_token = token;
}
void _setState(ChatConnectionState state) {
_currentState = state;
_stateController.add(state);
}
Future<void> connect() async {
if (_token == null) return;
if (_currentState == ChatConnectionState.connecting) return;
_intentionalDisconnect = false;
_setState(ChatConnectionState.connecting);
try {
_channel?.sink.close();
_channel = WebSocketChannel.connect(
Uri.parse('${ApiConfig.wsUrl}/ws/chat'),
);
// Send auth token immediately
_channel!.sink.add(jsonEncode({'token': _token}));
_channel!.stream.listen(
(data) {
try {
final message = jsonDecode(data);
_handleMessage(message);
} catch (e) {
_errorController.add('Failed to parse message: $e');
}
},
onError: (error) {
_errorController.add('WebSocket error: $error');
_setState(ChatConnectionState.error);
if (!_intentionalDisconnect) {
_scheduleReconnect();
}
},
onDone: () {
_setState(ChatConnectionState.disconnected);
if (!_intentionalDisconnect) {
_scheduleReconnect();
}
},
cancelOnError: false,
);
} catch (e) {
_errorController.add('Connection failed: $e');
_setState(ChatConnectionState.error);
if (!_intentionalDisconnect) {
_scheduleReconnect();
}
}
}
void _handleMessage(Map<String, dynamic> message) {
final type = message['type'];
switch (type) {
case 'init':
// Initial state with sessions list
_setState(ChatConnectionState.connected);
_reconnectAttempts = 0;
_startPingTimer();
_messagesController.add(message);
break;
case 'error':
final errorMsg = message['message'] ?? message['content'] ?? 'Unknown error';
_errorController.add(errorMsg);
break;
case 'pong':
break;
default:
// Forward all other messages (output, done, session_connected, etc.)
_messagesController.add(message);
}
}
void _startPingTimer() {
_pingTimer?.cancel();
_pingTimer = Timer.periodic(const Duration(seconds: 30), (_) {
if (_currentState == ChatConnectionState.connected) {
sendRaw({'type': 'ping'});
}
});
}
void _scheduleReconnect() {
if (_intentionalDisconnect) return;
if (_reconnectAttempts >= ApiConfig.maxReconnectAttempts) {
_errorController.add('Max reconnection attempts reached');
return;
}
_reconnectTimer?.cancel();
final delay = Duration(
seconds: ApiConfig.reconnectDelay.inSeconds * (1 << _reconnectAttempts),
);
_reconnectAttempts++;
_setState(ChatConnectionState.reconnecting);
_reconnectTimer = Timer(delay, () {
if (_token != null && !_intentionalDisconnect) {
connect();
}
});
}
/// Create a new chat session
void createSession(String name) {
sendRaw({
'type': 'create_session',
'name': name,
});
}
/// Connect to an existing session by session_id
void connectToSession(String sessionId) {
sendRaw({
'type': 'connect_session',
'session_id': sessionId,
});
}
/// Send message to connected session
void sendMessage(String content) {
if (_currentState != ChatConnectionState.connected) {
_errorController.add('Not connected');
return;
}
sendRaw({
'type': 'message',
'content': content,
});
}
/// Request sessions list
void listSessions() {
sendRaw({'type': 'list_sessions'});
}
void sendRaw(Map<String, dynamic> data) {
if (_channel != null) {
_channel!.sink.add(jsonEncode(data));
}
}
void disconnect() {
_intentionalDisconnect = true;
_pingTimer?.cancel();
_reconnectTimer?.cancel();
_channel?.sink.close();
_setState(ChatConnectionState.disconnected);
}
void dispose() {
disconnect();
_messagesController.close();
_stateController.close();
_errorController.close();
}
}

View File

@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
class ChatInput extends StatefulWidget {
final Function(String) onSend;
final bool isConnected;
final bool isLoading;
const ChatInput({
super.key,
required this.onSend,
this.isConnected = true,
this.isLoading = false,
});
@override
State<ChatInput> createState() => _ChatInputState();
}
class _ChatInputState extends State<ChatInput> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
bool get _canSend =>
widget.isConnected &&
!widget.isLoading &&
_controller.text.trim().isNotEmpty;
void _send() {
if (!_canSend) return;
final text = _controller.text.trim();
_controller.clear();
widget.onSend(text);
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(
left: 12,
right: 12,
top: 8,
bottom: MediaQuery.of(context).padding.bottom + 8,
),
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
border: Border(
top: BorderSide(color: Colors.grey.shade800),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Input field
Expanded(
child: Container(
constraints: const BoxConstraints(maxHeight: 120),
decoration: BoxDecoration(
color: const Color(0xFF2D2D2D),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: widget.isConnected
? Colors.grey.shade700
: Colors.red.shade700,
),
),
child: TextField(
controller: _controller,
focusNode: _focusNode,
maxLines: null,
textInputAction: TextInputAction.newline,
onChanged: (_) => setState(() {}),
style: const TextStyle(
color: Colors.white,
fontSize: 15,
),
decoration: InputDecoration(
hintText: widget.isConnected
? 'Message Claude...'
: 'Disconnected',
hintStyle: TextStyle(
color: Colors.grey.shade500,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
border: InputBorder.none,
),
),
),
),
const SizedBox(width: 8),
// Send button
Container(
decoration: BoxDecoration(
color: _canSend ? Colors.orange.shade700 : Colors.grey.shade800,
shape: BoxShape.circle,
),
child: IconButton(
onPressed: _canSend ? _send : null,
icon: widget.isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.grey.shade400,
),
)
: Icon(
Icons.send,
color: _canSend ? Colors.white : Colors.grey.shade600,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_highlight/flutter_highlight.dart';
import 'package:flutter_highlight/themes/atom-one-dark.dart';
class CodeBlock extends StatelessWidget {
final String code;
final String? language;
const CodeBlock({
super.key,
required this.code,
this.language,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFF282C34),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade800),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header with language and copy button
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.grey.shade900,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
language ?? 'code',
style: TextStyle(
color: Colors.grey.shade500,
fontSize: 12,
),
),
InkWell(
onTap: () {
Clipboard.setData(ClipboardData(text: code));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Copied to clipboard'),
duration: Duration(seconds: 1),
),
);
},
child: Icon(
Icons.copy,
size: 16,
color: Colors.grey.shade500,
),
),
],
),
),
// Code content
Padding(
padding: const EdgeInsets.all(12),
child: HighlightView(
code,
language: language ?? 'plaintext',
theme: atomOneDarkTheme,
textStyle: const TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: 13,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,195 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import '../models/message.dart';
import 'code_block.dart';
import 'tool_use_card.dart';
import 'thinking_indicator.dart';
class MessageBubble extends StatelessWidget {
final Message message;
const MessageBubble({super.key, required this.message});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: message.isUser ? _buildUserBubble() : _buildAssistantBubble(),
);
}
Widget _buildUserBubble() {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 48), // Spacing for alignment
Flexible(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade700,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(4),
),
),
child: Text(
message.content,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
),
),
),
),
const SizedBox(width: 8),
CircleAvatar(
radius: 16,
backgroundColor: Colors.orange.shade800,
child: const Icon(Icons.person, size: 18, color: Colors.white),
),
],
);
}
Widget _buildAssistantBubble() {
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 16,
backgroundColor: const Color(0xFF2D2D2D),
child: Icon(Icons.auto_awesome, size: 18, color: Colors.orange.shade400),
),
const SizedBox(width: 8),
Flexible(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF2D2D2D),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(16),
),
),
child: _buildContent(),
),
),
const SizedBox(width: 48), // Spacing for alignment
],
);
}
Widget _buildContent() {
if (message.isThinking && message.content.isEmpty) {
return const ThinkingIndicator();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Tool uses first
if (message.toolUses != null && message.toolUses!.isNotEmpty)
...message.toolUses!.map((tool) => ToolUseCard(toolUse: tool)),
// Then the text content with Markdown
if (message.content.isNotEmpty)
MarkdownBody(
data: message.content,
selectable: true,
styleSheet: MarkdownStyleSheet(
p: const TextStyle(
color: Colors.white,
fontSize: 15,
height: 1.5,
),
h1: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
h2: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
h3: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
code: TextStyle(
color: Colors.orange.shade300,
backgroundColor: Colors.black26,
fontFamily: 'JetBrainsMono',
fontSize: 13,
),
codeblockDecoration: BoxDecoration(
color: const Color(0xFF282C34),
borderRadius: BorderRadius.circular(8),
),
blockquote: const TextStyle(
color: Colors.white70,
fontStyle: FontStyle.italic,
),
blockquoteDecoration: BoxDecoration(
border: Border(
left: BorderSide(color: Colors.orange.shade400, width: 3),
),
),
listBullet: const TextStyle(color: Colors.white70),
a: TextStyle(color: Colors.orange.shade300),
),
builders: {
'code': _CodeBlockBuilder(),
},
),
// Streaming indicator
if (message.isStreaming && !message.isThinking)
Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.orange.shade400,
),
),
),
],
);
}
}
class _CodeBlockBuilder extends MarkdownElementBuilder {
@override
Widget? visitElementAfter(element, preferredStyle) {
// Check if it's a fenced code block
final content = element.textContent;
String? language;
// Try to detect language from class attribute
if (element.attributes.containsKey('class')) {
final classes = element.attributes['class']!;
if (classes.startsWith('language-')) {
language = classes.substring(9);
}
}
// For inline code, use default style
if (!content.contains('\n') && content.length < 50) {
return null; // Use default styling
}
return CodeBlock(
code: content,
language: language,
);
}
}

View File

@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
class ThinkingIndicator extends StatefulWidget {
const ThinkingIndicator({super.key});
@override
State<ThinkingIndicator> createState() => _ThinkingIndicatorState();
}
class _ThinkingIndicatorState extends State<ThinkingIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Row(
children: List.generate(3, (index) {
final delay = index * 0.2;
final value = (_controller.value + delay) % 1.0;
final opacity = (value < 0.5 ? value * 2 : 2 - value * 2);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Opacity(
opacity: 0.3 + opacity * 0.7,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: Colors.orange.shade400,
shape: BoxShape.circle,
),
),
),
);
}),
);
},
),
const SizedBox(width: 8),
Text(
'Claude is thinking...',
style: TextStyle(
color: Colors.grey.shade400,
fontSize: 14,
fontStyle: FontStyle.italic,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,165 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import '../models/message.dart';
class ToolUseCard extends StatefulWidget {
final ToolUse toolUse;
const ToolUseCard({super.key, required this.toolUse});
@override
State<ToolUseCard> createState() => _ToolUseCardState();
}
class _ToolUseCardState extends State<ToolUseCard> {
bool _isExpanded = false;
IconData get _toolIcon {
switch (widget.toolUse.tool.toLowerCase()) {
case 'bash':
return Icons.terminal;
case 'read':
return Icons.description;
case 'write':
case 'edit':
return Icons.edit_document;
case 'grep':
case 'glob':
return Icons.search;
default:
return Icons.build;
}
}
String get _inputDisplay {
final input = widget.toolUse.input;
if (input == null) return '';
if (input is String) return input;
if (input is Map) {
// For Bash tool, show command
if (input.containsKey('command')) {
return input['command'];
}
// For Read tool, show file path
if (input.containsKey('file_path')) {
return input['file_path'];
}
// Otherwise show JSON
try {
return const JsonEncoder.withIndent(' ').convert(input);
} catch (_) {
return input.toString();
}
}
return input.toString();
}
@override
Widget build(BuildContext context) {
final hasOutput = widget.toolUse.output != null &&
widget.toolUse.output!.isNotEmpty;
return Container(
margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF2D2D2D),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade700.withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
InkWell(
onTap: hasOutput
? () => setState(() => _isExpanded = !_isExpanded)
: null,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(_toolIcon, size: 18, color: Colors.orange.shade400),
const SizedBox(width: 8),
Text(
widget.toolUse.tool,
style: TextStyle(
color: Colors.orange.shade400,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
const Spacer(),
if (hasOutput)
Icon(
_isExpanded ? Icons.expand_less : Icons.expand_more,
color: Colors.grey.shade500,
size: 20,
),
if (!hasOutput && widget.toolUse.output == null)
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.orange.shade400,
),
),
],
),
),
),
// Input/Command
if (_inputDisplay.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.2),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'',
style: TextStyle(
color: Colors.green.shade400,
fontFamily: 'JetBrainsMono',
fontSize: 12,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_inputDisplay,
style: const TextStyle(
color: Colors.white70,
fontFamily: 'JetBrainsMono',
fontSize: 12,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
// Output (collapsible)
if (_isExpanded && hasOutput)
Container(
constraints: const BoxConstraints(maxHeight: 200),
padding: const EdgeInsets.all(12),
child: SingleChildScrollView(
child: Text(
widget.toolUse.output!,
style: const TextStyle(
color: Colors.white60,
fontFamily: 'JetBrainsMono',
fontSize: 11,
),
),
),
),
],
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More