nc-photos/lib/widget/viewer.dart

631 lines
20 KiB
Dart
Raw Normal View History

2021-04-10 06:28:12 +02:00
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:logging/logging.dart';
import 'package:nc_photos/account.dart';
2021-07-25 07:00:38 +02:00
import 'package:nc_photos/app_localizations.dart';
2021-04-10 06:28:12 +02:00
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/file.dart';
2021-05-24 09:09:25 +02:00
import 'package:nc_photos/entity/file/data_source.dart';
2021-05-06 13:36:20 +02:00
import 'package:nc_photos/entity/file_util.dart' as file_util;
2021-04-10 06:28:12 +02:00
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';
2021-04-10 06:28:12 +02:00
import 'package:nc_photos/mobile/platform.dart'
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
2021-04-29 17:42:44 +02:00
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/share_handler.dart';
2021-04-10 06:28:12 +02:00
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/remove.dart';
2021-05-05 10:45:56 +02:00
import 'package:nc_photos/widget/animated_visibility.dart';
2021-07-31 17:38:26 +02:00
import 'package:nc_photos/widget/horizontal_page_viewer.dart';
2021-05-02 16:18:26 +02:00
import 'package:nc_photos/widget/image_viewer.dart';
2021-05-06 13:36:20 +02:00
import 'package:nc_photos/widget/video_viewer.dart';
import 'package:nc_photos/widget/viewer_bottom_app_bar.dart';
2021-04-10 06:28:12 +02:00
import 'package:nc_photos/widget/viewer_detail_pane.dart';
class ViewerArguments {
ViewerArguments(this.account, this.streamFiles, this.startIndex);
final Account account;
final List<File> streamFiles;
final int startIndex;
}
class Viewer extends StatefulWidget {
static const routeName = "/viewer";
2021-07-23 22:05:57 +02:00
static Route buildRoute(ViewerArguments args) => MaterialPageRoute(
builder: (context) => Viewer.fromArgs(args),
);
2021-04-10 06:28:12 +02:00
Viewer({
2021-07-23 22:05:57 +02:00
Key? key,
required this.account,
required this.streamFiles,
required this.startIndex,
2021-04-10 06:28:12 +02:00
}) : super(key: key);
2021-07-23 22:05:57 +02:00
Viewer.fromArgs(ViewerArguments args, {Key? key})
2021-04-10 06:28:12 +02:00
: this(
key: key,
account: args.account,
streamFiles: args.streamFiles,
startIndex: args.startIndex,
);
@override
createState() => _ViewerState();
final Account account;
final List<File> streamFiles;
final int startIndex;
}
2021-05-02 16:18:26 +02:00
class _ViewerState extends State<Viewer> {
2021-04-10 06:28:12 +02:00
@override
build(BuildContext context) {
return AppTheme(
child: Scaffold(
2021-04-21 07:22:10 +02:00
body: Builder(
2021-07-31 17:38:26 +02:00
builder: _buildContent,
),
2021-04-10 06:28:12 +02:00
),
);
}
@override
dispose() {
super.dispose();
SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
}
2021-04-10 06:28:12 +02:00
Widget _buildContent(BuildContext context) {
2021-05-02 16:18:26 +02:00
return GestureDetector(
onTap: () {
setState(() {
_setShowActionBar(!_isShowAppBar);
});
2021-04-10 06:28:12 +02:00
},
2021-05-02 16:18:26 +02:00
child: Stack(
children: [
Container(color: Colors.black),
2021-07-31 17:38:26 +02:00
if (!_isViewerLoaded ||
!_pageStates[_viewerController.currentPage]!.hasLoaded)
2021-05-02 16:18:26 +02:00
Align(
alignment: Alignment.center,
child: const CircularProgressIndicator(),
2021-04-10 06:28:12 +02:00
),
2021-07-31 17:38:26 +02:00
HorizontalPageViewer(
pageCount: widget.streamFiles.length,
pageBuilder: _buildPage,
initialPage: widget.startIndex,
controller: _viewerController,
viewportFraction: _viewportFraction,
canSwitchPage: _canSwitchPage(),
2021-08-05 12:38:43 +02:00
onPageChanged: (_) {
setState(() {});
},
2021-05-02 16:18:26 +02:00
),
_buildBottomAppBar(context),
_buildAppBar(context),
],
2021-04-10 06:28:12 +02:00
),
);
}
Widget _buildAppBar(BuildContext context) {
return Wrap(
children: [
2021-05-05 10:45:56 +02:00
AnimatedVisibility(
2021-04-10 06:28:12 +02:00
opacity: _isShowAppBar ? 1.0 : 0.0,
duration: k.animationDurationNormal,
2021-05-05 10:45:56 +02:00
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),
],
2021-04-10 06:28:12 +02:00
),
),
2021-05-05 10:45:56 +02:00
),
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),
2021-07-25 07:00:38 +02:00
tooltip: L10n.of(context).detailsTooltip,
2021-05-05 10:45:56 +02:00
onPressed: _onDetailsPressed,
),
],
),
],
2021-04-10 06:28:12 +02:00
),
),
],
);
}
Widget _buildBottomAppBar(BuildContext context) {
return Align(
alignment: Alignment.bottomCenter,
child: Material(
type: MaterialType.transparency,
2021-05-05 10:45:56 +02:00
child: AnimatedVisibility(
opacity: (_isShowAppBar && !_isDetailPaneActive) ? 1.0 : 0.0,
duration: !_isDetailPaneActive
? k.animationDurationNormal
: const Duration(milliseconds: 1),
child: ViewerBottomAppBar(
2021-08-13 19:30:47 +02:00
children: [
if (platform_k.isAndroid)
IconButton(
icon: Icon(
Icons.share_outlined,
color: Colors.white.withOpacity(.87),
),
tooltip: L10n.of(context).shareTooltip,
onPressed: () => _onSharePressed(context),
),
IconButton(
icon: Icon(
Icons.download_outlined,
color: Colors.white.withOpacity(.87),
),
tooltip: L10n.of(context).downloadTooltip,
onPressed: () => _onDownloadPressed(context),
),
IconButton(
icon: Icon(
Icons.delete_outlined,
color: Colors.white.withOpacity(.87),
),
tooltip: L10n.of(context).deleteTooltip,
onPressed: () => _onDeletePressed(context),
),
],
2021-04-10 06:28:12 +02:00
),
),
),
);
}
Widget _buildPage(BuildContext context, int index) {
if (_pageStates[index] == null) {
_onCreateNewPage(context, index);
2021-07-23 22:05:57 +02:00
} else if (!_pageStates[index]!.scrollController.hasClients) {
2021-04-10 06:28:12 +02:00
// 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(
2021-07-31 17:38:26 +02:00
widthFactor: 1 / _viewportFraction,
2021-04-10 06:28:12 +02:00
child: NotificationListener<ScrollNotification>(
onNotification: (notif) => _onPageContentScrolled(notif, index),
child: SingleChildScrollView(
2021-07-23 22:05:57 +02:00
controller: _pageStates[index]!.scrollController,
2021-04-10 06:28:12 +02:00
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),
2021-04-17 10:59:16 +02:00
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
2021-04-10 06:28:12 +02:00
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) {
2021-05-06 13:36:20 +02:00
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}");
2021-07-23 22:05:57 +02:00
_pageStates[index]!.itemHeight = 0;
2021-05-06 13:36:20 +02:00
return Container();
}
}
Widget _buildImageView(BuildContext context, int index) {
2021-05-02 16:18:26 +02:00
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;
});
},
2021-04-10 06:28:12 +02:00
);
}
2021-05-06 13:36:20 +02:00
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,
);
}
2021-04-10 06:28:12 +02:00
bool _onPageContentScrolled(ScrollNotification notification, int index) {
if (!_canOpenDetailPane()) {
return false;
}
if (notification is ScrollEndNotification) {
2021-07-23 22:05:57 +02:00
final scrollPos = _pageStates[index]!.scrollController.position;
2021-04-10 06:28:12 +02:00
if (scrollPos.pixels == 0) {
setState(() {
_onDetailPaneClosed();
});
} else if (scrollPos.pixels <
_calcDetailPaneOpenedScrollPosition(index) - 1) {
2021-04-10 06:28:12 +02:00
if (scrollPos.userScrollDirection == ScrollDirection.reverse) {
// upward, open the pane to its minimal size
Future.delayed(Duration.zero, () {
setState(() {
2021-07-31 17:38:26 +02:00
_openDetailPane(_viewerController.currentPage,
2021-04-10 06:28:12 +02:00
shouldAnimate: true);
});
});
} else if (scrollPos.userScrollDirection == ScrollDirection.forward) {
2021-04-10 06:28:12 +02:00
// downward, close the pane
Future.delayed(Duration.zero, () {
2021-07-31 17:38:26 +02:00
_closeDetailPane(_viewerController.currentPage,
2021-07-23 22:05:57 +02:00
shouldAnimate: true);
2021-04-10 06:28:12 +02:00
});
}
}
}
return false;
}
2021-05-02 16:18:26 +02:00
void _onImageLoaded(int index) {
2021-04-10 06:28:12 +02:00
// currently pageview doesn't pre-load pages, we do it manually
// don't pre-load if user already navigated away
2021-07-31 17:38:26 +02:00
if (_viewerController.currentPage == index &&
2021-07-23 22:05:57 +02:00
!_pageStates[index]!.hasLoaded) {
2021-05-02 16:18:26 +02:00
_log.info("[_onImageLoaded] Pre-loading nearby images");
2021-04-10 06:28:12 +02:00
if (index > 0) {
2021-05-02 16:18:26 +02:00
final prevFile = widget.streamFiles[index - 1];
2021-05-06 13:36:20 +02:00
if (file_util.isSupportedImageFormat(prevFile)) {
ImageViewer.preloadImage(widget.account, prevFile);
}
2021-04-10 06:28:12 +02:00
}
if (index + 1 < widget.streamFiles.length) {
2021-05-02 16:18:26 +02:00
final nextFile = widget.streamFiles[index + 1];
2021-05-06 13:36:20 +02:00
if (file_util.isSupportedImageFormat(nextFile)) {
ImageViewer.preloadImage(widget.account, nextFile);
}
2021-04-10 06:28:12 +02:00
}
}
2021-08-05 12:38:43 +02:00
setState(() {
_pageStates[index]!.hasLoaded = true;
_isViewerLoaded = true;
});
2021-04-10 06:28:12 +02:00
}
2021-05-06 13:36:20 +02:00
void _onVideoLoaded(int index) {
2021-08-05 12:38:43 +02:00
setState(() {
_pageStates[index]!.hasLoaded = true;
_isViewerLoaded = true;
});
2021-05-06 13:36:20 +02:00
}
void _onVideoPlay() {
setState(() {
_setShowActionBar(false);
});
}
void _onVideoPause() {
setState(() {
_setShowActionBar(true);
});
}
2021-04-10 06:28:12 +02:00
/// 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) {
2021-07-23 22:05:57 +02:00
WidgetsBinding.instance!.addPostFrameCallback((_) {
if (_pageStates[index]!.itemHeight != null) {
2021-04-10 06:28:12 +02:00
setState(() {
_openDetailPane(index);
});
}
});
} else {
2021-07-23 22:05:57 +02:00
WidgetsBinding.instance!.addPostFrameCallback((_) {
_pageStates[index]!.scrollController.jumpTo(0);
2021-04-10 06:28:12 +02:00
});
}
}
void _onDetailsPressed() {
if (!_isDetailPaneActive) {
setState(() {
2021-07-31 17:38:26 +02:00
_openDetailPane(_viewerController.currentPage, shouldAnimate: true);
2021-04-10 06:28:12 +02:00
});
}
}
void _onSharePressed(BuildContext context) {
assert(platform_k.isAndroid);
2021-07-31 17:38:26 +02:00
final file = widget.streamFiles[_viewerController.currentPage];
ShareHandler().shareFiles(context, widget.account, [file]);
}
2021-04-10 06:28:12 +02:00
void _onDownloadPressed(BuildContext context) async {
2021-07-31 17:38:26 +02:00
final file = widget.streamFiles[_viewerController.currentPage];
2021-04-10 06:28:12 +02:00
_log.info("[_onDownloadPressed] Downloading file: ${file.path}");
var controller = SnackBarManager().showSnackBar(SnackBar(
2021-07-25 07:00:38 +02:00
content: Text(L10n.of(context).downloadProcessingNotification),
2021-04-10 06:28:12 +02:00
duration: k.snackBarDurationShort,
));
2021-07-23 22:05:57 +02:00
controller?.closed.whenComplete(() {
2021-04-10 06:28:12 +02:00
controller = null;
});
dynamic result;
2021-04-10 06:28:12 +02:00
try {
result = await platform.Downloader().downloadFile(widget.account, file);
2021-04-10 06:28:12 +02:00
controller?.close();
} on PermissionException catch (_) {
_log.warning("[_onDownloadPressed] Permission not granted");
controller?.close();
SnackBarManager().showSnackBar(SnackBar(
2021-07-25 07:00:38 +02:00
content: Text(L10n.of(context).downloadFailureNoPermissionNotification),
2021-04-10 06:28:12 +02:00
duration: k.snackBarDurationNormal,
));
return;
2021-04-10 06:28:12 +02:00
} catch (e, stacktrace) {
2021-04-27 22:06:16 +02:00
_log.shout(
2021-04-10 06:28:12 +02:00
"[_onDownloadPressed] Failed while downloadFile", e, stacktrace);
controller?.close();
SnackBarManager().showSnackBar(SnackBar(
2021-07-25 07:00:38 +02:00
content: Text("${L10n.of(context).downloadFailureNotification}: "
2021-07-23 22:05:57 +02:00
"${exception_util.toUserString(e, context)}"),
2021-04-10 06:28:12 +02:00
duration: k.snackBarDurationNormal,
));
return;
}
_onDownloadSuccessful(file, result);
}
void _onDownloadSuccessful(File file, dynamic result) {
var notif;
if (platform_k.isAndroid) {
notif = AndroidItemDownloadSuccessfulNotification(
[result], [file.contentType]);
2021-04-10 06:28:12 +02:00
}
if (notif != null) {
try {
notif.notify();
return;
} catch (e, stacktrace) {
_log.shout(
"[_onDownloadSuccessful] Failed showing platform notification",
e,
stacktrace);
}
}
// fallback
SnackBarManager().showSnackBar(SnackBar(
2021-07-25 07:00:38 +02:00
content: Text(L10n.of(context).downloadSuccessNotification),
duration: k.snackBarDurationShort,
));
2021-04-10 06:28:12 +02:00
}
void _onDeletePressed(BuildContext context) async {
2021-07-31 17:38:26 +02:00
final file = widget.streamFiles[_viewerController.currentPage];
2021-04-10 06:28:12 +02:00
_log.info("[_onDeletePressed] Removing file: ${file.path}");
var controller = SnackBarManager().showSnackBar(SnackBar(
2021-07-25 07:00:38 +02:00
content: Text(L10n.of(context).deleteProcessingNotification),
2021-04-10 06:28:12 +02:00
duration: k.snackBarDurationShort,
));
2021-07-23 22:05:57 +02:00
controller?.closed.whenComplete(() {
2021-04-10 06:28:12 +02:00
controller = null;
});
try {
await Remove(FileRepo(FileCachedDataSource()),
AlbumRepo(AlbumCachedDataSource()))(widget.account, file);
controller?.close();
SnackBarManager().showSnackBar(SnackBar(
2021-07-25 07:00:38 +02:00
content: Text(L10n.of(context).deleteSuccessNotification),
2021-04-10 06:28:12 +02:00
duration: k.snackBarDurationNormal,
));
Navigator.of(context).pop();
} catch (e, stacktrace) {
2021-04-27 22:06:16 +02:00
_log.shout(
"[_onDeletePressed] Failed while remove" +
(kDebugMode ? ": ${file.path}" : ""),
e,
2021-04-10 06:28:12 +02:00
stacktrace);
controller?.close();
SnackBarManager().showSnackBar(SnackBar(
2021-07-25 07:00:38 +02:00
content: Text("${L10n.of(context).deleteFailureNotification}: "
"${exception_util.toUserString(e, context)}"),
2021-04-10 06:28:12 +02:00
duration: k.snackBarDurationNormal,
));
}
}
double _calcDetailPaneOffset(int index) {
if (_pageStates[index]?.itemHeight == null) {
return MediaQuery.of(context).size.height;
} else {
2021-07-23 22:05:57 +02:00
return _pageStates[index]!.itemHeight! +
(MediaQuery.of(context).size.height -
_pageStates[index]!.itemHeight!) /
2021-04-10 06:28:12 +02:00
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) {
2021-07-23 22:05:57 +02:00
if (_pageStates[index]!.itemHeight != height) {
2021-04-10 06:28:12 +02:00
_log.fine("[_updateItemHeight] New height of item#$index: $height");
setState(() {
2021-07-23 22:05:57 +02:00
_pageStates[index]!.itemHeight = height;
2021-04-10 06:28:12 +02:00
if (_isDetailPaneActive) {
_openDetailPane(index);
}
});
}
}
void _setShowActionBar(bool flag) {
_isShowAppBar = flag;
if (flag) {
SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
} else {
2021-05-19 22:10:32 +02:00
SystemChrome.setEnabledSystemUIOverlays([]);
}
2021-04-10 06:28:12 +02:00
}
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) {
2021-07-23 22:05:57 +02:00
_pageStates[index]!.scrollController.animateTo(
2021-04-10 06:28:12 +02:00
_calcDetailPaneOpenedScrollPosition(index),
duration: k.animationDurationNormal,
curve: Curves.easeOut);
} else {
2021-07-23 22:05:57 +02:00
_pageStates[index]!
2021-04-10 06:28:12 +02:00
.scrollController
.jumpTo(_calcDetailPaneOpenedScrollPosition(index));
}
}
void _closeDetailPane(int index, {bool shouldAnimate = false}) {
_isClosingDetailPane = true;
if (shouldAnimate) {
2021-07-23 22:05:57 +02:00
_pageStates[index]!.scrollController.animateTo(0,
2021-04-10 06:28:12 +02:00
duration: k.animationDurationNormal, curve: Curves.easeOut);
}
}
void _onDetailPaneClosed() {
_isShowDetailPane = false;
_isClosingDetailPane = false;
}
2021-05-02 16:18:26 +02:00
bool _canSwitchPage() => !_isZoomed;
bool _canOpenDetailPane() => !_isZoomed;
2021-04-10 06:28:12 +02:00
bool _canZoom() => !_isDetailPaneActive;
var _isShowAppBar = true;
var _isShowDetailPane = false;
var _isDetailPaneActive = false;
var _isClosingDetailPane = false;
2021-05-02 16:18:26 +02:00
var _isZoomed = false;
2021-04-10 06:28:12 +02:00
2021-07-31 17:38:26 +02:00
final _viewerController = HorizontalPageViewerController();
bool _isViewerLoaded = false;
2021-04-10 06:28:12 +02:00
final _pageStates = <int, _PageState>{};
static final _log = Logger("widget.viewer._ViewerState");
2021-07-31 17:38:26 +02:00
static const _viewportFraction = 1.05;
2021-04-10 06:28:12 +02:00
}
class _PageState {
_PageState(this.scrollController);
ScrollController scrollController;
2021-07-23 22:05:57 +02:00
double? itemHeight;
2021-05-02 16:18:26 +02:00
bool hasLoaded = false;
2021-04-10 06:28:12 +02:00
}