import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:stomp_dart_client/stomp_dart_client.dart'; import 'config/app_config.dart'; import 'pages/webview_home_page.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); final config = await AppConfig.load(); runApp(SwyxApp(config: config)); } class SwyxApp extends StatelessWidget { const SwyxApp({super.key, required this.config}); final AppConfig config; @override Widget build(BuildContext context) { return MaterialApp( title: 'Swyx App', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), useMaterial3: true, ), home: WebViewHomePage(config: config), ); } } enum StompState { disconnected, connecting, connected, error } class StompDemoPage extends StatefulWidget { const StompDemoPage({super.key, required this.config}); final AppConfig config; @override State createState() => _StompDemoPageState(); } class _StompDemoPageState extends State { late final TextEditingController _urlController = TextEditingController(text: widget.config.stompUrl); late final TextEditingController _topicController = TextEditingController(text: widget.config.defaultTopic); late final TextEditingController _destinationController = TextEditingController(text: widget.config.defaultDestination); late final TextEditingController _payloadController = TextEditingController(text: widget.config.defaultPayload); final ScrollController _logScroll = ScrollController(); final List<_LogEntry> _log = []; final Map _subscriptions = {}; StompClient? _client; StompState _state = StompState.disconnected; String? _lastError; @override void dispose() { _disconnect(); _urlController.dispose(); _topicController.dispose(); _destinationController.dispose(); _payloadController.dispose(); _logScroll.dispose(); super.dispose(); } void _connect() { final url = _urlController.text.trim(); if (url.isEmpty) return; setState(() { _state = StompState.connecting; _lastError = null; }); final client = StompClient( config: StompConfig( url: url, onConnect: _onConnect, onWebSocketError: (dynamic e) { _appendLog(_LogEntry.error('ws error: $e')); setState(() { _state = StompState.error; _lastError = e.toString(); }); }, onStompError: (StompFrame f) { _appendLog(_LogEntry.error( 'stomp error: ${f.headers['message']} — ${f.body}')); setState(() => _state = StompState.error); }, onDisconnect: (StompFrame f) { _appendLog(_LogEntry.system('disconnected')); if (mounted) setState(() => _state = StompState.disconnected); }, onWebSocketDone: () { _appendLog(_LogEntry.system('websocket closed')); if (mounted && _state != StompState.error) { setState(() => _state = StompState.disconnected); } }, reconnectDelay: widget.config.reconnectDelay, ), ); client.activate(); _client = client; } void _onConnect(StompFrame frame) { _appendLog(_LogEntry.system('STOMP connected (server=${frame.headers['server'] ?? '?'})')); if (mounted) setState(() => _state = StompState.connected); } Future _disconnect() async { for (final unsub in _subscriptions.values) { unsub(); } _subscriptions.clear(); _client?.deactivate(); _client = null; if (mounted) { setState(() => _state = StompState.disconnected); } } void _subscribe() { final client = _client; if (client == null || _state != StompState.connected) return; final topic = _topicController.text.trim(); if (topic.isEmpty || _subscriptions.containsKey(topic)) return; final unsub = client.subscribe( destination: topic, callback: (StompFrame frame) { final body = frame.body ?? ''; String pretty = body; try { pretty = const JsonEncoder.withIndent(' ').convert(jsonDecode(body)); } catch (_) {} _appendLog(_LogEntry.incoming(topic, pretty)); }, ); setState(() => _subscriptions[topic] = unsub); _appendLog(_LogEntry.system('subscribed to $topic')); } void _unsubscribe(String topic) { final unsub = _subscriptions.remove(topic); unsub?.call(); setState(() {}); _appendLog(_LogEntry.system('unsubscribed $topic')); } void _send() { final client = _client; if (client == null || _state != StompState.connected) return; final destination = _destinationController.text.trim(); final payload = _payloadController.text.trim(); if (destination.isEmpty || payload.isEmpty) return; try { jsonDecode(payload); } catch (e) { _appendLog(_LogEntry.error('not valid JSON: $e')); return; } client.send( destination: destination, body: payload, headers: {'content-type': 'application/json'}, ); _appendLog(_LogEntry.outgoing(destination, payload)); } void _appendLog(_LogEntry entry) { setState(() => _log.add(entry)); WidgetsBinding.instance.addPostFrameCallback((_) { if (_logScroll.hasClients) { _logScroll.animateTo( _logScroll.position.maxScrollExtent, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); } }); } Color _statusColor() { switch (_state) { case StompState.connected: return Colors.green; case StompState.connecting: return Colors.orange; case StompState.error: return Colors.red; case StompState.disconnected: return Colors.grey; } } String _statusLabel() { switch (_state) { case StompState.connected: return 'verbunden'; case StompState.connecting: return 'verbinde…'; case StompState.error: return 'Fehler'; case StompState.disconnected: return 'getrennt'; } } @override Widget build(BuildContext context) { final isConnected = _state == StompState.connected; return Scaffold( appBar: AppBar( title: const Text('Swyx – STOMP'), actions: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row(children: [ Icon(Icons.circle, size: 12, color: _statusColor()), const SizedBox(width: 6), Text(_statusLabel()), ]), ), ], ), body: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row(children: [ Expanded( child: TextField( controller: _urlController, enabled: !isConnected && _state != StompState.connecting, decoration: const InputDecoration( labelText: 'STOMP URL', border: OutlineInputBorder(), ), ), ), const SizedBox(width: 8), FilledButton.icon( onPressed: isConnected ? _disconnect : _connect, icon: Icon(isConnected ? Icons.link_off : Icons.link), label: Text(isConnected ? 'Trennen' : 'Verbinden'), ), ]), if (_lastError != null) ...[ const SizedBox(height: 8), Text(_lastError!, style: const TextStyle(color: Colors.red, fontSize: 12)), ], const SizedBox(height: 16), _sectionTitle('Abonnements'), Row(children: [ Expanded( child: TextField( controller: _topicController, decoration: const InputDecoration( labelText: 'Topic', border: OutlineInputBorder(), isDense: true, ), ), ), const SizedBox(width: 8), FilledButton.tonalIcon( onPressed: isConnected ? _subscribe : null, icon: const Icon(Icons.add), label: const Text('Subscribe'), ), ]), const SizedBox(height: 8), Wrap( spacing: 6, runSpacing: 4, children: [ for (final t in widget.config.topicPresets) ActionChip( label: Text(t, style: const TextStyle(fontSize: 11)), onPressed: () => _topicController.text = t, ), ], ), const SizedBox(height: 8), if (_subscriptions.isNotEmpty) Wrap( spacing: 6, runSpacing: 4, children: [ for (final t in _subscriptions.keys) InputChip( label: Text(t, style: const TextStyle(fontSize: 11)), onDeleted: () => _unsubscribe(t), ), ], ), const SizedBox(height: 16), Expanded( child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), child: ListView.builder( controller: _logScroll, itemCount: _log.length, itemBuilder: (context, i) => _LogTile(entry: _log[i]), ), ), ), const SizedBox(height: 12), _sectionTitle('Senden'), TextField( controller: _destinationController, decoration: const InputDecoration( labelText: 'Destination (z.B. /app/ping)', border: OutlineInputBorder(), isDense: true, ), ), const SizedBox(height: 8), TextField( controller: _payloadController, minLines: 2, maxLines: 5, style: const TextStyle(fontFamily: 'monospace'), decoration: const InputDecoration( labelText: 'JSON Payload', border: OutlineInputBorder(), ), ), const SizedBox(height: 8), Row(children: [ FilledButton.icon( onPressed: isConnected ? _send : null, icon: const Icon(Icons.send), label: const Text('Senden'), ), const SizedBox(width: 8), TextButton.icon( onPressed: () => setState(_log.clear), icon: const Icon(Icons.clear_all), label: const Text('Log leeren'), ), ]), ], ), ), ); } Widget _sectionTitle(String text) => Padding( padding: const EdgeInsets.only(bottom: 6), child: Text(text, style: Theme.of(context).textTheme.titleSmall?.copyWith( color: Theme.of(context).colorScheme.primary, )), ); } enum _LogKind { incoming, outgoing, system, error } class _LogEntry { final DateTime timestamp; final _LogKind kind; final String destination; final String text; _LogEntry(this.kind, this.destination, this.text) : timestamp = DateTime.now(); factory _LogEntry.incoming(String dest, String s) => _LogEntry(_LogKind.incoming, dest, s); factory _LogEntry.outgoing(String dest, String s) => _LogEntry(_LogKind.outgoing, dest, s); factory _LogEntry.system(String s) => _LogEntry(_LogKind.system, '', s); factory _LogEntry.error(String s) => _LogEntry(_LogKind.error, '', s); } class _LogTile extends StatelessWidget { const _LogTile({required this.entry}); final _LogEntry entry; @override Widget build(BuildContext context) { final (icon, color, prefix) = switch (entry.kind) { _LogKind.incoming => (Icons.arrow_downward, Colors.green, '<-'), _LogKind.outgoing => (Icons.arrow_upward, Colors.blue, '->'), _LogKind.system => (Icons.info_outline, Colors.grey, '*'), _LogKind.error => (Icons.error_outline, Colors.red, '!'), }; final time = entry.timestamp.toIso8601String().substring(11, 19); final header = entry.destination.isEmpty ? '$time $prefix ' : '$time $prefix ${entry.destination} '; return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, size: 16, color: color), const SizedBox(width: 8), Text(header, style: TextStyle( fontFamily: 'monospace', color: color, fontSize: 12)), Expanded( child: SelectableText( entry.text, style: const TextStyle(fontFamily: 'monospace', fontSize: 12), ), ), ], ), ); } }