- Flutter-App (app/) für iOS, Android, macOS, Windows und Linux erstellt - WebView-Startseite mit flutter_inappwebview (iOS/Android/macOS/Windows), Linux-Fallback mit url_launcher - STOMP-over-WebSocket: Topic-basierte Echtzeit-Kommunikation zwischen Flutter-App und Spring Boot Core - Core: STOMP-Broker (/ws/stomp), CallEventBroadcaster auf /topic/calls, StompMessageController für /app/ping und /app/broadcast - SecurityConfig: /ws/** permitAll + CSRF-Ausnahme - Asset-basierte Konfigurationsdatei (app_config.json) für Server-URL, STOMP-Reconnect, Topics und WebView-URL - launch.json um Flutter-Debug/Profile/Release-Konfigurationen erweitert - macOS: FLTEnableMergedPlatformUIThread deaktiviert (WKWebView-Kompatibilität), network.client Entitlement gesetzt - iOS: NSAllowsLocalNetworking für lokale Entwicklung - Android: INTERNET-Permission und usesCleartextTraffic Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
427 lines
13 KiB
Dart
427 lines
13 KiB
Dart
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<void> 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<StompDemoPage> createState() => _StompDemoPageState();
|
||
}
|
||
|
||
class _StompDemoPageState extends State<StompDemoPage> {
|
||
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<String, void Function()> _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<void> _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),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|