From 3663e4c622c973526c7dea733f5f0626c5adbb54 Mon Sep 17 00:00:00 2001 From: ARCHITECT Date: Fri, 16 Jan 2026 18:34:02 +0000 Subject: [PATCH] Initial commit: Captain Claude Mobile App - Flutter app with chat and terminal screens - WebSocket integration for real-time chat - xterm integration for screen sessions - Markdown rendering with code blocks - JWT authentication Co-Authored-By: Claude Opus 4.5 --- .gitignore | 69 +++++ README.md | 108 ++++++++ analysis_options.yaml | 10 + android/app/build.gradle | 65 +++++ android/app/src/main/AndroidManifest.xml | 39 +++ .../captain_mobile/MainActivity.kt | 6 + .../main/res/drawable/launch_background.xml | 4 + android/app/src/main/res/values/styles.xml | 18 ++ android/build.gradle | 31 +++ android/gradle.properties | 3 + android/settings.gradle | 26 ++ lib/config/api_config.dart | 18 ++ lib/main.dart | 93 +++++++ lib/models/message.dart | 65 +++++ lib/models/session.dart | 50 ++++ lib/models/user.dart | 29 +++ lib/providers/auth_provider.dart | 63 +++++ lib/providers/chat_provider.dart | 145 +++++++++++ lib/screens/chat_screen.dart | 244 ++++++++++++++++++ lib/screens/login_screen.dart | 174 +++++++++++++ lib/screens/sessions_screen.dart | 147 +++++++++++ lib/screens/terminal_screen.dart | 201 +++++++++++++++ lib/services/api_service.dart | 82 ++++++ lib/services/auth_service.dart | 61 +++++ lib/services/chat_service.dart | 105 ++++++++ lib/services/terminal_service.dart | 115 +++++++++ lib/widgets/code_block.dart | 111 ++++++++ lib/widgets/markdown_viewer.dart | 68 +++++ lib/widgets/message_bubble.dart | 139 ++++++++++ lib/widgets/terminal_view.dart | 4 + pubspec.yaml | 50 ++++ 31 files changed, 2343 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 android/app/build.gradle create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/me/tzzrarchitect/captain_mobile/MainActivity.kt create mode 100644 android/app/src/main/res/drawable/launch_background.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/build.gradle create mode 100644 android/gradle.properties create mode 100644 android/settings.gradle create mode 100644 lib/config/api_config.dart create mode 100644 lib/main.dart create mode 100644 lib/models/message.dart create mode 100644 lib/models/session.dart create mode 100644 lib/models/user.dart create mode 100644 lib/providers/auth_provider.dart create mode 100644 lib/providers/chat_provider.dart create mode 100644 lib/screens/chat_screen.dart create mode 100644 lib/screens/login_screen.dart create mode 100644 lib/screens/sessions_screen.dart create mode 100644 lib/screens/terminal_screen.dart create mode 100644 lib/services/api_service.dart create mode 100644 lib/services/auth_service.dart create mode 100644 lib/services/chat_service.dart create mode 100644 lib/services/terminal_service.dart create mode 100644 lib/widgets/code_block.dart create mode 100644 lib/widgets/markdown_viewer.dart create mode 100644 lib/widgets/message_bubble.dart create mode 100644 lib/widgets/terminal_view.dart create mode 100644 pubspec.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29f5b10 --- /dev/null +++ b/.gitignore @@ -0,0 +1,69 @@ +# Flutter/Dart +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +*.iml +*.ipr +*.iws +.idea/ +.vscode/ + +# Android +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.* +**/android/key.properties +*.jks +*.keystore + +# iOS +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/.last_build_id +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Misc +*.class +*.lock +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ +pubspec.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..84d4cac --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# Captain Claude Mobile + +App Android para interactuar con Captain Claude via chat y acceder a sesiones screen. + +## Características + +- Chat en tiempo real con Captain Claude via WebSocket +- Renderizado de Markdown con syntax highlighting +- Terminal interactiva para sesiones screen +- Autenticación JWT +- Tema oscuro + +## Requisitos + +- Flutter SDK >= 3.0.0 +- Android SDK +- Backend API corriendo en `captain.tzzrarchitect.me` + +## Instalación + +```bash +# Clonar repo +git clone https://git.tzzr.net/tzzr/captain-mobile.git +cd captain-mobile + +# Instalar dependencias +flutter pub get + +# Ejecutar en debug +flutter run + +# Build APK release +flutter build apk --release +``` + +## Estructura + +``` +lib/ +├── main.dart # Entry point +├── config/ +│ └── api_config.dart # Configuración API +├── models/ +│ ├── message.dart # Modelo de mensaje +│ ├── session.dart # Modelo de sesión screen +│ └── user.dart # Modelo de usuario +├── services/ +│ ├── api_service.dart # Llamadas REST +│ ├── auth_service.dart # Autenticación +│ ├── chat_service.dart # WebSocket chat +│ └── terminal_service.dart # WebSocket terminal +├── providers/ +│ ├── auth_provider.dart # Estado autenticación +│ └── chat_provider.dart # Estado chat +├── screens/ +│ ├── login_screen.dart # Pantalla login +│ ├── chat_screen.dart # Pantalla chat principal +│ ├── sessions_screen.dart # Lista sesiones screen +│ └── terminal_screen.dart # Terminal interactiva +└── widgets/ + ├── message_bubble.dart # Burbuja de mensaje + ├── code_block.dart # Bloque de código + └── markdown_viewer.dart # Visor markdown +``` + +## Backend API + +El backend está en `apps/captain-mobile/` del repo captain-claude: + +- `captain_api.py` - FastAPI con WebSocket +- Puerto: 3030 +- Endpoints: + - `POST /auth/login` - Autenticación + - `GET /sessions` - Listar sesiones screen + - `GET /history` - Historial conversaciones + - `WS /ws/chat` - Chat con Captain + - `WS /ws/terminal/{session}` - Terminal + +## Configuración + +Editar `lib/config/api_config.dart` para cambiar la URL del servidor: + +```dart +static const String baseUrl = 'https://captain.tzzrarchitect.me'; +static const String wsUrl = 'wss://captain.tzzrarchitect.me'; +``` + +## Credenciales por defecto + +- Usuario: `captain` +- Password: `tzzr2025` + +## Desarrollo + +### Backend +```bash +cd apps/captain-mobile +./venv/bin/uvicorn captain_api:app --reload --port 3030 +``` + +### App +```bash +flutter run -d +``` + +## Licencia + +Propietario - TZZR diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..2168b9a --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,10 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + prefer_const_constructors: true + prefer_const_declarations: true + prefer_final_fields: true + prefer_final_locals: true + avoid_print: false + use_key_in_widget_constructors: true diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..0bcb138 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,65 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +android { + namespace "me.tzzrarchitect.captain_mobile" + compileSdk flutter.compileSdkVersion + ndkVersion flutter.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + applicationId "me.tzzrarchitect.captain_mobile" + minSdk 21 + targetSdk flutter.targetSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + signingConfig signingConfigs.debug + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } +} + +flutter { + source '../..' +} + +dependencies {} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..470cdc2 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/me/tzzrarchitect/captain_mobile/MainActivity.kt b/android/app/src/main/kotlin/me/tzzrarchitect/captain_mobile/MainActivity.kt new file mode 100644 index 0000000..ecbf84d --- /dev/null +++ b/android/app/src/main/kotlin/me/tzzrarchitect/captain_mobile/MainActivity.kt @@ -0,0 +1,6 @@ +package me.tzzrarchitect.captain_mobile + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..bd63960 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..3afa56f --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..68b15a8 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.9.0' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +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 +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..598d13f --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..ea1cefc --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,26 @@ +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 + } + settings.ext.flutterSdkPath = flutterSdkPath() + + includeBuild("${settings.ext.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.9.0" apply false +} + +include ":app" diff --git a/lib/config/api_config.dart b/lib/config/api_config.dart new file mode 100644 index 0000000..ed33be8 --- /dev/null +++ b/lib/config/api_config.dart @@ -0,0 +1,18 @@ +class ApiConfig { + static const String baseUrl = 'https://captain.tzzrarchitect.me'; + static const String wsUrl = 'wss://captain.tzzrarchitect.me'; + + // Endpoints + static const String login = '/auth/login'; + static const String sessions = '/sessions'; + static const String history = '/history'; + static const String upload = '/upload'; + + // WebSocket endpoints + static const String wsChat = '/ws/chat'; + static String wsTerminal(String sessionName) => '/ws/terminal/$sessionName'; + + // Timeouts + static const Duration connectionTimeout = Duration(seconds: 30); + static const Duration receiveTimeout = Duration(seconds: 60); +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..f7a14e0 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:google_fonts/google_fonts.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(); + runApp(const CaptainMobileApp()); +} + +class CaptainMobileApp extends StatelessWidget { + const CaptainMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => AuthProvider()), + ChangeNotifierProxyProvider( + create: (_) => ChatProvider(), + update: (_, auth, chat) => chat!..updateAuth(auth), + ), + ], + child: MaterialApp( + title: 'Captain Claude', + debugShowCheckedModeBanner: false, + theme: _buildTheme(Brightness.dark), + home: const AuthWrapper(), + ), + ); + } + + ThemeData _buildTheme(Brightness brightness) { + final baseTheme = ThemeData( + brightness: brightness, + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFFD97706), // Amber/orange accent + brightness: brightness, + ), + ); + + return baseTheme.copyWith( + textTheme: GoogleFonts.interTextTheme(baseTheme.textTheme), + scaffoldBackgroundColor: const Color(0xFF1A1A1A), + appBarTheme: const AppBarTheme( + backgroundColor: Color(0xFF1A1A1A), + elevation: 0, + ), + cardTheme: const CardTheme( + color: Color(0xFF2D2D2D), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: const Color(0xFF2D2D2D), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + ); + } +} + +class AuthWrapper extends StatelessWidget { + const AuthWrapper({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, auth, _) { + if (auth.isLoading) { + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } + + if (auth.isAuthenticated) { + return const ChatScreen(); + } + + return const LoginScreen(); + }, + ); + } +} diff --git a/lib/models/message.dart b/lib/models/message.dart new file mode 100644 index 0000000..1e91ceb --- /dev/null +++ b/lib/models/message.dart @@ -0,0 +1,65 @@ +import 'package:uuid/uuid.dart'; + +class Message { + final String id; + final String role; // 'user' or 'assistant' + final String content; + final DateTime timestamp; + final bool isStreaming; + final List? attachments; + + Message({ + String? id, + required this.role, + required this.content, + DateTime? timestamp, + this.isStreaming = false, + this.attachments, + }) : id = id ?? const Uuid().v4(), + timestamp = timestamp ?? DateTime.now(); + + Message copyWith({ + String? id, + String? role, + String? content, + DateTime? timestamp, + bool? isStreaming, + List? attachments, + }) { + return Message( + id: id ?? this.id, + role: role ?? this.role, + content: content ?? this.content, + timestamp: timestamp ?? this.timestamp, + isStreaming: isStreaming ?? this.isStreaming, + attachments: attachments ?? this.attachments, + ); + } + + factory Message.fromJson(Map json) { + return Message( + id: json['id'], + role: json['role'], + content: json['content'], + timestamp: json['timestamp'] != null + ? DateTime.parse(json['timestamp']) + : DateTime.now(), + attachments: json['attachments'] != null + ? List.from(json['attachments']) + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'role': role, + 'content': content, + 'timestamp': timestamp.toIso8601String(), + 'attachments': attachments, + }; + } + + bool get isUser => role == 'user'; + bool get isAssistant => role == 'assistant'; +} diff --git a/lib/models/session.dart b/lib/models/session.dart new file mode 100644 index 0000000..08bcef4 --- /dev/null +++ b/lib/models/session.dart @@ -0,0 +1,50 @@ +class ScreenSession { + final String name; + final String pid; + final bool attached; + + ScreenSession({ + required this.name, + required this.pid, + required this.attached, + }); + + factory ScreenSession.fromJson(Map json) { + return ScreenSession( + name: json['name'] ?? '', + pid: json['pid'] ?? '', + attached: json['attached'] ?? false, + ); + } + + Map toJson() { + return { + 'name': name, + 'pid': pid, + 'attached': attached, + }; + } +} + +class Conversation { + final String id; + final String title; + final DateTime createdAt; + final int messageCount; + + Conversation({ + required this.id, + required this.title, + required this.createdAt, + required this.messageCount, + }); + + factory Conversation.fromJson(Map json) { + return Conversation( + id: json['id'], + title: json['title'] ?? 'Untitled', + createdAt: DateTime.parse(json['created_at']), + messageCount: json['message_count'] ?? 0, + ); + } +} diff --git a/lib/models/user.dart b/lib/models/user.dart new file mode 100644 index 0000000..38e9069 --- /dev/null +++ b/lib/models/user.dart @@ -0,0 +1,29 @@ +class User { + final String username; + final String token; + final DateTime expiresAt; + + User({ + required this.username, + required this.token, + required this.expiresAt, + }); + + bool get isExpired => DateTime.now().isAfter(expiresAt); + + factory User.fromJson(Map json) { + return User( + username: json['username'], + token: json['token'], + expiresAt: DateTime.parse(json['expires_at']), + ); + } + + Map toJson() { + return { + 'username': username, + 'token': token, + 'expires_at': expiresAt.toIso8601String(), + }; + } +} diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart new file mode 100644 index 0000000..3aea26e --- /dev/null +++ b/lib/providers/auth_provider.dart @@ -0,0 +1,63 @@ +import 'package:flutter/foundation.dart'; +import '../models/user.dart'; +import '../services/api_service.dart'; +import '../services/auth_service.dart'; + +class AuthProvider with ChangeNotifier { + final ApiService _apiService = ApiService(); + late final AuthService _authService; + + User? _user; + bool _isLoading = true; + String? _error; + + AuthProvider() { + _authService = AuthService(_apiService); + _loadStoredUser(); + } + + User? get user => _user; + bool get isLoading => _isLoading; + bool get isAuthenticated => _user != null && !_user!.isExpired; + String? get error => _error; + String? get token => _user?.token; + ApiService get apiService => _apiService; + + Future _loadStoredUser() async { + _isLoading = true; + notifyListeners(); + + _user = await _authService.getStoredUser(); + _isLoading = false; + notifyListeners(); + } + + Future login(String username, String password) async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + _user = await _authService.login(username, password); + _isLoading = false; + + if (_user == null) { + _error = 'Invalid credentials'; + } + + notifyListeners(); + return _user != null; + } catch (e) { + _error = 'Connection error'; + _isLoading = false; + notifyListeners(); + return false; + } + } + + Future logout() async { + await _authService.logout(); + _user = null; + notifyListeners(); + } +} diff --git a/lib/providers/chat_provider.dart b/lib/providers/chat_provider.dart new file mode 100644 index 0000000..c5643cc --- /dev/null +++ b/lib/providers/chat_provider.dart @@ -0,0 +1,145 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import '../models/message.dart'; +import '../services/chat_service.dart'; +import '../providers/auth_provider.dart'; + +class ChatProvider with ChangeNotifier { + final ChatService _chatService = ChatService(); + final List _messages = []; + + AuthProvider? _authProvider; + StreamSubscription? _messageSubscription; + StreamSubscription? _stateSubscription; + + String? _currentConversationId; + bool _isTyping = false; + String _streamingContent = ''; + Message? _streamingMessage; + + List get messages => List.unmodifiable(_messages); + bool get isTyping => _isTyping; + bool get isConnected => _chatService.currentState == ChatConnectionState.connected; + ChatConnectionState get connectionState => _chatService.currentState; + String? get currentConversationId => _currentConversationId; + + void updateAuth(AuthProvider auth) { + _authProvider = auth; + _chatService.setToken(auth.token); + + if (auth.isAuthenticated && !isConnected) { + connect(); + } else if (!auth.isAuthenticated && isConnected) { + disconnect(); + } + } + + Future connect() async { + if (_authProvider?.token == null) return; + + _chatService.setToken(_authProvider!.token); + + _stateSubscription?.cancel(); + _stateSubscription = _chatService.connectionState.listen((state) { + notifyListeners(); + }); + + _messageSubscription?.cancel(); + _messageSubscription = _chatService.messages.listen(_handleMessage); + + await _chatService.connect(); + } + + void _handleMessage(Map data) { + final type = data['type']; + + switch (type) { + case 'connected': + notifyListeners(); + break; + + case 'start': + _currentConversationId = data['conversation_id']; + _isTyping = true; + _streamingContent = ''; + _streamingMessage = Message( + role: 'assistant', + content: '', + isStreaming: true, + ); + _messages.add(_streamingMessage!); + notifyListeners(); + break; + + case 'delta': + _streamingContent += data['content'] ?? ''; + if (_streamingMessage != null) { + final index = _messages.indexOf(_streamingMessage!); + if (index >= 0) { + _messages[index] = _streamingMessage!.copyWith( + content: _streamingContent, + ); + } + } + notifyListeners(); + break; + + case 'done': + _isTyping = false; + if (_streamingMessage != null) { + final index = _messages.indexOf(_streamingMessage!); + if (index >= 0) { + _messages[index] = _streamingMessage!.copyWith( + content: data['content'] ?? _streamingContent, + isStreaming: false, + ); + } + } + _streamingMessage = null; + _streamingContent = ''; + notifyListeners(); + break; + + case 'error': + _isTyping = false; + _messages.add(Message( + role: 'assistant', + content: 'Error: ${data['message']}', + )); + notifyListeners(); + break; + } + } + + void sendMessage(String content, {List? files}) { + if (content.trim().isEmpty) return; + + _messages.add(Message( + role: 'user', + content: content, + attachments: files, + )); + notifyListeners(); + + _chatService.sendMessage(content, files: files); + } + + void clearMessages() { + _messages.clear(); + _currentConversationId = null; + notifyListeners(); + } + + void disconnect() { + _chatService.disconnect(); + _messageSubscription?.cancel(); + _stateSubscription?.cancel(); + } + + @override + void dispose() { + disconnect(); + _chatService.dispose(); + super.dispose(); + } +} diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart new file mode 100644 index 0000000..c4eda15 --- /dev/null +++ b/lib/screens/chat_screen.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/auth_provider.dart'; +import '../providers/chat_provider.dart'; +import '../widgets/message_bubble.dart'; +import '../services/chat_service.dart'; +import 'sessions_screen.dart'; + +class ChatScreen extends StatefulWidget { + const ChatScreen({super.key}); + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + final _messageController = TextEditingController(); + final _scrollController = ScrollController(); + final _focusNode = FocusNode(); + + @override + void dispose() { + _messageController.dispose(); + _scrollController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _sendMessage() { + final message = _messageController.text.trim(); + if (message.isEmpty) return; + + context.read().sendMessage(message); + _messageController.clear(); + _scrollToBottom(); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Captain Claude'), + actions: [ + // Connection status + Consumer( + builder: (context, chat, _) { + final color = switch (chat.connectionState) { + ChatConnectionState.connected => Colors.green, + ChatConnectionState.connecting => Colors.orange, + _ => Colors.red, + }; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: Icon(Icons.circle, size: 12, color: color), + ); + }, + ), + // Terminal sessions + IconButton( + icon: const Icon(Icons.terminal), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SessionsScreen()), + ); + }, + tooltip: 'Screen Sessions', + ), + // New conversation + IconButton( + icon: const Icon(Icons.add), + onPressed: () { + context.read().clearMessages(); + }, + tooltip: 'New Conversation', + ), + // Logout + PopupMenuButton( + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'logout', + child: Row( + children: [ + Icon(Icons.logout), + SizedBox(width: 8), + Text('Logout'), + ], + ), + ), + ], + onSelected: (value) { + if (value == 'logout') { + context.read().logout(); + } + }, + ), + ], + ), + body: Column( + children: [ + // Messages + Expanded( + child: Consumer( + builder: (context, chat, _) { + if (chat.messages.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.chat_bubble_outline, + size: 64, + color: Colors.grey.shade600, + ), + const SizedBox(height: 16), + Text( + 'Start a conversation', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 16, + ), + ), + ], + ), + ); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(); + }); + + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: chat.messages.length, + itemBuilder: (context, index) { + final message = chat.messages[index]; + return MessageBubble(message: message); + }, + ); + }, + ), + ), + + // Typing indicator + Consumer( + builder: (context, chat, _) { + if (!chat.isTyping) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + alignment: Alignment.centerLeft, + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 8), + Text( + 'Captain is typing...', + style: TextStyle(color: Colors.grey.shade500), + ), + ], + ), + ); + }, + ), + + // Input + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFF2D2D2D), + border: Border( + top: BorderSide(color: Colors.grey.shade800), + ), + ), + child: SafeArea( + child: Row( + children: [ + // Attach file button + IconButton( + icon: const Icon(Icons.attach_file), + onPressed: () { + // TODO: Implement file picker + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('File attachment coming soon')), + ); + }, + ), + // Text input + Expanded( + child: TextField( + controller: _messageController, + focusNode: _focusNode, + decoration: const InputDecoration( + hintText: 'Message Captain Claude...', + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + maxLines: 5, + minLines: 1, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendMessage(), + ), + ), + // Send button + Consumer( + builder: (context, chat, _) { + return IconButton( + icon: const Icon(Icons.send), + onPressed: chat.isConnected ? _sendMessage : null, + color: Theme.of(context).colorScheme.primary, + ); + }, + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart new file mode 100644 index 0000000..30002d2 --- /dev/null +++ b/lib/screens/login_screen.dart @@ -0,0 +1,174 @@ +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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _formKey = GlobalKey(); + bool _obscurePassword = true; + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _login() async { + if (!_formKey.currentState!.validate()) return; + + final auth = context.read(); + final success = await auth.login( + _usernameController.text, + _passwordController.text, + ); + + if (!success && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(auth.error ?? 'Login failed'), + backgroundColor: Colors.red, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Logo/Icon + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withOpacity(0.2), + borderRadius: BorderRadius.circular(24), + ), + child: Icon( + Icons.terminal_rounded, + size: 50, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 24), + + // Title + Text( + 'Captain Claude', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'TZZR Mobile Interface', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + + // Username + TextFormField( + controller: _usernameController, + decoration: const InputDecoration( + labelText: 'Username', + prefixIcon: Icon(Icons.person_outline), + ), + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Enter username'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Password + TextFormField( + controller: _passwordController, + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility : Icons.visibility_off, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ), + obscureText: _obscurePassword, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _login(), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Enter password'; + } + return null; + }, + ), + const SizedBox(height: 32), + + // Login Button + Consumer( + builder: (context, auth, _) { + return ElevatedButton( + onPressed: auth.isLoading ? null : _login, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + ), + child: auth.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text( + 'Login', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ); + }, + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/sessions_screen.dart b/lib/screens/sessions_screen.dart new file mode 100644 index 0000000..e4e8e60 --- /dev/null +++ b/lib/screens/sessions_screen.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/auth_provider.dart'; +import '../models/session.dart'; +import 'terminal_screen.dart'; + +class SessionsScreen extends StatefulWidget { + const SessionsScreen({super.key}); + + @override + State createState() => _SessionsScreenState(); +} + +class _SessionsScreenState extends State { + List? _sessions; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadSessions(); + } + + Future _loadSessions() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final auth = context.read(); + final sessions = await auth.apiService.getSessions(); + setState(() { + _sessions = sessions; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Screen Sessions'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadSessions, + ), + ], + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 48, color: Colors.red.shade400), + const SizedBox(height: 16), + Text(_error!, style: const TextStyle(color: Colors.red)), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadSessions, + child: const Text('Retry'), + ), + ], + ), + ); + } + + if (_sessions == null || _sessions!.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.terminal, size: 64, color: Colors.grey.shade600), + const SizedBox(height: 16), + Text( + 'No active screen sessions', + style: TextStyle(color: Colors.grey.shade500, fontSize: 16), + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: _loadSessions, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _sessions!.length, + itemBuilder: (context, index) { + final session = _sessions![index]; + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: ListTile( + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: session.attached + ? Colors.green.withOpacity(0.2) + : Colors.grey.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.terminal, + color: session.attached ? Colors.green : Colors.grey, + ), + ), + title: Text( + session.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + 'PID: ${session.pid} - ${session.attached ? "Attached" : "Detached"}', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => TerminalScreen(sessionName: session.name), + ), + ); + }, + ), + ); + }, + ), + ); + } +} diff --git a/lib/screens/terminal_screen.dart b/lib/screens/terminal_screen.dart new file mode 100644 index 0000000..36491f0 --- /dev/null +++ b/lib/screens/terminal_screen.dart @@ -0,0 +1,201 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:xterm/xterm.dart'; +import '../providers/auth_provider.dart'; +import '../services/terminal_service.dart'; + +class TerminalScreen extends StatefulWidget { + final String sessionName; + + const TerminalScreen({super.key, required this.sessionName}); + + @override + State createState() => _TerminalScreenState(); +} + +class _TerminalScreenState extends State { + late Terminal _terminal; + late TerminalService _terminalService; + StreamSubscription? _outputSubscription; + StreamSubscription? _stateSubscription; + final _terminalController = TerminalController(); + + @override + void initState() { + super.initState(); + _terminal = Terminal( + maxLines: 10000, + ); + _terminalService = TerminalService(); + _connect(); + } + + void _connect() { + final auth = context.read(); + _terminalService.setToken(auth.token); + + _outputSubscription = _terminalService.output.listen((data) { + _terminal.write(data); + }); + + _stateSubscription = _terminalService.connectionState.listen((state) { + setState(() {}); + if (state == TerminalConnectionState.connected) { + _terminal.write('Connected to ${widget.sessionName}\r\n'); + } else if (state == TerminalConnectionState.error) { + _terminal.write('\r\n[Connection Error]\r\n'); + } + }); + + _terminal.onOutput = (data) { + _terminalService.sendInput(data); + }; + + _terminal.onResize = (width, height, pixelWidth, pixelHeight) { + _terminalService.resize(height, width); + }; + + _terminalService.connect(widget.sessionName); + } + + @override + void dispose() { + _outputSubscription?.cancel(); + _stateSubscription?.cancel(); + _terminalService.dispose(); + _terminalController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.sessionName), + actions: [ + // Connection status indicator + Padding( + padding: const EdgeInsets.only(right: 8), + child: Icon( + Icons.circle, + size: 12, + color: switch (_terminalService.currentState) { + TerminalConnectionState.connected => Colors.green, + TerminalConnectionState.connecting => Colors.orange, + _ => Colors.red, + }, + ), + ), + // Copy button + IconButton( + icon: const Icon(Icons.copy), + onPressed: () { + final selection = _terminalController.selection; + if (selection != null) { + final text = _terminal.buffer.getText(selection); + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Copied to clipboard')), + ); + } + }, + tooltip: 'Copy Selection', + ), + // Paste button + IconButton( + icon: const Icon(Icons.paste), + onPressed: () async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null) { + _terminalService.sendInput(data!.text!); + } + }, + tooltip: 'Paste', + ), + // Reconnect button + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () { + _terminalService.disconnect(); + _terminal.write('\r\n[Reconnecting...]\r\n'); + _connect(); + }, + tooltip: 'Reconnect', + ), + ], + ), + body: SafeArea( + child: TerminalView( + _terminal, + controller: _terminalController, + textStyle: const TerminalStyle( + fontSize: 13, + fontFamily: 'JetBrainsMono', + ), + theme: const TerminalTheme( + cursor: Color(0xFFD97706), + selection: Color(0x40D97706), + foreground: Color(0xFFE5E5E5), + background: Color(0xFF1A1A1A), + black: Color(0xFF1A1A1A), + red: Color(0xFFEF4444), + green: Color(0xFF22C55E), + yellow: Color(0xFFEAB308), + blue: Color(0xFF3B82F6), + magenta: Color(0xFFA855F7), + cyan: Color(0xFF06B6D4), + white: Color(0xFFE5E5E5), + brightBlack: Color(0xFF6B7280), + brightRed: Color(0xFFF87171), + brightGreen: Color(0xFF4ADE80), + brightYellow: Color(0xFFFDE047), + brightBlue: Color(0xFF60A5FA), + brightMagenta: Color(0xFFC084FC), + brightCyan: Color(0xFF22D3EE), + brightWhite: Color(0xFFFFFFFF), + searchHitBackground: Color(0xFFD97706), + searchHitBackgroundCurrent: Color(0xFFEF4444), + searchHitForeground: Color(0xFF1A1A1A), + ), + autofocus: true, + alwaysShowCursor: true, + ), + ), + // Quick action buttons for mobile + bottomNavigationBar: Container( + height: 48, + color: const Color(0xFF2D2D2D), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildQuickButton('Ctrl+C', () => _terminalService.sendInput('\x03')), + _buildQuickButton('Ctrl+D', () => _terminalService.sendInput('\x04')), + _buildQuickButton('Tab', () => _terminalService.sendInput('\t')), + _buildQuickButton('Esc', () => _terminalService.sendInput('\x1b')), + _buildQuickButton('Up', () => _terminalService.sendInput('\x1b[A')), + _buildQuickButton('Down', () => _terminalService.sendInput('\x1b[B')), + ], + ), + ), + ); + } + + Widget _buildQuickButton(String label, VoidCallback onTap) { + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Text( + label, + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } +} diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart new file mode 100644 index 0000000..753d62a --- /dev/null +++ b/lib/services/api_service.dart @@ -0,0 +1,82 @@ +import 'package:dio/dio.dart'; +import '../config/api_config.dart'; +import '../models/session.dart'; +import '../models/message.dart'; + +class ApiService { + late final Dio _dio; + String? _token; + + ApiService() { + _dio = Dio(BaseOptions( + baseUrl: ApiConfig.baseUrl, + connectTimeout: ApiConfig.connectionTimeout, + receiveTimeout: ApiConfig.receiveTimeout, + headers: { + 'Content-Type': 'application/json', + }, + )); + + _dio.interceptors.add(InterceptorsWrapper( + onRequest: (options, handler) { + if (_token != null) { + options.headers['Authorization'] = 'Bearer $_token'; + } + return handler.next(options); + }, + onError: (error, handler) { + if (error.response?.statusCode == 401) { + // Token expired, trigger re-login + } + return handler.next(error); + }, + )); + } + + void setToken(String? token) { + _token = token; + } + + Future> login(String username, String password) async { + final response = await _dio.post( + ApiConfig.login, + data: { + 'username': username, + 'password': password, + }, + ); + return response.data; + } + + Future> getSessions() async { + final response = await _dio.get(ApiConfig.sessions); + return (response.data as List) + .map((json) => ScreenSession.fromJson(json)) + .toList(); + } + + Future> getHistory({int limit = 20}) async { + final response = await _dio.get( + ApiConfig.history, + queryParameters: {'limit': limit}, + ); + return (response.data as List) + .map((json) => Conversation.fromJson(json)) + .toList(); + } + + Future> getConversationMessages(String conversationId) async { + final response = await _dio.get('${ApiConfig.history}/$conversationId'); + return (response.data as List) + .map((json) => Message.fromJson(json)) + .toList(); + } + + Future> uploadFile(String filePath) async { + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile(filePath), + }); + final response = await _dio.post(ApiConfig.upload, data: formData); + return response.data; + } +} diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart new file mode 100644 index 0000000..25b7626 --- /dev/null +++ b/lib/services/auth_service.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../models/user.dart'; +import 'api_service.dart'; + +class AuthService { + final ApiService _apiService; + final FlutterSecureStorage _storage = const FlutterSecureStorage(); + + static const String _userKey = 'captain_user'; + + AuthService(this._apiService); + + Future login(String username, String password) async { + try { + final response = await _apiService.login(username, password); + final user = User( + username: username, + token: response['token'], + expiresAt: DateTime.parse(response['expires_at']), + ); + + await _saveUser(user); + _apiService.setToken(user.token); + + return user; + } catch (e) { + return null; + } + } + + Future getStoredUser() async { + try { + final data = await _storage.read(key: _userKey); + if (data == null) return null; + + final user = User.fromJson(jsonDecode(data)); + if (user.isExpired) { + await logout(); + return null; + } + + _apiService.setToken(user.token); + return user; + } catch (e) { + return null; + } + } + + Future _saveUser(User user) async { + await _storage.write( + key: _userKey, + value: jsonEncode(user.toJson()), + ); + } + + Future logout() async { + await _storage.delete(key: _userKey); + _apiService.setToken(null); + } +} diff --git a/lib/services/chat_service.dart b/lib/services/chat_service.dart new file mode 100644 index 0000000..361ad80 --- /dev/null +++ b/lib/services/chat_service.dart @@ -0,0 +1,105 @@ +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, + error, +} + +class ChatService { + WebSocketChannel? _channel; + String? _token; + + final _messageController = StreamController>.broadcast(); + final _stateController = StreamController.broadcast(); + + Stream> get messages => _messageController.stream; + Stream get connectionState => _stateController.stream; + + ChatConnectionState _currentState = ChatConnectionState.disconnected; + ChatConnectionState get currentState => _currentState; + + void setToken(String? token) { + _token = token; + } + + Future connect() async { + if (_token == null) { + _updateState(ChatConnectionState.error); + return; + } + + _updateState(ChatConnectionState.connecting); + + try { + final uri = Uri.parse('${ApiConfig.wsUrl}${ApiConfig.wsChat}'); + _channel = WebSocketChannel.connect(uri); + + await _channel!.ready; + + // Send auth token + _channel!.sink.add(jsonEncode({'token': _token})); + + _channel!.stream.listen( + (data) { + try { + final message = jsonDecode(data); + if (message['type'] == 'connected') { + _updateState(ChatConnectionState.connected); + } + _messageController.add(message); + } catch (e) { + // Handle parse error + } + }, + onError: (error) { + _updateState(ChatConnectionState.error); + }, + onDone: () { + _updateState(ChatConnectionState.disconnected); + }, + ); + } catch (e) { + _updateState(ChatConnectionState.error); + } + } + + void sendMessage(String content, {List? files}) { + if (_channel == null || _currentState != ChatConnectionState.connected) { + return; + } + + _channel!.sink.add(jsonEncode({ + 'type': 'message', + 'content': content, + 'files': files ?? [], + })); + } + + void ping() { + if (_channel != null && _currentState == ChatConnectionState.connected) { + _channel!.sink.add(jsonEncode({'type': 'ping'})); + } + } + + void _updateState(ChatConnectionState state) { + _currentState = state; + _stateController.add(state); + } + + void disconnect() { + _channel?.sink.close(); + _channel = null; + _updateState(ChatConnectionState.disconnected); + } + + void dispose() { + disconnect(); + _messageController.close(); + _stateController.close(); + } +} diff --git a/lib/services/terminal_service.dart b/lib/services/terminal_service.dart new file mode 100644 index 0000000..1ebcf8b --- /dev/null +++ b/lib/services/terminal_service.dart @@ -0,0 +1,115 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import '../config/api_config.dart'; + +enum TerminalConnectionState { + disconnected, + connecting, + connected, + error, +} + +class TerminalService { + WebSocketChannel? _channel; + String? _token; + String? _sessionName; + + final _outputController = StreamController.broadcast(); + final _stateController = StreamController.broadcast(); + + Stream get output => _outputController.stream; + Stream get connectionState => _stateController.stream; + + TerminalConnectionState _currentState = TerminalConnectionState.disconnected; + TerminalConnectionState get currentState => _currentState; + + void setToken(String? token) { + _token = token; + } + + Future connect(String sessionName) async { + if (_token == null) { + _updateState(TerminalConnectionState.error); + return; + } + + _sessionName = sessionName; + _updateState(TerminalConnectionState.connecting); + + try { + final uri = Uri.parse('${ApiConfig.wsUrl}${ApiConfig.wsTerminal(sessionName)}'); + _channel = WebSocketChannel.connect(uri); + + await _channel!.ready; + + // Send auth token + _channel!.sink.add(jsonEncode({'token': _token})); + + _channel!.stream.listen( + (data) { + try { + final message = jsonDecode(data); + if (message['type'] == 'connected') { + _updateState(TerminalConnectionState.connected); + } else if (message['type'] == 'output') { + _outputController.add(message['data'] ?? ''); + } + } catch (e) { + // Raw data + _outputController.add(data.toString()); + } + }, + onError: (error) { + _updateState(TerminalConnectionState.error); + }, + onDone: () { + _updateState(TerminalConnectionState.disconnected); + }, + ); + } catch (e) { + _updateState(TerminalConnectionState.error); + } + } + + void sendInput(String input) { + if (_channel == null || _currentState != TerminalConnectionState.connected) { + return; + } + + _channel!.sink.add(jsonEncode({ + 'type': 'input', + 'data': input, + })); + } + + void resize(int rows, int cols) { + if (_channel == null || _currentState != TerminalConnectionState.connected) { + return; + } + + _channel!.sink.add(jsonEncode({ + 'type': 'resize', + 'rows': rows, + 'cols': cols, + })); + } + + void _updateState(TerminalConnectionState state) { + _currentState = state; + _stateController.add(state); + } + + void disconnect() { + _channel?.sink.close(); + _channel = null; + _sessionName = null; + _updateState(TerminalConnectionState.disconnected); + } + + void dispose() { + disconnect(); + _outputController.close(); + _stateController.close(); + } +} diff --git a/lib/widgets/code_block.dart b/lib/widgets/code_block.dart new file mode 100644 index 0000000..fb09f6b --- /dev/null +++ b/lib/widgets/code_block.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:markdown/markdown.dart' as md; + +class CodeBlockBuilder extends MarkdownElementBuilder { + @override + Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { + final code = element.textContent; + final language = element.attributes['class']?.replaceFirst('language-', '') ?? ''; + + return CodeBlockWidget( + code: code, + language: language, + ); + } +} + +class CodeBlockWidget extends StatelessWidget { + final String code; + final String language; + + const CodeBlockWidget({ + 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(0xFF0D1117), + 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: 8), + 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.isNotEmpty ? language : 'code', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade400, + fontFamily: 'JetBrainsMono', + ), + ), + InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: code)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Code copied'), + duration: Duration(seconds: 1), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.all(4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.copy, size: 14, color: Colors.grey.shade400), + const SizedBox(width: 4), + Text( + 'Copy', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade400, + ), + ), + ], + ), + ), + ), + ], + ), + ), + // Code content + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.all(12), + child: SelectableText( + code.trimRight(), + style: const TextStyle( + fontFamily: 'JetBrainsMono', + fontSize: 13, + color: Color(0xFFE6EDF3), + height: 1.5, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/markdown_viewer.dart b/lib/widgets/markdown_viewer.dart new file mode 100644 index 0000000..b7850ae --- /dev/null +++ b/lib/widgets/markdown_viewer.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'code_block.dart'; + +class MarkdownViewer extends StatelessWidget { + final String data; + final bool selectable; + + const MarkdownViewer({ + super.key, + required this.data, + this.selectable = true, + }); + + @override + Widget build(BuildContext context) { + return MarkdownBody( + data: data, + selectable: selectable, + styleSheet: MarkdownStyleSheet( + p: const TextStyle(fontSize: 15, height: 1.5), + h1: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + h2: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + h3: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + h4: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + code: TextStyle( + fontFamily: 'JetBrainsMono', + backgroundColor: Colors.black.withOpacity(0.3), + fontSize: 13, + ), + codeblockDecoration: BoxDecoration( + color: const Color(0xFF0D1117), + borderRadius: BorderRadius.circular(8), + ), + blockquotePadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + blockquoteDecoration: BoxDecoration( + border: Border( + left: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 3, + ), + ), + ), + listBullet: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + tableHead: const TextStyle(fontWeight: FontWeight.bold), + tableBody: const TextStyle(), + tableBorder: TableBorder.all( + color: Colors.grey.shade700, + width: 1, + ), + tableCellsPadding: const EdgeInsets.all(8), + horizontalRuleDecoration: BoxDecoration( + border: Border( + top: BorderSide(color: Colors.grey.shade700, width: 1), + ), + ), + ), + builders: { + 'code': CodeBlockBuilder(), + }, + ); + } +} diff --git a/lib/widgets/message_bubble.dart b/lib/widgets/message_bubble.dart new file mode 100644 index 0000000..63b33fd --- /dev/null +++ b/lib/widgets/message_bubble.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import '../models/message.dart'; +import 'code_block.dart'; + +class MessageBubble extends StatelessWidget { + final Message message; + + const MessageBubble({super.key, required this.message}); + + @override + Widget build(BuildContext context) { + final isUser = message.isUser; + + return Padding( + padding: EdgeInsets.only( + top: 8, + bottom: 8, + left: isUser ? 40 : 0, + right: isUser ? 0 : 40, + ), + child: Column( + crossAxisAlignment: + isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + // Role label + Padding( + padding: const EdgeInsets.only(bottom: 4, left: 4, right: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isUser ? Icons.person : Icons.smart_toy, + size: 14, + color: Colors.grey.shade500, + ), + const SizedBox(width: 4), + Text( + isUser ? 'You' : 'Captain', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade500, + fontWeight: FontWeight.w500, + ), + ), + if (message.isStreaming) ...[ + const SizedBox(width: 8), + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ], + ), + ), + // Message content + GestureDetector( + onLongPress: () { + Clipboard.setData(ClipboardData(text: message.content)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Copied to clipboard'), + duration: Duration(seconds: 1), + ), + ); + }, + child: Container( + decoration: BoxDecoration( + color: isUser + ? Theme.of(context).colorScheme.primary.withOpacity(0.2) + : const Color(0xFF2D2D2D), + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.all(12), + child: isUser + ? SelectableText( + message.content, + style: const TextStyle(fontSize: 15), + ) + : MarkdownBody( + data: message.content, + selectable: true, + styleSheet: MarkdownStyleSheet( + p: const TextStyle(fontSize: 15), + code: TextStyle( + fontFamily: 'JetBrainsMono', + backgroundColor: Colors.black.withOpacity(0.3), + fontSize: 13, + ), + codeblockDecoration: BoxDecoration( + color: Colors.black.withOpacity(0.3), + borderRadius: BorderRadius.circular(8), + ), + blockquotePadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + blockquoteDecoration: BoxDecoration( + border: Border( + left: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 3, + ), + ), + ), + ), + builders: { + 'code': CodeBlockBuilder(), + }, + ), + ), + ), + // Timestamp + Padding( + padding: const EdgeInsets.only(top: 4, left: 4, right: 4), + child: Text( + _formatTime(message.timestamp), + style: TextStyle( + fontSize: 10, + color: Colors.grey.shade600, + ), + ), + ), + ], + ), + ); + } + + String _formatTime(DateTime time) { + final hour = time.hour.toString().padLeft(2, '0'); + final minute = time.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } +} diff --git a/lib/widgets/terminal_view.dart b/lib/widgets/terminal_view.dart new file mode 100644 index 0000000..1710e2f --- /dev/null +++ b/lib/widgets/terminal_view.dart @@ -0,0 +1,4 @@ +// Re-export xterm's TerminalView for convenience +// This file exists for future customization if needed + +export 'package:xterm/xterm.dart' show Terminal, TerminalView, TerminalController, TerminalStyle, TerminalTheme; diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..17eb045 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,50 @@ +name: captain_mobile +description: Captain Claude Mobile App - Chat and Terminal access + +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + + # State management + provider: ^6.1.1 + + # Networking + dio: ^5.4.0 + web_socket_channel: ^2.4.0 + + # Storage + flutter_secure_storage: ^9.0.0 + shared_preferences: ^2.2.2 + + # UI + flutter_markdown: ^0.6.18 + google_fonts: ^6.1.0 + + # Terminal + xterm: ^4.0.0 + + # Utilities + uuid: ^4.2.2 + intl: ^0.19.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +flutter: + uses-material-design: true + + fonts: + - family: JetBrainsMono + fonts: + - asset: assets/fonts/JetBrainsMono-Regular.ttf + - asset: assets/fonts/JetBrainsMono-Bold.ttf + weight: 700