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

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