Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
2026-07-26 13:49:05 +02:00
5 changed files with 92 additions and 47 deletions
@@ -7,6 +7,7 @@ import '../../../api/marianumconnect/queries/get_newsletter_file/get_newsletter_
import '../../../widget/app_progress_indicator.dart'; import '../../../widget/app_progress_indicator.dart';
import '../../../widget/confirm_dialog.dart'; import '../../../widget/confirm_dialog.dart';
import '../../../widget/placeholder_view.dart'; import '../../../widget/placeholder_view.dart';
import '../../../widget/route_transition_gate.dart';
class MessageView extends StatefulWidget { class MessageView extends StatefulWidget {
final String id; final String id;
@@ -35,11 +36,13 @@ class _MessageViewState extends State<MessageView> {
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const Center(child: AppProgressIndicator.large()); return const Center(child: AppProgressIndicator.large());
} }
return SfPdfViewer.memory( return RouteTransitionGate(
builder: (context) => SfPdfViewer.memory(
snapshot.data!, snapshot.data!,
enableHyperlinkNavigation: true, enableHyperlinkNavigation: true,
onHyperlinkClicked: (PdfHyperlinkClickedDetails e) => onHyperlinkClicked: (PdfHyperlinkClickedDetails e) =>
ConfirmDialog.openBrowser(context, e.uri), ConfirmDialog.openBrowser(context, e.uri),
),
); );
}, },
), ),
@@ -17,6 +17,7 @@ import '../../../../theming/app_theme.dart';
import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/placeholder_view.dart'; import '../../../../widget/placeholder_view.dart';
import '../../../../widget/prosemirror/pm_json_view.dart'; import '../../../../widget/prosemirror/pm_json_view.dart';
import '../../../../widget/route_transition_gate.dart';
import 'ticker_content_card.dart'; import 'ticker_content_card.dart';
import 'ticker_updated_bar.dart'; import 'ticker_updated_bar.dart';
@@ -187,7 +188,9 @@ class _ProxiedFileViewState extends State<_ProxiedFileView> {
text: 'Das Dokument konnte nicht geladen werden.', text: 'Das Dokument konnte nicht geladen werden.',
); );
} }
return SfPdfViewer.memory(bytes); return RouteTransitionGate(
builder: (context) => SfPdfViewer.memory(bytes),
);
}, },
); );
} }
@@ -3,51 +3,16 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import '../app_progress_indicator.dart'; import '../route_transition_gate.dart';
/// SfPdfViewer asserts on `localToGlobal` if mounted during the page-push /// SfPdfViewer asserts on `localToGlobal` if mounted during the page-push
/// animation. Defer until the route enter animation completes. /// animation. Defer until the route enter animation completes.
class DeferredPdfViewer extends StatefulWidget { class DeferredPdfViewer extends StatelessWidget {
const DeferredPdfViewer({super.key, required this.path}); const DeferredPdfViewer({super.key, required this.path});
final String path; final String path;
@override @override
State<DeferredPdfViewer> createState() => _DeferredPdfViewerState(); Widget build(BuildContext context) => RouteTransitionGate(
} builder: (context) => SfPdfViewer.file(File(path)),
);
class _DeferredPdfViewerState extends State<DeferredPdfViewer> {
bool _ready = false;
Animation<double>? _routeAnimation;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_ready || _routeAnimation != null) return;
final animation = ModalRoute.of(context)?.animation;
if (animation == null || animation.isCompleted) {
_ready = true;
return;
}
_routeAnimation = animation..addStatusListener(_onAnimationStatus);
}
void _onAnimationStatus(AnimationStatus status) {
if (status == AnimationStatus.completed && mounted) {
setState(() => _ready = true);
}
}
@override
void dispose() {
_routeAnimation?.removeStatusListener(_onAnimationStatus);
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_ready) {
return const Center(child: AppProgressIndicator.large());
}
return SfPdfViewer.file(File(widget.path));
}
} }
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'app_progress_indicator.dart';
/// Delays building [builder] until the enclosing route's enter animation has
/// finished. Some widgets (notably `SfPdfViewer`) call `localToGlobal` during
/// their first layout and assert with `RenderBox was not laid out` when an
/// ancestor page-transition `RenderTransform` still has no size mid-push.
/// Gating the mount behind the settled animation avoids that race.
class RouteTransitionGate extends StatefulWidget {
const RouteTransitionGate({super.key, required this.builder, this.placeholder});
final WidgetBuilder builder;
/// Shown while the route is still animating in. Defaults to a centered
/// large progress indicator.
final Widget? placeholder;
@override
State<RouteTransitionGate> createState() => _RouteTransitionGateState();
}
class _RouteTransitionGateState extends State<RouteTransitionGate> {
bool _ready = false;
Animation<double>? _routeAnimation;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_ready || _routeAnimation != null) return;
final animation = ModalRoute.of(context)?.animation;
if (animation == null || animation.isCompleted) {
_ready = true;
return;
}
_routeAnimation = animation..addStatusListener(_onAnimationStatus);
}
void _onAnimationStatus(AnimationStatus status) {
if (status == AnimationStatus.completed && mounted) {
setState(() => _ready = true);
}
}
@override
void dispose() {
_routeAnimation?.removeStatusListener(_onAnimationStatus);
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_ready) {
return widget.placeholder ??
const Center(child: AppProgressIndicator.large());
}
return widget.builder(context);
}
}
@@ -81,7 +81,22 @@ void main() {
testWidgets('tapping a toolbar button switches the shared mode', ( testWidgets('tapping a toolbar button switches the shared mode', (
tester, tester,
) async { ) async {
await tester.pumpWidget(_host(_tableDoc())); // The mode toolbar only renders once the table overflows its width — at the
// full test surface the fixture table fits and the toggle stays hidden. Pin
// a narrow width so it overflows, then let the post-frame overflow
// measurement settle so the toolbar is present before tapping.
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: Center(
child: SizedBox(width: 200, child: PmDocumentView(doc: _tableDoc())),
),
),
),
),
);
await tester.pumpAndSettle();
expect(pmTableMode.value, PmTableMode.scroll); expect(pmTableMode.value, PmTableMode.scroll);
await tester.tap(find.byTooltip('Spalten umbrechen').first); await tester.tap(find.byTooltip('Spalten umbrechen').first);