diff --git a/lib/screens/add_issue_screen.dart b/lib/screens/add_issue_screen.dart index 1ec5920..30233ec 100644 --- a/lib/screens/add_issue_screen.dart +++ b/lib/screens/add_issue_screen.dart @@ -99,6 +99,7 @@ class _AddIssueScreenState extends State { void _showImageSourceModal() { showModalBottomSheet( context: context, + useSafeArea: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), @@ -196,7 +197,12 @@ class _AddIssueScreenState extends State { title: const Text('Mangel melden'), ), 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( key: _formKey, child: Column( diff --git a/lib/screens/add_post_screen.dart b/lib/screens/add_post_screen.dart index 8db7f8a..3c80fe1 100644 --- a/lib/screens/add_post_screen.dart +++ b/lib/screens/add_post_screen.dart @@ -63,6 +63,7 @@ class _AddPostScreenState extends State { void _showImageSourceModal() { showModalBottomSheet( context: context, + useSafeArea: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), @@ -155,7 +156,12 @@ class _AddPostScreenState extends State { title: Text(widget.screenTitle), ), 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( key: _formKey, child: Column( diff --git a/lib/screens/admin_messages_screen.dart b/lib/screens/admin_messages_screen.dart index c4972c1..d4739b4 100644 --- a/lib/screens/admin_messages_screen.dart +++ b/lib/screens/admin_messages_screen.dart @@ -89,14 +89,20 @@ class _AdminMessagesScreenState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (ctx) => StatefulBuilder( - builder: (ctx, setModalState) => Padding( - padding: EdgeInsets.only( - left: 20, - right: 20, - top: 20, - bottom: MediaQuery.of(ctx).viewInsets.bottom + 20, - ), - child: SingleChildScrollView( + 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( + left: 20, + right: 20, + top: 20, + bottom: bottomInset + keyboardInset + 20, + ), + child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -269,10 +275,11 @@ class _AdminMessagesScreenState extends State { ], ), ), - ), - ), - ); - } + ); + }, + ), + ); +} Widget _buildStatusButton( String docId, @@ -494,8 +501,11 @@ class _AdminMessagesScreenState extends State { ); } - return ListView.separated( - itemCount: filteredDocs.length, + return ListView.separated( + padding: EdgeInsets.only( + bottom: MediaQuery.paddingOf(context).bottom + 20, + ), + itemCount: filteredDocs.length, separatorBuilder: (ctx, i) => const Divider(height: 1), itemBuilder: (context, index) { final doc = filteredDocs[index]; diff --git a/lib/screens/detail_screen.dart b/lib/screens/detail_screen.dart index 3b116db..fb886b8 100644 --- a/lib/screens/detail_screen.dart +++ b/lib/screens/detail_screen.dart @@ -27,7 +27,12 @@ class DetailScreen extends StatelessWidget { ], ), 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( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/screens/pdf_viewer_screen.dart b/lib/screens/pdf_viewer_screen.dart index d4f6bb4..7838bcc 100644 --- a/lib/screens/pdf_viewer_screen.dart +++ b/lib/screens/pdf_viewer_screen.dart @@ -19,37 +19,62 @@ class PdfViewerScreen extends StatefulWidget { State createState() => _PdfViewerScreenState(); } -class _PdfViewerScreenState extends State { +class _PdfViewerScreenState extends State + with SingleTickerProviderStateMixin { final TransformationController _transformationController = TransformationController(); - double _zoomLevel = 1.0; + late AnimationController _animationController; + Animation? _zoomAnimation; + TapDownDetails? _doubleTapDetails; 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 void dispose() { + _animationController.dispose(); _transformationController.dispose(); super.dispose(); } - void _zoomIn() { - setState(() { - _zoomLevel = (_zoomLevel * 1.3).clamp(0.8, 5.0); - _transformationController.value = Matrix4.diagonal3Values(_zoomLevel, _zoomLevel, _zoomLevel); - }); - } + void _handleDoubleTap() { + if (_animationController.isAnimating) return; - void _zoomOut() { - setState(() { - _zoomLevel = (_zoomLevel / 1.3).clamp(0.8, 5.0); - _transformationController.value = Matrix4.diagonal3Values(_zoomLevel, _zoomLevel, _zoomLevel); - }); - } + final currentMatrix = _transformationController.value; + final currentScale = currentMatrix.getMaxScaleOnAxis(); - void _resetZoom() { - setState(() { - _zoomLevel = 1.0; - _transformationController.value = Matrix4.identity(); - }); + 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); + } + + _zoomAnimation = Matrix4Tween( + begin: currentMatrix, + end: targetMatrix, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + )); + _animationController.forward(from: 0); } Future _openExternal() async { @@ -110,78 +135,74 @@ class _PdfViewerScreenState extends State { ), ], ), - body: Stack( - children: [ - PdfPreview.builder( - build: (PdfPageFormat format) async { - if (_cachedBytes != null) return _cachedBytes!; - final response = await http - .get(Uri.parse(widget.pdfUrl)) - .timeout(const Duration(seconds: 15)); - if (response.statusCode == 200) { - _cachedBytes = response.bodyBytes; - return response.bodyBytes; - } - throw Exception( - 'Fehler beim Abrufen der Datei (${response.statusCode})'); - }, - canChangeOrientation: false, - canChangePageFormat: false, - canDebug: false, - allowPrinting: false, - allowSharing: false, - useActions: false, - pdfFileName: '$safeTitle.pdf', - loadingWidget: const Center( + body: SafeArea( + top: false, + child: PdfPreview.builder( + build: (PdfPageFormat format) async { + if (_cachedBytes != null) return _cachedBytes!; + final response = await http + .get(Uri.parse(widget.pdfUrl)) + .timeout(const Duration(seconds: 15)); + if (response.statusCode == 200) { + _cachedBytes = response.bodyBytes; + return response.bodyBytes; + } + throw Exception( + 'Fehler beim Abrufen der Datei (${response.statusCode})'); + }, + canChangeOrientation: false, + canChangePageFormat: false, + canDebug: false, + allowPrinting: false, + allowSharing: false, + useActions: false, + pdfFileName: '$safeTitle.pdf', + loadingWidget: const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Aussendung wird geladen...'), + ], + ), + ), + onError: (context, error) => Center( + child: Padding( + padding: const EdgeInsets.all(24.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Aussendung wird geladen...'), + const Icon(Icons.error_outline, color: Colors.red, size: 48), + const SizedBox(height: 12), + Text( + 'Fehler beim Laden des Dokuments: $error', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _openExternal, + icon: const Icon(Icons.open_in_browser), + label: const Text('Direkt im Browser öffnen'), + ), ], ), ), - onError: (context, error) => Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.error_outline, color: Colors.red, size: 48), - const SizedBox(height: 12), - Text( - 'Fehler beim Laden des Dokuments: $error', - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.red), - ), - const SizedBox(height: 16), - ElevatedButton.icon( - onPressed: _openExternal, - icon: const Icon(Icons.open_in_browser), - label: const Text('Direkt im Browser öffnen'), - ), - ], - ), - ), - ), - pagesBuilder: (context, pages) { - return InteractiveViewer( + ), + pagesBuilder: (context, pages) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onDoubleTapDown: (details) => _doubleTapDetails = details, + onDoubleTap: _handleDoubleTap, + child: InteractiveViewer( transformationController: _transformationController, - minScale: 0.8, + minScale: 1.0, maxScale: 5.0, panEnabled: true, scaleEnabled: true, - onInteractionEnd: (details) { - final scale = - _transformationController.value.getMaxScaleOnAxis(); - if (scale != _zoomLevel) { - setState(() { - _zoomLevel = scale; - }); - } - }, child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), padding: const EdgeInsets.symmetric( horizontal: 8.0, vertical: 16.0), child: Center( @@ -224,59 +245,10 @@ class _PdfViewerScreenState extends State { ), ), ), - ); - }, - ), - // 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, - ), - ], - ), ), - ), - ), - ], + ); + }, + ), ), ); } diff --git a/lib/screens/user_management_screen.dart b/lib/screens/user_management_screen.dart index 2004a90..0537e8e 100644 --- a/lib/screens/user_management_screen.dart +++ b/lib/screens/user_management_screen.dart @@ -365,6 +365,9 @@ class _UserManagementScreenState extends State { } return ListView.separated( + padding: EdgeInsets.only( + bottom: MediaQuery.paddingOf(context).bottom + 20, + ), itemCount: filteredUsers.length, separatorBuilder: (ctx, i) => const Divider(height: 1), itemBuilder: (context, index) { diff --git a/test/widget_test.dart b/test/widget_test.dart index de596da..0dadaf4 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:oggauergemeindetrommler/models/post_item.dart'; @@ -167,4 +168,55 @@ void main() { 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); + }); + }); }