From fb9f297b4baa25e54e8704afee00d92d9dad4a3d Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 22 Apr 2020 11:46:28 +0900 Subject: [PATCH] selection: switch mode, add/remove items --- lib/model/collection_lens.dart | 45 ++++ lib/widgets/album/app_bar.dart | 222 +++++++++----------- lib/widgets/album/collection_page.dart | 14 +- lib/widgets/album/grid/list_sliver.dart | 15 +- lib/widgets/album/grid/scaling.dart | 3 +- lib/widgets/album/thumbnail.dart | 201 ------------------ lib/widgets/album/thumbnail/decorated.dart | 68 ++++++ lib/widgets/album/thumbnail/overlay.dart | 89 ++++++++ lib/widgets/album/thumbnail/raster.dart | 89 ++++++++ lib/widgets/album/thumbnail/vector.dart | 43 ++++ lib/widgets/album/thumbnail_collection.dart | 9 - 11 files changed, 450 insertions(+), 348 deletions(-) delete mode 100644 lib/widgets/album/thumbnail.dart create mode 100644 lib/widgets/album/thumbnail/decorated.dart create mode 100644 lib/widgets/album/thumbnail/overlay.dart create mode 100644 lib/widgets/album/thumbnail/raster.dart create mode 100644 lib/widgets/album/thumbnail/vector.dart diff --git a/lib/model/collection_lens.dart b/lib/model/collection_lens.dart index cc500fb4a..480d6fc75 100644 --- a/lib/model/collection_lens.dart +++ b/lib/model/collection_lens.dart @@ -16,6 +16,7 @@ class CollectionLens with ChangeNotifier { GroupFactor groupFactor; SortFactor sortFactor; final AChangeNotifier filterChangeNotifier = AChangeNotifier(); + final AChangeNotifier selectionChangeNotifier = AChangeNotifier(); List _filteredEntries; List _subscriptions = []; @@ -194,8 +195,52 @@ class CollectionLens with ChangeNotifier { _applySort(); _applyGroup(); } + + // selection + + final ValueNotifier _activityNotifier = ValueNotifier(Activity.browse); + + ValueNotifier get activityNotifier => _activityNotifier; + + bool get isBrowsing => _activityNotifier.value == Activity.browse; + + bool get isSelecting => _activityNotifier.value == Activity.select; + + void browse() { + _activityNotifier.value = Activity.browse; + _clearSelection(); + } + + void select() => _activityNotifier.value = Activity.select; + + final Set _selection = {}; + + Set get selection => _selection; + + void addToSelection(List entries) { + _selection.addAll(entries); + selectionChangeNotifier.notifyListeners(); + } + + void removeFromSelection(List entries) { + _selection.removeAll(entries); + selectionChangeNotifier.notifyListeners(); + } + + void _clearSelection() { + _selection.clear(); + selectionChangeNotifier.notifyListeners(); + } + + void toggleSelection(ImageEntry entry) { + if (_selection.isEmpty) select(); + if (!_selection.remove(entry)) _selection.add(entry); + selectionChangeNotifier.notifyListeners(); + } } enum SortFactor { date, size, name } enum GroupFactor { album, month, day } + +enum Activity { browse, select } diff --git a/lib/widgets/album/app_bar.dart b/lib/widgets/album/app_bar.dart index f40668c30..f6ee63f16 100644 --- a/lib/widgets/album/app_bar.dart +++ b/lib/widgets/album/app_bar.dart @@ -1,26 +1,22 @@ import 'package:aves/model/collection_lens.dart'; -import 'package:aves/model/filters/query.dart'; import 'package:aves/model/settings.dart'; import 'package:aves/utils/constants.dart'; -import 'package:aves/widgets/album/collection_page.dart'; import 'package:aves/widgets/album/filter_bar.dart'; import 'package:aves/widgets/album/search/search_delegate.dart'; import 'package:aves/widgets/common/menu_row.dart'; import 'package:aves/widgets/stats/stats.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:outline_material_icons/outline_material_icons.dart'; import 'package:pedantic/pedantic.dart'; -import 'package:provider/provider.dart'; class CollectionAppBar extends StatefulWidget { - final ValueNotifier stateNotifier; final ValueNotifier appBarHeightNotifier; final CollectionLens collection; const CollectionAppBar({ Key key, - @required this.stateNotifier, @required this.appBarHeightNotifier, @required this.collection, }) : super(key: key); @@ -32,9 +28,7 @@ class CollectionAppBar extends StatefulWidget { class _CollectionAppBarState extends State with SingleTickerProviderStateMixin { final TextEditingController _searchFieldController = TextEditingController(); - AnimationController _browseToSearchAnimation; - - ValueNotifier get stateNotifier => widget.stateNotifier; + AnimationController _browseToSelectAnimation; CollectionLens get collection => widget.collection; @@ -43,7 +37,7 @@ class _CollectionAppBarState extends State with SingleTickerPr @override void initState() { super.initState(); - _browseToSearchAnimation = AnimationController( + _browseToSelectAnimation = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); @@ -61,25 +55,25 @@ class _CollectionAppBarState extends State with SingleTickerPr @override void dispose() { _unregisterWidget(widget); - _browseToSearchAnimation.dispose(); + _browseToSelectAnimation.dispose(); super.dispose(); } void _registerWidget(CollectionAppBar widget) { - widget.stateNotifier.addListener(_onStateChange); + widget.collection.activityNotifier.addListener(_onActivityChange); widget.collection.filterChangeNotifier.addListener(_updateHeight); } void _unregisterWidget(CollectionAppBar widget) { - widget.stateNotifier.removeListener(_onStateChange); + widget.collection.activityNotifier.removeListener(_onActivityChange); widget.collection.filterChangeNotifier.removeListener(_updateHeight); } @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: stateNotifier, - builder: (context, state, child) { + return ValueListenableBuilder( + valueListenable: collection.activityNotifier, + builder: (context, activity, child) { return AnimatedBuilder( animation: collection.filterChangeNotifier, builder: (context, child) => SliverAppBar( @@ -98,20 +92,17 @@ class _CollectionAppBarState extends State with SingleTickerPr Widget _buildAppBarLeading() { VoidCallback onPressed; String tooltip; - switch (stateNotifier.value) { - case PageState.browse: - onPressed = () => Scaffold.of(context).openDrawer(); - tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip; - break; - case PageState.search: - onPressed = () => stateNotifier.value = PageState.browse; - tooltip = MaterialLocalizations.of(context).backButtonTooltip; - break; + if (collection.isBrowsing) { + onPressed = Scaffold.of(context).openDrawer; + tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip; + } else if (collection.isSelecting) { + onPressed = collection.browse; + tooltip = MaterialLocalizations.of(context).backButtonTooltip; } return IconButton( icon: AnimatedIcon( icon: AnimatedIcons.menu_arrow, - progress: _browseToSearchAnimation, + progress: _browseToSelectAnimation, ), onPressed: onPressed, tooltip: tooltip, @@ -119,83 +110,53 @@ class _CollectionAppBarState extends State with SingleTickerPr } Widget _buildAppBarTitle() { - switch (stateNotifier.value) { - case PageState.browse: - return GestureDetector( - onTap: _goToSearch, - // use a `Container` with a dummy color to make it expand - // so that we can also detect taps around the title `Text` - child: Container( - alignment: AlignmentDirectional.centerStart, - padding: const EdgeInsets.symmetric(horizontal: NavigationToolbar.kMiddleSpacing), - color: Colors.transparent, - height: kToolbarHeight, - child: const Text('Aves'), - ), - ); - case PageState.search: - return SearchField( - stateNotifier: stateNotifier, - controller: _searchFieldController, - ); + if (collection.isBrowsing) { + return GestureDetector( + onTap: _goToSearch, + // use a `Container` with a dummy color to make it expand + // so that we can also detect taps around the title `Text` + child: Container( + alignment: AlignmentDirectional.centerStart, + padding: const EdgeInsets.symmetric(horizontal: NavigationToolbar.kMiddleSpacing), + color: Colors.transparent, + height: kToolbarHeight, + child: const Text('Aves'), + ), + ); + } else if (collection.isSelecting) { + return AnimatedBuilder( + animation: collection.selectionChangeNotifier, + builder: (context, child) { + final selection = collection.selection; + return Text(selection.isEmpty ? 'Select items' : '${selection.length} ${Intl.plural(selection.length, one: 'item', other: 'items')}'); + }, + ); } return null; } List _buildActions() { return [ - Builder( - builder: (context) { - switch (stateNotifier.value) { - case PageState.browse: - return IconButton( - icon: const Icon(OMIcons.search), - onPressed: _goToSearch, - ); - case PageState.search: - return IconButton( - icon: const Icon(OMIcons.clear), - onPressed: () => _searchFieldController.clear(), - ); - } - return null; - }, - ), + if (collection.isBrowsing) + IconButton( + icon: const Icon(OMIcons.search), + onPressed: _goToSearch, + ), Builder( builder: (context) => PopupMenuButton( itemBuilder: (context) => [ - PopupMenuItem( - value: CollectionAction.sortByDate, - child: MenuRow(text: 'Sort by date', checked: collection.sortFactor == SortFactor.date), - ), - PopupMenuItem( - value: CollectionAction.sortBySize, - child: MenuRow(text: 'Sort by size', checked: collection.sortFactor == SortFactor.size), - ), - PopupMenuItem( - value: CollectionAction.sortByName, - child: MenuRow(text: 'Sort by name', checked: collection.sortFactor == SortFactor.name), - ), - const PopupMenuDivider(), - if (collection.sortFactor == SortFactor.date) ...[ - PopupMenuItem( - value: CollectionAction.groupByAlbum, - child: MenuRow(text: 'Group by album', checked: collection.groupFactor == GroupFactor.album), + ..._buildSortMenuItems(), + ..._buildGroupMenuItems(), + if (collection.isBrowsing) ...[ + const PopupMenuItem( + value: CollectionAction.select, + child: MenuRow(text: 'Select', icon: OMIcons.selectAll), ), - PopupMenuItem( - value: CollectionAction.groupByMonth, - child: MenuRow(text: 'Group by month', checked: collection.groupFactor == GroupFactor.month), + const PopupMenuItem( + value: CollectionAction.stats, + child: MenuRow(text: 'Stats', icon: OMIcons.pieChart), ), - PopupMenuItem( - value: CollectionAction.groupByDay, - child: MenuRow(text: 'Group by day', checked: collection.groupFactor == GroupFactor.day), - ), - const PopupMenuDivider(), ], - const PopupMenuItem( - value: CollectionAction.stats, - child: MenuRow(text: 'Stats', icon: OMIcons.pieChart), - ), ], onSelected: _onActionSelected, ), @@ -203,10 +164,51 @@ class _CollectionAppBarState extends State with SingleTickerPr ]; } + List> _buildSortMenuItems() { + return [ + PopupMenuItem( + value: CollectionAction.sortByDate, + child: MenuRow(text: 'Sort by date', checked: collection.sortFactor == SortFactor.date), + ), + PopupMenuItem( + value: CollectionAction.sortBySize, + child: MenuRow(text: 'Sort by size', checked: collection.sortFactor == SortFactor.size), + ), + PopupMenuItem( + value: CollectionAction.sortByName, + child: MenuRow(text: 'Sort by name', checked: collection.sortFactor == SortFactor.name), + ), + const PopupMenuDivider(), + ]; + } + + List> _buildGroupMenuItems() { + return collection.sortFactor == SortFactor.date + ? [ + PopupMenuItem( + value: CollectionAction.groupByAlbum, + child: MenuRow(text: 'Group by album', checked: collection.groupFactor == GroupFactor.album), + ), + PopupMenuItem( + value: CollectionAction.groupByMonth, + child: MenuRow(text: 'Group by month', checked: collection.groupFactor == GroupFactor.month), + ), + PopupMenuItem( + value: CollectionAction.groupByDay, + child: MenuRow(text: 'Group by day', checked: collection.groupFactor == GroupFactor.day), + ), + const PopupMenuDivider(), + ] + : []; + } + void _onActionSelected(CollectionAction action) async { // wait for the popup menu to hide before proceeding with the action await Future.delayed(Constants.popupMenuTransitionDuration); switch (action) { + case CollectionAction.select: + collection.select(); + break; case CollectionAction.stats: unawaited(_goToStats()); break; @@ -256,11 +258,11 @@ class _CollectionAppBarState extends State with SingleTickerPr ); } - void _onStateChange() { - if (stateNotifier.value == PageState.search) { - _browseToSearchAnimation.forward(); + void _onActivityChange() { + if (collection.isSelecting) { + _browseToSelectAnimation.forward(); } else { - _browseToSearchAnimation.reverse(); + _browseToSelectAnimation.reverse(); _searchFieldController.clear(); } } @@ -270,34 +272,4 @@ class _CollectionAppBarState extends State with SingleTickerPr } } -class SearchField extends StatelessWidget { - final ValueNotifier stateNotifier; - final TextEditingController controller; - - const SearchField({ - @required this.stateNotifier, - @required this.controller, - }); - - @override - Widget build(BuildContext context) { - final collection = Provider.of(context); - return TextField( - controller: controller, - decoration: const InputDecoration( - hintText: 'Search...', - border: InputBorder.none, - ), - autofocus: true, - onSubmitted: (query) { - final cleanQuery = query.trim(); - if (cleanQuery.isNotEmpty) { - collection.addFilter(QueryFilter(cleanQuery)); - } - stateNotifier.value = PageState.browse; - }, - ); - } -} - -enum CollectionAction { stats, groupByAlbum, groupByMonth, groupByDay, sortByDate, sortBySize, sortByName } +enum CollectionAction { select, stats, groupByAlbum, groupByMonth, groupByDay, sortByDate, sortBySize, sortByName } diff --git a/lib/widgets/album/collection_page.dart b/lib/widgets/album/collection_page.dart index d1d254cb9..804e9da19 100644 --- a/lib/widgets/album/collection_page.dart +++ b/lib/widgets/album/collection_page.dart @@ -9,9 +9,7 @@ import 'package:provider/provider.dart'; class CollectionPage extends StatelessWidget { final CollectionLens collection; - final ValueNotifier _stateNotifier = ValueNotifier(PageState.browse); - - CollectionPage(this.collection); + const CollectionPage(this.collection); @override Widget build(BuildContext context) { @@ -21,15 +19,13 @@ class CollectionPage extends StatelessWidget { child: Scaffold( body: WillPopScope( onWillPop: () { - if (_stateNotifier.value == PageState.search) { - _stateNotifier.value = PageState.browse; + if (collection.isSelecting) { + collection.browse(); return SynchronousFuture(false); } return SynchronousFuture(true); }, - child: ThumbnailCollection( - stateNotifier: _stateNotifier, - ), + child: ThumbnailCollection(), ), drawer: CollectionDrawer( source: collection.source, @@ -40,5 +36,3 @@ class CollectionPage extends StatelessWidget { ); } } - -enum PageState { browse, search } diff --git a/lib/widgets/album/grid/list_sliver.dart b/lib/widgets/album/grid/list_sliver.dart index 3aeaf654b..88a0ae81c 100644 --- a/lib/widgets/album/grid/list_sliver.dart +++ b/lib/widgets/album/grid/list_sliver.dart @@ -2,7 +2,7 @@ import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/widgets/album/grid/list_known_extent.dart'; import 'package:aves/widgets/album/grid/list_section_layout.dart'; -import 'package:aves/widgets/album/thumbnail.dart'; +import 'package:aves/widgets/album/thumbnail/decorated.dart'; import 'package:aves/widgets/common/transparent_material_page_route.dart'; import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; import 'package:flutter/material.dart'; @@ -51,7 +51,18 @@ class GridThumbnail extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( key: ValueKey(entry.uri), - onTap: () => _goToFullscreen(context), + onTap: () { + if (collection.isBrowsing) { + _goToFullscreen(context); + } else { + collection.toggleSelection(entry); + } + }, + onLongPress: () { + if (collection.isBrowsing) { + collection.toggleSelection(entry); + } + }, child: MetaData( metaData: ThumbnailMetadata(index, entry), child: DecoratedThumbnail( diff --git a/lib/widgets/album/grid/scaling.dart b/lib/widgets/album/grid/scaling.dart index 453f16103..84df5070c 100644 --- a/lib/widgets/album/grid/scaling.dart +++ b/lib/widgets/album/grid/scaling.dart @@ -5,7 +5,7 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/widgets/album/grid/list_section_layout.dart'; import 'package:aves/widgets/album/grid/list_sliver.dart'; import 'package:aves/widgets/album/grid/tile_extent_manager.dart'; -import 'package:aves/widgets/album/thumbnail.dart'; +import 'package:aves/widgets/album/thumbnail/decorated.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -207,6 +207,7 @@ class _ScaleOverlayState extends State { child: DecoratedThumbnail( entry: widget.imageEntry, extent: extent, + showOverlay: false, ), ), ], diff --git a/lib/widgets/album/thumbnail.dart b/lib/widgets/album/thumbnail.dart deleted file mode 100644 index 0e9acf5fd..000000000 --- a/lib/widgets/album/thumbnail.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'dart:math'; - -import 'package:aves/model/image_entry.dart'; -import 'package:aves/utils/constants.dart'; -import 'package:aves/widgets/common/icons.dart'; -import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; -import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; -import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart'; -import 'package:aves/widgets/common/transition_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; - -class DecoratedThumbnail extends StatelessWidget { - final ImageEntry entry; - final double extent; - final Object heroTag; - - static final Color borderColor = Colors.grey.shade700; - static const double borderWidth = .5; - - const DecoratedThumbnail({ - Key key, - @required this.entry, - @required this.extent, - this.heroTag, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - border: Border.all( - color: borderColor, - width: borderWidth, - ), - ), - width: extent, - height: extent, - child: Stack( - alignment: AlignmentDirectional.bottomStart, - children: [ - entry.isSvg - ? _buildVectorImage() - : ThumbnailRasterImage( - entry: entry, - extent: extent, - heroTag: heroTag, - ), - _ThumbnailOverlay( - entry: entry, - extent: extent, - ), - ], - ), - ); - } - - Widget _buildVectorImage() { - final child = Container( - // center `SvgPicture` inside `Container` with the thumbnail dimensions - // so that `SvgPicture` doesn't get aligned by the `Stack` like the overlay icons - width: extent, - height: extent, - child: SvgPicture( - UriPicture( - uri: entry.uri, - mimeType: entry.mimeType, - colorFilter: Constants.svgColorFilter, - ), - width: extent, - height: extent, - ), - ); - return heroTag == null - ? child - : Hero( - tag: heroTag, - child: child, - ); - } -} - -class ThumbnailRasterImage extends StatefulWidget { - final ImageEntry entry; - final double extent; - final Object heroTag; - - const ThumbnailRasterImage({ - Key key, - @required this.entry, - @required this.extent, - this.heroTag, - }) : super(key: key); - - @override - _ThumbnailRasterImageState createState() => _ThumbnailRasterImageState(); -} - -class _ThumbnailRasterImageState extends State { - ThumbnailProvider _imageProvider; - - ImageEntry get entry => widget.entry; - - double get extent => widget.extent; - - Object get heroTag => widget.heroTag; - - @override - void initState() { - super.initState(); - _initProvider(); - } - - @override - void didUpdateWidget(ThumbnailRasterImage oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.entry != entry) { - _cancelProvider(); - _initProvider(); - } - } - - @override - void dispose() { - _cancelProvider(); - super.dispose(); - } - - void _initProvider() => _imageProvider = ThumbnailProvider(entry: entry, extent: Constants.thumbnailCacheExtent); - - void _cancelProvider() => _imageProvider?.cancel(); - - @override - Widget build(BuildContext context) { - final image = Image( - image: _imageProvider, - width: extent, - height: extent, - fit: BoxFit.cover, - ); - return heroTag == null - ? image - : Hero( - tag: heroTag, - flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) { - ImageProvider heroImageProvider = _imageProvider; - if (!entry.isVideo && !entry.isSvg) { - final imageProvider = UriImage( - uri: entry.uri, - mimeType: entry.mimeType, - ); - if (imageCache.statusForKey(imageProvider).keepAlive) { - heroImageProvider = imageProvider; - } - } - return TransitionImage( - image: heroImageProvider, - animation: animation, - ); - }, - child: image, - ); - } -} - -class _ThumbnailOverlay extends StatelessWidget { - final ImageEntry entry; - final double extent; - - const _ThumbnailOverlay({ - Key key, - @required this.entry, - @required this.extent, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final fontSize = min(14.0, (extent / 8)); - final iconSize = fontSize * 2; - return Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (entry.hasGps) GpsIcon(iconSize: iconSize), - if (entry.isAnimated) - AnimatedImageIcon(iconSize: iconSize) - else if (entry.isVideo) - DefaultTextStyle( - style: TextStyle( - color: Colors.grey[200], - fontSize: fontSize, - ), - child: VideoIcon( - entry: entry, - iconSize: iconSize, - ), - ), - ], - ); - } -} diff --git a/lib/widgets/album/thumbnail/decorated.dart b/lib/widgets/album/thumbnail/decorated.dart new file mode 100644 index 000000000..8b63d29ba --- /dev/null +++ b/lib/widgets/album/thumbnail/decorated.dart @@ -0,0 +1,68 @@ +import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/album/thumbnail/overlay.dart'; +import 'package:aves/widgets/album/thumbnail/raster.dart'; +import 'package:aves/widgets/album/thumbnail/vector.dart'; +import 'package:flutter/material.dart'; + +class DecoratedThumbnail extends StatelessWidget { + final ImageEntry entry; + final double extent; + final Object heroTag; + final bool showOverlay; + + static final Color borderColor = Colors.grey.shade700; + static const double borderWidth = .5; + + const DecoratedThumbnail({ + Key key, + @required this.entry, + @required this.extent, + this.heroTag, + this.showOverlay = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + border: Border.all( + color: borderColor, + width: borderWidth, + ), + ), + width: extent, + height: extent, + child: Stack( + children: [ + entry.isSvg + ? ThumbnailVectorImage( + entry: entry, + extent: extent, + heroTag: heroTag, + ) + : ThumbnailRasterImage( + entry: entry, + extent: extent, + heroTag: heroTag, + ), + if (showOverlay) Positioned( + bottom: 0, + left: 0, + child: ThumbnailEntryOverlay( + entry: entry, + extent: extent, + ), + ), + if (showOverlay) Positioned( + top: 0, + right: 0, + child: ThumbnailSelectionOverlay( + entry: entry, + extent: extent, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/album/thumbnail/overlay.dart b/lib/widgets/album/thumbnail/overlay.dart new file mode 100644 index 000000000..5480dc50c --- /dev/null +++ b/lib/widgets/album/thumbnail/overlay.dart @@ -0,0 +1,89 @@ +import 'dart:math'; + +import 'package:aves/model/collection_lens.dart'; +import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/common/icons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; + +class ThumbnailEntryOverlay extends StatelessWidget { + final ImageEntry entry; + final double extent; + + const ThumbnailEntryOverlay({ + Key key, + @required this.entry, + @required this.extent, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final fontSize = min(14.0, (extent / 8)); + final iconSize = fontSize * 2; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (entry.hasGps) GpsIcon(iconSize: iconSize), + if (entry.isAnimated) + AnimatedImageIcon(iconSize: iconSize) + else if (entry.isVideo) + DefaultTextStyle( + style: TextStyle( + color: Colors.grey[200], + fontSize: fontSize, + ), + child: VideoIcon( + entry: entry, + iconSize: iconSize, + ), + ), + ], + ); + } +} + +class ThumbnailSelectionOverlay extends StatelessWidget { + final ImageEntry entry; + final double extent; + + const ThumbnailSelectionOverlay({ + Key key, + @required this.entry, + @required this.extent, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final fontSize = min(14.0, (extent / 8)); + final iconSize = fontSize * 2; + final collection = Provider.of(context); + return ValueListenableBuilder( + valueListenable: collection.activityNotifier, + builder: (context, activity, child) { + final child = collection.isSelecting + ? AnimatedBuilder( + animation: collection.selectionChangeNotifier, + builder: (context, child) { + return OverlayIcon( + icon: collection.selection.contains(entry) ? Icons.check_circle_outline : Icons.radio_button_unchecked, + size: iconSize, + ); + }, + ) + : const SizedBox.shrink(); + return AnimatedSwitcher( + duration: Duration(milliseconds: (300 * timeDilation).toInt()), + switchInCurve: Curves.easeOutBack, + switchOutCurve: Curves.easeOutBack, + transitionBuilder: (child, animation) => ScaleTransition( + child: child, + scale: animation, + ), + child: child, + ); + }, + ); + } +} diff --git a/lib/widgets/album/thumbnail/raster.dart b/lib/widgets/album/thumbnail/raster.dart new file mode 100644 index 000000000..d6e2c33fd --- /dev/null +++ b/lib/widgets/album/thumbnail/raster.dart @@ -0,0 +1,89 @@ +import 'package:aves/model/image_entry.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; +import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; +import 'package:aves/widgets/common/transition_image.dart'; +import 'package:flutter/material.dart'; + +class ThumbnailRasterImage extends StatefulWidget { + final ImageEntry entry; + final double extent; + final Object heroTag; + + const ThumbnailRasterImage({ + Key key, + @required this.entry, + @required this.extent, + this.heroTag, + }) : super(key: key); + + @override + _ThumbnailRasterImageState createState() => _ThumbnailRasterImageState(); +} + +class _ThumbnailRasterImageState extends State { + ThumbnailProvider _imageProvider; + + ImageEntry get entry => widget.entry; + + double get extent => widget.extent; + + Object get heroTag => widget.heroTag; + + @override + void initState() { + super.initState(); + _initProvider(); + } + + @override + void didUpdateWidget(ThumbnailRasterImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.entry != entry) { + _cancelProvider(); + _initProvider(); + } + } + + @override + void dispose() { + _cancelProvider(); + super.dispose(); + } + + void _initProvider() => _imageProvider = ThumbnailProvider(entry: entry, extent: Constants.thumbnailCacheExtent); + + void _cancelProvider() => _imageProvider?.cancel(); + + @override + Widget build(BuildContext context) { + final image = Image( + image: _imageProvider, + width: extent, + height: extent, + fit: BoxFit.cover, + ); + return heroTag == null + ? image + : Hero( + tag: heroTag, + flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) { + ImageProvider heroImageProvider = _imageProvider; + if (!entry.isVideo && !entry.isSvg) { + final imageProvider = UriImage( + uri: entry.uri, + mimeType: entry.mimeType, + ); + if (imageCache.statusForKey(imageProvider).keepAlive) { + heroImageProvider = imageProvider; + } + } + return TransitionImage( + image: heroImageProvider, + animation: animation, + ); + }, + child: image, + ); + } +} diff --git a/lib/widgets/album/thumbnail/vector.dart b/lib/widgets/album/thumbnail/vector.dart new file mode 100644 index 000000000..02063b2ab --- /dev/null +++ b/lib/widgets/album/thumbnail/vector.dart @@ -0,0 +1,43 @@ +import 'package:aves/model/image_entry.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class ThumbnailVectorImage extends StatelessWidget { + final ImageEntry entry; + final double extent; + final Object heroTag; + + const ThumbnailVectorImage({ + Key key, + @required this.entry, + @required this.extent, + this.heroTag, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final child = Container( + // center `SvgPicture` inside `Container` with the thumbnail dimensions + // so that `SvgPicture` doesn't get aligned by the `Stack` like the overlay icons + width: extent, + height: extent, + child: SvgPicture( + UriPicture( + uri: entry.uri, + mimeType: entry.mimeType, + colorFilter: Constants.svgColorFilter, + ), + width: extent, + height: extent, + ), + ); + return heroTag == null + ? child + : Hero( + tag: heroTag, + child: child, + ); + } +} diff --git a/lib/widgets/album/thumbnail_collection.dart b/lib/widgets/album/thumbnail_collection.dart index 4d2d4af88..ebbefa2cc 100644 --- a/lib/widgets/album/thumbnail_collection.dart +++ b/lib/widgets/album/thumbnail_collection.dart @@ -3,7 +3,6 @@ import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/mime_types.dart'; import 'package:aves/widgets/album/app_bar.dart'; -import 'package:aves/widgets/album/collection_page.dart'; import 'package:aves/widgets/album/empty.dart'; import 'package:aves/widgets/album/grid/list_section_layout.dart'; import 'package:aves/widgets/album/grid/list_sliver.dart'; @@ -17,17 +16,10 @@ import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; class ThumbnailCollection extends StatelessWidget { - final ValueNotifier stateNotifier; - final ValueNotifier _appBarHeightNotifier = ValueNotifier(0); final ValueNotifier _tileExtentNotifier = ValueNotifier(0); final GlobalKey _scrollableKey = GlobalKey(); - ThumbnailCollection({ - Key key, - @required this.stateNotifier, - }) : super(key: key); - @override Widget build(BuildContext context) { return SafeArea( @@ -77,7 +69,6 @@ class ThumbnailCollection extends StatelessWidget { physics: collection.isEmpty ? const NeverScrollableScrollPhysics() : null, slivers: [ CollectionAppBar( - stateNotifier: stateNotifier, appBarHeightNotifier: _appBarHeightNotifier, collection: collection, ),