feat(ui): gesture zoom & double-tap for PDF viewer, fix navigation bar overlaps
Build Android APK / Build Release APKs (push) Failing after 1h4m35s
Build Android APK / Build Release APKs (push) Failing after 1h4m35s
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user