diff --git a/.gitignore b/.gitignore index 8449041..8bfa5e8 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ app.*.map.json # Java heap dumps *.hprof + +# Lokale Zugangsdaten für die VHS-Karte +/config/vhs.local.json diff --git a/.vscode/launch.json b/.vscode/launch.json index 90d8e16..402f04e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,10 @@ "type": "dart", "flutterMode": "debug", "emulatorId": "Pixel_10_Pro", - "program": "lib/main.dart" + "program": "lib/main.dart", + "toolArgs": [ + "--dart-define-from-file=config/vhs.local.json" + ] }, { "name": "Stadtbote - Android Emulator (Release)", @@ -15,7 +18,10 @@ "type": "dart", "flutterMode": "release", "emulatorId": "Pixel_10_Pro", - "program": "lib/main.dart" + "program": "lib/main.dart", + "toolArgs": [ + "--dart-define-from-file=config/vhs.local.json" + ] }, { "name": "Stadtbote - Pixel 7", @@ -23,7 +29,10 @@ "type": "dart", "flutterMode": "debug", "deviceId": "2B101FDH2003HB", - "program": "lib/main.dart" + "program": "lib/main.dart", + "toolArgs": [ + "--dart-define-from-file=config/vhs.local.json" + ] }, { "name": "Stadtbote - Pixel 7 (Release)", @@ -31,7 +40,10 @@ "type": "dart", "flutterMode": "release", "deviceId": "2B101FDH2003HB", - "program": "lib/main.dart" + "program": "lib/main.dart", + "toolArgs": [ + "--dart-define-from-file=config/vhs.local.json" + ] } ] } diff --git a/README.md b/README.md index f6a49a7..ae26fb4 100644 --- a/README.md +++ b/README.md @@ -1 +1,21 @@ dart run build_runner build + +## VHS-Karte + +Aufträge können im JSON eine optionale `map_url` enthalten. Die Schaltfläche +„Karte“ öffnet diese HTTPS-Adresse innerhalb der App. Ohne `map_url` wird die +bisherige Stationskarte verwendet. + +Die HTTP-Basic-Zugangsdaten werden beim Build übergeben. Dazu +`config/vhs.example.json` nach `config/vhs.local.json` kopieren und die beiden +Werte lokal eintragen. Die lokale Datei ist von Git ausgeschlossen. + +```bash +flutter run --dart-define-from-file=config/vhs.local.json +flutter build apk --release --dart-define-from-file=config/vhs.local.json +``` + +Die VS-Code-Startkonfigurationen und `build_release_apk.sh` verwenden diese +Datei ebenfalls. Zugangsdaten werden nur für den Host der ursprünglichen +Kartenadresse verwendet; die Ansicht erlaubt Navigation innerhalb desselben +HTTPS-Ursprungs (Host und Port). diff --git a/build_release_apk.sh b/build_release_apk.sh index c34f6d4..f7e5ebe 100755 --- a/build_release_apk.sh +++ b/build_release_apk.sh @@ -34,7 +34,11 @@ echo "Ziel-APK-Name: $APK_NAME" echo # Release-APK bauen -flutter build apk --release +BUILD_ARGS=(--release) +if [[ -f "config/vhs.local.json" ]]; then + BUILD_ARGS+=(--dart-define-from-file=config/vhs.local.json) +fi +flutter build apk "${BUILD_ARGS[@]}" # Pfad zur erzeugten APK BUILT_APK="build/app/outputs/flutter-apk/app-release.apk" diff --git a/config/vhs.example.json b/config/vhs.example.json new file mode 100644 index 0000000..cda41d6 --- /dev/null +++ b/config/vhs.example.json @@ -0,0 +1,4 @@ +{ + "VHS_BASIC_AUTH_USERNAME": "", + "VHS_BASIC_AUTH_PASSWORD": "" +} diff --git a/lib/content_views/messages_page.dart b/lib/content_views/messages_page.dart index c6a7659..7dd39ca 100644 --- a/lib/content_views/messages_page.dart +++ b/lib/content_views/messages_page.dart @@ -91,13 +91,12 @@ class _MessagesPageState extends State with ProducerMixin { void _handleSendPressed(types.PartialText text) async { var now = DateTime.now(); - var message = sbmessage.ChatMessage(); - message.text = text.text; - message.from = _sid; - message.subj = ''; - message.time = Util.nowUnixSecondsUtc(); - message.read = true; - message.id = await sbmessage.ChatMessage.getNextId(); + final message = sbmessage.ChatMessage.outgoing( + externalId: await sbmessage.ChatMessage.getNextId(), + time: Util.nowUnixSecondsUtc(), + from: _sid, + text: text.text, + ); final box = MyApp.objectBoxStore.box(); box.put(message); @@ -205,7 +204,7 @@ class _MessagesPageState extends State with ProducerMixin { if (unreadChatMessages.isNotEmpty) { List chatMessageIds = []; for (var message in unreadChatMessages) { - chatMessageIds.add(message.id.toString()); + chatMessageIds.add(message.externalId.toString()); } var chatMessagesShowed = ChatMessagesReceived(); diff --git a/lib/content_views/task_list_page.dart b/lib/content_views/task_list_page.dart index c680e9b..9f1837b 100644 --- a/lib/content_views/task_list_page.dart +++ b/lib/content_views/task_list_page.dart @@ -165,6 +165,14 @@ class _TaskListPageState extends State with ProducerMixin { // Aufbau der Seite // --------------------------------------------------------------------------- + String _taskText(TaskInfo taskInfo, String key) { + // Die Unterschriftenerfassung bleibt unabhängig von der App-Sprache deutsch. + return AppLocalizations.tr( + key, + language: taskInfo.task?.effectiveType == 2 ? AppLanguage.german : null, + ); + } + @override Widget build(BuildContext context) { if (!_initCompleted) { @@ -190,13 +198,13 @@ class _TaskListPageState extends State with ProducerMixin { child: Padding( padding: const EdgeInsets.only(bottom: 6), child: Text( - '${'commission_number_short'.tr}: $_commisionNumber', + '${_taskText(taskInfo, 'commission_number_short')}: $_commisionNumber', style: const TextStyle(fontSize: 18), ), ), ), Text( - '${'task'.tr} ${_index + 1} / ${_taskList.length}', + '${_taskText(taskInfo, 'task')} ${_index + 1} / ${_taskList.length}', style: const TextStyle(fontSize: 13, color: Colors.grey), ), const SizedBox(height: 4), @@ -238,7 +246,7 @@ class _TaskListPageState extends State with ProducerMixin { children: [ Expanded( child: _navButton( - 'back'.tr, + _taskText(taskInfo, 'back'), _index > 0 ? () => setState(() => _index--) : null, primary: false, ), @@ -247,9 +255,9 @@ class _TaskListPageState extends State with ProducerMixin { Expanded( child: isLast ? (widget.readOnly - ? _navButton('close'.tr, () => _popBack()) - : _navButton('finish'.tr, canProceed ? () => _onFinish() : null)) - : _navButton('next'.tr, canProceed ? () => _onNext() : null), + ? _navButton(_taskText(taskInfo, 'close'), () => _popBack()) + : _navButton(_taskText(taskInfo, 'finish'), canProceed ? () => _onFinish() : null)) + : _navButton(_taskText(taskInfo, 'next'), canProceed ? () => _onNext() : null), ), ], ), @@ -285,7 +293,11 @@ class _TaskListPageState extends State with ProducerMixin { // --------------------------------------------------------------------------- Widget _buildReadOnlyBody(TaskInfo taskInfo) { - final summary = (taskInfo.additionalText ?? '').trim(); + var summary = (taskInfo.additionalText ?? '').trim(); + if (taskInfo.task?.effectiveType == 2 && summary.isNotEmpty) { + // Auch früher in einer anderen Sprache gespeicherte Bestätigungen anzeigen. + summary = _taskText(taskInfo, 'signature_captured'); + } return Container( width: double.infinity, padding: const EdgeInsets.all(14), @@ -297,7 +309,7 @@ class _TaskListPageState extends State with ProducerMixin { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('completed'.tr, style: const TextStyle(fontWeight: FontWeight.bold)), + Text(_taskText(taskInfo, 'completed'), style: const TextStyle(fontWeight: FontWeight.bold)), if (summary.isNotEmpty) ...[ const SizedBox(height: 6), Text(summary, style: const TextStyle(fontSize: 15)), @@ -548,7 +560,7 @@ class _TaskListPageState extends State with ProducerMixin { alignment: Alignment.centerRight, child: TextButton.icon( icon: const Icon(Icons.clear), - label: Text('clear'.tr), + label: Text(_taskText(taskInfo, 'clear')), onPressed: () => setState(() => draft.signatureController!.clear()), ), ), @@ -818,7 +830,7 @@ class _TaskListPageState extends State with ProducerMixin { final answer = await _createAnswerHeader(taskInfo); answer.sign = _convertSignaturePoints(draft.signatureController!.points); taskInfo.answer = jsonEncode(answer.toJson()); - taskInfo.additionalText = 'signature_captured'.tr; + taskInfo.additionalText = _taskText(taskInfo, 'signature_captured'); taskInfo.completed = true; _persist(taskInfo); break; diff --git a/lib/content_views/tour_details_page.dart b/lib/content_views/tour_details_page.dart index f338d6b..8271c6e 100644 --- a/lib/content_views/tour_details_page.dart +++ b/lib/content_views/tour_details_page.dart @@ -4,10 +4,10 @@ import 'package:stadtbote/content_views/task_list_page.dart'; import 'package:stadtbote/content_views/utils/content_view_widget.dart'; import 'package:stadtbote/messages/pop_content_view_message.dart'; import 'package:stadtbote/model/job_tour.dart'; +import 'package:stadtbote/popups/job_map_popup.dart'; import '../model/job.dart'; import '../popups/files_popup.dart'; -import '../popups/map_popup.dart'; import '../tools/app_localizations.dart'; import '../tools/constants.dart'; import '../tools/util.dart'; @@ -124,18 +124,7 @@ class _TourDetailsPageState extends State with ProducerMixin { const SizedBox(height: 10), getDefaultButton( 'map'.tr, - () => { - if (widget.tour.pos != null) - { - showMapPopup( - context, widget.tour, widget.job.tours) - } - else - { - showAlertDialog('information'.tr, - 'no_position'.tr) - } - }), + () => showJobMapPopup(context, widget.job, currentTour: widget.tour)), ], ), )), diff --git a/lib/content_views/tour_list_page.dart b/lib/content_views/tour_list_page.dart index 3653377..2750910 100644 --- a/lib/content_views/tour_list_page.dart +++ b/lib/content_views/tour_list_page.dart @@ -4,7 +4,7 @@ import 'package:stadtbote/content_views/tour_details_page.dart'; import 'package:stadtbote/content_views/utils/content_view_widget.dart'; import 'package:stadtbote/messages/pop_content_view_message.dart'; import 'package:stadtbote/model/job_tour.dart'; -import 'package:stadtbote/popups/map_popup.dart'; +import 'package:stadtbote/popups/job_map_popup.dart'; import '../model/job.dart'; import '../popups/files_popup.dart'; @@ -90,7 +90,8 @@ class _TourListPageState extends State with ProducerMixin { SizedBox( width: 100, child: Visibility( - visible: widget.job.tours[0].pos != null, + visible: (widget.job.mapUrl?.trim().isNotEmpty ?? false) || + (_tours.isNotEmpty && _tours.first.pos != null), child: ElevatedButton( style: ButtonStyle( shape: WidgetStateProperty.all( @@ -102,7 +103,7 @@ class _TourListPageState extends State with ProducerMixin { backgroundColor: WidgetStateProperty.all(Colors.lightGreen), foregroundColor: WidgetStateProperty.all(Colors.black), ), - onPressed: () => {showMapPopup(context, widget.job.tours[0], widget.job.tours)}, + onPressed: () => showJobMapPopup(context, widget.job), child: Text('map'.tr)), )) ], @@ -305,4 +306,4 @@ class _HeaderWidgetState extends State<_HeaderWidget> with ProducerMixin { ), ); } -} \ No newline at end of file +} diff --git a/lib/content_views/vhs_map_page.dart b/lib/content_views/vhs_map_page.dart new file mode 100644 index 0000000..3248b81 --- /dev/null +++ b/lib/content_views/vhs_map_page.dart @@ -0,0 +1,246 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:stadtbote/tools/vhs_map_access.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +class VhsMapPage extends StatefulWidget { + const VhsMapPage({super.key, required this.uri}); + + final Uri uri; + + @override + State createState() => _VhsMapPageState(); +} + +class _VhsMapPageState extends State { + static const _username = String.fromEnvironment('VHS_BASIC_AUTH_USERNAME'); + static const _password = String.fromEnvironment('VHS_BASIC_AUTH_PASSWORD'); + static const _loadError = + 'Die VHS-Karte konnte nicht geladen werden. ' + 'Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.'; + + WebViewController? _controller; + String? _errorMessage; + int _loadId = 0; + int _progress = 0; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + unawaited(_loadMap()); + } + + bool _isCurrentLoad(int loadId) => mounted && _loadId == loadId; + + void _showError(int loadId, String message) { + if (!_isCurrentLoad(loadId)) return; + setState(() { + _errorMessage ??= message; + _isLoading = false; + }); + } + + Future _stopLoading(WebViewController? controller) async { + try { + await controller?.runJavaScript('window.stop();'); + } catch (_) { + // Beim Schließen kann die native WebView bereits entfernt worden sein. + } + } + + Future _loadMap() async { + final loadId = ++_loadId; + final previousController = _controller; + setState(() { + _controller = null; + _errorMessage = null; + _progress = 0; + _isLoading = true; + }); + await _stopLoading(previousController); + if (!_isCurrentLoad(loadId)) return; + + final access = VhsMapAccess(widget.uri); + if (!access.isValid) { + _showError( + loadId, + 'Für diesen Auftrag ist kein gültiger Kartenlink verfügbar.', + ); + return; + } + + Uri documentUri = widget.uri; + WebViewController? controller; + try { + controller = WebViewController(); + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + if (!_isCurrentLoad(loadId)) return; + await controller.setNavigationDelegate( + NavigationDelegate( + onNavigationRequest: (request) { + if (!_isCurrentLoad(loadId) || _errorMessage != null) { + return NavigationDecision.prevent; + } + final target = Uri.tryParse(request.url); + if (target == null || !access.allowsNavigation(target)) { + if (request.isMainFrame) { + _showError( + loadId, + 'Dieser Link kann in der VHS-Karte nicht geöffnet werden.', + ); + } + return NavigationDecision.prevent; + } + if (request.isMainFrame) documentUri = target; + return NavigationDecision.navigate; + }, + onPageStarted: (url) { + if (!_isCurrentLoad(loadId) || _errorMessage != null) return; + final target = Uri.tryParse(url); + if (target == null || !access.allowsNavigation(target)) { + unawaited(_stopLoading(controller)); + _showError( + loadId, + 'Dieser Link kann in der VHS-Karte nicht geöffnet werden.', + ); + return; + } + documentUri = target; + setState(() { + _progress = 0; + _isLoading = true; + }); + }, + onProgress: (progress) { + if (!_isCurrentLoad(loadId) || _errorMessage != null) return; + setState(() => _progress = progress); + }, + onPageFinished: (_) { + if (!_isCurrentLoad(loadId) || _errorMessage != null) return; + setState(() => _isLoading = false); + }, + onWebResourceError: (error) { + // Fehlende Kartenkacheln dürfen die gesamte Karte nicht verdecken. + if (error.isForMainFrame == true || + (error.isForMainFrame == null && + error.url != null && + Uri.tryParse(error.url!) == documentUri)) { + _showError(loadId, _loadError); + } + }, + onHttpError: (error) { + // 401 kann vor der erfolgreichen HTTP-Anmeldung gemeldet werden. + if (error.response?.statusCode == 401) return; + // WebKit meldet für Dokumentfehler teilweise keine Request-URI. + if (error.request == null || error.request!.uri == documentUri) { + _showError(loadId, _loadError); + } + }, + onHttpAuthRequest: (request) { + if (!_isCurrentLoad(loadId) || + _errorMessage != null || + !access.allowsAuthentication(request.host)) { + request.onCancel(); + return; + } + if (_username.isEmpty || _password.isEmpty) { + request.onCancel(); + _showError( + loadId, + 'Der Zugang zur VHS-Karte ist in dieser App-Version nicht eingerichtet.', + ); + return; + } + if (!access.tryAuthentication(request.host)) { + request.onCancel(); + _showError( + loadId, + 'Die Anmeldung an der VHS-Karte ist fehlgeschlagen.', + ); + return; + } + request.onProceed( + const WebViewCredential(user: _username, password: _password), + ); + }, + ), + ); + if (!_isCurrentLoad(loadId)) return; + setState(() => _controller = controller); + await controller.loadRequest(widget.uri); + } catch (_) { + _showError(loadId, _loadError); + } + } + + @override + void dispose() { + _loadId++; + unawaited(_stopLoading(_controller)); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = _controller; + final errorMessage = _errorMessage; + + return Scaffold( + appBar: AppBar( + title: const Text('VHS-Karte'), + leading: IconButton( + tooltip: 'Schließen', + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + actions: [ + IconButton( + tooltip: 'Neu laden', + icon: const Icon(Icons.refresh), + onPressed: _isLoading ? null : _loadMap, + ), + ], + ), + body: SafeArea( + child: errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 48), + const SizedBox(height: 16), + Text(errorMessage, textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton( + onPressed: _loadMap, + child: const Text('Erneut versuchen'), + ), + ], + ), + ), + ) + : Column( + children: [ + if (_isLoading) + LinearProgressIndicator( + value: _progress == 0 ? null : _progress / 100, + semanticsLabel: 'VHS-Karte wird geladen', + ), + Expanded( + child: controller == null + ? const Center(child: CircularProgressIndicator()) + : WebViewWidget( + key: ValueKey(_loadId), + controller: controller, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/model/chat_message.dart b/lib/model/chat_message.dart index 3240327..0c9deed 100644 --- a/lib/model/chat_message.dart +++ b/lib/model/chat_message.dart @@ -12,7 +12,7 @@ class ChatMessage { @Id() int id = 0; - // External ID from server + // Nachrichten-ID für den Server, getrennt von der lokalen ObjectBox-ID. @Unique() late int externalId; @@ -28,6 +28,22 @@ class ChatMessage { ChatMessage(); + factory ChatMessage.outgoing({ + required int externalId, + required int time, + required String from, + required String text, + }) { + // ObjectBox vergibt die lokale ID beim ersten Speichern selbst. + return ChatMessage() + ..externalId = externalId + ..time = time + ..from = from + ..subj = '' + ..text = text + ..read = true; + } + factory ChatMessage.fromJson(Map json) { final message = ChatMessage(); message.id = 0; // Let ObjectBox assign the ID diff --git a/lib/model/job.dart b/lib/model/job.dart index 7764d15..5bc4269 100644 --- a/lib/model/job.dart +++ b/lib/model/job.dart @@ -15,6 +15,7 @@ class Job { late String ver; late int state; String? crFilter; + String? mapUrl; late int ordertime; late int offsWarn; late bool global; @@ -63,6 +64,7 @@ class Job { job.ver = json['ver'] as String; job.state = json['state'] as int; job.crFilter = json['cr_filter'] as String?; + job.mapUrl = json['map_url'] as String?; job.ordertime = json['ordertime'] as int; job.offsWarn = json['offs_warn'] as int; job.global = json['global'] as bool; @@ -86,6 +88,7 @@ class Job { 'ver': ver, 'state': state, 'cr_filter': crFilter, + 'map_url': mapUrl, 'ordertime': ordertime, 'offs_warn': offsWarn, 'global': global, @@ -97,4 +100,3 @@ class Job { }; } } - diff --git a/lib/objectbox-model.json b/lib/objectbox-model.json index 0aec326..ff10726 100644 --- a/lib/objectbox-model.json +++ b/lib/objectbox-model.json @@ -34,7 +34,7 @@ }, { "id": "3:808686831791135454", - "lastPropertyId": "13:1803006975816940878", + "lastPropertyId": "14:4499212061291648440", "name": "Job", "properties": [ { @@ -104,6 +104,11 @@ "id": "13:1803006975816940878", "name": "filesJson", "type": 9 + }, + { + "id": "14:4499212061291648440", + "name": "mapUrl", + "type": 9 } ], "relations": [] diff --git a/lib/objectbox.g.dart b/lib/objectbox.g.dart index 2ac6781..466309a 100644 --- a/lib/objectbox.g.dart +++ b/lib/objectbox.g.dart @@ -60,7 +60,7 @@ final _entities = [ obx_int.ModelEntity( id: const obx_int.IdUid(3, 808686831791135454), name: 'Job', - lastPropertyId: const obx_int.IdUid(13, 1803006975816940878), + lastPropertyId: const obx_int.IdUid(14, 4499212061291648440), flags: 0, properties: [ obx_int.ModelProperty( @@ -128,6 +128,11 @@ final _entities = [ id: const obx_int.IdUid(13, 1803006975816940878), name: 'filesJson', type: 9, + flags: 0), + obx_int.ModelProperty( + id: const obx_int.IdUid(14, 4499212061291648440), + name: 'mapUrl', + type: 9, flags: 0) ], relations: [], @@ -542,7 +547,9 @@ obx_int.ModelDefinition getObjectBoxModel() { final remOffset = fbb.writeString(object.rem); final toursJsonOffset = fbb.writeString(object.toursJson); final filesJsonOffset = fbb.writeString(object.filesJson); - fbb.startTable(14); + final mapUrlOffset = + object.mapUrl == null ? null : fbb.writeString(object.mapUrl!); + fbb.startTable(15); fbb.addInt64(0, object.id); fbb.addOffset(1, verOffset); fbb.addInt64(2, object.state); @@ -556,6 +563,7 @@ obx_int.ModelDefinition getObjectBoxModel() { fbb.addOffset(10, toursJsonOffset); fbb.addInt64(11, object.externalId); fbb.addOffset(12, filesJsonOffset); + fbb.addOffset(13, mapUrlOffset); fbb.finish(fbb.endTable()); return object.id; }, @@ -587,7 +595,9 @@ obx_int.ModelDefinition getObjectBoxModel() { ..externalId = const fb.Int64Reader().vTableGet(buffer, rootOffset, 26, 0) ..filesJson = const fb.StringReader(asciiOptimization: true) - .vTableGet(buffer, rootOffset, 28, ''); + .vTableGet(buffer, rootOffset, 28, '') + ..mapUrl = const fb.StringReader(asciiOptimization: true) + .vTableGetNullable(buffer, rootOffset, 30); return object; }), @@ -965,6 +975,10 @@ class Job_ { /// See [Job.filesJson]. static final filesJson = obx.QueryStringProperty(_entities[1].properties[12]); + + /// See [Job.mapUrl]. + static final mapUrl = + obx.QueryStringProperty(_entities[1].properties[13]); } /// [LocationStatus] entity fields to define ObjectBox queries. diff --git a/lib/popups/files_popup.dart b/lib/popups/files_popup.dart index 695bdab..5dbbdd3 100644 --- a/lib/popups/files_popup.dart +++ b/lib/popups/files_popup.dart @@ -114,14 +114,13 @@ class _FileListItemState extends State<_FileListItem> { }, ); } else if (widget.file.isImage) { - // Show image in a dialog on top of the files dialog + // Bildanhänge wie PDFs vergrößern und im gezoomten Zustand verschieben. await showDialog( context: context, builder: (BuildContext context) { return Dialog( insetPadding: const EdgeInsets.all(16), child: Column( - mainAxisSize: MainAxisSize.min, children: [ AppBar( title: Text(widget.file.name), @@ -131,41 +130,47 @@ class _FileListItemState extends State<_FileListItem> { ), automaticallyImplyLeading: false, ), - Flexible( - child: Image.network( - widget.file.url, - fit: BoxFit.contain, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Center( - child: CircularProgressIndicator( - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), - ); - }, - errorBuilder: (context, error, stackTrace) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.error_outline, - size: 64, - color: Colors.red, + Expanded( + child: InteractiveViewer( + minScale: 1, + maxScale: 5, + child: SizedBox.expand( + child: Image.network( + widget.file.url, + fit: BoxFit.contain, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, ), - const SizedBox(height: 16), - Text( - 'Fehler beim Laden des Bildes:\n$error', - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.red), + ); + }, + errorBuilder: (context, error, stackTrace) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error_outline, + size: 64, + color: Colors.red, + ), + const SizedBox(height: 16), + Text( + 'Fehler beim Laden des Bildes:\n$error', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + ], ), - ], - ), - ); - }, + ); + }, + ), + ), ), ), ], diff --git a/lib/popups/job_map_popup.dart b/lib/popups/job_map_popup.dart new file mode 100644 index 0000000..1b2f169 --- /dev/null +++ b/lib/popups/job_map_popup.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:stadtbote/content_views/vhs_map_page.dart'; +import 'package:stadtbote/model/job.dart'; +import 'package:stadtbote/model/job_tour.dart'; +import 'package:stadtbote/popups/map_popup.dart'; +import 'package:stadtbote/tools/app_localizations.dart'; +import 'package:stadtbote/tools/util.dart'; + +Future showJobMapPopup( + BuildContext context, + Job job, { + JobTour? currentTour, +}) async { + final mapUrl = job.mapUrl?.trim(); + if (mapUrl != null && mapUrl.isNotEmpty) { + final uri = Uri.tryParse(mapUrl); + if (uri == null) { + await showAlertDialog('error'.tr, 'Die Kartenadresse ist ungültig.'); + return; + } + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => VhsMapPage(uri: uri))); + return; + } + + final tours = job.tours; + final tour = currentTour ?? (tours.isNotEmpty ? tours.first : null); + if (tour?.pos == null) { + await showAlertDialog('information'.tr, 'no_position'.tr); + return; + } + await showMapPopup(context, tour!, tours); +} diff --git a/lib/popups/tasks_popups/signature_popup.dart b/lib/popups/tasks_popups/signature_popup.dart index 671e238..0becfaa 100644 --- a/lib/popups/tasks_popups/signature_popup.dart +++ b/lib/popups/tasks_popups/signature_popup.dart @@ -13,7 +13,7 @@ showSignaturePopup(BuildContext context, TaskInfo taskInfo, Function(String?) on canPop: false, onPopInvokedWithResult: (bool didPop, dynamic result) async { if (!didPop) { - final shouldPop = await taskDialogSecurityException(context); + final shouldPop = await taskDialogSecurityException(context, language: AppLanguage.german); if (shouldPop) { SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); if (context.mounted) { @@ -79,13 +79,18 @@ showSignaturePopup(BuildContext context, TaskInfo taskInfo, Function(String?) on ), ), getDefaultButton( - 'apply'.tr, + AppLocalizations.tr('apply', language: AppLanguage.german), buttonHeight: 50, () => { if (controller.points.length > 50) {Navigator.of(context).pop(), onFinished(convertPoints(controller.points))} else - {showAlertDialog('error'.tr, 'signature_too_short'.tr)} + { + showAlertDialog( + AppLocalizations.tr('error', language: AppLanguage.german), + AppLocalizations.tr('signature_too_short', language: AppLanguage.german), + ) + } }) ], ), diff --git a/lib/tools/app_localizations.dart b/lib/tools/app_localizations.dart index b1e7573..52929d3 100644 --- a/lib/tools/app_localizations.dart +++ b/lib/tools/app_localizations.dart @@ -56,8 +56,8 @@ class AppLocalizations { } // Hilfsmethode für einfachen Zugriff - static String tr(String key) { - return _instance._translations[_instance._currentLanguage.code]?[key] ?? + static String tr(String key, {AppLanguage? language}) { + return _instance._translations[(language ?? _instance._currentLanguage).code]?[key] ?? _instance._translations['de']?[key] ?? key; } diff --git a/lib/tools/util.dart b/lib/tools/util.dart index c98e285..ea4af21 100644 --- a/lib/tools/util.dart +++ b/lib/tools/util.dart @@ -489,22 +489,22 @@ bool isJobTooFarInFuture(Job job) { return ordertime > dateTimeThreshold; } -Future taskDialogSecurityException(BuildContext context) async { +Future taskDialogSecurityException(BuildContext context, {AppLanguage? language}) async { final result = await showDialog( context: context, barrierDismissible: false, builder: (dialogContext) { return AlertDialog( - title: Text('attention'.tr), - content: Text('close_dialog_warning'.tr), + title: Text(AppLocalizations.tr('attention', language: language)), + content: Text(AppLocalizations.tr('close_dialog_warning', language: language)), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(true), - child: Text('close'.tr), + child: Text(AppLocalizations.tr('close', language: language)), ), TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), - child: Text('back'.tr), + child: Text(AppLocalizations.tr('back', language: language)), ), ], ); diff --git a/lib/tools/vhs_map_access.dart b/lib/tools/vhs_map_access.dart new file mode 100644 index 0000000..2f56a77 --- /dev/null +++ b/lib/tools/vhs_map_access.dart @@ -0,0 +1,33 @@ +class VhsMapAccess { + VhsMapAccess(this.uri); + + final Uri uri; + static const int maxAuthenticationAttempts = 3; + int _authenticationAttempts = 0; + + static bool _isValidUri(Uri value) => + value.scheme == 'https' && + value.hasAuthority && + value.host.isNotEmpty && + value.userInfo.isEmpty && + value.port > 0 && + value.port <= 65535; + + bool get isValid => _isValidUri(uri); + + bool allowsNavigation(Uri target) => + isValid && _isValidUri(target) && target.origin == uri.origin; + + // Die WebView liefert bei Authentifizierungsanfragen nur Host und Realm. + bool allowsAuthentication(String host) => + isValid && host.toLowerCase() == uri.host.toLowerCase(); + + bool tryAuthentication(String host) { + if (!allowsAuthentication(host) || + _authenticationAttempts >= maxAuthenticationAttempts) { + return false; + } + _authenticationAttempts++; + return true; + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 2828c4d..99babee 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -15,6 +15,7 @@ import pdfium_flutter import permission_manager import shared_preferences_foundation import url_launcher_macos +import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) @@ -27,4 +28,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PermissionManagerPlugin.register(with: registry.registrar(forPlugin: "PermissionManagerPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 8301df3..dba3382 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -458,7 +458,7 @@ packages: source: hosted version: "1.1.1" flat_buffers: - dependency: transitive + dependency: "direct dev" description: name: flat_buffers sha256: "380bdcba5664a718bfd4ea20a45d39e13684f5318fcd8883066a55e21f37f4c3" @@ -1629,6 +1629,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 + url: "https://pub.dev" + source: hosted + version: "4.14.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964" + url: "https://pub.dev" + source: hosted + version: "4.14.1" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: "0f784be1b080e5b918ef7c2c070c1f4616899fd23f1d001feb74b4978f92bdbc" + url: "https://pub.dev" + source: hosted + version: "3.26.2" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e461b12..ca0b21a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 355.0.0+355 +version: 357.0.0+357 environment: sdk: '>=3.9.0 <4.0.0' @@ -56,6 +56,7 @@ dependencies: geolocator: ^13.0.2 expandable: ^5.0.1 url_launcher: ^6.2.4 + webview_flutter: ^4.13.0 audioplayers: ^6.2.0 flutter_launcher_icons: ^0.14.3 package_info_plus: ^8.0.2 @@ -72,6 +73,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + flat_buffers: ^23.5.26 build_runner: ^2.4.13 objectbox_generator: ^4.0.3 json_serializable: ^6.6.2 diff --git a/test/model/chat_message_test.dart b/test/model/chat_message_test.dart new file mode 100644 index 0000000..489ca24 --- /dev/null +++ b/test/model/chat_message_test.dart @@ -0,0 +1,86 @@ +import 'package:flat_buffers/flat_buffers.dart' as fb; +import 'package:flutter_test/flutter_test.dart'; +import 'package:objectbox/internal.dart' show EntityDefinition; +import 'package:stadtbote/model/chat_message.dart'; +import 'package:stadtbote/objectbox.g.dart'; + +void main() { + group('ChatMessage IDs', () { + test('initialisiert ausgehende Nachrichten vollständig', () { + final message = _outgoingMessage(); + + expect(message.id, 0); + expect(message.toJson(), { + 'id': 42, + 'time': 1791366832, + 'from': 'courier-123', + 'subj': '', + 'text': 'Auftrag erledigt', + 'read': true, + }); + }); + + test('serialisiert ausgehende Nachrichten für ObjectBox ohne Fehler', () { + final message = _outgoingMessage(); + final definition = getObjectBoxModel().bindings[ChatMessage]! + as EntityDefinition; + final builder = fb.Builder(); + + expect(definition.objectToFB(message, builder), 0); + + final buffer = fb.BufferContext.fromBytes(builder.buffer); + final root = buffer.derefObject(0); + const intReader = fb.Int64Reader(); + const stringReader = fb.StringReader(); + + expect(intReader.vTableGet(buffer, root, 4, -1), 0); + expect(intReader.vTableGet(buffer, root, 6, -1), 42); + expect(intReader.vTableGet(buffer, root, 8, -1), 1791366832); + expect(stringReader.vTableGet(buffer, root, 10, ''), 'courier-123'); + expect(stringReader.vTableGet(buffer, root, 12, 'missing'), ''); + expect(stringReader.vTableGet(buffer, root, 14, ''), 'Auftrag erledigt'); + expect(const fb.BoolReader().vTableGet(buffer, root, 16, false), isTrue); + }); + + test('verwendet nach lokaler ID-Vergabe weiterhin die Nachrichten-ID', () { + final message = _outgoingMessage(); + final definition = getObjectBoxModel().bindings[ChatMessage]! + as EntityDefinition; + + definition.setId(message, 99); + + expect(message.id, 99); + expect(message.externalId, 42); + expect(message.toJson()['id'], 42); + }); + + test('übernimmt empfangene Server-IDs getrennt von der lokalen ID', () { + final message = ChatMessage.fromJson({ + 'id': 21891302, + 'time': 1791366832, + 'from': 'Zentrale', + 'subj': 'Rückfrage', + 'text': 'Bitte melden', + }); + + expect(message.id, 0); + expect(message.externalId, 21891302); + expect(message.read, isFalse); + expect(message.toJson(), { + 'id': 21891302, + 'time': 1791366832, + 'from': 'Zentrale', + 'subj': 'Rückfrage', + 'text': 'Bitte melden', + 'read': false, + }); + }); + }); +} + +ChatMessage _outgoingMessage() => ChatMessage.outgoing( + externalId: 42, + time: 1791366832, + from: 'courier-123', + text: 'Auftrag erledigt', +); diff --git a/test/model/job_map_url_test.dart b/test/model/job_map_url_test.dart new file mode 100644 index 0000000..e58989a --- /dev/null +++ b/test/model/job_map_url_test.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stadtbote/model/job.dart'; + +void main() { + group('Job map_url', () { + test('erhält die Server-URL einschließlich Query und Fragment', () { + const mapUrl = 'https://vhs.example.invalid/map?job=21891302#courier'; + final payload = _jobPayload()..['map_url'] = mapUrl; + + final job = Job.fromJson(payload); + final serialized = + jsonDecode(jsonEncode(job.toJson())) as Map; + final restored = Job.fromJson(serialized); + + expect(job.mapUrl, mapUrl); + expect(serialized['map_url'], mapUrl); + expect(restored.mapUrl, mapUrl); + expect(restored.externalId, 21891302); + }); + + test('liest ältere Aufträge ohne map_url weiterhin ein', () { + final job = Job.fromJson(_jobPayload()); + final restored = Job.fromJson(job.toJson()); + + expect(job.mapUrl, isNull); + expect(restored.mapUrl, isNull); + expect(restored.externalId, 21891302); + }); + + test('akzeptiert eine explizit fehlende map_url', () { + final payload = _jobPayload()..['map_url'] = null; + + expect(Job.fromJson(payload).mapUrl, isNull); + }); + }); +} + +Map _jobPayload() => { + 'id': 21891302, + 'ver': '0', + 'state': 0, + 'ordertime': 1791285073, + 'offs_warn': 0, + 'global': false, + 'vht_id': 2, + 'rem': '', + 'delayOrderTime': 0, + 'tours': >[], +}; diff --git a/test/tools/vhs_map_access_test.dart b/test/tools/vhs_map_access_test.dart new file mode 100644 index 0000000..d2ac85f --- /dev/null +++ b/test/tools/vhs_map_access_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stadtbote/tools/vhs_map_access.dart'; + +void main() { + group('VHS-Kartenlink', () { + test('erlaubt HTTPS inklusive Kartenparameter und Fragment', () { + final access = VhsMapAccess( + Uri.parse('https://vhs.example/map?job=42#courier'), + ); + expect(access.isValid, isTrue); + }); + + test('weist unsichere und unvollständige Links zurück', () { + for (final url in [ + 'http://vhs.example/map', + '/map?job=42', + '//vhs.example/map', + 'javascript:alert(1)', + 'https:///map', + 'https://user:password@vhs.example/map', + 'https://vhs.example:0/map', + 'https://vhs.example:65536/map', + ]) { + expect(VhsMapAccess(Uri.parse(url)).isValid, isFalse, reason: url); + } + }); + + test('erlaubt Navigation nur innerhalb derselben HTTPS-Origin', () { + final access = VhsMapAccess(Uri.parse('https://vhs.example/map?job=42')); + expect( + access.allowsNavigation(Uri.parse('https://vhs.example:443/other')), + isTrue, + ); + for (final url in [ + 'http://vhs.example/map', + 'https://other.example/map', + 'https://vhs.example.other.example/map', + 'https://vhs.example:8443/map', + 'https://user@vhs.example/map', + 'file:///map', + ]) { + expect(access.allowsNavigation(Uri.parse(url)), isFalse, reason: url); + } + }); + }); + + group('VHS-Anmeldung', () { + test('sendet Zugangsdaten nur an den exakten Kartenhost', () { + final access = VhsMapAccess(Uri.parse('https://vhs.example/map')); + expect(access.allowsAuthentication('VHS.EXAMPLE'), isTrue); + for (final host in [ + 'other.example', + 'vhs.example.other.example', + 'sub.vhs.example', + '', + ]) { + expect(access.allowsAuthentication(host), isFalse, reason: host); + } + expect( + VhsMapAccess( + Uri.parse('http://vhs.example'), + ).allowsAuthentication('vhs.example'), + isFalse, + ); + }); + + test('begrenzt wiederholte Authentifizierungsanfragen', () { + final access = VhsMapAccess(Uri.parse('https://vhs.example/map')); + expect(access.tryAuthentication('other.example'), isFalse); + for ( + var attempt = 0; + attempt < VhsMapAccess.maxAuthenticationAttempts; + attempt++ + ) { + expect(access.tryAuthentication('vhs.example'), isTrue); + } + expect(access.tryAuthentication('vhs.example'), isFalse); + expect(access.tryAuthentication('vhs.example'), isFalse); + expect(VhsMapAccess(access.uri).tryAuthentication('vhs.example'), isTrue); + }); + }); +}