Compare commits

..

47 Commits

Author SHA1 Message Date
ARCHITECT
41597fefc2 Update documentation for lock screen and security features
- deck-frontend/README.md: Added Security section with PIN info
- hst-frontend/README.md: Created with deploy and security docs
- Updated changelog with 2026-01-18 changes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 00:08:39 +00:00
ARCHITECT
48e66b1129 Captain Mobile v3.1 - App funcional
- Backend: claude -p --output-format stream-json
- --resume para mantener contexto de conversación
- Auto-connect al crear sesión
- UI simplificada (botón + y logout)
- Servicio systemd: captain-claude

Siguiente paso: sesiones screen

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 00:03:15 +00:00
ARCHITECT
f199daf4ba Change PIN to 1451
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 23:31:52 +00:00
ARCHITECT
c152cacb90 Add PIN lock screen and block search engines
DECK and HST frontends:
- Add 4-digit PIN lock screen (default: 1234)
- PIN stored in sessionStorage (persists during browser session)
- Keypad with keyboard support
- Add meta robots noindex/nofollow tags
- Created robots.txt to disallow all crawlers

Security note: This is client-side only, not cryptographically secure,
but sufficient to prevent casual access and search engine indexing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 23:28:09 +00:00
ARCHITECT
26c9f1f402 HST frontend: data-driven category classification using hst_rules
- Add tableRules to State for caching hst_rules data
- Add getTableRules() API function to fetch hst_rules table
- Modify getCategory() to check if table has categories (hst_permitidos)
- Only use set_hst for sub-categorization on tables with non-empty hst_permitidos
- Tables without categories now correctly show base name as category

Same fixes as DECK frontend for consistent behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 23:16:31 +00:00
ARCHITECT
bee5f9f939 DECK frontend: data-driven category classification using hst_rules
- Add tableRules to State for caching hst_rules data
- Add getTableRules() API function to fetch hst_rules table
- Modify getCategory() to check if table has categories (hst_permitidos)
- Only use set_hst for sub-categorization on tables with non-empty hst_permitidos
- Tables without categories now correctly show base name as category
- Load tableRules once on first data load for performance

Fixes issue where all items were showing sub-categories even when the
table (like itm, flg, loc) doesn't have category restrictions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 23:15:43 +00:00
ARCHITECT
4546003ce7 Fix getCategory to use set_hst for proper categorization
Tags are now categorized based on their set_hst field, which
points to the parent category tag (e.g., hst, spe, vue, vsn, msn).
This uses the actual semantic relationship defined in the database
rather than inferring from ref prefix or using the base.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 22:34:33 +00:00
ARCHITECT
7f247b446b Fix getCategory to use current base instead of ref prefix
Tags are categorized by the base they belong to, not by their
ref prefix. A tag in the hst table is always a hashtag, regardless
of whether its ref starts with "flg", "ply", etc.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 22:23:52 +00:00
ARCHITECT
2e8c1867b2 Graph sidebar shows only categories present in current data
- Count tags per category before rendering sidebar
- Only display categories that have tags in current view
- Show count next to each category name

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 22:19:18 +00:00
ARCHITECT
697bb27103 Fix graph sidebar state closure issue
Event handlers now read fresh state from State.get() instead of
using captured values from when bindSidebarEvents was called.
This ensures the sidebar updates correctly with each render.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 22:14:58 +00:00
ARCHITECT
0f2b28a88a Fix getLibraries to query library tables directly
Instead of depending on non-existent api_library_list_* views,
now queries library_${base} for unique mrf_library values and
fetches tag info from main table.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 22:01:33 +00:00
ARCHITECT
7d15ca010a Remove hst-frontend.html - moved to separate repo
HST frontend now lives in its own repository: tzzr/hst-frontend
This maintains clean separation between DECK and HST frontends.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 21:05:29 +00:00
ARCHITECT
00ea6cff8c Add HST-specific frontend (hst-frontend.html)
Customized version of DECK frontend for HST server (tzrtech.org):
- Remove DECK-specific bases: MST, BCK, MTH, ATC, Oracle, MAIL, CHAT
- Keep only taxonomy bases: HST, FLG, ITM, LOC, PLY
- Update IMG_BASE to https://tzrtech.org
- Update title to "hst" and logo to "HST"
- Clean up schema documentation

