feat(ui): gesture zoom & double-tap for PDF viewer, fix navigation bar overlaps
Build Android APK / Build Release APKs (push) Failing after 1h4m35s

This commit is contained in:
2026-09-17 09:37:40 +02:00
parent 1eb089f6b5
commit 0b3cacf4b3
7 changed files with 203 additions and 149 deletions
+7 -1
View File
@@ -99,6 +99,7 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
void _showImageSourceModal() { void _showImageSourceModal() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
useSafeArea: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
@@ -196,7 +197,12 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
title: const Text('Mangel melden'), title: const Text('Mangel melden'),
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0), padding: EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 16.0,
bottom: MediaQuery.paddingOf(context).bottom + 24.0,
),
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
+7 -1
View File
@@ -63,6 +63,7 @@ class _AddPostScreenState extends State<AddPostScreen> {
void _showImageSourceModal() { void _showImageSourceModal() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
useSafeArea: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
@@ -155,7 +156,12 @@ class _AddPostScreenState extends State<AddPostScreen> {
title: Text(widget.screenTitle), title: Text(widget.screenTitle),
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0), padding: EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 16.0,
bottom: MediaQuery.paddingOf(context).bottom + 24.0,
),
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
+14 -4
View File
@@ -89,12 +89,18 @@ class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
), ),
builder: (ctx) => StatefulBuilder( builder: (ctx) => StatefulBuilder(
builder: (ctx, setModalState) => Padding( builder: (ctx, setModalState) {
final bottomInset = MediaQuery.viewPaddingOf(ctx).bottom > 0
? MediaQuery.viewPaddingOf(ctx).bottom
: MediaQuery.paddingOf(ctx).bottom;
final keyboardInset = MediaQuery.viewInsetsOf(ctx).bottom;
return Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: 20, left: 20,
right: 20, right: 20,
top: 20, top: 20,
bottom: MediaQuery.of(ctx).viewInsets.bottom + 20, bottom: bottomInset + keyboardInset + 20,
), ),
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
@@ -269,10 +275,11 @@ class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
], ],
), ),
), ),
), );
},
), ),
); );
} }
Widget _buildStatusButton( Widget _buildStatusButton(
String docId, String docId,
@@ -495,6 +502,9 @@ class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
} }
return ListView.separated( return ListView.separated(
padding: EdgeInsets.only(
bottom: MediaQuery.paddingOf(context).bottom + 20,
),
itemCount: filteredDocs.length, itemCount: filteredDocs.length,
separatorBuilder: (ctx, i) => const Divider(height: 1), separatorBuilder: (ctx, i) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
+6 -1
View File
@@ -27,7 +27,12 @@ class DetailScreen extends StatelessWidget {
], ],
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0), padding: EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 16.0,
bottom: MediaQuery.paddingOf(context).bottom + 24.0,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
+55 -83
View File
@@ -19,37 +19,62 @@ class PdfViewerScreen extends StatefulWidget {
State<PdfViewerScreen> createState() => _PdfViewerScreenState(); State<PdfViewerScreen> createState() => _PdfViewerScreenState();
} }
class _PdfViewerScreenState extends State<PdfViewerScreen> { class _PdfViewerScreenState extends State<PdfViewerScreen>
with SingleTickerProviderStateMixin {
final TransformationController _transformationController = final TransformationController _transformationController =
TransformationController(); TransformationController();
double _zoomLevel = 1.0; late AnimationController _animationController;
Animation<Matrix4>? _zoomAnimation;
TapDownDetails? _doubleTapDetails;
Uint8List? _cachedBytes; Uint8List? _cachedBytes;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 250),
)..addListener(() {
if (_zoomAnimation != null) {
_transformationController.value = _zoomAnimation!.value;
}
});
}
@override @override
void dispose() { void dispose() {
_animationController.dispose();
_transformationController.dispose(); _transformationController.dispose();
super.dispose(); super.dispose();
} }
void _zoomIn() { void _handleDoubleTap() {
setState(() { if (_animationController.isAnimating) return;
_zoomLevel = (_zoomLevel * 1.3).clamp(0.8, 5.0);
_transformationController.value = Matrix4.diagonal3Values(_zoomLevel, _zoomLevel, _zoomLevel); final currentMatrix = _transformationController.value;
}); final currentScale = currentMatrix.getMaxScaleOnAxis();
Matrix4 targetMatrix;
if (currentScale > 1.05) {
// Zoomed in -> smoothly animate back to standard size (scale 1.0)
targetMatrix = Matrix4.identity();
} else {
// At standard size -> zoom in to tapped point (2.5x)
final position = _doubleTapDetails?.localPosition ?? Offset.zero;
final x = -position.dx * (2.5 - 1.0);
final y = -position.dy * (2.5 - 1.0);
targetMatrix = Matrix4.diagonal3Values(2.5, 2.5, 1.0)
..setTranslationRaw(x, y, 0.0);
} }
void _zoomOut() { _zoomAnimation = Matrix4Tween(
setState(() { begin: currentMatrix,
_zoomLevel = (_zoomLevel / 1.3).clamp(0.8, 5.0); end: targetMatrix,
_transformationController.value = Matrix4.diagonal3Values(_zoomLevel, _zoomLevel, _zoomLevel); ).animate(CurvedAnimation(
}); parent: _animationController,
} curve: Curves.easeOutCubic,
));
void _resetZoom() { _animationController.forward(from: 0);
setState(() {
_zoomLevel = 1.0;
_transformationController.value = Matrix4.identity();
});
} }
Future<void> _openExternal() async { Future<void> _openExternal() async {
@@ -110,9 +135,9 @@ class _PdfViewerScreenState extends State<PdfViewerScreen> {
), ),
], ],
), ),
body: Stack( body: SafeArea(
children: [ top: false,
PdfPreview.builder( child: PdfPreview.builder(
build: (PdfPageFormat format) async { build: (PdfPageFormat format) async {
if (_cachedBytes != null) return _cachedBytes!; if (_cachedBytes != null) return _cachedBytes!;
final response = await http final response = await http
@@ -166,22 +191,18 @@ class _PdfViewerScreenState extends State<PdfViewerScreen> {
), ),
), ),
pagesBuilder: (context, pages) { pagesBuilder: (context, pages) {
return InteractiveViewer( return GestureDetector(
behavior: HitTestBehavior.translucent,
onDoubleTapDown: (details) => _doubleTapDetails = details,
onDoubleTap: _handleDoubleTap,
child: InteractiveViewer(
transformationController: _transformationController, transformationController: _transformationController,
minScale: 0.8, minScale: 1.0,
maxScale: 5.0, maxScale: 5.0,
panEnabled: true, panEnabled: true,
scaleEnabled: true, scaleEnabled: true,
onInteractionEnd: (details) {
final scale =
_transformationController.value.getMaxScaleOnAxis();
if (scale != _zoomLevel) {
setState(() {
_zoomLevel = scale;
});
}
},
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8.0, vertical: 16.0), horizontal: 8.0, vertical: 16.0),
child: Center( child: Center(
@@ -224,59 +245,10 @@ class _PdfViewerScreenState extends State<PdfViewerScreen> {
), ),
), ),
), ),
),
); );
}, },
), ),
// Floating Zoom Controls Overlay
Positioned(
right: 16,
bottom: 24,
child: Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24)),
color: Colors.white.withValues(alpha: 0.92),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.zoom_in, color: Colors.black87),
tooltip: 'Vergrößern (+)',
onPressed: _zoomIn,
),
InkWell(
onTap: _resetZoom,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 4, horizontal: 6),
child: Text(
'${(_zoomLevel * 100).round()}%',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
),
),
IconButton(
icon: const Icon(Icons.zoom_out, color: Colors.black87),
tooltip: 'Verkleinern (-)',
onPressed: _zoomOut,
),
IconButton(
icon: const Icon(Icons.restart_alt, size: 20, color: Colors.grey),
tooltip: 'Zoom zurücksetzen (1:1)',
onPressed: _resetZoom,
),
],
),
),
),
),
],
), ),
); );
} }
+3
View File
@@ -365,6 +365,9 @@ class _UserManagementScreenState extends State<UserManagementScreen> {
} }
return ListView.separated( return ListView.separated(
padding: EdgeInsets.only(
bottom: MediaQuery.paddingOf(context).bottom + 20,
),
itemCount: filteredUsers.length, itemCount: filteredUsers.length,
separatorBuilder: (ctx, i) => const Divider(height: 1), separatorBuilder: (ctx, i) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
+52
View File
@@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:oggauergemeindetrommler/models/post_item.dart'; import 'package:oggauergemeindetrommler/models/post_item.dart';
@@ -167,4 +168,55 @@ void main() {
expect(canDemote('current_admin', 'current_admin'), isFalse); expect(canDemote('current_admin', 'current_admin'), isFalse);
}); });
}); });
group('PDF Viewer & Navigation Overlap Tests', () {
test('verifies double-tap zoom matrix calculation and reset', () {
// Test at standard scale (1.0) -> zoom to 2.5x
final currentMatrix = Matrix4.identity();
final currentScale = currentMatrix.getMaxScaleOnAxis();
expect(currentScale, 1.0);
const tapPosition = Offset(100, 200);
Matrix4 targetMatrix;
if (currentScale > 1.05) {
targetMatrix = Matrix4.identity();
} else {
final x = -tapPosition.dx * (2.5 - 1.0);
final y = -tapPosition.dy * (2.5 - 1.0);
targetMatrix = Matrix4.diagonal3Values(2.5, 2.5, 1.0)
..setTranslationRaw(x, y, 0.0);
}
expect(targetMatrix.getMaxScaleOnAxis(), 2.5);
expect(targetMatrix.getTranslation().x, -150.0);
expect(targetMatrix.getTranslation().y, -300.0);
// Test when zoomed in -> reset to standard size (scale 1.0)
final zoomedScale = targetMatrix.getMaxScaleOnAxis();
expect(zoomedScale > 1.05, isTrue);
Matrix4 resetMatrix;
if (zoomedScale > 1.05) {
resetMatrix = Matrix4.identity();
} else {
resetMatrix = targetMatrix;
}
expect(resetMatrix.getMaxScaleOnAxis(), 1.0);
expect(resetMatrix.getTranslation().x, 0.0);
expect(resetMatrix.getTranslation().y, 0.0);
});
test('calculates safe bottom padding above 3-button navigation bar', () {
// 3-button navigation typical height is ~48dp
const systemNavBarHeight = 48.0;
const keyboardHeight = 0.0;
const extraPadding = 20.0;
final totalBottomPadding = systemNavBarHeight + keyboardHeight + extraPadding;
expect(totalBottomPadding, 68.0);
// Ensures interactive action buttons sit strictly above the navigation bar
expect(totalBottomPadding > systemNavBarHeight, isTrue);
});
});
} }