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

+7 -8
View File
@@ -91,13 +91,12 @@ class _MessagesPageState extends State<MessagesPage> with ProducerMixin {
void _handleSendPressed(types.PartialText text) async {
var now = DateTime.now();
var message = sbmessage.ChatMessage();
message.text = text.text;
message.from = _sid;
message.subj = '';
message.time = Util.nowUnixSecondsUtc();
message.read = true;
message.id = await sbmessage.ChatMessage.getNextId();
final message = sbmessage.ChatMessage.outgoing(
externalId: await sbmessage.ChatMessage.getNextId(),
time: Util.nowUnixSecondsUtc(),
from: _sid,
text: text.text,
);
final box = MyApp.objectBoxStore.box<sbmessage.ChatMessage>();
box.put(message);
@@ -205,7 +204,7 @@ class _MessagesPageState extends State<MessagesPage> with ProducerMixin {
if (unreadChatMessages.isNotEmpty) {
List<String> chatMessageIds = [];
for (var message in unreadChatMessages) {
chatMessageIds.add(message.id.toString());
chatMessageIds.add(message.externalId.toString());
}
var chatMessagesShowed = ChatMessagesReceived();
+22 -10
View File
@@ -165,6 +165,14 @@ class _TaskListPageState extends State<TaskListPage> with ProducerMixin {
// Aufbau der Seite
// ---------------------------------------------------------------------------
String _taskText(TaskInfo taskInfo, String key) {
// Die Unterschriftenerfassung bleibt unabhängig von der App-Sprache deutsch.
return AppLocalizations.tr(
key,
language: taskInfo.task?.effectiveType == 2 ? AppLanguage.german : null,
);
}
@override
Widget build(BuildContext context) {
if (!_initCompleted) {
@@ -190,13 +198,13 @@ class _TaskListPageState extends State<TaskListPage> with ProducerMixin {
child: Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
'${'commission_number_short'.tr}: $_commisionNumber',
'${_taskText(taskInfo, 'commission_number_short')}: $_commisionNumber',
style: const TextStyle(fontSize: 18),
),
),
),
Text(
'${'task'.tr} ${_index + 1} / ${_taskList.length}',
'${_taskText(taskInfo, 'task')} ${_index + 1} / ${_taskList.length}',
style: const TextStyle(fontSize: 13, color: Colors.grey),
),
const SizedBox(height: 4),
@@ -238,7 +246,7 @@ class _TaskListPageState extends State<TaskListPage> with ProducerMixin {
children: [
Expanded(
child: _navButton(
'back'.tr,
_taskText(taskInfo, 'back'),
_index > 0 ? () => setState(() => _index--) : null,
primary: false,
),
@@ -247,9 +255,9 @@ class _TaskListPageState extends State<TaskListPage> with ProducerMixin {
Expanded(
child: isLast
? (widget.readOnly
? _navButton('close'.tr, () => _popBack())
: _navButton('finish'.tr, canProceed ? () => _onFinish() : null))
: _navButton('next'.tr, canProceed ? () => _onNext() : null),
? _navButton(_taskText(taskInfo, 'close'), () => _popBack())
: _navButton(_taskText(taskInfo, 'finish'), canProceed ? () => _onFinish() : null))
: _navButton(_taskText(taskInfo, 'next'), canProceed ? () => _onNext() : null),
),
],
),
@@ -285,7 +293,11 @@ class _TaskListPageState extends State<TaskListPage> with ProducerMixin {
// ---------------------------------------------------------------------------
Widget _buildReadOnlyBody(TaskInfo taskInfo) {
final summary = (taskInfo.additionalText ?? '').trim();
var summary = (taskInfo.additionalText ?? '').trim();
if (taskInfo.task?.effectiveType == 2 && summary.isNotEmpty) {
// Auch früher in einer anderen Sprache gespeicherte Bestätigungen anzeigen.
summary = _taskText(taskInfo, 'signature_captured');
}
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
@@ -297,7 +309,7 @@ class _TaskListPageState extends State<TaskListPage> with ProducerMixin {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('completed'.tr, style: const TextStyle(fontWeight: FontWeight.bold)),
Text(_taskText(taskInfo, 'completed'), style: const TextStyle(fontWeight: FontWeight.bold)),
if (summary.isNotEmpty) ...[
const SizedBox(height: 6),
Text(summary, style: const TextStyle(fontSize: 15)),
@@ -548,7 +560,7 @@ class _TaskListPageState extends State<TaskListPage> with ProducerMixin {
alignment: Alignment.centerRight,
child: TextButton.icon(
icon: const Icon(Icons.clear),
label: Text('clear'.tr),
label: Text(_taskText(taskInfo, 'clear')),
onPressed: () => setState(() => draft.signatureController!.clear()),
),
),
@@ -818,7 +830,7 @@ class _TaskListPageState extends State<TaskListPage> with ProducerMixin {
final answer = await _createAnswerHeader(taskInfo);
answer.sign = _convertSignaturePoints(draft.signatureController!.points);
taskInfo.answer = jsonEncode(answer.toJson());
taskInfo.additionalText = 'signature_captured'.tr;
taskInfo.additionalText = _taskText(taskInfo, 'signature_captured');
taskInfo.completed = true;
_persist(taskInfo);
break;
+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/messages/pop_content_view_message.dart';
import 'package:stadtbote/model/job_tour.dart';
import 'package:stadtbote/popups/job_map_popup.dart';
import '../model/job.dart';
import '../popups/files_popup.dart';
import '../popups/map_popup.dart';
import '../tools/app_localizations.dart';
import '../tools/constants.dart';
import '../tools/util.dart';
@@ -124,18 +124,7 @@ class _TourDetailsPageState extends State<TourDetailsPage> with ProducerMixin {
const SizedBox(height: 10),
getDefaultButton(
'map'.tr,
() => {
if (widget.tour.pos != null)
{
showMapPopup(
context, widget.tour, widget.job.tours)
}
else
{
showAlertDialog('information'.tr,
'no_position'.tr)
}
}),
() => showJobMapPopup(context, widget.job, currentTour: widget.tour)),
],
),
)),
+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/messages/pop_content_view_message.dart';
import 'package:stadtbote/model/job_tour.dart';
import 'package:stadtbote/popups/map_popup.dart';
import 'package:stadtbote/popups/job_map_popup.dart';
import '../model/job.dart';
import '../popups/files_popup.dart';
@@ -90,7 +90,8 @@ class _TourListPageState extends State<TourListPage> with ProducerMixin {
SizedBox(
width: 100,
child: Visibility(
visible: widget.job.tours[0].pos != null,
visible: (widget.job.mapUrl?.trim().isNotEmpty ?? false) ||
(_tours.isNotEmpty && _tours.first.pos != null),
child: ElevatedButton(
style: ButtonStyle(
shape: WidgetStateProperty.all<RoundedRectangleBorder>(
@@ -102,7 +103,7 @@ class _TourListPageState extends State<TourListPage> with ProducerMixin {
backgroundColor: WidgetStateProperty.all<Color>(Colors.lightGreen),
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)),
))
],
@@ -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,
),
),
],
),
),
);
}
}