HST serves as the semantic tag model/template that DECK can copy from.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 20:43:21 +00:00
ARCHITECT
bc952aa25d Remove flow-ui and mindlink submodules
Services archived and removed from DECK server.
Documentation preserved in Gitea repositories.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 11:19:07 +00:00
ARCHITECT
f91485f866 Security and multi-instance improvements
captain_api.py:
- Generate random JWT_SECRET if not configured (with warning)
- Restrict CORS to specific origins
- Add POST /sessions endpoint to create screen sessions
- Sanitize session names (prevent command injection)
- Validate file paths to upload directory only
- Improve JWT error handling
- Set TERM environment for screen sessions

flow-ui: Add multi-instance support with server validation
mindlink: Add multi-instance support with category filtering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 11:13:40 +00:00
ARCHITECT
60ca0640a3 Add DECK server Docker Compose configurations
- tzzr-stack: PostgreSQL, PostgREST, Directus (unified DB stack)
- services: Shlink, Filebrowser, Redis, Vaultwarden, ntfy

PostgreSQL migrated from host to Docker with all services
connected via tzzr-net network.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 21:31:31 +00:00
ARCHITECT
4f7f069e18 Add session log: Sistema Multi-Bucket ATC implementado
Implementación completa de soporte multi-bucket para tabla ATC:
- Tabla public.bucket_registry (metadata de buckets)
- Tabla public.bucket_access_log (auditoría)
- Campo bucket_mrf en tzzr_core_secretaria.atc
- 2275 archivos migrados a bucket 'deck'
- Documentación completa de sesión

Cambios menores:
- deck-v4.6.html actualizado
- Caddyfile para captain-mobile

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 20:35:28 +00:00
ARCHITECT
5e8e6a8428 DECK Frontend v4.6 - Fix API configuration for production
- Change API_BASE to https://tzrtech.org/api (HST server)
- Update all schemas to 'public' (actual DB schema)
- Fixes TreeView data loading in production

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 20:15:27 +00:00
ARCHITECT
66c45910da Add debug logs to EventBus
Events now logs to console when _debug is true:
- [Events.on] event (N handlers) - green
- [Events.off] event - orange
- [Events.emit] event data → N handlers - blue

Toggle in browser console:
  Events._debug = false  // disable
  Events._debug = true   // enable (default)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 19:56:05 +00:00
ARCHITECT
7672d5582f DECK Frontend v4.6 - EventBus decoupling
Added Events module for inter-component communication:
- Events.on(event, handler) - subscribe
- Events.off(event, handler) - unsubscribe
- Events.emit(event, data) - publish

Decoupled:
- GroupsBar → Events.emit('render') instead of App.renderView()
- LibrariesPanel → Events.emit('render')
- GraphView sidebar → Events.emit('render')
- GraphView node click → Events.emit('detail:show', mrf)

App subscribes to events in init():
- Events.on('render', () => this.renderView())
- Events.on('detail:show', (mrf) => DetailPanel.show(mrf))

Now modules don't know about each other - changes are isolated.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 19:37:34 +00:00
ARCHITECT
daea7753b6 DECK Frontend v4.5 - All 12 bases + generic structure
Added bases:
- MST (Masters) - tzzr_core_produccion
- BCK (Backlog) - tzzr_core_produccion
- Oracle - tzzr_core_secretaria

Reorganized UI groups:
- Taxonomía: HST, FLG, ITM, LOC, PLY
- Producción: MST, BCK, MTH
- Secretaría: ATC, Oracle
- Comunicación: MAIL, CHAT

Added categories for graph visualization:
- mst, bck, mth, atc, ora with distinct colors

