Compare commits
50 Commits
d21bd9e650
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41597fefc2 | ||
|
|
48e66b1129 | ||
|
|
f199daf4ba | ||
|
|
c152cacb90 | ||
|
|
26c9f1f402 | ||
|
|
bee5f9f939 | ||
|
|
4546003ce7 | ||
|
|
7f247b446b | ||
|
|
2e8c1867b2 | ||
|
|
697bb27103 | ||
|
|
0f2b28a88a | ||
|
|
7d15ca010a | ||
|
|
00ea6cff8c | ||
|
|
bc952aa25d | ||
|
|
f91485f866 | ||
|
|
60ca0640a3 | ||
|
|
4f7f069e18 | ||
|
|
5e8e6a8428 | ||
|
|
66c45910da | ||
|
|
7672d5582f | ||
|
|
daea7753b6 | ||
|
|
171a356b25 | ||
|
|
0bd1d6fbff | ||
|
|
9b244138b5 | ||
|
|
17506aaee2 | ||
|
|
384e9129c1 | ||
|
|
ec650d06df | ||
|
|
3c0f18529a | ||
|
|
295caa58c1 | ||
|
|
62408182a0 | ||
|
|
6d6e4e1bdf | ||
|
|
131e198851 | ||
|
|
f55945fdb8 | ||
|
|
0c3f95750c | ||
|
|
79b7389f1f | ||
|
|
a0b20fc5db | ||
|
|
a7ab3bab7d | ||
|
|
030b2a5312 | ||
|
|
4e9377cf09 | ||
|
|
0980688b21 | ||
|
|
61dfcc5478 | ||
|
|
66f4c8ab64 | ||
|
|
341221dd6e | ||
|
|
633aa48a8e | ||
|
|
10da3857f7 | ||
|
|
8201f499bb | ||
|
|
313ba83a52 | ||
|
|
d35f11e2f7 | ||
|
|
acffd2a2a7 | ||
|
|
224d2f522c |
1
LLMChat
Submodule
307
PLAN_CAPTAIN_MOBILE_V2.md
Normal 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?
|
||||
352
README.md
Normal file
@@ -0,0 +1,352 @@
|
||||
# CAPTAIN CLAUDE - Sistema Multiagente TZZR
|
||||
|
||||
Coordinador central del sistema TZZR (The Zero-Trust Resilient Resource Network). CAPTAIN CLAUDE gestiona la infraestructura distribuida, servicios centralizados y coordina agentes especializados en múltiples servidores.
|
||||
|
||||
## Visión General
|
||||
|
||||
CAPTAIN CLAUDE es un sistema multiagente que coordina la infraestructura TZZR:
|
||||
|
||||
- **Servidor Central**: 69.62.126.110 (Gitea, PostgreSQL)
|
||||
- **Servidores Remotos**: DECK, CORP, HST
|
||||
- **Almacenamiento**: Cloudflare R2 (s3://architect/)
|
||||
- **Coordinación**: Agentes especializados para tareas específicas
|
||||
|
||||
## Infraestructura
|
||||
|
||||
### Servidores
|
||||
|
||||
| Servidor | IP | Función |
|
||||
|----------|-----|---------|
|
||||
| **Central** | 69.62.126.110 | Control central, Gitea, PostgreSQL |
|
||||
| **DECK** | 72.62.1.113 | Servicios, Agentes (Clara, Alfred, Mason, Feldman) |
|
||||
| **CORP** | 92.112.181.188 | ERP (Odoo), CMS (Directus), Agentes (Margaret, Jared) |
|
||||
| **HST** | 72.62.2.84 | Directus, Gestión de imágenes |
|
||||
|
||||
### Acceso SSH
|
||||
|
||||
Todos los servidores remotos son accesibles via SSH usando la clave `~/.ssh/tzzr`:
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/tzzr root@72.62.1.113 # DECK
|
||||
ssh -i ~/.ssh/tzzr root@92.112.181.188 # CORP
|
||||
ssh -i ~/.ssh/tzzr root@72.62.2.84 # HST
|
||||
```
|
||||
|
||||
## Almacenamiento R2
|
||||
|
||||
Cloudflare R2 almacena documentos, configuraciones y backups:
|
||||
|
||||
```bash
|
||||
# Endpoint
|
||||
https://7dedae6030f5554d99d37e98a5232996.r2.cloudflarestorage.com
|
||||
|
||||
# Estructura
|
||||
s3://architect/
|
||||
├── documentos adjuntos/ # Documentos para compartir
|
||||
├── documentos adjuntos/architect/ # Reportes generados
|
||||
├── system/ # Configs, backups internos
|
||||
├── gpu-services/ # Servicios GRACE/PENNY/FACTORY
|
||||
├── backups/ # Backups Gitea y sistema
|
||||
└── auditorias/ # Logs de auditoría
|
||||
```
|
||||
|
||||
### Comandos R2
|
||||
|
||||
```bash
|
||||
# Listar contenido
|
||||
aws s3 ls s3://architect/ --endpoint-url https://7dedae6030f5554d99d37e98a5232996.r2.cloudflarestorage.com
|
||||
|
||||
# Subir archivo para usuario
|
||||
aws s3 cp archivo.md "s3://architect/documentos adjuntos/archivo.md" \
|
||||
--endpoint-url https://7dedae6030f5554d99d37e98a5232996.r2.cloudflarestorage.com
|
||||
|
||||
# Subir archivo interno
|
||||
aws s3 cp archivo "s3://architect/system/archivo" \
|
||||
--endpoint-url https://7dedae6030f5554d99d37e98a5232996.r2.cloudflarestorage.com
|
||||
```
|
||||
|
||||
## Agentes Especializados
|
||||
|
||||
CAPTAIN CLAUDE coordina múltiples agentes para diferentes tareas:
|
||||
|
||||
### Agentes Disponibles
|
||||
|
||||
- **Captain**: Coordinador principal, análisis de tareas, delegación
|
||||
- **Coder**: Implementación de código, desarrollo de features
|
||||
- **Reviewer**: Revisión de código, calidad, estándares
|
||||
- **Researcher**: Investigación, análisis, documentación
|
||||
- **Architect**: Diseño de sistemas, arquitectura, optimización
|
||||
|
||||
### Ejecución de Agentes
|
||||
|
||||
Los agentes pueden ejecutarse:
|
||||
- **En paralelo**: Para tareas independientes
|
||||
- **Secuencialmente**: Para tareas dependientes
|
||||
- **Interactivamente**: Con feedback del usuario
|
||||
|
||||
## Servicios en Cada Servidor
|
||||
|
||||
### DECK (72.62.1.113)
|
||||
|
||||
**Microservicios:**
|
||||
- Clara (5051) - Log inmutable y auditoría
|
||||
- Alfred (5052) - Automatización de workflows
|
||||
- Mason (5053) - Enriquecimiento de datos
|
||||
- Feldman (5054) - Validador Merkle
|
||||
|
||||
**Aplicaciones:**
|
||||
- Nextcloud (8084) - Almacenamiento en la nube
|
||||
- Odoo (8069) - ERP
|
||||
- Vaultwarden (8085) - Gestor de contraseñas
|
||||
- Directus (8055) - CMS
|
||||
- Mailcow (8180) - Servidor de correo
|
||||
|
||||
**Infraestructura:**
|
||||
- PostgreSQL (5432) - Base de datos con pgvector
|
||||
- Redis (6379) - Cache en memoria
|
||||
|
||||
### CORP (92.112.181.188)
|
||||
|
||||
**Aplicaciones:**
|
||||
- Odoo 17 (8069) - Sistema ERP empresarial
|
||||
- Directus 11 (8055) - CMS y gestor de contenidos
|
||||
- Nextcloud (8080) - Almacenamiento compartido
|
||||
- Vaultwarden (8081) - Gestor de contraseñas
|
||||
|
||||
**Microservicios:**
|
||||
- Margaret (5051) - Orquestación y coordinación
|
||||
- Jared (5052) - Procesamiento de datos
|
||||
- Mason (5053) - Generación de reportes
|
||||
- Feldman (5054) - Auditoría y logging
|
||||
|
||||
**Infraestructura:**
|
||||
- PostgreSQL (5432) - Base de datos
|
||||
|
||||
### HST (72.62.2.84)
|
||||
|
||||
- Directus
|
||||
- Gestión de imágenes
|
||||
- Servicios de almacenamiento
|
||||
|
||||
## Context-Manager
|
||||
|
||||
Sistema central para gestión de contexto persistente. Disponible en DECK.
|
||||
|
||||
### Instalación
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/tzzr root@72.62.1.113 "context-manager --help"
|
||||
```
|
||||
|
||||
### Comandos Principales
|
||||
|
||||
```bash
|
||||
# Ver ayuda
|
||||
context-manager --help
|
||||
|
||||
# Listar bloques de contexto
|
||||
context-manager block list
|
||||
|
||||
# Ver contenido de bloque
|
||||
context-manager block view <ID>
|
||||
|
||||
# Crear bloque
|
||||
context-manager block add "nombre_bloque" \
|
||||
--tipo "project" \
|
||||
--contenido '{"estado": "en_progreso"}'
|
||||
|
||||
# Eliminar bloque
|
||||
context-manager block remove <ID>
|
||||
|
||||
# Listar memoria compartida
|
||||
context-manager memory list
|
||||
|
||||
# Agregar a memoria
|
||||
context-manager memory add "clave" "contenido"
|
||||
|
||||
# Chat interactivo
|
||||
context-manager chat
|
||||
```
|
||||
|
||||
## Documentación
|
||||
|
||||
Manuales disponibles para cada servidor:
|
||||
|
||||
- **MANUAL_USUARIO_DECK.md**: Guía completa del servidor DECK
|
||||
- Servicios, configuración, troubleshooting
|
||||
- PostgreSQL y administración
|
||||
- Guías rápidas de inicio
|
||||
|
||||
- **MANUAL_USUARIO_CORP.md**: Guía completa del servidor CORP
|
||||
- Odoo 17 y Directus 11
|
||||
- Administración y troubleshooting
|
||||
- Procedimientos frecuentes
|
||||
|
||||
- **MANUAL_USUARIO_HST.md**: Documentación del servidor HST
|
||||
|
||||
Todos los manuales se encuentran en:
|
||||
- Repositorio: `/home/architect/captain-claude/`
|
||||
- R2: `s3://architect/system/skynet v8/`
|
||||
|
||||
## Reglas de Operación
|
||||
|
||||
### Principio Fundamental
|
||||
|
||||
**No guardar documentos en servidor local.**
|
||||
|
||||
- Los documentos y reportes generados van a R2, NO al filesystem local
|
||||
- El servidor solo mantiene código, configuraciones y aplicaciones activas
|
||||
- Limpieza automática después de generar archivos
|
||||
|
||||
### Limpieza Automática
|
||||
|
||||
Al finalizar cualquier tarea que genere archivos:
|
||||
|
||||
1. Subir TODOS los archivos generados a R2
|
||||
```bash
|
||||
aws s3 cp archivo "s3://architect/destino/archivo" \
|
||||
--endpoint-url https://7dedae6030f5554d99d37e98a5232996.r2.cloudflarestorage.com
|
||||
```
|
||||
|
||||
2. Verificar que están en R2
|
||||
```bash
|
||||
aws s3 ls s3://architect/destino/ \
|
||||
--endpoint-url https://7dedae6030f5554d99d37e98a5232996.r2.cloudflarestorage.com
|
||||
```
|
||||
|
||||
3. Eliminar archivos locales
|
||||
```bash
|
||||
rm -rf carpeta_local/
|
||||
```
|
||||
|
||||
4. No esperar a que el usuario lo pida - ejecutar automáticamente
|
||||
|
||||
### Destinos R2 por Tipo
|
||||
|
||||
| Tipo | Destino R2 |
|
||||
|------|-----------|
|
||||
| Auditorías | `s3://architect/auditorias/` |
|
||||
| Reportes para usuario | `s3://architect/documentos adjuntos/architect/` |
|
||||
| Configs/backups internos | `s3://architect/system/` |
|
||||
| Documentos de usuario | `s3://architect/documentos adjuntos/` |
|
||||
|
||||
## Ejecución
|
||||
|
||||
### Inicio
|
||||
|
||||
```bash
|
||||
# Ejecutar CAPTAIN CLAUDE
|
||||
python captain_claude.py
|
||||
|
||||
# O via script
|
||||
./run.sh
|
||||
```
|
||||
|
||||
### Variables de Entorno
|
||||
|
||||
Se requieren:
|
||||
- Acceso SSH a servidores remotos (clave `~/.ssh/tzzr`)
|
||||
- Credenciales de R2 (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
|
||||
- APIs configuradas (Anthropic, OpenAI, etc.)
|
||||
|
||||
### Logs y Monitoreo
|
||||
|
||||
Los logs se almacenan en:
|
||||
- Local: `captain_output/`
|
||||
- R2: `s3://architect/auditorias/`
|
||||
|
||||
## Casos de Uso
|
||||
|
||||
### 1. Supervisar Estado de la Infraestructura
|
||||
|
||||
```bash
|
||||
python captain_claude.py --action health-check --all-servers
|
||||
```
|
||||
|
||||
### 2. Generar Reportes
|
||||
|
||||
```bash
|
||||
python captain_claude.py --action report --type performance --output r2
|
||||
```
|
||||
|
||||
### 3. Administrar Servicios
|
||||
|
||||
```bash
|
||||
# Ver estado de servicio en DECK
|
||||
ssh -i ~/.ssh/tzzr root@72.62.1.113 "docker ps"
|
||||
|
||||
# Reiniciar servicio
|
||||
ssh -i ~/.ssh/tzzr root@72.62.1.113 "docker restart clara-service"
|
||||
```
|
||||
|
||||
### 4. Gestionar Contexto
|
||||
|
||||
```bash
|
||||
# Crear bloque de contexto para coordinación
|
||||
context-manager block add "tarea_importante" \
|
||||
--tipo "coordination" \
|
||||
--contenido '{"agentes": ["coder", "reviewer"], "estado": "en_progreso"}'
|
||||
```
|
||||
|
||||
## Contacto y Soporte
|
||||
|
||||
### Coordinación
|
||||
|
||||
- **Servidor Central**: Git en http://localhost:3000
|
||||
- **Bitácora**: Logs en R2 `s3://architect/auditorias/`
|
||||
- **Documentación**: Manuales en R2 `s3://architect/system/skynet v8/`
|
||||
|
||||
### Escalation
|
||||
|
||||
1. Revisar logs relevantes
|
||||
2. Consultar documentación
|
||||
3. Crear ticket en Gitea
|
||||
4. Contactar administrador del sistema
|
||||
|
||||
## Información Técnica
|
||||
|
||||
### Dependencias
|
||||
|
||||
- Python 3.8+
|
||||
- SSH (conexión a servidores remotos)
|
||||
- AWS CLI (acceso a R2)
|
||||
- Docker (para servicios)
|
||||
- PostgreSQL (base de datos)
|
||||
|
||||
### Estructura del Proyecto
|
||||
|
||||
```
|
||||
captain-claude/
|
||||
├── README.md # Este archivo
|
||||
├── CAPTAIN_CLAUDE.md # Instrucciones de operación
|
||||
├── captain_claude.py # Coordinador principal
|
||||
├── captain # Script de ejecución
|
||||
├── apps/ # Aplicaciones integradas
|
||||
├── context-manager/ # Sistema de gestión de contexto
|
||||
├── venv/ # Entorno virtual Python
|
||||
└── captain_output/ # Salidas y logs
|
||||
```
|
||||
|
||||
### Permisos y Seguridad
|
||||
|
||||
- Clave SSH protegida: `~/.ssh/tzzr`
|
||||
- Credenciales R2 en variables de entorno
|
||||
- Logs auditados y almacenados en R2
|
||||
- Acceso restringido por rol
|
||||
|
||||
## Versión y Actualización
|
||||
|
||||
**Versión**: 1.0
|
||||
**Última actualización**: 2025-12-30
|
||||
**Sistema**: TZZR - Skynet v8
|
||||
|
||||
## Licencia
|
||||
|
||||
Proyecto interno del sistema TZZR.
|
||||
|
||||
---
|
||||
|
||||
Para más información, consultar:
|
||||
- Gitea: http://69.62.126.110:3000
|
||||
- R2 System Docs: s3://architect/system/
|
||||
- Manuales: s3://architect/system/skynet v8/
|
||||
131
apps/captain-mobile-v2/ESTADO_PROYECTO.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Captain Claude Mobile - Estado del Proyecto
|
||||
|
||||
**Fecha:** 2026-01-17
|
||||
**Versión:** v3.1
|
||||
**Estado:** FUNCIONAL
|
||||
|
||||
---
|
||||
|
||||
## Resumen
|
||||
|
||||
App móvil para chatear con Claude Code usando `claude -p --output-format stream-json`.
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura v3
|
||||
|
||||
### Backend (`captain_api_v3.py`)
|
||||
|
||||
- **Puerto:** 3030
|
||||
- **Servicio systemd:** `captain-claude`
|
||||
- **Método:** `claude -p --output-format stream-json`
|
||||
- **Contexto:** `--resume SESSION_ID` mantiene historial de conversación
|
||||
- **Auto-connect:** Al crear sesión, se conecta automáticamente
|
||||
- **Almacenamiento:** Sesiones en memoria (se pierden al reiniciar)
|
||||
|
||||
### Flutter App
|
||||
|
||||
- **APK:** `260117_captain-claude-v3.1.apk`
|
||||
- **UI:** Simplificada - botón + para nuevo chat, logout
|
||||
- **Conexión:** WebSocket a `wss://captain.tzzrarchitect.me/ws/chat`
|
||||
|
||||
---
|
||||
|
||||
## API WebSocket
|
||||
|
||||
### Mensajes del Cliente
|
||||
|
||||
```json
|
||||
{"token": "jwt"} // Autenticación
|
||||
{"type": "create_session", "name": "Chat 1"} // Crear sesión
|
||||
{"type": "message", "content": "hola"} // Enviar mensaje
|
||||
{"type": "ping"} // Keep-alive
|
||||
```
|
||||
|
||||
### Respuestas del Servidor
|
||||
|
||||
```json
|
||||
{"type": "init", "sessions": [...]} // Estado inicial
|
||||
{"type": "session_created", "session_id": "x", "name": "y"} // Sesión creada
|
||||
{"type": "session_connected", "session_id": "x", "name": "y"} // Conectado
|
||||
{"type": "output", "content": "..."} // Respuesta de Claude
|
||||
{"type": "done", "session_id": "uuid"} // Respuesta completa
|
||||
{"type": "error", "message": "..."} // Error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Archivos
|
||||
|
||||
```
|
||||
apps/captain-mobile-v2/
|
||||
├── backend/
|
||||
│ ├── captain_api_v3.py # Backend actual
|
||||
│ ├── captain-claude.service # Servicio systemd
|
||||
│ └── test_*.py # Tests
|
||||
├── flutter/
|
||||
│ ├── lib/
|
||||
│ │ ├── screens/chat_screen.dart
|
||||
│ │ ├── providers/chat_provider.dart
|
||||
│ │ ├── services/chat_service.dart
|
||||
│ │ └── config/api_config.dart
|
||||
│ └── build/app/outputs/flutter-apk/app-release.apk
|
||||
└── ESTADO_PROYECTO.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comandos Útiles
|
||||
|
||||
```bash
|
||||
# Estado del servicio
|
||||
systemctl status captain-claude
|
||||
|
||||
# Logs
|
||||
journalctl -u captain-claude -f
|
||||
|
||||
# Reiniciar
|
||||
systemctl restart captain-claude
|
||||
|
||||
# Compilar APK
|
||||
cd /home/architect/captain-claude/apps/captain-mobile-v2/flutter
|
||||
/home/architect/flutter/bin/flutter build apk --release
|
||||
|
||||
# Subir a Nextcloud
|
||||
/home/architect/bin/rclone copy build/app/outputs/flutter-apk/app-release.apk nextcloud-architect:app/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Siguiente Paso: Sesiones Screen
|
||||
|
||||
### Objetivo
|
||||
|
||||
Crear versión capaz de:
|
||||
1. Listar sesiones screen existentes
|
||||
2. Conectarse a sesiones screen en tiempo real
|
||||
3. Crear nuevas sesiones screen con Claude Code
|
||||
4. Enviar input a sesiones activas
|
||||
|
||||
### Diferencia
|
||||
|
||||
| v3 Actual | Screen Sessions |
|
||||
|-----------|-----------------|
|
||||
| `claude -p` por mensaje | Proceso persistente en screen |
|
||||
| Sesiones en memoria | Sesiones reales en servidor |
|
||||
| Solo texto limpio | Output completo |
|
||||
| Sin acceso a otras screens | Acceso a cualquier screen |
|
||||
|
||||
### Implementación
|
||||
|
||||
1. Backend v4 con endpoints para screen
|
||||
2. Parsing de output ANSI
|
||||
3. Flutter con selector de screens
|
||||
|
||||
---
|
||||
|
||||
## Historial
|
||||
|
||||
- **v3.1** (17/01/26): UI simplificada, auto-connect en backend
|
||||
- **v3.0** (17/01/26): Cambio a `claude -p --output-format stream-json`
|
||||
- **v2.x** (17/01/26): Intentos con screen+hardcopy (descartado)
|
||||
17
apps/captain-mobile-v2/backend/captain-api-v2.service
Normal 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
|
||||
15
apps/captain-mobile-v2/backend/captain-claude.service
Normal 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
|
||||
1127
apps/captain-mobile-v2/backend/captain_api_v2.py
Normal file
620
apps/captain-mobile-v2/backend/captain_api_v3.py
Normal 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)
|
||||
5
apps/captain-mobile-v2/backend/requirements.txt
Normal 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
|
||||
45
apps/captain-mobile-v2/backend/test_autoconnect.py
Normal 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())
|
||||
65
apps/captain-mobile-v2/backend/test_connect.py
Normal 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())
|
||||
35
apps/captain-mobile-v2/backend/test_connect2.py
Normal 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())
|
||||
118
apps/captain-mobile-v2/backend/test_final.py
Normal 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())
|
||||
40
apps/captain-mobile-v2/backend/test_simple.py
Normal 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())
|
||||
40
apps/captain-mobile-v2/backend/test_v3.py
Normal 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())
|
||||
46
apps/captain-mobile-v2/backend/test_v3_full.py
Normal 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())
|
||||
128
apps/captain-mobile-v2/backend/test_websocket.py
Executable 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())
|
||||
1
apps/captain-mobile-v2/data/.admin_password
Normal file
@@ -0,0 +1 @@
|
||||
admin
|
||||
1
apps/captain-mobile-v2/data/.jwt_secret
Normal file
@@ -0,0 +1 @@
|
||||
9bb9be71305495d244b9d4966699190ab607163867e52513375575496010238f
|
||||
BIN
apps/captain-mobile-v2/data/captain.db
Normal file
43
apps/captain-mobile-v2/flutter/.gitignore
vendored
Normal 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
|
||||
45
apps/captain-mobile-v2/flutter/.metadata
Normal 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'
|
||||
16
apps/captain-mobile-v2/flutter/README.md
Normal 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.
|
||||
28
apps/captain-mobile-v2/flutter/analysis_options.yaml
Normal 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
|
||||
13
apps/captain-mobile-v2/flutter/android/.gitignore
vendored
Normal 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
|
||||
44
apps/captain-mobile-v2/flutter/android/app/build.gradle
Normal 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 = "../.."
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.tzzr.captain_mobile_v2
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity: FlutterActivity()
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
18
apps/captain-mobile-v2/flutter/android/build.gradle
Normal 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
|
||||
}
|
||||
3
apps/captain-mobile-v2/flutter/android/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
5
apps/captain-mobile-v2/flutter/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||
25
apps/captain-mobile-v2/flutter/android/settings.gradle
Normal 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"
|
||||
34
apps/captain-mobile-v2/flutter/ios/.gitignore
vendored
Normal 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
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -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 */;
|
||||
}
|
||||
7
apps/captain-mobile-v2/flutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
7
apps/captain-mobile-v2/flutter/ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
13
apps/captain-mobile-v2/flutter/ios/Runner/AppDelegate.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
apps/captain-mobile-v2/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
apps/captain-mobile-v2/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
apps/captain-mobile-v2/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
apps/captain-mobile-v2/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
5
apps/captain-mobile-v2/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
vendored
Normal 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.
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
49
apps/captain-mobile-v2/flutter/ios/Runner/Info.plist
Normal 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>
|
||||
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
13
apps/captain-mobile-v2/flutter/lib/config/api_config.dart
Normal 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;
|
||||
}
|
||||
78
apps/captain-mobile-v2/flutter/lib/main.dart
Normal 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();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
38
apps/captain-mobile-v2/flutter/lib/models/conversation.dart
Normal 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}';
|
||||
}
|
||||
}
|
||||
85
apps/captain-mobile-v2/flutter/lib/models/message.dart
Normal 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';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
295
apps/captain-mobile-v2/flutter/lib/providers/chat_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
358
apps/captain-mobile-v2/flutter/lib/screens/chat_screen.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
255
apps/captain-mobile-v2/flutter/lib/screens/history_screen.dart
Normal 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);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
193
apps/captain-mobile-v2/flutter/lib/screens/login_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
55
apps/captain-mobile-v2/flutter/lib/services/api_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
223
apps/captain-mobile-v2/flutter/lib/services/chat_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
128
apps/captain-mobile-v2/flutter/lib/widgets/chat_input.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
84
apps/captain-mobile-v2/flutter/lib/widgets/code_block.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
195
apps/captain-mobile-v2/flutter/lib/widgets/message_bubble.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||