From d64a6da2f3dade9641abb866ad8f13cc17e4ec7d Mon Sep 17 00:00:00 2001 From: Ralf Weinbrecher Date: Wed, 5 May 2021 21:16:33 +0200 Subject: [PATCH] First import --- .gitignore | 74 +++++ .metadata | 10 + CHANGELOG.md | 3 + LICENSE | 1 + README.md | 15 + lib/flutter_neat_pdf_viewer.dart | 7 + lib/src/document.dart | 147 ++++++++++ lib/src/page.dart | 75 +++++ lib/src/tooltip.dart | 16 + lib/src/viewer.dart | 345 ++++++++++++++++++++++ lib/src/zoomable_widget.dart | 392 +++++++++++++++++++++++++ pubspec.lock | 343 ++++++++++++++++++++++ pubspec.yaml | 29 ++ test/flutter_neat_pdf_viewer_test.dart | 12 + 14 files changed, 1469 insertions(+) create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 lib/flutter_neat_pdf_viewer.dart create mode 100644 lib/src/document.dart create mode 100644 lib/src/page.dart create mode 100644 lib/src/tooltip.dart create mode 100644 lib/src/viewer.dart create mode 100644 lib/src/zoomable_widget.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 test/flutter_neat_pdf_viewer_test.dart diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1985397 --- /dev/null +++ b/.gitignore @@ -0,0 +1,74 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..89d95f1 --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: adc687823a831bbebe28bdccfac1a628ca621513 + channel: stable + +project_type: package diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ac07159 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## [0.0.1] - TODO: Add release date. + +* TODO: Describe initial release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ba75c69 --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c37a48e --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# flutter_neat_pdf_viewer + +A new Flutter package project. + +## Getting Started + +This project is a starting point for a Dart +[package](https://flutter.dev/developing-packages/), +a library module containing code that can be shared easily across +multiple Flutter or Dart projects. + +For help getting started with Flutter, view our +[online documentation](https://flutter.dev/docs), which offers tutorials, +samples, guidance on mobile development, and a full API reference. +# flutter_neat_pdf_viewer diff --git a/lib/flutter_neat_pdf_viewer.dart b/lib/flutter_neat_pdf_viewer.dart new file mode 100644 index 0000000..a7722d7 --- /dev/null +++ b/lib/flutter_neat_pdf_viewer.dart @@ -0,0 +1,7 @@ +library flutter_neat_pdf_viewer; + +export 'src/document.dart' show PDFDocument; +export 'src/page.dart' show PDFPage; +export 'src/viewer.dart' show PDFViewer, IndicatorPosition; +export 'src/tooltip.dart' show PDFViewerTooltip; +export 'package:flutter_cache_manager/flutter_cache_manager.dart'; diff --git a/lib/src/document.dart b/lib/src/document.dart new file mode 100644 index 0000000..47d6dc1 --- /dev/null +++ b/lib/src/document.dart @@ -0,0 +1,147 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:flutter_neat_pdf_viewer/src/page.dart'; +import 'package:path_provider/path_provider.dart'; + +class PDFDocument { + static const MethodChannel _channel = + const MethodChannel('flutter_plugin_pdf_viewer'); + + String _filePath; + int count; + List _pages = []; + bool _preloaded = false; + + /// Load a PDF File from a given File + /// [File file], file to be loaded + /// + static Future fromFile(File file) async { + PDFDocument document = PDFDocument(); + document._filePath = file.path; + try { + var pageCount = await _channel + .invokeMethod('getNumberOfPages', {'filePath': file.path}); + document.count = document.count = int.parse(pageCount); + } catch (e) { + throw Exception('Error reading PDF!'); + } + return document; + } + + /// Load a PDF File from a given URL. + /// File is saved in cache + /// [String url] url of the pdf file + /// [Map fromURL(String url, + {Map headers, CacheManager cacheManager}) async { + // Download into cache + File f = await (cacheManager ?? DefaultCacheManager()) + .getSingleFile(url, headers: headers); + PDFDocument document = PDFDocument(); + document._filePath = f.path; + try { + var pageCount = + await _channel.invokeMethod('getNumberOfPages', {'filePath': f.path}); + document.count = document.count = int.parse(pageCount); + } catch (e) { + throw Exception('Error reading PDF!'); + } + return document; + } + + /// Load a PDF File from assets folder + /// [String asset] path of the asset to be loaded + /// + static Future fromAsset(String asset) async { + File file; + try { + var dir = await getApplicationDocumentsDirectory(); + file = File("${dir.path}/file.pdf"); + var data = await rootBundle.load(asset); + var bytes = data.buffer.asUint8List(); + await file.writeAsBytes(bytes, flush: true); + } catch (e) { + throw Exception('Error parsing asset file!'); + } + PDFDocument document = PDFDocument(); + document._filePath = file.path; + try { + var pageCount = await _channel + .invokeMethod('getNumberOfPages', {'filePath': file.path}); + document.count = document.count = int.parse(pageCount); + } catch (e) { + throw Exception('Error reading PDF!'); + } + return document; + } + + /// Load specific page + /// + /// [page] defaults to `1` and must be equal or above it + Future get({ + int page = 1, + final Function(double) onZoomChanged, + final int zoomSteps, + final double minScale, + final double maxScale, + final double panLimit, + }) async { + assert(page > 0); + if (_preloaded && _pages.isNotEmpty) return _pages[page - 1]; + var data = await _channel + .invokeMethod('getPage', {'filePath': _filePath, 'pageNumber': page}); + return new PDFPage( + data, + page, + onZoomChanged: onZoomChanged, + zoomSteps: zoomSteps, + minScale: minScale, + maxScale: maxScale, + panLimit: panLimit, + ); + } + + Future preloadPages({ + final Function(double) onZoomChanged, + final int zoomSteps, + final double minScale, + final double maxScale, + final double panLimit, + }) async { + int countvar = 1; + await Future.forEach(List(count), (i) async { + final data = await _channel.invokeMethod( + 'getPage', {'filePath': _filePath, 'pageNumber': countvar}); + _pages.add(PDFPage( + data, + countvar, + onZoomChanged: onZoomChanged, + zoomSteps: zoomSteps, + minScale: minScale, + maxScale: maxScale, + panLimit: panLimit, + )); + countvar++; + }); + _preloaded = true; + } + + // Stream all pages + Stream getAll({final Function(double) onZoomChanged}) { + return Future.forEach(List(count), (i) async { + print(i); + final data = await _channel + .invokeMethod('getPage', {'filePath': _filePath, 'pageNumber': i}); + return new PDFPage( + data, + 1, + onZoomChanged: onZoomChanged, + ); + }).asStream(); + } +} diff --git a/lib/src/page.dart b/lib/src/page.dart new file mode 100644 index 0000000..2513d59 --- /dev/null +++ b/lib/src/page.dart @@ -0,0 +1,75 @@ +import 'dart:io'; +import 'dart:ui'; +import 'package:flutter_neat_pdf_viewer/src/zoomable_widget.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter/painting.dart'; + +/// A class to represent PDF page +/// [imgPath], path of the image (pdf page) +/// [num], page number +/// [onZoomChanged], function called when zoom is changed +/// [zoomSteps], number of zoom steps on double tap +/// [minScale] minimum zoom scale +/// [maxScale] maximum zoom scale +/// [panLimit] limit for pan +class PDFPage extends StatefulWidget { + final String imgPath; + final int num; + final Function(double) onZoomChanged; + final int zoomSteps; + final double minScale; + final double maxScale; + final double panLimit; + PDFPage( + this.imgPath, + this.num, { + this.onZoomChanged, + this.zoomSteps = 3, + this.minScale = 1.0, + this.maxScale = 5.0, + this.panLimit = 1.0, + }); + + @override + _PDFPageState createState() => _PDFPageState(); +} + +class _PDFPageState extends State { + ImageProvider provider; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _repaint(); + } + + @override + void didUpdateWidget(PDFPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.imgPath != widget.imgPath) { + _repaint(); + } + } + + _repaint() { + provider = FileImage(File(widget.imgPath)); + final resolver = provider.resolve(createLocalImageConfiguration(context)); + resolver.addListener(ImageStreamListener((imgInfo, alreadyPainted) { + if (!alreadyPainted) setState(() {}); + })); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: null, + child: ZoomableWidget( + onZoomChanged: widget.onZoomChanged, + zoomSteps: widget.zoomSteps ?? 3, + minScale: widget.minScale ?? 1.0, + panLimit: widget.panLimit ?? 1.0, + maxScale: widget.maxScale ?? 5.0, + child: Image(image: provider), + )); + } +} diff --git a/lib/src/tooltip.dart b/lib/src/tooltip.dart new file mode 100644 index 0000000..e2bf213 --- /dev/null +++ b/lib/src/tooltip.dart @@ -0,0 +1,16 @@ +class PDFViewerTooltip { + final String first; + final String previous; + final String next; + final String last; + final String pick; + final String jump; + + const PDFViewerTooltip( + {this.first = "First", + this.previous = "Previous", + this.next = "Next", + this.last = "Last", + this.pick = "Pick a page", + this.jump = "Jump"}); +} diff --git a/lib/src/viewer.dart b/lib/src/viewer.dart new file mode 100644 index 0000000..1e8572e --- /dev/null +++ b/lib/src/viewer.dart @@ -0,0 +1,345 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_neat_pdf_viewer/flutter_neat_pdf_viewer.dart'; +import 'package:numberpicker/numberpicker.dart'; + +/// enum to describe indicator position +enum IndicatorPosition { topLeft, topRight, bottomLeft, bottomRight } + +/// PDFViewer, a inbuild pdf viewer, you can create your own too. +/// [document] an instance of `PDFDocument`, document to be loaded +/// [indicatorText] color of indicator text +/// [indicatorBackground] color of indicator background +/// [pickerButtonColor] the picker button background color +/// [pickerIconColor] the picker button icon color +/// [indicatorPosition] position of the indicator position defined by `IndicatorPosition` enum +/// [showIndicator] show,hide indicator +/// [showPicker] show hide picker +/// [showNavigation] show hide navigation bar +/// [toolTip] tooltip, instance of `PDFViewerTooltip` +/// [enableSwipeNavigation] enable,disable swipe navigation +/// [scrollDirection] scroll direction horizontal or vertical +/// [lazyLoad] lazy load pages or load all at once +/// [controller] page controller to control page viewer +/// [zoomSteps] zoom steps for pdf page +/// [minScale] minimum zoom scale for pdf page +/// [maxScale] maximum zoom scale for pdf page +/// [panLimit] pan limit for pdf page +/// [onPageChanged] function called when page changes +/// +class PDFViewer extends StatefulWidget { + final PDFDocument document; + final Color indicatorText; + final Color indicatorBackground; + final Color pickerButtonColor; + final Color pickerIconColor; + final IndicatorPosition indicatorPosition; + final bool showIndicator; + final bool showPicker; + final bool showNavigation; + final PDFViewerTooltip tooltip; + final bool enableSwipeNavigation; + final Axis scrollDirection; + final bool lazyLoad; + final PageController controller; + final int zoomSteps; + final double minScale; + final double maxScale; + final double panLimit; + final ValueChanged onPageChanged; + + final Widget Function( + BuildContext, + int pageNumber, + int totalPages, + void Function({int page}) jumpToPage, + void Function({int page}) animateToPage, + ) navigationBuilder; + final Widget progressIndicator; + + PDFViewer({ + Key key, + @required this.document, + this.scrollDirection, + this.lazyLoad = true, + this.indicatorText = Colors.white, + this.indicatorBackground = Colors.black54, + this.showIndicator = true, + this.showPicker = true, + this.showNavigation = true, + this.enableSwipeNavigation = true, + this.tooltip = const PDFViewerTooltip(), + this.navigationBuilder, + this.controller, + this.indicatorPosition = IndicatorPosition.topRight, + this.zoomSteps, + this.minScale, + this.maxScale, + this.panLimit, + this.progressIndicator, + this.pickerButtonColor, + this.pickerIconColor, + this.onPageChanged, + }) : super(key: key); + + _PDFViewerState createState() => _PDFViewerState(); +} + +class _PDFViewerState extends State { + bool _isLoading = true; + int _pageNumber; + bool _swipeEnabled = true; + List _pages; + PageController _pageController; + final Duration animationDuration = Duration(milliseconds: 200); + final Curve animationCurve = Curves.easeIn; + + @override + void initState() { + super.initState(); + _pages = List(widget.document.count); + _pageController = widget.controller ?? PageController(); + _pageNumber = _pageController.initialPage + 1; + if (!widget.lazyLoad) + widget.document.preloadPages( + onZoomChanged: onZoomChanged, + zoomSteps: widget.zoomSteps, + minScale: widget.minScale, + maxScale: widget.maxScale, + panLimit: widget.panLimit, + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _pageNumber = _pageController.initialPage + 1; + _isLoading = true; + _pages = List(widget.document.count); + // _loadAllPages(); + _loadPage(); + } + + @override + void didUpdateWidget(PDFViewer oldWidget) { + super.didUpdateWidget(oldWidget); + } + + onZoomChanged(double scale) { + if (scale != 1.0) { + setState(() { + _swipeEnabled = false; + }); + } else { + setState(() { + _swipeEnabled = true; + }); + } + } + + _loadPage() async { + if (_pages[_pageNumber - 1] != null) return; + setState(() { + _isLoading = true; + }); + final data = await widget.document.get( + page: _pageNumber, + onZoomChanged: onZoomChanged, + zoomSteps: widget.zoomSteps, + minScale: widget.minScale, + maxScale: widget.maxScale, + panLimit: widget.panLimit, + ); + _pages[_pageNumber - 1] = data; + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + _animateToPage({int page}) { + _pageController.animateToPage(page != null ? page : _pageNumber - 1, + duration: animationDuration, curve: animationCurve); + } + + _jumpToPage({int page}) { + _pageController.jumpToPage(page != null ? page : _pageNumber - 1); + } + + Widget _drawIndicator() { + Widget child = GestureDetector( + onTap: + widget.showPicker && widget.document.count > 1 ? _pickPage : null, + child: Container( + padding: + EdgeInsets.only(top: 4.0, left: 16.0, bottom: 4.0, right: 16.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4.0), + color: widget.indicatorBackground), + child: Text("$_pageNumber/${widget.document.count}", + style: TextStyle( + color: widget.indicatorText, + fontSize: 16.0, + fontWeight: FontWeight.w400)))); + + switch (widget.indicatorPosition) { + case IndicatorPosition.topLeft: + return Positioned(top: 20, left: 20, child: child); + case IndicatorPosition.topRight: + return Positioned(top: 20, right: 20, child: child); + case IndicatorPosition.bottomLeft: + return Positioned(bottom: 20, left: 20, child: child); + case IndicatorPosition.bottomRight: + return Positioned(bottom: 20, right: 20, child: child); + default: + return Positioned(top: 20, right: 20, child: child); + } + } + + _pickPage() { + showDialog( + context: context, + builder: (BuildContext context) { + return NumberPickerDialog.integer( + title: Text(widget.tooltip.pick), + minValue: 1, + cancelWidget: Container(), + maxValue: widget.document.count, + initialIntegerValue: _pageNumber, + ); + }).then((int value) { + if (value != null) { + _pageNumber = value; + _jumpToPage(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + PageView.builder( + physics: + _swipeEnabled && widget.enableSwipeNavigation && !_isLoading + ? null + : NeverScrollableScrollPhysics(), + onPageChanged: (page) { + setState(() { + _pageNumber = page + 1; + }); + _loadPage(); + widget.onPageChanged?.call(page); + }, + scrollDirection: widget.scrollDirection ?? Axis.horizontal, + controller: _pageController, + itemCount: _pages?.length ?? 0, + itemBuilder: (context, index) => _pages[index] == null + ? Center( + child: + widget.progressIndicator ?? CircularProgressIndicator(), + ) + : _pages[index], + ), + (widget.showIndicator && !_isLoading) + ? _drawIndicator() + : Container(), + ], + ), + floatingActionButton: widget.showPicker && widget.document.count > 1 + ? FloatingActionButton( + elevation: 4.0, + tooltip: widget.tooltip.jump, + child: Icon( + Icons.view_carousel, + color: widget.pickerIconColor ?? Colors.white, + ), + backgroundColor: widget.pickerButtonColor ?? Colors.blue, + onPressed: () { + _pickPage(); + }, + ) + : null, + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + bottomNavigationBar: (widget.showNavigation && widget.document.count > 1) + ? widget.navigationBuilder != null + ? widget.navigationBuilder( + context, + _pageNumber, + widget.document.count, + _jumpToPage, + _animateToPage, + ) + : BottomAppBar( + child: new Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: IconButton( + icon: Icon(Icons.first_page), + tooltip: widget.tooltip.first, + onPressed: _pageNumber == 1 || _isLoading + ? null + : () { + _pageNumber = 1; + _jumpToPage(); + }, + ), + ), + Expanded( + child: IconButton( + icon: Icon(Icons.chevron_left), + tooltip: widget.tooltip.previous, + onPressed: _pageNumber == 1 || _isLoading + ? null + : () { + _pageNumber--; + if (1 > _pageNumber) { + _pageNumber = 1; + } + _animateToPage(); + }, + ), + ), + widget.showPicker + ? Expanded(child: Text('')) + : SizedBox(width: 1), + Expanded( + child: IconButton( + icon: Icon(Icons.chevron_right), + tooltip: widget.tooltip.next, + onPressed: + _pageNumber == widget.document.count || _isLoading + ? null + : () { + _pageNumber++; + if (widget.document.count < _pageNumber) { + _pageNumber = widget.document.count; + } + _animateToPage(); + }, + ), + ), + Expanded( + child: IconButton( + icon: Icon(Icons.last_page), + tooltip: widget.tooltip.last, + onPressed: + _pageNumber == widget.document.count || _isLoading + ? null + : () { + _pageNumber = widget.document.count; + _jumpToPage(); + }, + ), + ), + ], + ), + ) + : Container( + height: 0, + ), + ); + } +} diff --git a/lib/src/zoomable_widget.dart b/lib/src/zoomable_widget.dart new file mode 100644 index 0000000..ee13468 --- /dev/null +++ b/lib/src/zoomable_widget.dart @@ -0,0 +1,392 @@ +/// originally from https://github.com/mchome/flutter_advanced_networkimage +import 'dart:math'; + +import 'package:flutter/widgets.dart'; + +class ZoomableWidget extends StatefulWidget { + ZoomableWidget({ + Key key, + this.minScale: 0.7, + this.maxScale: 1.4, + this.initialScale: 1.0, + this.initialOffset: Offset.zero, + this.initialRotation: 0.0, + this.enableZoom: true, + this.panLimit: 1.0, + this.singleFingerPan: true, + this.multiFingersPan: true, + this.enableRotate: false, + this.child, + this.onTap, + this.zoomSteps: 0, + this.autoCenter: false, + this.bounceBackBoundary: true, + this.enableFling: true, + this.flingFactor: 1.0, + this.onZoomChanged, + this.resetDuration: const Duration(milliseconds: 250), + this.resetCurve: Curves.easeInOut, + }) : assert(minScale != null), + assert(maxScale != null), + assert(initialScale != null), + assert(initialOffset != null), + assert(initialRotation != null), + assert(enableZoom != null), + assert(panLimit != null), + assert(singleFingerPan != null), + assert(multiFingersPan != null), + assert(enableRotate != null), + assert(zoomSteps != null), + assert(autoCenter != null), + assert(bounceBackBoundary != null), + assert(enableFling != null), + assert(flingFactor != null); + + /// The minimum size for scaling. + final double minScale; + + /// The maximum size for scaling. + final double maxScale; + + /// The initial scale. + final double initialScale; + + /// The initial offset. + final Offset initialOffset; + + /// The initial rotation. + final double initialRotation; + + /// Allow zooming the child widget. + final bool enableZoom; + + /// Allow panning with one finger. + final bool singleFingerPan; + + /// Allow panning with more than one finger. + final bool multiFingersPan; + + /// Allow rotating the [image]. + final bool enableRotate; + + /// Create a boundary with the factor. + final double panLimit; + + /// The child widget that is display. + final Widget child; + + /// Tap callback for this widget. + final VoidCallback onTap; + + /// Allow users to zoom with double tap steps by steps. + final int zoomSteps; + + /// Center offset when zooming to minimum scale. + final bool autoCenter; + + /// Enable the bounce-back boundary. + final bool bounceBackBoundary; + + /// Allow fling child widget after panning. + final bool enableFling; + + /// Greater value create greater fling distance. + final double flingFactor; + + /// When the scale value changed, the callback will be invoked. + final ValueChanged onZoomChanged; + + /// The duration of reset animation. + final Duration resetDuration; + + /// The curve of reset animation. + final Curve resetCurve; + + @override + _ZoomableWidgetState createState() => _ZoomableWidgetState(); +} + +class _ZoomableWidgetState extends State { + final GlobalKey _key = GlobalKey(); + + double _zoom = 1.0; + double _previousZoom = 1.0; + Offset _previousPanOffset = Offset.zero; + Offset _pan = Offset.zero; + Offset _zoomOriginOffset = Offset.zero; + double _rotation = 0.0; + double _previousRotation = 0.0; + + Size _childSize = Size.zero; + Size _containerSize = Size.zero; + + Duration _duration = const Duration(milliseconds: 100); + Curve _curve = Curves.easeOut; + + @override + void initState() { + super.initState(); + _zoom = widget.initialScale; + _pan = widget.initialOffset; + _rotation = widget.initialRotation; + } + + void _onScaleStart(ScaleStartDetails details) { + if (_childSize == Size.zero) { + final RenderBox renderbox = _key.currentContext.findRenderObject(); + _childSize = renderbox.size; + } + setState(() { + _zoomOriginOffset = details.focalPoint; + _previousPanOffset = _pan; + _previousZoom = _zoom; + _previousRotation = _rotation; + }); + } + + void _onScaleUpdate(ScaleUpdateDetails details) { + Size boundarySize = _boundarySize; + + Size _marginSize = const Size(100.0, 100.0); + + _duration = const Duration(milliseconds: 50); + _curve = Curves.easeOut; + + setState(() { + if (widget.enableRotate) + _rotation = (_previousRotation + details.rotation).clamp(-pi, pi); + if (widget.enableZoom && details.scale != 1.0) { + _zoom = (_previousZoom * details.scale) + .clamp(widget.minScale, widget.maxScale); + if (widget.onZoomChanged != null) widget.onZoomChanged(_zoom); + } + }); + + if ((widget.singleFingerPan && details.scale == 1.0) || + (widget.multiFingersPan && details.scale != 1.0)) { + Offset _panRealOffset = (details.focalPoint - + _zoomOriginOffset + + _previousPanOffset * _previousZoom) / + _zoom; + + if (widget.panLimit == 0.0) { + _pan = _panRealOffset; + } else { + Offset _baseOffset = Offset( + _panRealOffset.dx + .clamp(-boundarySize.width / 2, boundarySize.width / 2), + _panRealOffset.dy + .clamp(-boundarySize.height / 2, boundarySize.height / 2), + ); + + Offset _marginOffset = _panRealOffset - _baseOffset; + double _widthFactor = sqrt(_marginOffset.dx.abs()) / _marginSize.width; + double _heightFactor = + sqrt(_marginOffset.dy.abs()) / _marginSize.height; + _marginOffset = Offset( + _marginOffset.dx * _widthFactor * 2, + _marginOffset.dy * _heightFactor * 2, + ); + _pan = _baseOffset + _marginOffset; + } + setState(() {}); + } + } + + void _onScaleEnd(ScaleEndDetails details) { + Size boundarySize = _boundarySize; + + _duration = widget.resetDuration; + _curve = widget.resetCurve; + + final Offset velocity = details.velocity.pixelsPerSecond; + final double magnitude = velocity.distance; + if (magnitude > 800.0 * _zoom && widget.enableFling) { + final Offset direction = velocity / magnitude; + final double distance = (Offset.zero & context.size).shortestSide; + final Offset endOffset = + _pan + direction * distance * widget.flingFactor * 0.5; + _pan = Offset( + endOffset.dx.clamp(-boundarySize.width / 2, boundarySize.width / 2), + endOffset.dy.clamp(-boundarySize.height / 2, boundarySize.height / 2), + ); + } + Offset _clampedOffset = Offset( + _pan.dx.clamp(-boundarySize.width / 2, boundarySize.width / 2), + _pan.dy.clamp(-boundarySize.height / 2, boundarySize.height / 2), + ); + if (_zoom == widget.minScale && widget.autoCenter) { + _clampedOffset = Offset.zero; + } + setState(() => _pan = _clampedOffset); + } + + Size get _boundarySize { + Size _boundarySize = Size( + (_containerSize.width == _childSize.width) + ? (_containerSize.width - _childSize.width / _zoom).abs() + : (_containerSize.width - _childSize.width * _zoom).abs() / _zoom, + (_containerSize.height == _childSize.height) + ? (_containerSize.height - _childSize.height / _zoom).abs() + : (_containerSize.height - _childSize.height * _zoom).abs() / + _zoom, + ) * + widget.panLimit; + + return _boundarySize; + } + + void _handleDoubleTap() { + double _stepLength = 0.0; + + _duration = widget.resetDuration; + _curve = widget.resetCurve; + + if (widget.zoomSteps > 0) + _stepLength = (widget.maxScale - 1.0) / widget.zoomSteps; + + double _tmpZoom = _zoom + _stepLength; + if (_tmpZoom > widget.maxScale || _stepLength == 0.0) _tmpZoom = 1.0; + + setState(() { + _zoom = _tmpZoom; + if (widget.onZoomChanged != null) widget.onZoomChanged(_zoom); + _pan = Offset.zero; + _rotation = 0.0; + _previousZoom = _tmpZoom; + if (_tmpZoom == 1.0) { + _zoomOriginOffset = Offset.zero; + _previousPanOffset = Offset.zero; + } + }); + } + + @override + Widget build(BuildContext context) { + if (widget.child == null) return SizedBox(); + + return CustomMultiChildLayout( + delegate: _ZoomableWidgetLayout(), + children: [ + LayoutId( + id: _ZoomableWidgetLayout.painter, + child: _ZoomableChild( + duration: _duration, + curve: _curve, + zoom: _zoom, + panOffset: _pan, + rotation: _rotation, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + _containerSize = + Size(constraints.maxWidth, constraints.maxHeight); + return Center( + child: Container(key: _key, child: widget.child), + ); + }, + ), + ), + ), + LayoutId( + id: _ZoomableWidgetLayout.gestureContainer, + child: GestureDetector( + child: Container(color: Color(0)), + onScaleStart: _onScaleStart, + onScaleUpdate: _onScaleUpdate, + onScaleEnd: widget.bounceBackBoundary ? _onScaleEnd : null, + onDoubleTap: _handleDoubleTap, + onTap: widget.onTap, + ), + ), + ], + ); + } +} + +class _ZoomableWidgetLayout extends MultiChildLayoutDelegate { + _ZoomableWidgetLayout(); + + static final String gestureContainer = 'gesturecontainer'; + static final String painter = 'painter'; + + @override + void performLayout(Size size) { + layoutChild(gestureContainer, + BoxConstraints.tightFor(width: size.width, height: size.height)); + positionChild(gestureContainer, Offset.zero); + layoutChild(painter, + BoxConstraints.tightFor(width: size.width, height: size.height)); + positionChild(painter, Offset.zero); + } + + @override + bool shouldRelayout(_ZoomableWidgetLayout oldDelegate) => false; +} + +class _ZoomableChild extends ImplicitlyAnimatedWidget { + const _ZoomableChild({ + Duration duration, + Curve curve = Curves.linear, + @required this.zoom, + @required this.panOffset, + @required this.rotation, + @required this.child, + }) : super(duration: duration, curve: curve); + + final double zoom; + final Offset panOffset; + final double rotation; + final Widget child; + + @override + ImplicitlyAnimatedWidgetState createState() => + _ZoomableChildState(); +} + +class _ZoomableChildState extends AnimatedWidgetBaseState<_ZoomableChild> { + DoubleTween _zoom; + OffsetTween _panOffset; + // OffsetTween _zoomOriginOffset; + DoubleTween _rotation; + + @override + void forEachTween(visitor) { + _zoom = visitor( + _zoom, widget.zoom, (dynamic value) => DoubleTween(begin: value)); + _panOffset = visitor(_panOffset, widget.panOffset, + (dynamic value) => OffsetTween(begin: value)); + _rotation = visitor(_rotation, widget.rotation, + (dynamic value) => DoubleTween(begin: value)); + } + + @override + Widget build(BuildContext context) { + return Transform( + alignment: Alignment.center, + origin: Offset(-_panOffset.evaluate(animation).dx, + -_panOffset.evaluate(animation).dy), + transform: Matrix4.identity() + ..translate(_panOffset.evaluate(animation).dx, + _panOffset.evaluate(animation).dy) + ..scale(_zoom.evaluate(animation), _zoom.evaluate(animation)), + child: Transform.rotate( + angle: _rotation.evaluate(animation), + child: widget.child, + ), + ); + } +} + +class DoubleTween extends Tween { + DoubleTween({double begin, double end}) : super(begin: begin, end: end); + + @override + double lerp(double t) => (begin + (end - begin) * t); +} + +class OffsetTween extends Tween { + OffsetTween({Offset begin, Offset end}) : super(begin: begin, end: end); + + @override + Offset lerp(double t) => (begin + (end - begin) * t); +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..c0e2407 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,343 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.2" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.5.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + charcode: + dependency: transitive + description: + name: charcode + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + ffi: + dependency: transitive + description: + name: ffi + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: "direct main" + description: + name: flutter_cache_manager + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: transitive + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.13.3" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.0" + image: + dependency: transitive + description: + name: image + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" + infinite_listview: + dependency: transitive + description: + name: infinite_listview + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.10" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + numberpicker: + dependency: "direct main" + description: + name: numberpicker + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.28" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.1+2" + path_provider_macos: + dependency: transitive + description: + name: path_provider_macos + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.4+8" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.5" + pedantic: + dependency: transitive + description: + name: pedantic + url: "https://pub.dartlang.org" + source: hosted + version: "1.11.0" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.0" + platform: + dependency: transitive + description: + name: platform + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" + process: + dependency: transitive + description: + name: process + url: "https://pub.dartlang.org" + source: hosted + version: "4.2.1" + rxdart: + dependency: transitive + description: + name: rxdart + url: "https://pub.dartlang.org" + source: hosted + version: "0.25.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + sqflite: + dependency: transitive + description: + name: sqflite + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0+2" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + synchronized: + dependency: transitive + description: + name: synchronized + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.19" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + uuid: + dependency: transitive + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.4" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + win32: + dependency: transitive + description: + name: win32 + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.2" + xml: + dependency: transitive + description: + name: xml + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.0" +sdks: + dart: ">=2.12.0 <3.0.0" + flutter: ">=1.24.0-10" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..7fd7544 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,29 @@ +name: flutter_neat_pdf_viewer +description: A flutter plugin for handling PDF files. Works on both Android & iOS +version: 0.0.1 +author: Ralf Weinbrecher +homepage: https://github.com/rwbr/flutter_neat_pdf_viewer + +environment: + sdk: ">=2.7.0 <3.0.0" + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + flutter_cache_manager: ^2.0.0 + numberpicker: ^1.3.0 + path_provider: ^1.6.24 + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + plugin: + platforms: + android: + package: com.rwbr.flutter_neat_pdf_viewer + pluginClass: FlutterNeatPdfViewer + ios: + pluginClass: FlutterNeatPdfViewer diff --git a/test/flutter_neat_pdf_viewer_test.dart b/test/flutter_neat_pdf_viewer_test.dart new file mode 100644 index 0000000..5639556 --- /dev/null +++ b/test/flutter_neat_pdf_viewer_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:flutter_neat_pdf_viewer/flutter_neat_pdf_viewer.dart'; + +void main() { + test('adds one to input values', () { + final calculator = Calculator(); + expect(calculator.addOne(2), 3); + expect(calculator.addOne(-7), -6); + expect(calculator.addOne(0), 1); + }); +}