Tables MST/BCK/Oracle don't exist in DB yet - frontend ready for them.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 19:10:52 +00:00
ARCHITECT
171a356b25 DECK Frontend v4.4 - Tree from tree_* relational tables
- Added API.getTree() to query tree_{base} tables
- TreeView now builds real 1:N hierarchy from tree_* data
- Recursive rendering with proper parent-child relationships
- Library filter still works (filters tags before building tree)
- Updated CSS for hierarchical tree display with depth indicators

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 18:53:37 +00:00
ARCHITECT
0bd1d6fbff DECK Frontend v4.3 - Portable with trivial extraction
- Simplified header (removed tag examples that interfered with extraction)
- Added extract.sh script for 3-file separation
- Structure: <style>, <body>, <script> cleanly separable
- Run: ./extract.sh deck.html → styles.css, app.js, index.html

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 18:37:31 +00:00
ARCHITECT
9b244138b5 Add pending apps and frontend components
- apps/captain-mobile: Mobile API service
- apps/flow-ui: Flow UI application
- apps/mindlink: Mindlink application
- apps/storage: Storage API and workers
- apps/tzzr-cli: TZZR CLI tool
- deck-frontend/backups: Historical TypeScript versions
- hst-frontend: Standalone HST frontend

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 18:26:59 +00:00
ARCHITECT
17506aaee2 Fix extraction markers interference in header comments
The example sed commands in the header contained the same
marker strings, causing extraction to capture header content.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 18:15:46 +00:00
ARCHITECT
384e9129c1 DECK Frontend v4.2 - Minor Accessibility Polish
- Toast: aria-live="assertive" + role="alert" for immediate announcements
- CSS: prefers-reduced-motion support added
- Version bump v4.1 → v4.2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 18:01:39 +00:00
ARCHITECT
ec650d06df DECK Frontend v4.1 - Security & Accessibility Update
Changes:
- XSS protection with escapeHtml() utility
- Responsive CSS (3 mobile breakpoints)
- Full accessibility: aria-labels, roles, focus-visible
- GraphView memory leak fix
- Immutable state management

Files:
- deck-v4.1.html: Production-ready monolith
- CHANGELOG.md: Version history and documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 19:35:52 +00:00
ARCHITECT
3c0f18529a Add session log: Directus DECK configuration
Documents complete Directus setup on DECK server including:
- Multi-schema PostgreSQL access configuration
- External-image extension for thumbnails
- 57 collections with presets and relations
- Troubleshooting guide

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:43:16 +00:00
ARCHITECT
295caa58c1 Fix graph view overflow and sidebar visibility
- Add graph-mode class to parent containers when graph view is active
- Add CSS rules for .graph-mode and :has(.graph-view) for overflow: visible
- Ensure sidebar and controls are not clipped by parent overflow
- Add width/height 100% to content-area.graph-view

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 00:47:57 +00:00
ARCHITECT
62408182a0 Fix graph sidebar visibility with overflow visible
- Add overflow: visible to .graph-view to prevent clipping
- Add .content-area.graph-view rule for parent overflow fix

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 00:19:34 +00:00
ARCHITECT
6d6e4e1bdf Fix view-tab reactivity using direct onclick binding
- Change view-tabs from delegateEvent to direct onclick like hst-frontend
- Remove hardcoded 'active' class from grid tab in HTML
- Update active class immediately on click before state update
- Update proxy target to tzrtech.org

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 00:16:38 +00:00
ARCHITECT
131e198851 Refactor GraphView with floating sidebar and zoom controls
- Move graph options from left panel to floating sidebar in GraphView
- Add zoom controls (fit, +, -) in top-right corner
- Add dynamic legend showing active categories
- Add cleanup system to View base class for event listeners
- Update delegateEvent to return cleanup function
- Filter nodes by category before rendering
- Fix text color variable (--text-primary to --text)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 00:07:26 +00:00
ARCHITECT
f55945fdb8 Add ATC thumbnail URL resolution for R2 storage
- Add resolveImgUrl() to prepend https://atc.tzzrdeck.me/ to relative paths
- Update getImg() and getFullImg() to use the new resolver
- Enables thumbnail display for attachments stored in R2 bucket

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 12:30:56 +00:00
ARCHITECT
0c3f95750c Move SEL/GET buttons to view-bar as grouped button pair
- Moved SEL and GET buttons from topbar to view-bar
- Created button group with consistent styling
- Positioned to the left of Grid/Tree/Graph tabs
- Added spacer for balanced layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 10:09:31 +00:00
ARCHITECT
79b7389f1f Add graph options panel and separate view bar
- Move view tabs (Grid/Tree/Graph) to dedicated bar below topbar
- Add graph options panel in left sidebar when in graph view:
  - Stats: node count, edge count
  - Category filters: Hashtags, Specs, Values, Visions, Missions, Flags
  - Relation filters: all edge types with color indicators
  - Visualization: images toggle, labels toggle, node size slider, link distance slider
