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:
82
lib/services/api_service.dart
Normal file
82
lib/services/api_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
61
lib/services/auth_service.dart
Normal file
61
lib/services/auth_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
105
lib/services/chat_service.dart
Normal file
105
lib/services/chat_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
115
lib/services/terminal_service.dart
Normal file
115
lib/services/terminal_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user