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>
This commit is contained in:
2026-04-20 10:32:14 +02:00
parent a5ed2b3355
commit ef4fa38244
134 changed files with 5961 additions and 1 deletions

View File

@@ -0,0 +1,46 @@
import 'dart:convert';
import 'package:flutter/services.dart' show rootBundle;
class AppConfig {
static const String _assetPath = 'assets/config/app_config.json';
final String stompUrl;
final Duration reconnectDelay;
final String webviewUrl;
final String defaultTopic;
final List<String> topicPresets;
final String defaultDestination;
final String defaultPayload;
const AppConfig({
required this.stompUrl,
required this.reconnectDelay,
required this.webviewUrl,
required this.defaultTopic,
required this.topicPresets,
required this.defaultDestination,
required this.defaultPayload,
});
static Future<AppConfig> load() async {
final raw = await rootBundle.loadString(_assetPath);
final json = jsonDecode(raw) as Map<String, dynamic>;
final server = json['server'] as Map<String, dynamic>;
final topics = json['topics'] as Map<String, dynamic>;
final send = json['send'] as Map<String, dynamic>;
return AppConfig(
stompUrl: server['stompUrl'] as String,
reconnectDelay: Duration(
seconds: (server['reconnectDelaySeconds'] as num).toInt(),
),
webviewUrl: server['webviewUrl'] as String,
defaultTopic: topics['default'] as String,
topicPresets: (topics['presets'] as List).cast<String>(),
defaultDestination: send['defaultDestination'] as String,
defaultPayload:
const JsonEncoder.withIndent(' ').convert(send['defaultPayload']),
);
}
}

426
app/lib/main.dart Normal file
View File

@@ -0,0 +1,426 @@
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),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,131 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher.dart';
import '../config/app_config.dart';
import '../main.dart';
class WebViewHomePage extends StatefulWidget {
const WebViewHomePage({super.key, required this.config});
final AppConfig config;
@override
State<WebViewHomePage> createState() => _WebViewHomePageState();
}
class _WebViewHomePageState extends State<WebViewHomePage> {
InAppWebViewController? _controller;
int _progress = 0;
bool get _platformSupportsWebView =>
!kIsWeb &&
(Platform.isAndroid ||
Platform.isIOS ||
Platform.isMacOS ||
Platform.isWindows);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Swyx'),
actions: [
if (_platformSupportsWebView)
IconButton(
tooltip: 'Neu laden',
onPressed: () => _controller?.reload(),
icon: const Icon(Icons.refresh),
),
IconButton(
tooltip: 'Im Browser öffnen',
onPressed: _openExternally,
icon: const Icon(Icons.open_in_browser),
),
IconButton(
tooltip: 'STOMP',
onPressed: _openStomp,
icon: const Icon(Icons.bolt),
),
],
),
body:
_platformSupportsWebView ? _buildWebView() : _buildLinuxFallback(),
);
}
Widget _buildWebView() {
return Column(
children: [
if (_progress < 100) LinearProgressIndicator(value: _progress / 100),
Expanded(
child: InAppWebView(
initialUrlRequest:
URLRequest(url: WebUri(widget.config.webviewUrl)),
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
transparentBackground: true,
),
onWebViewCreated: (controller) => _controller = controller,
onProgressChanged: (_, progress) =>
setState(() => _progress = progress),
onLoadStart: (_, _) => setState(() => _progress = 0),
onLoadStop: (_, _) => setState(() => _progress = 100),
),
),
],
);
}
Widget _buildLinuxFallback() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.web_asset_off, size: 48),
const SizedBox(height: 16),
Text(
'Eingebettete Webansicht ist unter Linux nicht verfügbar.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SelectableText(
widget.config.webviewUrl,
style: const TextStyle(fontFamily: 'monospace'),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _openExternally,
icon: const Icon(Icons.open_in_browser),
label: const Text('Im Browser öffnen'),
),
],
),
),
);
}
Future<void> _openExternally() async {
final uri = Uri.parse(widget.config.webviewUrl);
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Konnte ${widget.config.webviewUrl} nicht öffnen')),
);
}
}
void _openStomp() {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => StompDemoPage(config: widget.config),
));
}
}