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() {
|
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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,7 +275,8 @@ class _AdminMessagesScreenState extends State<AdminMessagesScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user