import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:server_box/data/model/opencode/models.dart'; import 'package:server_box/data/provider/opencode/opencode.dart'; import 'package:server_box/data/provider/server/single.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/store/opencode_store.dart'; /// Opencode 连接/安装向导页面 class OpencodeSetupPage extends ConsumerStatefulWidget { final String serverId; const OpencodeSetupPage({ super.key, required this.serverId, }); @override ConsumerState createState() => _OpencodeSetupPageState(); } class _OpencodeSetupPageState extends ConsumerState { final _formKey = GlobalKey(); final _pathController = TextEditingController(text: '/usr/local/bin/opencode'); final _configController = TextEditingController(text: '~/.config/opencode'); final _portController = TextEditingController(text: '8080'); final _apiKeyController = TextEditingController(); bool _autoStart = true; bool _isConnecting = false; String? _statusMessage; @override void initState() { super.initState(); _loadExistingConfig(); } @override void dispose() { _pathController.dispose(); _configController.dispose(); _portController.dispose(); _apiKeyController.dispose(); super.dispose(); } void _loadExistingConfig() { final config = OpencodeConfigStore().getConfig(widget.serverId); _pathController.text = config.opencodePath; _configController.text = config.configPath; _portController.text = config.apiPort; _autoStart = config.autoStart; if (config.apiKey != null) { _apiKeyController.text = config.apiKey!; } } Future _connect() async { if (_isConnecting) return; setState(() { _isConnecting = true; _statusMessage = 'Connecting to server...'; }); try { final serverState = ref.read(serverProvider(widget.serverId)); final connection = ref.read(opencodeConnectionProvider.notifier); final pki = serverState.spi.keyId != null ? Stores.key.get(serverState.spi.keyId!) : null; await connection.connect( serverState.spi, privateKey: pki?.key, passphrase: pki?.password, ); if (mounted) { setState(() { _statusMessage = 'Connected! Checking Opencode installation...'; }); final config = OpencodeServerConfig( serverId: widget.serverId, opencodePath: _pathController.text, configPath: _configController.text, apiPort: _portController.text, autoStart: _autoStart, apiKey: _apiKeyController.text.isNotEmpty ? _apiKeyController.text : null, ); await OpencodeConfigStore().saveConfig(config); await connection.checkAndInstall(config: config); } } catch (e) { if (mounted) { setState(() { _statusMessage = 'Error: $e'; }); context.showSnackBar('Connection failed: $e'); } } finally { if (mounted) { setState(() { _isConnecting = false; }); } } } Future _quickInstall() async { setState(() { _isConnecting = true; _statusMessage = 'Starting automatic installation...'; }); try { final connection = ref.read(opencodeConnectionProvider.notifier); final config = OpencodeServerConfig( serverId: widget.serverId, opencodePath: _pathController.text, configPath: _configController.text, apiPort: _portController.text, autoStart: _autoStart, apiKey: _apiKeyController.text.isNotEmpty ? _apiKeyController.text : null, ); await connection.checkAndInstall(config: config); if (mounted) { setState(() { _statusMessage = 'Installation complete!'; }); await Future.delayed(const Duration(seconds: 1)); if (mounted) { context.pushReplacement('/opencode/chat/${widget.serverId}'); } } } catch (e) { if (mounted) { setState(() { _statusMessage = 'Installation failed: $e'; }); } } finally { if (mounted) { setState(() { _isConnecting = false; }); } } } @override Widget build(BuildContext context) { final connectionState = ref.watch(opencodeConnectionProvider); final serverState = ref.watch(serverProvider(widget.serverId)); if (connectionState.status == OpencodeConnectionStatus.connected) { WidgetsBinding.instance.addPostFrameCallback((_) { context.pushReplacement('/opencode/chat/${widget.serverId}'); }); } return Scaffold( appBar: CustomAppBar( title: Text('Setup Opencode - ${serverState.spi.name}'), ), body: SingleChildScrollView( padding: const EdgeInsets.all(24), child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildInfoCard(), const SizedBox(height: 24), Text( 'Configuration', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 16), TextFormField( controller: _pathController, decoration: const InputDecoration( labelText: 'Opencode Binary Path', hintText: '/usr/local/bin/opencode', prefixIcon: Icon(Icons.file_present), border: OutlineInputBorder(), ), validator: (value) { if (value == null || value.isEmpty) { return 'Please enter the path'; } return null; }, ), const SizedBox(height: 16), TextFormField( controller: _configController, decoration: const InputDecoration( labelText: 'Config Directory', hintText: '~/.config/opencode', prefixIcon: Icon(Icons.folder), border: OutlineInputBorder(), ), ), const SizedBox(height: 16), TextFormField( controller: _portController, decoration: const InputDecoration( labelText: 'API Port', hintText: '8080', prefixIcon: Icon(Icons.settings_ethernet), border: OutlineInputBorder(), ), keyboardType: TextInputType.number, validator: (value) { if (value == null || value.isEmpty) { return 'Please enter the port'; } final port = int.tryParse(value); if (port == null || port < 1 || port > 65535) { return 'Invalid port number'; } return null; }, ), const SizedBox(height: 16), TextFormField( controller: _apiKeyController, decoration: const InputDecoration( labelText: 'Opencode API Key (Optional)', hintText: 'opc_...', prefixIcon: Icon(Icons.key), border: OutlineInputBorder(), ), obscureText: true, ), const SizedBox(height: 16), SwitchListTile( title: const Text('Auto-start API Server'), subtitle: const Text('Automatically start Opencode server on connection'), value: _autoStart, onChanged: (value) { setState(() { _autoStart = value; }); }, ), const SizedBox(height: 24), if (_statusMessage != null) ...[ Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: _getStatusColor(connectionState.status).withOpacity(0.1), borderRadius: BorderRadius.circular(12), ), child: Column( children: [ if (_isConnecting) const Padding( padding: EdgeInsets.only(bottom: 8), child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ), Text( _statusMessage!, textAlign: TextAlign.center, style: TextStyle( color: _getStatusColor(connectionState.status), ), ), ], ), ), const SizedBox(height: 24), ], SizedBox( width: double.infinity, child: ElevatedButton.icon( onPressed: _isConnecting ? null : _quickInstall, icon: _isConnecting ? const SizedBox.shrink() : const Icon(Icons.rocket_launch), label: Text(_isConnecting ? 'Please wait...' : 'Quick Install & Connect'), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), ), ), ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: _isConnecting ? null : _connect, icon: const Icon(Icons.link), label: const Text('Connect Only'), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), ), ), ), ], ), ), ), ); } Widget _buildInfoCard() { return Card( elevation: 0, color: UIs.primaryColor.withOpacity(0.1), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.info, color: UIs.primaryColor), const SizedBox(width: 8), Text( 'About Opencode', style: TextStyle( fontWeight: FontWeight.bold, color: UIs.primaryColor, ), ), ], ), const SizedBox(height: 12), const Text( 'Opencode (opencode.ai) is an AI assistant for developers. ' 'This app connects to your server via SSH to provide AI assistance.', style: TextStyle(fontSize: 14), ), const SizedBox(height: 12), const Text( '• If Opencode is not installed, the app can auto-install it\n' '• Your API key is saved in plaintext on the server\n' '• All communication is encrypted via SSH tunnel', style: TextStyle(fontSize: 13, color: Colors.black87), ), ], ), ), ); } Color _getStatusColor(OpencodeConnectionStatus status) { switch (status) { case OpencodeConnectionStatus.connected: return Colors.green; case OpencodeConnectionStatus.error: return Colors.red; case OpencodeConnectionStatus.installing: case OpencodeConnectionStatus.startingServer: return Colors.orange; default: return UIs.primaryColor; } } }