import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/exception.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/mobile/notification.dart'; import 'package:nc_photos/mobile/platform.dart' if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform; import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/share_handler.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/use_case/remove.dart'; import 'package:nc_photos/widget/animated_visibility.dart'; import 'package:nc_photos/widget/image_viewer.dart'; import 'package:nc_photos/widget/video_viewer.dart'; import 'package:nc_photos/widget/viewer_detail_pane.dart'; class ViewerArguments { ViewerArguments(this.account, this.streamFiles, this.startIndex); final Account account; final List streamFiles; final int startIndex; } class Viewer extends StatefulWidget { static const routeName = "/viewer"; Viewer({ Key key, @required this.account, @required this.streamFiles, @required this.startIndex, }) : super(key: key); Viewer.fromArgs(ViewerArguments args, {Key key}) : this( key: key, account: args.account, streamFiles: args.streamFiles, startIndex: args.startIndex, ); @override createState() => _ViewerState(); final Account account; final List streamFiles; final int startIndex; } class _ViewerState extends State { @override void initState() { super.initState(); _pageController = PageController( initialPage: widget.startIndex, viewportFraction: 1.05, keepPage: false); _pageFocus.requestFocus(); } @override build(BuildContext context) { if (!_hasInit) { _updateNavigationState(widget.startIndex); _hasInit = true; } return AppTheme( child: Scaffold( body: Builder( builder: (context) => platform_k.isWeb ? _buildWebContent(context) : _buildContent(context)), ), ); } @override dispose() { super.dispose(); SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values); } Widget _buildWebContent(BuildContext context) { assert(platform_k.isWeb); // support switching pages with keyboard on web return RawKeyboardListener( onKey: (ev) { if (!_canSwitchPage()) { return; } if (ev.isKeyPressed(LogicalKeyboardKey.arrowLeft)) { _switchToLeftImage(); } else if (ev.isKeyPressed(LogicalKeyboardKey.arrowRight)) { _switchToRightImage(); } }, focusNode: _pageFocus, child: _buildContent(context), ); } Widget _buildContent(BuildContext context) { return GestureDetector( onTap: () { setState(() { _setShowActionBar(!_isShowAppBar); }); }, child: Stack( children: [ Container(color: Colors.black), if (!_pageController.hasClients || !_pageStates[_pageController.page.round()].hasLoaded) Align( alignment: Alignment.center, child: const CircularProgressIndicator(), ), PageView.builder( controller: _pageController, itemCount: widget.streamFiles.length, itemBuilder: _buildPage, physics: !platform_k.isWeb && _canSwitchPage() ? null : const NeverScrollableScrollPhysics(), ), if (platform_k.isWeb) ..._buildNavigationButtons(context), _buildBottomAppBar(context), _buildAppBar(context), ], ), ); } List _buildNavigationButtons(BuildContext context) { return [ if (_canSwitchRight) Align( alignment: Alignment.centerRight, child: Material( type: MaterialType.transparency, child: Visibility( visible: _canSwitchPage(), child: AnimatedOpacity( opacity: _isShowRight ? 1.0 : 0.0, duration: k.animationDurationShort, child: MouseRegion( onEnter: (details) { setState(() { _isShowRight = true; }); }, onExit: (details) { setState(() { _isShowRight = false; }); }, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 24, vertical: 36), child: IconButton( icon: Icon( Icons.arrow_forward_ios_outlined, color: Colors.white, ), onPressed: _switchToRightImage, ), ), ), ), ), ), ), if (_canSwitchLeft) Align( alignment: Alignment.centerLeft, child: Material( type: MaterialType.transparency, child: Visibility( visible: _canSwitchPage(), child: AnimatedOpacity( opacity: _isShowLeft ? 1.0 : 0.0, duration: k.animationDurationShort, child: MouseRegion( onEnter: (details) { setState(() { _isShowLeft = true; }); }, onExit: (details) { setState(() { _isShowLeft = false; }); }, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 24, vertical: 36), child: IconButton( icon: Icon( Icons.arrow_back_ios_outlined, color: Colors.white, ), onPressed: _switchToLeftImage, ), ), ), ), ), ), ), ]; } Widget _buildAppBar(BuildContext context) { return Wrap( children: [ AnimatedVisibility( opacity: _isShowAppBar ? 1.0 : 0.0, duration: k.animationDurationNormal, child: Stack( children: [ Container( // + status bar height height: kToolbarHeight + MediaQuery.of(context).padding.top, decoration: BoxDecoration( gradient: LinearGradient( begin: const Alignment(0, -1), end: const Alignment(0, 1), colors: [ Color.fromARGB(192, 0, 0, 0), Color.fromARGB(0, 0, 0, 0), ], ), ), ), AppBar( backgroundColor: Colors.transparent, shadowColor: Colors.transparent, brightness: Brightness.dark, iconTheme: Theme.of(context).iconTheme.copyWith( color: Colors.white.withOpacity(.87), ), actionsIconTheme: Theme.of(context).iconTheme.copyWith( color: Colors.white.withOpacity(.87), ), actions: [ if (!_isDetailPaneActive && _canOpenDetailPane()) IconButton( icon: const Icon(Icons.more_vert), tooltip: AppLocalizations.of(context).detailsTooltip, onPressed: _onDetailsPressed, ), ], ), ], ), ), ], ); } Widget _buildBottomAppBar(BuildContext context) { return Align( alignment: Alignment.bottomCenter, child: Material( type: MaterialType.transparency, child: AnimatedVisibility( opacity: (_isShowAppBar && !_isDetailPaneActive) ? 1.0 : 0.0, duration: !_isDetailPaneActive ? k.animationDurationNormal : const Duration(milliseconds: 1), child: Container( height: kToolbarHeight, alignment: Alignment.center, decoration: BoxDecoration( gradient: LinearGradient( begin: const Alignment(0, -1), end: const Alignment(0, 1), colors: [ Color.fromARGB(0, 0, 0, 0), Color.fromARGB(192, 0, 0, 0), ], ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.max, children: [ if (platform_k.isAndroid) Expanded( flex: 1, child: IconButton( icon: Icon( Icons.share_outlined, color: Colors.white.withOpacity(.87), ), tooltip: AppLocalizations.of(context).shareTooltip, onPressed: () => _onSharePressed(context), ), ), Expanded( flex: 1, child: IconButton( icon: Icon( Icons.download_outlined, color: Colors.white.withOpacity(.87), ), tooltip: AppLocalizations.of(context).downloadTooltip, onPressed: () => _onDownloadPressed(context), ), ), Expanded( flex: 1, child: IconButton( icon: Icon( Icons.delete_outlined, color: Colors.white.withOpacity(.87), ), tooltip: AppLocalizations.of(context).deleteTooltip, onPressed: () => _onDeletePressed(context), ), ), ], ), ), ), ), ); } Widget _buildPage(BuildContext context, int index) { if (_pageStates[index] == null) { _onCreateNewPage(context, index); } else if (!_pageStates[index].scrollController.hasClients) { // the page has been moved out of view and is now coming back _log.fine("[_buildPage] Recreating page#$index"); _onRecreatePageAfterMovedOut(context, index); } if (kDebugMode) { _log.info("[_buildPage] $index"); } return FractionallySizedBox( widthFactor: 1 / _pageController.viewportFraction, child: NotificationListener( onNotification: (notif) => _onPageContentScrolled(notif, index), child: SingleChildScrollView( controller: _pageStates[index].scrollController, physics: _isDetailPaneActive ? null : const NeverScrollableScrollPhysics(), child: Stack( children: [ _buildItemView(context, index), Visibility( visible: _isDetailPaneActive, child: AnimatedOpacity( opacity: _isShowDetailPane ? 1 : 0, duration: k.animationDurationNormal, onEnd: () { if (!_isShowDetailPane) { setState(() { _isDetailPaneActive = false; }); } }, child: Container( alignment: Alignment.topLeft, constraints: BoxConstraints( minHeight: MediaQuery.of(context).size.height), decoration: BoxDecoration( color: Theme.of(context).scaffoldBackgroundColor, borderRadius: const BorderRadius.vertical( top: const Radius.circular(4)), ), margin: EdgeInsets.only(top: _calcDetailPaneOffset(index)), child: ViewerDetailPane( account: widget.account, file: widget.streamFiles[index], ), ), ), ), ], ), ), ), ); } Widget _buildItemView(BuildContext context, int index) { final file = widget.streamFiles[index]; if (file_util.isSupportedImageFormat(file)) { return _buildImageView(context, index); } else if (file_util.isSupportedVideoFormat(file)) { return _buildVideoView(context, index); } else { _log.shout("[_buildItemView] Unknown file format: ${file.contentType}"); _pageStates[index].itemHeight = 0; return Container(); } } Widget _buildImageView(BuildContext context, int index) { return ImageViewer( account: widget.account, file: widget.streamFiles[index], canZoom: _canZoom(), onLoaded: () => _onImageLoaded(index), onHeightChanged: (height) => _updateItemHeight(index, height), onZoomStarted: () { setState(() { _isZoomed = true; }); }, onZoomEnded: () { setState(() { _isZoomed = false; }); }, ); } Widget _buildVideoView(BuildContext context, int index) { return VideoViewer( account: widget.account, file: widget.streamFiles[index], onLoaded: () => _onVideoLoaded(index), onHeightChanged: (height) => _updateItemHeight(index, height), onPlay: _onVideoPlay, onPause: _onVideoPause, isControlVisible: _isShowAppBar && !_isDetailPaneActive, canPlay: !_isDetailPaneActive, ); } bool _onPageContentScrolled(ScrollNotification notification, int index) { if (!_canOpenDetailPane()) { return false; } if (notification is ScrollEndNotification) { final scrollPos = _pageStates[index].scrollController.position; if (scrollPos.pixels == 0) { setState(() { _onDetailPaneClosed(); }); } else if (scrollPos.pixels < _calcDetailPaneOpenedScrollPosition(index) - 1) { if (scrollPos.userScrollDirection == ScrollDirection.reverse) { // upward, open the pane to its minimal size Future.delayed(Duration.zero, () { setState(() { _openDetailPane(_pageController.page.toInt(), shouldAnimate: true); }); }); } else if (scrollPos.userScrollDirection == ScrollDirection.forward) { // downward, close the pane Future.delayed(Duration.zero, () { _closeDetailPane(_pageController.page.toInt(), shouldAnimate: true); }); } } } return false; } void _onImageLoaded(int index) { // currently pageview doesn't pre-load pages, we do it manually // don't pre-load if user already navigated away if (_pageController.page.round() == index && !_pageStates[index].hasLoaded) { _log.info("[_onImageLoaded] Pre-loading nearby images"); if (index > 0) { final prevFile = widget.streamFiles[index - 1]; if (file_util.isSupportedImageFormat(prevFile)) { ImageViewer.preloadImage(widget.account, prevFile); } } if (index + 1 < widget.streamFiles.length) { final nextFile = widget.streamFiles[index + 1]; if (file_util.isSupportedImageFormat(nextFile)) { ImageViewer.preloadImage(widget.account, nextFile); } } setState(() { _pageStates[index].hasLoaded = true; }); } } void _onVideoLoaded(int index) { if (_pageController.page.round() == index && !_pageStates[index].hasLoaded) { setState(() { _pageStates[index].hasLoaded = true; }); } } void _onVideoPlay() { setState(() { _setShowActionBar(false); }); } void _onVideoPause() { setState(() { _setShowActionBar(true); }); } /// Called when the page is being built for the first time void _onCreateNewPage(BuildContext context, int index) { _pageStates[index] = _PageState(ScrollController( initialScrollOffset: _isShowDetailPane && !_isClosingDetailPane ? _calcDetailPaneOpenedScrollPosition(index) : 0)); } /// Called when the page is being built after previously moved out of view void _onRecreatePageAfterMovedOut(BuildContext context, int index) { if (_isShowDetailPane && !_isClosingDetailPane) { WidgetsBinding.instance.addPostFrameCallback((_) { if (_pageStates[index].itemHeight != null) { setState(() { _openDetailPane(index); }); } }); } else { WidgetsBinding.instance.addPostFrameCallback((_) { _pageStates[index].scrollController.jumpTo(0); }); } } void _onDetailsPressed() { if (!_isDetailPaneActive) { setState(() { _openDetailPane(_pageController.page.toInt(), shouldAnimate: true); }); } } void _onSharePressed(BuildContext context) { assert(platform_k.isAndroid); final file = widget.streamFiles[_pageController.page.round()]; ShareHandler().shareFiles(context, widget.account, [file]); } void _onDownloadPressed(BuildContext context) async { final file = widget.streamFiles[_pageController.page.round()]; _log.info("[_onDownloadPressed] Downloading file: ${file.path}"); var controller = SnackBarManager().showSnackBar(SnackBar( content: Text(AppLocalizations.of(context).downloadProcessingNotification), duration: k.snackBarDurationShort, )); controller?.closed?.whenComplete(() { controller = null; }); dynamic result; try { result = await platform.Downloader().downloadFile(widget.account, file); controller?.close(); } on PermissionException catch (_) { _log.warning("[_onDownloadPressed] Permission not granted"); controller?.close(); SnackBarManager().showSnackBar(SnackBar( content: Text(AppLocalizations.of(context) .downloadFailureNoPermissionNotification), duration: k.snackBarDurationNormal, )); return; } catch (e, stacktrace) { _log.shout( "[_onDownloadPressed] Failed while downloadFile", e, stacktrace); controller?.close(); SnackBarManager().showSnackBar(SnackBar( content: Text("${AppLocalizations.of(context).downloadFailureNotification}: " "${exception_util.toUserString(e, context)}"), duration: k.snackBarDurationNormal, )); return; } _onDownloadSuccessful(file, result); } void _onDownloadSuccessful(File file, dynamic result) { var notif; if (platform_k.isAndroid) { notif = AndroidItemDownloadSuccessfulNotification( [result], [file.contentType]); } if (notif != null) { try { notif.notify(); return; } catch (e, stacktrace) { _log.shout( "[_onDownloadSuccessful] Failed showing platform notification", e, stacktrace); } } // fallback SnackBarManager().showSnackBar(SnackBar( content: Text(AppLocalizations.of(context).downloadSuccessNotification), duration: k.snackBarDurationShort, )); } void _onDeletePressed(BuildContext context) async { final file = widget.streamFiles[_pageController.page.round()]; _log.info("[_onDeletePressed] Removing file: ${file.path}"); var controller = SnackBarManager().showSnackBar(SnackBar( content: Text(AppLocalizations.of(context).deleteProcessingNotification), duration: k.snackBarDurationShort, )); controller?.closed?.whenComplete(() { controller = null; }); try { await Remove(FileRepo(FileCachedDataSource()), AlbumRepo(AlbumCachedDataSource()))(widget.account, file); controller?.close(); SnackBarManager().showSnackBar(SnackBar( content: Text(AppLocalizations.of(context).deleteSuccessNotification), duration: k.snackBarDurationNormal, )); Navigator.of(context).pop(); } catch (e, stacktrace) { _log.shout( "[_onDeletePressed] Failed while remove" + (kDebugMode ? ": ${file.path}" : ""), e, stacktrace); controller?.close(); SnackBarManager().showSnackBar(SnackBar( content: Text("${AppLocalizations.of(context).deleteFailureNotification}: " "${exception_util.toUserString(e, context)}"), duration: k.snackBarDurationNormal, )); } } double _calcDetailPaneOffset(int index) { if (_pageStates[index]?.itemHeight == null) { return MediaQuery.of(context).size.height; } else { return _pageStates[index].itemHeight + (MediaQuery.of(context).size.height - _pageStates[index].itemHeight) / 2 - 4; } } double _calcDetailPaneOpenedScrollPosition(int index) { // distance of the detail pane from the top edge const distanceFromTop = 196; return max(_calcDetailPaneOffset(index) - distanceFromTop, 0); } void _updateItemHeight(int index, double height) { if (_pageStates[index].itemHeight != height) { _log.fine("[_updateItemHeight] New height of item#$index: $height"); setState(() { _pageStates[index].itemHeight = height; if (_isDetailPaneActive) { _openDetailPane(index); } }); } } void _setShowActionBar(bool flag) { _isShowAppBar = flag; if (flag) { SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values); } else { SystemChrome.setEnabledSystemUIOverlays([]); } } void _openDetailPane(int index, {bool shouldAnimate = false}) { if (!_canOpenDetailPane()) { _log.warning("[_openDetailPane] Can't open detail pane right now"); return; } _isShowDetailPane = true; _isDetailPaneActive = true; if (shouldAnimate) { _pageStates[index].scrollController.animateTo( _calcDetailPaneOpenedScrollPosition(index), duration: k.animationDurationNormal, curve: Curves.easeOut); } else { _pageStates[index] .scrollController .jumpTo(_calcDetailPaneOpenedScrollPosition(index)); } } void _closeDetailPane(int index, {bool shouldAnimate = false}) { _isClosingDetailPane = true; if (shouldAnimate) { _pageStates[index].scrollController.animateTo(0, duration: k.animationDurationNormal, curve: Curves.easeOut); } } void _onDetailPaneClosed() { _isShowDetailPane = false; _isClosingDetailPane = false; } /// Switch to the previous image in the stream void _switchToPrevImage() { _pageController .previousPage( duration: k.animationDurationNormal, curve: Curves.easeInOut) .whenComplete( () => _updateNavigationState(_pageController.page.round())); } /// Switch to the next image in the stream void _switchToNextImage() { _pageController .nextPage(duration: k.animationDurationNormal, curve: Curves.easeInOut) .whenComplete( () => _updateNavigationState(_pageController.page.round())); } /// Switch to the image on the "left", what that means depend on the current /// text direction void _switchToLeftImage() { if (Directionality.of(context) == TextDirection.ltr) { _switchToPrevImage(); } else { _switchToNextImage(); } } /// Switch to the image on the "right", what that means depend on the current /// text direction void _switchToRightImage() { if (Directionality.of(context) == TextDirection.ltr) { _switchToNextImage(); } else { _switchToPrevImage(); } } /// Update the navigation state for [page] void _updateNavigationState(int page) { // currently useless to run on non-web platform if (!platform_k.isWeb) { return; } final hasNext = page < widget.streamFiles.length - 1; final hasPrev = page > 0; final hasLeft = Directionality.of(context) == TextDirection.ltr ? hasPrev : hasNext; if (_canSwitchLeft != hasLeft) { setState(() { _canSwitchLeft = hasLeft; if (!_canSwitchLeft) { _isShowLeft = false; } }); } final hasRight = Directionality.of(context) == TextDirection.ltr ? hasNext : hasPrev; if (_canSwitchRight != hasRight) { setState(() { _canSwitchRight = hasRight; if (!_canSwitchRight) { _isShowRight = false; } }); } } bool _canSwitchPage() => !_isZoomed; bool _canOpenDetailPane() => !_isZoomed; bool _canZoom() => !_isDetailPaneActive; var _hasInit = false; var _isShowAppBar = true; var _isShowDetailPane = false; var _isDetailPaneActive = false; var _isClosingDetailPane = false; var _canSwitchRight = true; var _canSwitchLeft = true; var _isShowRight = false; var _isShowLeft = false; var _isZoomed = false; PageController _pageController; final _pageStates = {}; /// used to gain focus on web for keyboard support final _pageFocus = FocusNode(); static final _log = Logger("widget.viewer._ViewerState"); } class _PageState { _PageState(this.scrollController); ScrollController scrollController; double itemHeight; bool hasLoaded = false; }