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:
426
app/lib/main.dart
Normal file
426
app/lib/main.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user