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 <noreply@anthropic.com>
This commit is contained in:
ARCHITECT
2026-01-16 18:34:02 +00:00
commit 3663e4c622
31 changed files with 2343 additions and 0 deletions

69
.gitignore vendored Normal file
View File

@@ -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

108
README.md Normal file
View File

@@ -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 <device_id>
```
## Licencia
Propietario - TZZR

10
analysis_options.yaml Normal file
View File

@@ -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

65
android/app/build.gradle Normal file
View File

@@ -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 {}

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<application
android:label="Captain Claude"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<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>
<meta-data
android:name="flutterEmbedding"
android:value="2"/>
</application>
</manifest>

View File

@@ -0,0 +1,6 @@
package me.tzzrarchitect.captain_mobile
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity() {
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/black"/>
</layer-list>

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.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>

31
android/build.gradle Normal file
View File

@@ -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
}

View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx4G
android.useAndroidX=true
android.enableJetifier=true

26
android/settings.gradle Normal file
View File

@@ -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"

View File

@@ -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);
}

93
lib/main.dart Normal file
View File

@@ -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<AuthProvider, ChatProvider>(
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<AuthProvider>(
builder: (context, auth, _) {
if (auth.isLoading) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
if (auth.isAuthenticated) {
return const ChatScreen();
}
return const LoginScreen();
},
);
}
}

65
lib/models/message.dart Normal file
View File

@@ -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<String>? 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<String>? 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<String, dynamic> 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<String>.from(json['attachments'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'role': role,
'content': content,
'timestamp': timestamp.toIso8601String(),
'attachments': attachments,
};
}
bool get isUser => role == 'user';
bool get isAssistant => role == 'assistant';
}

50
lib/models/session.dart Normal file
View File

@@ -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<String, dynamic> json) {
return ScreenSession(
name: json['name'] ?? '',
pid: json['pid'] ?? '',
attached: json['attached'] ?? false,
);
}
Map<String, dynamic> 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<String, dynamic> json) {
return Conversation(
id: json['id'],
title: json['title'] ?? 'Untitled',
createdAt: DateTime.parse(json['created_at']),
messageCount: json['message_count'] ?? 0,
);
}
}

29
lib/models/user.dart Normal file
View File

@@ -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<String, dynamic> json) {
return User(
username: json['username'],
token: json['token'],
expiresAt: DateTime.parse(json['expires_at']),
);
}
Map<String, dynamic> toJson() {
return {
'username': username,
'token': token,
'expires_at': expiresAt.toIso8601String(),
};
}
}

View File

@@ -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<void> _loadStoredUser() async {
_isLoading = true;
notifyListeners();
_user = await _authService.getStoredUser();
_isLoading = false;
notifyListeners();
}
Future<bool> 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<void> logout() async {
await _authService.logout();
_user = null;
notifyListeners();
}
}

View File

@@ -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<Message> _messages = [];
AuthProvider? _authProvider;
StreamSubscription? _messageSubscription;
StreamSubscription? _stateSubscription;
String? _currentConversationId;
bool _isTyping = false;
String _streamingContent = '';
Message? _streamingMessage;
List<Message> 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<void> 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<String, dynamic> 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<String>? 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();
}
}

View File

@@ -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<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
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<ChatProvider>().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<ChatProvider>(
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<ChatProvider>().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<AuthProvider>().logout();
}
},
),
],
),
body: Column(
children: [
// Messages
Expanded(
child: Consumer<ChatProvider>(
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<ChatProvider>(
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<ChatProvider>(
builder: (context, chat, _) {
return IconButton(
icon: const Icon(Icons.send),
onPressed: chat.isConnected ? _sendMessage : null,
color: Theme.of(context).colorScheme.primary,
);
},
),
],
),
),
),
],
),
);
}
}

View File

@@ -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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _obscurePassword = true;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _login() async {
if (!_formKey.currentState!.validate()) return;
final auth = context.read<AuthProvider>();
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<AuthProvider>(
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,
),
),
);
},
),
],
),
),
),
),
),
);
}
}

View File

@@ -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<SessionsScreen> createState() => _SessionsScreenState();
}
class _SessionsScreenState extends State<SessionsScreen> {
List<ScreenSession>? _sessions;
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_loadSessions();
}
Future<void> _loadSessions() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final auth = context.read<AuthProvider>();
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),
),
);
},
),
);
},
),
);
}
}

View File

@@ -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<TerminalScreen> createState() => _TerminalScreenState();
}
class _TerminalScreenState extends State<TerminalScreen> {
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<AuthProvider>();
_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,
),
),
),
);
}
}

View File

@@ -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<Map<String, dynamic>> login(String username, String password) async {
final response = await _dio.post(
ApiConfig.login,
data: {
'username': username,
'password': password,
},
);
return response.data;
}
Future<List<ScreenSession>> getSessions() async {
final response = await _dio.get(ApiConfig.sessions);
return (response.data as List)
.map((json) => ScreenSession.fromJson(json))
.toList();
}
Future<List<Conversation>> 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<List<Message>> getConversationMessages(String conversationId) async {
final response = await _dio.get('${ApiConfig.history}/$conversationId');
return (response.data as List)
.map((json) => Message.fromJson(json))
.toList();
}
Future<Map<String, dynamic>> 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;
}
}

View File

@@ -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<User?> 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<User?> 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<void> _saveUser(User user) async {
await _storage.write(
key: _userKey,
value: jsonEncode(user.toJson()),
);
}
Future<void> logout() async {
await _storage.delete(key: _userKey);
_apiService.setToken(null);
}
}

View File

@@ -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<Map<String, dynamic>>.broadcast();
final _stateController = StreamController<ChatConnectionState>.broadcast();
Stream<Map<String, dynamic>> get messages => _messageController.stream;
Stream<ChatConnectionState> get connectionState => _stateController.stream;
ChatConnectionState _currentState = ChatConnectionState.disconnected;
ChatConnectionState get currentState => _currentState;
void setToken(String? token) {
_token = token;
}
Future<void> 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<String>? 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();
}
}

View File

@@ -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<String>.broadcast();
final _stateController = StreamController<TerminalConnectionState>.broadcast();
Stream<String> get output => _outputController.stream;
Stream<TerminalConnectionState> get connectionState => _stateController.stream;
TerminalConnectionState _currentState = TerminalConnectionState.disconnected;
TerminalConnectionState get currentState => _currentState;
void setToken(String? token) {
_token = token;
}
Future<void> 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();
}
}

111
lib/widgets/code_block.dart Normal file
View File

@@ -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,
),
),
),
],
),
);
}
}

View File

@@ -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(),
},
);
}
}

View File

@@ -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';
}
}

View File

@@ -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;

50
pubspec.yaml Normal file
View File

@@ -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