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:
1 parent
c0b667c4ee
commit
5e7babf265
26 files changed
+752
-93
No files matched your search
@@ -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
|
||||||
Vendored
+16
-4
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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).
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"VHS_BASIC_AUTH_USERNAME": "",
|
||||||
|
"VHS_BASIC_AUTH_PASSWORD": ""
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -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 {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
@@ -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)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
@@ -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>>[],
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user