- Left panel shows libraries in grid/tree view, graph options in graph view

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 00:59:00 +00:00
ARCHITECT
a0b20fc5db Fix library loading per base with new API views
- Use api_library_list_{base} views instead of generic query
- Add name_en and member_count to Library type
- Created PostgreSQL views for hst, flg, itm, loc, ply libraries

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 00:54:18 +00:00
ARCHITECT
a7ab3bab7d Add multi-schema support and per-base libraries
- Swap MST/BCK and MTH/ATC button positions
- Add schema support for different PostgreSQL schemas:
  - secretaria_clara: atc, mst, bck
  - production_alfred: mth
  - mail_manager: mail
  - context_manager: chat
- Libraries now load per-base (library_{base})
- Add new base types: key, mindlink
- Update FetchOptions to use schema instead of headers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 00:43:54 +00:00
ARCHITECT
030b2a5312 Rename hst-frontend-new to deck-frontend
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 00:00:56 +00:00
ARCHITECT
4e9377cf09 Add DECK Frontend - Vite + TypeScript migration
- Migrated vanilla JS frontend to Vite + TypeScript
- Modular architecture: views, components, utils, api, state
- Three-panel layout: libraries (left), content (center), detail (right)
- Group name resolution via hstTags (set_hst -> readable name)
- Name priority: name_es -> alias -> ref -> hash truncated
- D3.js lazy loading in GraphView
- Hash-based routing
- Deployed to tzzrdeck.me

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:55:09 +00:00
ARCHITECT
0980688b21 Update HST session - database schema changes
- Renamed fields to English (nombre→name, estandar→standard, grupo→group)
- Renamed relational fields (padre→parent, hijo→child, biblioteca→library)
- Removed legacy fields from ply, itm, loc
- Generated backups v3 with new schema
- Created TZZR_Database_Spec_v2.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 03:12:32 +00:00
ARCHITECT
61dfcc5478 Add HST session 2026-01-04 documentation
- Kelvin temperature images management (delete/add)
- New ribbon cable record (rbc)
- Full database backup (26 tables)
- Image backup to R2 (875 images)
- Generated listado_hst_flg.md and backup_hst_flg.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 02:31:09 +00:00
ARCHITECT
66f4c8ab64 Add HST image system manual
Documents how the image system works internally:
- img vs mrf fields explained
- File naming convention (SHA256 hash)
- Step-by-step image upload procedure
- Common errors and solutions
- Diagnostic commands

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 16:06:14 +00:00
ARCHITECT
341221dd6e Update HST session docs - complete state documentation
- Tags: 542/679 detailed (79.8%)
- Graph: 507/810 reclassified (62.6%)
- Tree: 188 hierarchies
- Library: 12 active, 0 orphans
- Decision: nid and uid kept separate

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 15:48:08 +00:00
ARCHITECT
633aa48a8e Update HST session docs - 80.2% detailed tags achieved
- Fixed 8 orphan libraries (SHA256 hashes without HST records)
- Reassigned 5 to existing tags (cmd, aco, qlt, nid, lib_ilum)
- Created 3 new library tags (lib_pind, lib_ent, lib_mat)
- Final: 12 libraries, 82 tag associations, 0 orphans

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 15:42:41 +00:00
ARCHITECT
10da3857f7 Update HST session docs - 80.2% detailed tags achieved
- 542/676 tags with detailed standards (80.2%)
- 188 tree hierarchies
- 87 library associations
- 9 edge types in graph (303 relation, 245 specialization, etc.)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 13:40:15 +00:00
ARCHITECT
8201f499bb Update HST session docs - 61.3% detailed tags achieved
- Extended session with 7 update batches (~200 tags)
- Multi-jurisdiction standards (EU/Spain, USA, China, UAE, Singapore)
- Improved from 18.9% to 61.3% detailed coverage
- Changelog v3 uploaded to R2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 12:54:56 +00:00
ARCHITECT
313ba83a52 Add HST session notes - 10 refinement iterations
- 957 tags standardized (99.7% coverage)
- 127 detailed standards with IEC/ISO/EN/MIL references
- 810 graph relations reclassified to 8 edge types
- 166 tree hierarchies maintained
- Full changelog in R2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 12:16:16 +00:00
401 changed files with 37235 additions and 0 deletions

BIN
- Normal file

Binary file not shown.

1
LLMChat Submodule

Submodule LLMChat added at 01f53de663

307
PLAN_CAPTAIN_MOBILE_V2.md Normal file
View File

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

View File

@@ -0,0 +1,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)

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
admin

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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