Files
swyx/app/lib/main.dart
Sven Carstensen ef4fa38244 Flutter-App mit WebView, STOMP-Client und Konfigurationssystem
- 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>
2026-04-20 10:32:14 +02:00

427 lines
13 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
),
),
],
),
);
}
}