diff --git a/lib/shared/image_viewer.dart b/lib/shared/image_viewer.dart index 0f14537ca..9e77dba33 100644 --- a/lib/shared/image_viewer.dart +++ b/lib/shared/image_viewer.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:extended_image/extended_image.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:gal/gal.dart'; import 'package:share_plus/share_plus.dart'; @@ -161,377 +162,420 @@ class _ImageViewerState extends State with TickerProviderStateMixin Animation? animation; return Stack( children: [ + AppBar( + backgroundColor: Colors.transparent, + systemOverlayStyle: const SystemUiOverlayStyle( + // Forcing status bar to display bright icons even in light mode + statusBarIconBrightness: Brightness.light, // For Android (dark icons) + statusBarBrightness: Brightness.dark, // For iOS (dark icons) + ), + ), AnimatedContainer( duration: const Duration(milliseconds: 400), - color: fullscreen ? Colors.black : Colors.transparent, + color: fullscreen ? Colors.black : Colors.black.withOpacity(slideTransparency), ), - Scaffold( - appBar: AppBar( - iconTheme: IconThemeData( - color: fullscreen ? Colors.transparent : Colors.white, - shadows: fullscreen ? null : [const Shadow(color: Colors.black, blurRadius: 50.0)], - ), - backgroundColor: Colors.transparent, - toolbarHeight: 70.0, - ), - backgroundColor: Colors.black.withOpacity(slideTransparency), - body: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: GestureDetector( - onLongPress: () { - HapticFeedback.lightImpact(); + Positioned.fill( + child: GestureDetector( + onLongPress: () { + HapticFeedback.lightImpact(); + setState(() { + fullscreen = !fullscreen; + }); + }, + onTap: () { + if (!fullscreen) { + slidePagekey.currentState!.popPage(); + Navigator.pop(context); + } else { + setState(() { + fullscreen = false; + }); + } + }, + // Start doubletap zoom if conditions are met + onVerticalDragStart: maybeSlideZooming + ? (details) { + setState(() { + slideZooming = true; + }); + } + : null, + // Zoom image in an out based on movement in vertical axis if conditions are met + onVerticalDragUpdate: maybeSlideZooming || slideZooming + ? (details) { + // Need to catch the drag during "maybe" phase or it wont activate fast enough + if (slideZooming) { + double newScale = max(gestureKey.currentState!.gestureDetails!.totalScale! * (1 + (details.delta.dy / 150)), 1); + gestureKey.currentState?.handleDoubleTap(scale: newScale, doubleTapPosition: gestureKey.currentState!.pointerDownPosition); + } + } + : null, + // End doubletap zoom + onVerticalDragEnd: slideZooming + ? (details) { + setState(() { + slideZooming = false; + }); + } + : null, + child: Listener( + // Start watching for double tap zoom + onPointerDown: (details) { + downCoord = details.position; + }, + onPointerUp: (details) { + delta = (downCoord - details.position).distance; + if (!slideZooming && delta < 0.5) { + _maybeSlide(context); + } + }, + child: ExtendedImageSlidePage( + key: slidePagekey, + slideAxis: SlideAxis.both, + slideType: SlideType.onlyImage, + slidePageBackgroundHandler: (offset, pageSize) { + return Colors.transparent; + }, + onSlidingPage: (state) { + // Fade out image and background when sliding to dismiss + var offset = state.offset; + var pageSize = state.pageSize; + + var scale = offset.distance / Offset(pageSize.width, pageSize.height).distance; + + if (state.isSliding) { setState(() { - fullscreen = !fullscreen; + slideTransparency = 0.9 - min(0.9, scale * 0.5); + imageTransparency = 1.0 - min(1.0, scale * 10); }); + } + }, + slideEndHandler: ( + // Decrease slide to dismiss threshold so it can be done easier + Offset offset, { + ExtendedImageSlidePageState? state, + ScaleEndDetails? details, + }) { + if (state != null) { + var offset = state.offset; + var pageSize = state.pageSize; + return offset.distance.greaterThan(Offset(pageSize.width, pageSize.height).distance / 10); + } + return true; + }, + child: widget.url != null + ? ExtendedImage.network( + widget.url!, + color: Colors.white.withOpacity(imageTransparency), + colorBlendMode: BlendMode.dstIn, + enableSlideOutPage: true, + mode: ExtendedImageMode.gesture, + extendedImageGestureKey: gestureKey, + cache: true, + clearMemoryCacheWhenDispose: thunderState.imageCachingMode == ImageCachingMode.relaxed, + layoutInsets: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 50, top: MediaQuery.of(context).padding.top + 50), + initGestureConfigHandler: (ExtendedImageState state) { + return GestureConfig( + minScale: 0.8, + animationMinScale: 0.8, + maxScale: maxZoomLevel.toDouble(), + animationMaxScale: maxZoomLevel.toDouble(), + speed: 1.0, + inertialSpeed: 250.0, + initialScale: 1.0, + inPageView: false, + initialAlignment: InitialAlignment.center, + reverseMousePointerScrollDirection: true, + gestureDetailsIsChanged: (GestureDetails? details) {}, + ); }, - onTap: () { - if (!fullscreen) { - slidePagekey.currentState!.popPage(); - Navigator.pop(context); + onDoubleTap: (ExtendedImageGestureState state) { + var pointerDownPosition = state.pointerDownPosition; + double begin = state.gestureDetails!.totalScale!; + double end; + + animation?.removeListener(animationListener); + animationController.stop(); + animationController.reset(); + + if (begin == 1) { + end = 2; + } else if (begin > 1.99 && begin < 2.01) { + end = 4; } else { - setState(() { - fullscreen = false; - }); + end = 1; + } + animationListener = () { + state.handleDoubleTap(scale: animation!.value, doubleTapPosition: pointerDownPosition); + }; + animation = animationController.drive(Tween(begin: begin, end: end)); + + animation!.addListener(animationListener); + + animationController.forward(); + }, + loadStateChanged: (state) { + if (state.extendedImageLoadState == LoadState.loading) { + return Center( + child: CircularProgressIndicator( + color: Colors.white.withOpacity(0.90), + ), + ); } + return null; }, - // Start doubletap zoom if conditions are met - onVerticalDragStart: maybeSlideZooming - ? (details) { - setState(() { - slideZooming = true; - }); - } - : null, - // Zoom image in an out based on movement in vertical axis if conditions are met - onVerticalDragUpdate: maybeSlideZooming || slideZooming - ? (details) { - // Need to catch the drag during "maybe" phase or it wont activate fast enough - if (slideZooming) { - double newScale = max(gestureKey.currentState!.gestureDetails!.totalScale! * (1 + (details.delta.dy / 150)), 1); - gestureKey.currentState?.handleDoubleTap(scale: newScale, doubleTapPosition: gestureKey.currentState!.pointerDownPosition); - } - } - : null, - // End doubltap zoom - onVerticalDragEnd: slideZooming - ? (details) { - setState(() { - slideZooming = false; - }); - } - : null, - child: Listener( - // Start watching for double tap zoom - onPointerDown: (details) { - downCoord = details.position; - }, - onPointerUp: (details) { - delta = (downCoord - details.position).distance; - if (!slideZooming && delta < 0.5) { - _maybeSlide(context); - } - }, - child: ExtendedImageSlidePage( - key: slidePagekey, - slideAxis: SlideAxis.both, - slideType: SlideType.onlyImage, - slidePageBackgroundHandler: (offset, pageSize) { - return Colors.transparent; - }, - onSlidingPage: (state) { - // Fade out image and background when sliding to dismiss - var offset = state.offset; - var pageSize = state.pageSize; - - var scale = offset.distance / Offset(pageSize.width, pageSize.height).distance; - - if (state.isSliding) { - setState(() { - slideTransparency = 0.9 - min(0.9, scale * 0.5); - imageTransparency = 1.0 - min(1.0, scale * 10); - }); - } - }, - slideEndHandler: ( - // Decrease slide to dismiss threshold so it can be done easier - Offset offset, { - ExtendedImageSlidePageState? state, - ScaleEndDetails? details, - }) { - if (state != null) { - var offset = state.offset; - var pageSize = state.pageSize; - return offset.distance.greaterThan(Offset(pageSize.width, pageSize.height).distance / 10); - } - return true; - }, - child: widget.url != null - ? ExtendedImage.network( - widget.url!, - color: Colors.white.withOpacity(imageTransparency), - colorBlendMode: BlendMode.dstIn, - enableSlideOutPage: true, - mode: ExtendedImageMode.gesture, - extendedImageGestureKey: gestureKey, - cache: true, - clearMemoryCacheWhenDispose: thunderState.imageCachingMode == ImageCachingMode.relaxed, - initGestureConfigHandler: (ExtendedImageState state) { - return GestureConfig( - minScale: 0.8, - animationMinScale: 0.8, - maxScale: maxZoomLevel.toDouble(), - animationMaxScale: maxZoomLevel.toDouble(), - speed: 1.0, - inertialSpeed: 250.0, - initialScale: 1.0, - inPageView: false, - initialAlignment: InitialAlignment.center, - reverseMousePointerScrollDirection: true, - gestureDetailsIsChanged: (GestureDetails? details) {}, - ); - }, - onDoubleTap: (ExtendedImageGestureState state) { - var pointerDownPosition = state.pointerDownPosition; - double begin = state.gestureDetails!.totalScale!; - double end; - - animation?.removeListener(animationListener); - animationController.stop(); - animationController.reset(); - - if (begin == 1) { - end = 2; - } else if (begin > 1.99 && begin < 2.01) { - end = 4; - } else { - end = 1; - } - animationListener = () { - state.handleDoubleTap(scale: animation!.value, doubleTapPosition: pointerDownPosition); - }; - animation = animationController.drive(Tween(begin: begin, end: end)); - - animation!.addListener(animationListener); - - animationController.forward(); - }, - loadStateChanged: (state) { - if (state.extendedImageLoadState == LoadState.loading) { - return Center( - child: CircularProgressIndicator( - color: Colors.white.withOpacity(0.90), - ), - ); - } - return null; - }, - ) - : ExtendedImage.memory( - widget.bytes!, - color: Colors.white.withOpacity(imageTransparency), - colorBlendMode: BlendMode.dstIn, - enableSlideOutPage: true, - mode: ExtendedImageMode.gesture, - extendedImageGestureKey: gestureKey, - clearMemoryCacheWhenDispose: true, - initGestureConfigHandler: (ExtendedImageState state) { - return GestureConfig( - minScale: 0.8, - animationMinScale: 0.8, - maxScale: 4.0, - animationMaxScale: 4.0, - speed: 1.0, - inertialSpeed: 250.0, - initialScale: 1.0, - inPageView: false, - initialAlignment: InitialAlignment.center, - reverseMousePointerScrollDirection: true, - gestureDetailsIsChanged: (GestureDetails? details) {}, - ); - }, - onDoubleTap: (ExtendedImageGestureState state) { - var pointerDownPosition = state.pointerDownPosition; - double begin = state.gestureDetails!.totalScale!; - double end; - - animation?.removeListener(animationListener); - animationController.stop(); - animationController.reset(); - - if (begin == 1) { - end = 2; - } else if (begin > 1.99 && begin < 2.01) { - end = 4; - } else { - end = 1; - } - animationListener = () { - state.handleDoubleTap(scale: animation!.value, doubleTapPosition: pointerDownPosition); - }; - animation = animationController.drive(Tween(begin: begin, end: end)); - - animation!.addListener(animationListener); - - animationController.forward(); - }, - loadStateChanged: (state) { - if (state.extendedImageLoadState == LoadState.loading) { - return Center( - child: CircularProgressIndicator( - color: Colors.white.withOpacity(0.90), - ), - ); - } - return null; - }, - ), + ) + : ExtendedImage.memory( + widget.bytes!, + color: Colors.white.withOpacity(imageTransparency), + colorBlendMode: BlendMode.dstIn, + enableSlideOutPage: true, + mode: ExtendedImageMode.gesture, + extendedImageGestureKey: gestureKey, + clearMemoryCacheWhenDispose: true, + initGestureConfigHandler: (ExtendedImageState state) { + return GestureConfig( + minScale: 0.8, + animationMinScale: 0.8, + maxScale: 4.0, + animationMaxScale: 4.0, + speed: 1.0, + inertialSpeed: 250.0, + initialScale: 1.0, + inPageView: false, + initialAlignment: InitialAlignment.center, + reverseMousePointerScrollDirection: true, + gestureDetailsIsChanged: (GestureDetails? details) {}, + ); + }, + onDoubleTap: (ExtendedImageGestureState state) { + var pointerDownPosition = state.pointerDownPosition; + double begin = state.gestureDetails!.totalScale!; + double end; + + animation?.removeListener(animationListener); + animationController.stop(); + animationController.reset(); + + if (begin == 1) { + end = 2; + } else if (begin > 1.99 && begin < 2.01) { + end = 4; + } else { + end = 1; + } + animationListener = () { + state.handleDoubleTap(scale: animation!.value, doubleTapPosition: pointerDownPosition); + }; + animation = animationController.drive(Tween(begin: begin, end: end)); + + animation!.addListener(animationListener); + + animationController.forward(); + }, + loadStateChanged: (state) { + if (state.extendedImageLoadState == LoadState.loading) { + return Center( + child: CircularProgressIndicator( + color: Colors.white.withOpacity(0.90), + ), + ); + } + return null; + }, + ), + ), + ), + ), + ), + if (!widget.isPeek) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AnimatedOpacity( + opacity: fullscreen ? 0.0 : 1.0, + duration: const Duration(milliseconds: 200), + child: Container( + padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + stops: [0, 0.3, 1], + colors: [ + Colors.transparent, + Colors.black26, + Colors.black45, + ], ), ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(4.0), + child: IconButton( + onPressed: () { + Navigator.pop(context); + }, + icon: Icon( + Icons.arrow_back, + semanticLabel: "Back", + color: Colors.white.withOpacity(0.90), + ), + ), + ), + ], + ), ), ), - if (!widget.isPeek) - AnimatedOpacity( - opacity: fullscreen ? 0.0 : 1.0, - duration: const Duration(milliseconds: 200), - child: Container( - decoration: const BoxDecoration(color: Colors.transparent), - padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (widget.url != null) - Padding( - padding: const EdgeInsets.all(4.0), - child: IconButton( - onPressed: fullscreen - ? null - : () async { - try { - // Try to get the cached image first - var media = await DefaultCacheManager().getFileFromCache(widget.url!); - File? mediaFile = media?.file; - - if (media == null) { - setState(() => isDownloadingMedia = true); - - // Download - mediaFile = await DefaultCacheManager().getSingleFile(widget.url!); - } - - // Share - await Share.shareXFiles([XFile(mediaFile!.path)]); - } catch (e) { - // Tell the user that the download failed - showSnackbar(l10n.errorDownloadingMedia(e)); - } finally { - setState(() => isDownloadingMedia = false); - } - }, - icon: isDownloadingMedia - ? SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - color: Colors.white.withOpacity(0.90), - ), - ) - : Icon( - Icons.share_rounded, - semanticLabel: "Share", - color: Colors.white.withOpacity(0.90), - shadows: const [Shadow(color: Colors.black, blurRadius: 50.0)], - ), - ), - ), - if (widget.url != null) - Padding( - padding: const EdgeInsets.all(4.0), - child: IconButton( - onPressed: (fullscreen || widget.url == null || kIsWeb) - ? null - : () async { - File file = await DefaultCacheManager().getSingleFile(widget.url!); - bool hasPermission = await _requestPermission(); - - if (!hasPermission) { - if (context.mounted) showPermissionDeniedDialog(context); - return; - } - - setState(() => isSavingMedia = true); - - try { - // Save image on Linux platform - if (Platform.isLinux) { - final filePath = '${(await getApplicationDocumentsDirectory()).path}/Thunder/${basename(file.path)}'; - - File(filePath) - ..createSync(recursive: true) - ..writeAsBytesSync(file.readAsBytesSync()); - - return setState(() => downloaded = true); - } - - // Save image on all other supported platforms (Android, iOS, macOS, Windows) - try { - await Gal.putImage(file.path, album: "Thunder"); - setState(() => downloaded = true); - } on GalException catch (e) { - if (context.mounted) showSnackbar(e.type.message); - setState(() => downloaded = false); - } - } finally { - setState(() => isSavingMedia = false); - } - }, - icon: isSavingMedia - ? SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - color: Colors.white.withOpacity(0.90), - ), - ) - : downloaded - ? const Icon( - Icons.check_circle, - semanticLabel: 'Downloaded', - color: Colors.white, - shadows: [Shadow(color: Colors.black45, blurRadius: 50.0)], - ) - : Icon( - Icons.download, - semanticLabel: "Download", - color: Colors.white.withOpacity(0.90), - shadows: const [Shadow(color: Colors.black, blurRadius: 50.0)], - ), + const Spacer(), + AnimatedOpacity( + opacity: fullscreen ? 0.0 : 1.0, + duration: const Duration(milliseconds: 200), + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0, 0.3, 1], + colors: [ + Colors.transparent, + Colors.black26, + Colors.black45, + ], + ), + ), + padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (widget.url != null) + Padding( + padding: const EdgeInsets.all(4.0), + child: IconButton( + onPressed: fullscreen + ? null + : () async { + try { + // Try to get the cached image first + var media = await DefaultCacheManager().getFileFromCache(widget.url!); + File? mediaFile = media?.file; + + if (media == null) { + setState(() => isDownloadingMedia = true); + + // Download + mediaFile = await DefaultCacheManager().getSingleFile(widget.url!); + } + + // Share + await Share.shareXFiles([XFile(mediaFile!.path)]); + } catch (e) { + // Tell the user that the download failed + showSnackbar(l10n.errorDownloadingMedia(e)); + } finally { + setState(() => isDownloadingMedia = false); + } + }, + icon: isDownloadingMedia + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: Colors.white.withOpacity(0.90), + ), + ) + : Icon( + Icons.share_rounded, + semanticLabel: "Share", + color: Colors.white.withOpacity(0.90), ), ), - if (widget.navigateToPost != null) - Padding( - padding: const EdgeInsets.all(4.0), - child: IconButton( - onPressed: () { - Navigator.pop(context); - widget.navigateToPost!(); - }, - icon: Icon( - Icons.chat_rounded, - semanticLabel: "Comments", + ), + if (widget.url != null) + Padding( + padding: const EdgeInsets.all(4.0), + child: IconButton( + onPressed: (downloaded || isSavingMedia || fullscreen || widget.url == null || kIsWeb) + ? null + : () async { + File file = await DefaultCacheManager().getSingleFile(widget.url!); + bool hasPermission = await _requestPermission(); + + if (!hasPermission) { + if (context.mounted) showPermissionDeniedDialog(context); + return; + } + + setState(() => isSavingMedia = true); + + try { + // Save image on Linux platform + if (Platform.isLinux) { + final filePath = '${(await getApplicationDocumentsDirectory()).path}/Thunder/${basename(file.path)}'; + + File(filePath) + ..createSync(recursive: true) + ..writeAsBytesSync(file.readAsBytesSync()); + + return setState(() => downloaded = true); + } + + // Save image on all other supported platforms (Android, iOS, macOS, Windows) + try { + await Gal.putImage(file.path, album: "Thunder"); + setState(() => downloaded = true); + } on GalException catch (e) { + if (context.mounted) showSnackbar(e.type.message); + setState(() => downloaded = false); + } + } finally { + setState(() => isSavingMedia = false); + } + }, + icon: isSavingMedia + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( color: Colors.white.withOpacity(0.90), - shadows: const [Shadow(color: Colors.black, blurRadius: 50.0)], ), + ) + : downloaded + ? Icon( + Icons.check_circle, + semanticLabel: 'Downloaded', + color: Colors.white.withOpacity(0.90), + ) + : Icon( + Icons.download, + semanticLabel: "Download", + color: Colors.white.withOpacity(0.90), ), ), - ], - ), + ), + if (widget.navigateToPost != null) + Padding( + padding: const EdgeInsets.all(4.0), + child: IconButton( + onPressed: () { + Navigator.pop(context); + widget.navigateToPost!(); + }, + icon: Icon( + Icons.chat_rounded, + semanticLabel: "Comments", + color: Colors.white.withOpacity(0.90), + ), + ), + ), + ], ), ), + ), ], ), - ), if (widget.altText?.isNotEmpty == true) Positioned( bottom: kBottomNavigationBarHeight + 25, @@ -669,3 +713,4 @@ class ImageAltText extends StatelessWidget { ); } } +