VHS-Karten integriert, Bildzoom ergänzt und Chat-IDs korrigiert

Unterschriftenerfassung dauerhaft auf Deutsch anzeigen, lokale VHS-Buildkonfiguration und Regressionstests ergänzen sowie Version auf 357.0.0+357 anheben.
This commit is contained in:
Sven committed 2026-10-07 10:41:41 +02:00
1 parent c0b667c4ee
commit 5e7babf265
26 files changed
+752 -93

No files matched your search

+3
View File
@@ -48,3 +48,6 @@ app.*.map.json
# Java heap dumps # Java heap dumps
*.hprof *.hprof
# Lokale Zugangsdaten für die VHS-Karte
/config/vhs.local.json
+16 -4
View File
@@ -7,7 +7,10 @@
"type": "dart", "type": "dart",
"flutterMode": "debug", "flutterMode": "debug",
"emulatorId": "Pixel_10_Pro", "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)", "name": "Stadtbote - Android Emulator (Release)",
@@ -15,7 +18,10 @@
"type": "dart", "type": "dart",
"flutterMode": "release", "flutterMode": "release",
"emulatorId": "Pixel_10_Pro", "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", "name": "Stadtbote - Pixel 7",
@@ -23,7 +29,10 @@
"type": "dart", "type": "dart",
"flutterMode": "debug", "flutterMode": "debug",
"deviceId": "2B101FDH2003HB", "deviceId": "2B101FDH2003HB",
"program": "lib/main.dart" "program": "lib/main.dart",
"toolArgs": [
"--dart-define-from-file=config/vhs.local.json"
]
}, },
{ {
"name": "Stadtbote - Pixel 7 (Release)", "name": "Stadtbote - Pixel 7 (Release)",
@@ -31,7 +40,10 @@
"type": "dart", "type": "dart",
"flutterMode": "release", "flutterMode": "release",
"deviceId": "2B101FDH2003HB", "deviceId": "2B101FDH2003HB",
"program": "lib/main.dart" "program": "lib/main.dart",
"toolArgs": [
"--dart-define-from-file=config/vhs.local.json"
]
} }
] ]
} }
+20
View File
@@ -1 +1,21 @@
dart run build_runner build 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).
+5 -1
View File
@@ -34,7 +34,11 @@ echo "Ziel-APK-Name: $APK_NAME"
echo echo
# Release-APK bauen # 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 # Pfad zur erzeugten APK
BUILT_APK="build/app/outputs/flutter-apk/app-release.apk" BUILT_APK="build/app/outputs/flutter-apk/app-release.apk"
+4
View File
@@ -0,0 +1,4 @@
{
"VHS_BASIC_AUTH_USERNAME": "",
"VHS_BASIC_AUTH_PASSWORD": ""
}
+7 -8
View File
@@ -91,13 +91,12 @@ class _MessagesPageState extends State<MessagesPage> with ProducerMixin {
void _handleSendPressed(types.PartialText text) async { void _handleSendPressed(types.PartialText text) async {
var now = DateTime.now(); var now = DateTime.now();
var message = sbmessage.ChatMessage(); final message = sbmessage.ChatMessage.outgoing(
message.text = text.text; externalId: await sbmessage.ChatMessage.getNextId(),
message.from = _sid; time: Util.nowUnixSecondsUtc(),
message.subj = ''; from: _sid,
message.time = Util.nowUnixSecondsUtc(); text: text.text,
message.read = true; );
message.id = await sbmessage.ChatMessage.getNextId();
final box = MyApp.objectBoxStore.box<sbmessage.ChatMessage>(); final box = MyApp.objectBoxStore.box<sbmessage.ChatMessage>();
box.put(message); box.put(message);
@@ -205,7 +204,7 @@ class _MessagesPageState extends State<MessagesPage> with ProducerMixin {
if (unreadChatMessages.isNotEmpty) { if (unreadChatMessages.isNotEmpty) {
List<String> chatMessageIds = []; List<String> chatMessageIds = [];
for (var message in unreadChatMessages) { for (var message in unreadChatMessages) {
chatMessageIds.add(message.id.toString()); chatMessageIds.add(message.externalId.toString());
} }
var chatMessagesShowed = ChatMessagesReceived(); var chatMessagesShowed = ChatMessagesReceived();
+22 -10
View File
@@ -165,6 +165,14 @@ class _TaskListPageState extends State<TaskListPage> with ProducerMixin {
// Aufbau der Seite // 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!_initCompleted) { if (!_initCompleted) {
@@ -190,13 +198,13 @@ class _TaskListPageState extends State<TaskListPage> with ProducerMixin {
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.only(bottom: 6),
child: Text( child: Text(
'${'commission_number_short'.tr}: $_commisionNumber', '${_taskText(taskInfo, 'commission_number_short')}: $_commisionNumber',
style: const TextStyle(fontSize: 18), style: const TextStyle(fontSize: 18),
), ),
), ),
), ),
Text( Text(
'${'task'.tr} ${_index + 1} / ${_taskList.length}', '${_taskText(taskInfo, 'task')} ${_index + 1} / ${_taskList.length}',
style: const TextStyle(fontSize: 13, color: Colors.grey), style: const TextStyle(fontSize: 13, color: Colors.grey),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -238,7 +246,7 @@ class _TaskListPageState extends State<TaskListPage> with ProducerMixin {
children: [ children: [
Expanded( Expanded(
child: _navButton( child: _navButton(
'back'.tr, _taskText(taskInfo, 'back'),
_index > 0 ? () => setState(() => _index--) : null, _index > 0 ? () => setState(() => _index--) : null,
primary: false, primary: false,
), ),
@@ -247,9 +255,9 @@ class _TaskListPageState extends State<TaskListPage> with ProducerMixin {
Expanded( Expanded(
child: isLast child: isLast
? (widget.readOnly ? (widget.readOnly
? _navButton('close'.tr, () => _popBack()) ? _navButton(_taskText(taskInfo, 'close'), () => _popBack())
: _navButton('finish'.tr, canProceed ? () => _onFinish() : null)) : _navButton(_taskText(taskInfo, 'finish'), canProceed ? () => _onFinish() : null))
: _navButton('next'.tr, canProceed ? () => _onNext() : null), : _navButton(_taskText(taskInfo, 'next'), canProceed ? () => _onNext() : null),
), ),
], ],
), ),
@@ -285,7 +293,11 @@ class _TaskListPageState extends State<TaskListPage> with ProducerMixin {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
Widget _buildReadOnlyBody(TaskInfo taskInfo) { 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( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
@@ -297,7 +309,7 @@ class _TaskListPageState extends State<TaskListPage> with ProducerMixin {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('completed'.tr, style: const TextStyle(fontWeight: FontWeight.bold)), Text(_taskText(taskInfo, 'completed'), style: const TextStyle(fontWeight: FontWeight.bold)),
if (summary.isNotEmpty) ...[ if (summary.isNotEmpty) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Text(summary, style: const TextStyle(fontSize: 15)), Text(summary, style: const TextStyle(fontSize: 15)),
@@ -548,7 +560,7 @@ class _TaskListPageState extends State<TaskListPage> with ProducerMixin {
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: TextButton.icon( child: TextButton.icon(
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
label: Text('clear'.tr), label: Text(_taskText(taskInfo, 'clear')),
onPressed: () => setState(() => draft.signatureController!.clear()), onPressed: () => setState(() => draft.signatureController!.clear()),
), ),
), ),
@@ -818,7 +830,7 @@ class _TaskListPageState extends State<TaskListPage> with ProducerMixin {
final answer = await _createAnswerHeader(taskInfo); final answer = await _createAnswerHeader(taskInfo);
answer.sign = _convertSignaturePoints(draft.signatureController!.points); answer.sign = _convertSignaturePoints(draft.signatureController!.points);
taskInfo.answer = jsonEncode(answer.toJson()); taskInfo.answer = jsonEncode(answer.toJson());
taskInfo.additionalText = 'signature_captured'.tr; taskInfo.additionalText = _taskText(taskInfo, 'signature_captured');
taskInfo.completed = true; taskInfo.completed = true;
_persist(taskInfo); _persist(taskInfo);
break; break;
+2 -13
View File
@@ -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/content_views/utils/content_view_widget.dart';
import 'package:stadtbote/messages/pop_content_view_message.dart'; import 'package:stadtbote/messages/pop_content_view_message.dart';
import 'package:stadtbote/model/job_tour.dart'; import 'package:stadtbote/model/job_tour.dart';
import 'package:stadtbote/popups/job_map_popup.dart';
import '../model/job.dart'; import '../model/job.dart';
import '../popups/files_popup.dart'; import '../popups/files_popup.dart';
import '../popups/map_popup.dart';
import '../tools/app_localizations.dart'; import '../tools/app_localizations.dart';
import '../tools/constants.dart'; import '../tools/constants.dart';
import '../tools/util.dart'; import '../tools/util.dart';
@@ -124,18 +124,7 @@ class _TourDetailsPageState extends State<TourDetailsPage> with ProducerMixin {
const SizedBox(height: 10), const SizedBox(height: 10),
getDefaultButton( getDefaultButton(
'map'.tr, 'map'.tr,
() => { () => showJobMapPopup(context, widget.job, currentTour: widget.tour)),
if (widget.tour.pos != null)
{
showMapPopup(
context, widget.tour, widget.job.tours)
}
else
{
showAlertDialog('information'.tr,
'no_position'.tr)
}
}),
], ],
), ),
)), )),
+5 -4
View File
@@ -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/content_views/utils/content_view_widget.dart';
import 'package:stadtbote/messages/pop_content_view_message.dart'; import 'package:stadtbote/messages/pop_content_view_message.dart';
import 'package:stadtbote/model/job_tour.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 '../model/job.dart';
import '../popups/files_popup.dart'; import '../popups/files_popup.dart';
@@ -90,7 +90,8 @@ class _TourListPageState extends State<TourListPage> with ProducerMixin {
SizedBox( SizedBox(
width: 100, width: 100,
child: Visibility( child: Visibility(
visible: widget.job.tours[0].pos != null, visible: (widget.job.mapUrl?.trim().isNotEmpty ?? false) ||
(_tours.isNotEmpty && _tours.first.pos != null),
child: ElevatedButton( child: ElevatedButton(
style: ButtonStyle( style: ButtonStyle(
shape: WidgetStateProperty.all<RoundedRectangleBorder>( shape: WidgetStateProperty.all<RoundedRectangleBorder>(
@@ -102,7 +103,7 @@ class _TourListPageState extends State<TourListPage> with ProducerMixin {
backgroundColor: WidgetStateProperty.all<Color>(Colors.lightGreen), backgroundColor: WidgetStateProperty.all<Color>(Colors.lightGreen),
foregroundColor: WidgetStateProperty.all<Color>(Colors.black), foregroundColor: WidgetStateProperty.all<Color>(Colors.black),
), ),
onPressed: () => {showMapPopup(context, widget.job.tours[0], widget.job.tours)}, onPressed: () => showJobMapPopup(context, widget.job),
child: Text('map'.tr)), child: Text('map'.tr)),
)) ))
], ],
@@ -305,4 +306,4 @@ class _HeaderWidgetState extends State<_HeaderWidget> with ProducerMixin {
), ),
); );
} }
} }
+246
View File
@@ -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<VhsMapPage> createState() => _VhsMapPageState();
}
class _VhsMapPageState extends State<VhsMapPage> {
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<void> _stopLoading(WebViewController? controller) async {
try {
await controller?.runJavaScript('window.stop();');
} catch (_) {
// Beim Schließen kann die native WebView bereits entfernt worden sein.
}
}
Future<void> _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,
),
),
],
),
),
);
}
}
+17 -1
View File
@@ -12,7 +12,7 @@ class ChatMessage {
@Id() @Id()
int id = 0; int id = 0;
// External ID from server // Nachrichten-ID für den Server, getrennt von der lokalen ObjectBox-ID.
@Unique() @Unique()
late int externalId; late int externalId;
@@ -28,6 +28,22 @@ class ChatMessage {
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<String, dynamic> json) { factory ChatMessage.fromJson(Map<String, dynamic> json) {
final message = ChatMessage(); final message = ChatMessage();
message.id = 0; // Let ObjectBox assign the ID message.id = 0; // Let ObjectBox assign the ID
+3 -1
View File
@@ -15,6 +15,7 @@ class Job {
late String ver; late String ver;
late int state; late int state;
String? crFilter; String? crFilter;
String? mapUrl;
late int ordertime; late int ordertime;
late int offsWarn; late int offsWarn;
late bool global; late bool global;
@@ -63,6 +64,7 @@ class Job {
job.ver = json['ver'] as String; job.ver = json['ver'] as String;
job.state = json['state'] as int; job.state = json['state'] as int;
job.crFilter = json['cr_filter'] as String?; job.crFilter = json['cr_filter'] as String?;
job.mapUrl = json['map_url'] as String?;
job.ordertime = json['ordertime'] as int; job.ordertime = json['ordertime'] as int;
job.offsWarn = json['offs_warn'] as int; job.offsWarn = json['offs_warn'] as int;
job.global = json['global'] as bool; job.global = json['global'] as bool;
@@ -86,6 +88,7 @@ class Job {
'ver': ver, 'ver': ver,
'state': state, 'state': state,
'cr_filter': crFilter, 'cr_filter': crFilter,
'map_url': mapUrl,
'ordertime': ordertime, 'ordertime': ordertime,
'offs_warn': offsWarn, 'offs_warn': offsWarn,
'global': global, 'global': global,
@@ -97,4 +100,3 @@ class Job {
}; };
} }
} }
+6 -1
View File
@@ -34,7 +34,7 @@
}, },
{ {
"id": "3:808686831791135454", "id": "3:808686831791135454",
"lastPropertyId": "13:1803006975816940878", "lastPropertyId": "14:4499212061291648440",
"name": "Job", "name": "Job",
"properties": [ "properties": [
{ {
@@ -104,6 +104,11 @@
"id": "13:1803006975816940878", "id": "13:1803006975816940878",
"name": "filesJson", "name": "filesJson",
"type": 9 "type": 9
},
{
"id": "14:4499212061291648440",
"name": "mapUrl",
"type": 9
} }
], ],
"relations": [] "relations": []
+17 -3
View File
@@ -60,7 +60,7 @@ final _entities = <obx_int.ModelEntity>[
obx_int.ModelEntity( obx_int.ModelEntity(
id: const obx_int.IdUid(3, 808686831791135454), id: const obx_int.IdUid(3, 808686831791135454),
name: 'Job', name: 'Job',
lastPropertyId: const obx_int.IdUid(13, 1803006975816940878), lastPropertyId: const obx_int.IdUid(14, 4499212061291648440),
flags: 0, flags: 0,
properties: <obx_int.ModelProperty>[ properties: <obx_int.ModelProperty>[
obx_int.ModelProperty( obx_int.ModelProperty(
@@ -128,6 +128,11 @@ final _entities = <obx_int.ModelEntity>[
id: const obx_int.IdUid(13, 1803006975816940878), id: const obx_int.IdUid(13, 1803006975816940878),
name: 'filesJson', name: 'filesJson',
type: 9, type: 9,
flags: 0),
obx_int.ModelProperty(
id: const obx_int.IdUid(14, 4499212061291648440),
name: 'mapUrl',
type: 9,
flags: 0) flags: 0)
], ],
relations: <obx_int.ModelRelation>[], relations: <obx_int.ModelRelation>[],
@@ -542,7 +547,9 @@ obx_int.ModelDefinition getObjectBoxModel() {
final remOffset = fbb.writeString(object.rem); final remOffset = fbb.writeString(object.rem);
final toursJsonOffset = fbb.writeString(object.toursJson); final toursJsonOffset = fbb.writeString(object.toursJson);
final filesJsonOffset = fbb.writeString(object.filesJson); 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.addInt64(0, object.id);
fbb.addOffset(1, verOffset); fbb.addOffset(1, verOffset);
fbb.addInt64(2, object.state); fbb.addInt64(2, object.state);
@@ -556,6 +563,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
fbb.addOffset(10, toursJsonOffset); fbb.addOffset(10, toursJsonOffset);
fbb.addInt64(11, object.externalId); fbb.addInt64(11, object.externalId);
fbb.addOffset(12, filesJsonOffset); fbb.addOffset(12, filesJsonOffset);
fbb.addOffset(13, mapUrlOffset);
fbb.finish(fbb.endTable()); fbb.finish(fbb.endTable());
return object.id; return object.id;
}, },
@@ -587,7 +595,9 @@ obx_int.ModelDefinition getObjectBoxModel() {
..externalId = ..externalId =
const fb.Int64Reader().vTableGet(buffer, rootOffset, 26, 0) const fb.Int64Reader().vTableGet(buffer, rootOffset, 26, 0)
..filesJson = const fb.StringReader(asciiOptimization: true) ..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; return object;
}), }),
@@ -965,6 +975,10 @@ class Job_ {
/// See [Job.filesJson]. /// See [Job.filesJson].
static final filesJson = static final filesJson =
obx.QueryStringProperty<Job>(_entities[1].properties[12]); obx.QueryStringProperty<Job>(_entities[1].properties[12]);
/// See [Job.mapUrl].
static final mapUrl =
obx.QueryStringProperty<Job>(_entities[1].properties[13]);
} }
/// [LocationStatus] entity fields to define ObjectBox queries. /// [LocationStatus] entity fields to define ObjectBox queries.
+40 -35
View File
@@ -114,14 +114,13 @@ class _FileListItemState extends State<_FileListItem> {
}, },
); );
} else if (widget.file.isImage) { } 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( await showDialog(
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return Dialog( return Dialog(
insetPadding: const EdgeInsets.all(16), insetPadding: const EdgeInsets.all(16),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
AppBar( AppBar(
title: Text(widget.file.name), title: Text(widget.file.name),
@@ -131,41 +130,47 @@ class _FileListItemState extends State<_FileListItem> {
), ),
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
), ),
Flexible( Expanded(
child: Image.network( child: InteractiveViewer(
widget.file.url, minScale: 1,
fit: BoxFit.contain, maxScale: 5,
loadingBuilder: (context, child, loadingProgress) { child: SizedBox.expand(
if (loadingProgress == null) return child; child: Image.network(
return Center( widget.file.url,
child: CircularProgressIndicator( fit: BoxFit.contain,
value: loadingProgress.expectedTotalBytes != null loadingBuilder: (context, child, loadingProgress) {
? loadingProgress.cumulativeBytesLoaded / if (loadingProgress == null) return child;
loadingProgress.expectedTotalBytes! return Center(
: null, child: CircularProgressIndicator(
), value: loadingProgress.expectedTotalBytes != null
); ? loadingProgress.cumulativeBytesLoaded /
}, loadingProgress.expectedTotalBytes!
errorBuilder: (context, error, stackTrace) { : null,
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', errorBuilder: (context, error, stackTrace) {
textAlign: TextAlign.center, return Center(
style: const TextStyle(color: Colors.red), 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),
),
],
), ),
], );
), },
); ),
}, ),
), ),
), ),
], ],
+34
View File
@@ -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<void> 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<void>(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);
}
+8 -3
View File
@@ -13,7 +13,7 @@ showSignaturePopup(BuildContext context, TaskInfo taskInfo, Function(String?) on
canPop: false, canPop: false,
onPopInvokedWithResult: (bool didPop, dynamic result) async { onPopInvokedWithResult: (bool didPop, dynamic result) async {
if (!didPop) { if (!didPop) {
final shouldPop = await taskDialogSecurityException(context); final shouldPop = await taskDialogSecurityException(context, language: AppLanguage.german);
if (shouldPop) { if (shouldPop) {
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
if (context.mounted) { if (context.mounted) {
@@ -79,13 +79,18 @@ showSignaturePopup(BuildContext context, TaskInfo taskInfo, Function(String?) on
), ),
), ),
getDefaultButton( getDefaultButton(
'apply'.tr, AppLocalizations.tr('apply', language: AppLanguage.german),
buttonHeight: 50, buttonHeight: 50,
() => { () => {
if (controller.points.length > 50) if (controller.points.length > 50)
{Navigator.of(context).pop(), onFinished(convertPoints(controller.points))} {Navigator.of(context).pop(), onFinished(convertPoints(controller.points))}
else else
{showAlertDialog('error'.tr, 'signature_too_short'.tr)} {
showAlertDialog(
AppLocalizations.tr('error', language: AppLanguage.german),
AppLocalizations.tr('signature_too_short', language: AppLanguage.german),
)
}
}) })
], ],
), ),
+2 -2
View File
@@ -56,8 +56,8 @@ class AppLocalizations {
} }
// Hilfsmethode für einfachen Zugriff // Hilfsmethode für einfachen Zugriff
static String tr(String key) { static String tr(String key, {AppLanguage? language}) {
return _instance._translations[_instance._currentLanguage.code]?[key] ?? return _instance._translations[(language ?? _instance._currentLanguage).code]?[key] ??
_instance._translations['de']?[key] ?? _instance._translations['de']?[key] ??
key; key;
} }
+5 -5
View File
@@ -489,22 +489,22 @@ bool isJobTooFarInFuture(Job job) {
return ordertime > dateTimeThreshold; return ordertime > dateTimeThreshold;
} }
Future<bool> taskDialogSecurityException(BuildContext context) async { Future<bool> taskDialogSecurityException(BuildContext context, {AppLanguage? language}) async {
final result = await showDialog<bool>( final result = await showDialog<bool>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) { builder: (dialogContext) {
return AlertDialog( return AlertDialog(
title: Text('attention'.tr), title: Text(AppLocalizations.tr('attention', language: language)),
content: Text('close_dialog_warning'.tr), content: Text(AppLocalizations.tr('close_dialog_warning', language: language)),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(dialogContext).pop(true), onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text('close'.tr), child: Text(AppLocalizations.tr('close', language: language)),
), ),
TextButton( TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false), onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text('back'.tr), child: Text(AppLocalizations.tr('back', language: language)),
), ),
], ],
); );
+33
View File
@@ -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;
}
}
@@ -15,6 +15,7 @@ import pdfium_flutter
import permission_manager import permission_manager
import shared_preferences_foundation import shared_preferences_foundation
import url_launcher_macos import url_launcher_macos
import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
@@ -27,4 +28,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PermissionManagerPlugin.register(with: registry.registrar(forPlugin: "PermissionManagerPlugin")) PermissionManagerPlugin.register(with: registry.registrar(forPlugin: "PermissionManagerPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
} }
+33 -1
View File
@@ -458,7 +458,7 @@ packages:
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
flat_buffers: flat_buffers:
dependency: transitive dependency: "direct dev"
description: description:
name: flat_buffers name: flat_buffers
sha256: "380bdcba5664a718bfd4ea20a45d39e13684f5318fcd8883066a55e21f37f4c3" sha256: "380bdcba5664a718bfd4ea20a45d39e13684f5318fcd8883066a55e21f37f4c3"
@@ -1629,6 +1629,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" 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: win32:
dependency: transitive dependency: transitive
description: description:
+3 -1
View File
@@ -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 # 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 # 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. # 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: environment:
sdk: '>=3.9.0 <4.0.0' sdk: '>=3.9.0 <4.0.0'
@@ -56,6 +56,7 @@ dependencies:
geolocator: ^13.0.2 geolocator: ^13.0.2
expandable: ^5.0.1 expandable: ^5.0.1
url_launcher: ^6.2.4 url_launcher: ^6.2.4
webview_flutter: ^4.13.0
audioplayers: ^6.2.0 audioplayers: ^6.2.0
flutter_launcher_icons: ^0.14.3 flutter_launcher_icons: ^0.14.3
package_info_plus: ^8.0.2 package_info_plus: ^8.0.2
@@ -72,6 +73,7 @@ dependencies:
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flat_buffers: ^23.5.26
build_runner: ^2.4.13 build_runner: ^2.4.13
objectbox_generator: ^4.0.3 objectbox_generator: ^4.0.3
json_serializable: ^6.6.2 json_serializable: ^6.6.2
+86
View File
@@ -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<ChatMessage>;
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<ChatMessage>;
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',
);
+51
View File
@@ -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<String, dynamic>;
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<String, dynamic> _jobPayload() => {
'id': 21891302,
'ver': '0',
'state': 0,
'ordertime': 1791285073,
'offs_warn': 0,
'global': false,
'vht_id': 2,
'rem': '',
'delayOrderTime': 0,
'tours': <Map<String, dynamic>>[],
};
+82
View File
@@ -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);
});
});
}