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() {
showModalBottomSheet(
context: context,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
@@ -196,7 +197,12 @@ class _AddIssueScreenState extends State<AddIssueScreen> {
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(
+7 -1
View File
@@ -63,6 +63,7 @@ class _AddPostScreenState extends State<AddPostScreen> {
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<AddPostScreen> {
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(
+24 -14
View File
@@ -89,14 +89,20 @@ class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
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<AdminMessagesScreen> {
],
),
),
),
),
);
}
);
},
),
);
}
Widget _buildStatusButton(
String docId,
@@ -494,8 +501,11 @@ class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
);
}
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];
+6 -1
View File
@@ -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: [
+104 -132
View File
@@ -19,37 +19,62 @@ class PdfViewerScreen extends StatefulWidget {
State<PdfViewerScreen> createState() => _PdfViewerScreenState();
}
class _PdfViewerScreenState extends State<PdfViewerScreen> {
class _PdfViewerScreenState extends State<PdfViewerScreen>
with SingleTickerProviderStateMixin {
final TransformationController _transformationController =
TransformationController();
double _zoomLevel = 1.0;
late AnimationController _animationController;
Animation<Matrix4>? _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<void> _openExternal() async {
@@ -110,78 +135,74 @@ class _PdfViewerScreenState extends State<PdfViewerScreen> {
),
],
),
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<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(
padding: EdgeInsets.only(
bottom: MediaQuery.paddingOf(context).bottom + 20,
),
itemCount: filteredUsers.length,
separatorBuilder: (ctx, i) => const Divider(height: 1),
itemBuilder: (context, index) {