selection: switch mode, add/remove items

This commit is contained in:
Thibault Deckers 2020-04-22 11:46:28 +09:00
parent b228fcf55d
commit fb9f297b4b
11 changed files with 450 additions and 348 deletions

View file

@ -16,6 +16,7 @@ class CollectionLens with ChangeNotifier {
GroupFactor groupFactor; GroupFactor groupFactor;
SortFactor sortFactor; SortFactor sortFactor;
final AChangeNotifier filterChangeNotifier = AChangeNotifier(); final AChangeNotifier filterChangeNotifier = AChangeNotifier();
final AChangeNotifier selectionChangeNotifier = AChangeNotifier();
List<ImageEntry> _filteredEntries; List<ImageEntry> _filteredEntries;
List<StreamSubscription> _subscriptions = []; List<StreamSubscription> _subscriptions = [];
@ -194,8 +195,52 @@ class CollectionLens with ChangeNotifier {
_applySort(); _applySort();
_applyGroup(); _applyGroup();
} }
// selection
final ValueNotifier<Activity> _activityNotifier = ValueNotifier(Activity.browse);
ValueNotifier<Activity> 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<ImageEntry> _selection = {};
Set<ImageEntry> get selection => _selection;
void addToSelection(List<ImageEntry> entries) {
_selection.addAll(entries);
selectionChangeNotifier.notifyListeners();
}
void removeFromSelection(List<ImageEntry> 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 SortFactor { date, size, name }
enum GroupFactor { album, month, day } enum GroupFactor { album, month, day }
enum Activity { browse, select }

View file

@ -1,26 +1,22 @@
import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings.dart';
import 'package:aves/utils/constants.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/filter_bar.dart';
import 'package:aves/widgets/album/search/search_delegate.dart'; import 'package:aves/widgets/album/search/search_delegate.dart';
import 'package:aves/widgets/common/menu_row.dart'; import 'package:aves/widgets/common/menu_row.dart';
import 'package:aves/widgets/stats/stats.dart'; import 'package:aves/widgets/stats/stats.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:outline_material_icons/outline_material_icons.dart'; import 'package:outline_material_icons/outline_material_icons.dart';
import 'package:pedantic/pedantic.dart'; import 'package:pedantic/pedantic.dart';
import 'package:provider/provider.dart';
class CollectionAppBar extends StatefulWidget { class CollectionAppBar extends StatefulWidget {
final ValueNotifier<PageState> stateNotifier;
final ValueNotifier<double> appBarHeightNotifier; final ValueNotifier<double> appBarHeightNotifier;
final CollectionLens collection; final CollectionLens collection;
const CollectionAppBar({ const CollectionAppBar({
Key key, Key key,
@required this.stateNotifier,
@required this.appBarHeightNotifier, @required this.appBarHeightNotifier,
@required this.collection, @required this.collection,
}) : super(key: key); }) : super(key: key);
@ -32,9 +28,7 @@ class CollectionAppBar extends StatefulWidget {
class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin { class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin {
final TextEditingController _searchFieldController = TextEditingController(); final TextEditingController _searchFieldController = TextEditingController();
AnimationController _browseToSearchAnimation; AnimationController _browseToSelectAnimation;
ValueNotifier<PageState> get stateNotifier => widget.stateNotifier;
CollectionLens get collection => widget.collection; CollectionLens get collection => widget.collection;
@ -43,7 +37,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_browseToSearchAnimation = AnimationController( _browseToSelectAnimation = AnimationController(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
vsync: this, vsync: this,
); );
@ -61,25 +55,25 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
@override @override
void dispose() { void dispose() {
_unregisterWidget(widget); _unregisterWidget(widget);
_browseToSearchAnimation.dispose(); _browseToSelectAnimation.dispose();
super.dispose(); super.dispose();
} }
void _registerWidget(CollectionAppBar widget) { void _registerWidget(CollectionAppBar widget) {
widget.stateNotifier.addListener(_onStateChange); widget.collection.activityNotifier.addListener(_onActivityChange);
widget.collection.filterChangeNotifier.addListener(_updateHeight); widget.collection.filterChangeNotifier.addListener(_updateHeight);
} }
void _unregisterWidget(CollectionAppBar widget) { void _unregisterWidget(CollectionAppBar widget) {
widget.stateNotifier.removeListener(_onStateChange); widget.collection.activityNotifier.removeListener(_onActivityChange);
widget.collection.filterChangeNotifier.removeListener(_updateHeight); widget.collection.filterChangeNotifier.removeListener(_updateHeight);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ValueListenableBuilder<PageState>( return ValueListenableBuilder<Activity>(
valueListenable: stateNotifier, valueListenable: collection.activityNotifier,
builder: (context, state, child) { builder: (context, activity, child) {
return AnimatedBuilder( return AnimatedBuilder(
animation: collection.filterChangeNotifier, animation: collection.filterChangeNotifier,
builder: (context, child) => SliverAppBar( builder: (context, child) => SliverAppBar(
@ -98,20 +92,17 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
Widget _buildAppBarLeading() { Widget _buildAppBarLeading() {
VoidCallback onPressed; VoidCallback onPressed;
String tooltip; String tooltip;
switch (stateNotifier.value) { if (collection.isBrowsing) {
case PageState.browse: onPressed = Scaffold.of(context).openDrawer;
onPressed = () => Scaffold.of(context).openDrawer(); tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip; } else if (collection.isSelecting) {
break; onPressed = collection.browse;
case PageState.search: tooltip = MaterialLocalizations.of(context).backButtonTooltip;
onPressed = () => stateNotifier.value = PageState.browse;
tooltip = MaterialLocalizations.of(context).backButtonTooltip;
break;
} }
return IconButton( return IconButton(
icon: AnimatedIcon( icon: AnimatedIcon(
icon: AnimatedIcons.menu_arrow, icon: AnimatedIcons.menu_arrow,
progress: _browseToSearchAnimation, progress: _browseToSelectAnimation,
), ),
onPressed: onPressed, onPressed: onPressed,
tooltip: tooltip, tooltip: tooltip,
@ -119,83 +110,53 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
} }
Widget _buildAppBarTitle() { Widget _buildAppBarTitle() {
switch (stateNotifier.value) { if (collection.isBrowsing) {
case PageState.browse: return GestureDetector(
return GestureDetector( onTap: _goToSearch,
onTap: _goToSearch, // use a `Container` with a dummy color to make it expand
// use a `Container` with a dummy color to make it expand // so that we can also detect taps around the title `Text`
// so that we can also detect taps around the title `Text` child: Container(
child: Container( alignment: AlignmentDirectional.centerStart,
alignment: AlignmentDirectional.centerStart, padding: const EdgeInsets.symmetric(horizontal: NavigationToolbar.kMiddleSpacing),
padding: const EdgeInsets.symmetric(horizontal: NavigationToolbar.kMiddleSpacing), color: Colors.transparent,
color: Colors.transparent, height: kToolbarHeight,
height: kToolbarHeight, child: const Text('Aves'),
child: const Text('Aves'), ),
), );
); } else if (collection.isSelecting) {
case PageState.search: return AnimatedBuilder(
return SearchField( animation: collection.selectionChangeNotifier,
stateNotifier: stateNotifier, builder: (context, child) {
controller: _searchFieldController, final selection = collection.selection;
); return Text(selection.isEmpty ? 'Select items' : '${selection.length} ${Intl.plural(selection.length, one: 'item', other: 'items')}');
},
);
} }
return null; return null;
} }
List<Widget> _buildActions() { List<Widget> _buildActions() {
return [ return [
Builder( if (collection.isBrowsing)
builder: (context) { IconButton(
switch (stateNotifier.value) { icon: const Icon(OMIcons.search),
case PageState.browse: onPressed: _goToSearch,
return IconButton( ),
icon: const Icon(OMIcons.search),
onPressed: _goToSearch,
);
case PageState.search:
return IconButton(
icon: const Icon(OMIcons.clear),
onPressed: () => _searchFieldController.clear(),
);
}
return null;
},
),
Builder( Builder(
builder: (context) => PopupMenuButton<CollectionAction>( builder: (context) => PopupMenuButton<CollectionAction>(
itemBuilder: (context) => [ itemBuilder: (context) => [
PopupMenuItem( ..._buildSortMenuItems(),
value: CollectionAction.sortByDate, ..._buildGroupMenuItems(),
child: MenuRow(text: 'Sort by date', checked: collection.sortFactor == SortFactor.date), if (collection.isBrowsing) ...[
), const PopupMenuItem(
PopupMenuItem( value: CollectionAction.select,
value: CollectionAction.sortBySize, child: MenuRow(text: 'Select', icon: OMIcons.selectAll),
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),
), ),
PopupMenuItem( const PopupMenuItem(
value: CollectionAction.groupByMonth, value: CollectionAction.stats,
child: MenuRow(text: 'Group by month', checked: collection.groupFactor == GroupFactor.month), 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, onSelected: _onActionSelected,
), ),
@ -203,10 +164,51 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
]; ];
} }
List<PopupMenuEntry<CollectionAction>> _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<PopupMenuEntry<CollectionAction>> _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 { void _onActionSelected(CollectionAction action) async {
// wait for the popup menu to hide before proceeding with the action // wait for the popup menu to hide before proceeding with the action
await Future.delayed(Constants.popupMenuTransitionDuration); await Future.delayed(Constants.popupMenuTransitionDuration);
switch (action) { switch (action) {
case CollectionAction.select:
collection.select();
break;
case CollectionAction.stats: case CollectionAction.stats:
unawaited(_goToStats()); unawaited(_goToStats());
break; break;
@ -256,11 +258,11 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
); );
} }
void _onStateChange() { void _onActivityChange() {
if (stateNotifier.value == PageState.search) { if (collection.isSelecting) {
_browseToSearchAnimation.forward(); _browseToSelectAnimation.forward();
} else { } else {
_browseToSearchAnimation.reverse(); _browseToSelectAnimation.reverse();
_searchFieldController.clear(); _searchFieldController.clear();
} }
} }
@ -270,34 +272,4 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
} }
} }
class SearchField extends StatelessWidget { enum CollectionAction { select, stats, groupByAlbum, groupByMonth, groupByDay, sortByDate, sortBySize, sortByName }
final ValueNotifier<PageState> stateNotifier;
final TextEditingController controller;
const SearchField({
@required this.stateNotifier,
@required this.controller,
});
@override
Widget build(BuildContext context) {
final collection = Provider.of<CollectionLens>(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 }

View file

@ -9,9 +9,7 @@ import 'package:provider/provider.dart';
class CollectionPage extends StatelessWidget { class CollectionPage extends StatelessWidget {
final CollectionLens collection; final CollectionLens collection;
final ValueNotifier<PageState> _stateNotifier = ValueNotifier(PageState.browse); const CollectionPage(this.collection);
CollectionPage(this.collection);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -21,15 +19,13 @@ class CollectionPage extends StatelessWidget {
child: Scaffold( child: Scaffold(
body: WillPopScope( body: WillPopScope(
onWillPop: () { onWillPop: () {
if (_stateNotifier.value == PageState.search) { if (collection.isSelecting) {
_stateNotifier.value = PageState.browse; collection.browse();
return SynchronousFuture(false); return SynchronousFuture(false);
} }
return SynchronousFuture(true); return SynchronousFuture(true);
}, },
child: ThumbnailCollection( child: ThumbnailCollection(),
stateNotifier: _stateNotifier,
),
), ),
drawer: CollectionDrawer( drawer: CollectionDrawer(
source: collection.source, source: collection.source,
@ -40,5 +36,3 @@ class CollectionPage extends StatelessWidget {
); );
} }
} }
enum PageState { browse, search }

View file

@ -2,7 +2,7 @@ import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.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_known_extent.dart';
import 'package:aves/widgets/album/grid/list_section_layout.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/common/transparent_material_page_route.dart';
import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -51,7 +51,18 @@ class GridThumbnail extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
key: ValueKey(entry.uri), 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( child: MetaData(
metaData: ThumbnailMetadata(index, entry), metaData: ThumbnailMetadata(index, entry),
child: DecoratedThumbnail( child: DecoratedThumbnail(

View file

@ -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_section_layout.dart';
import 'package:aves/widgets/album/grid/list_sliver.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/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:aves/widgets/common/data_providers/media_query_data_provider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
@ -207,6 +207,7 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
child: DecoratedThumbnail( child: DecoratedThumbnail(
entry: widget.imageEntry, entry: widget.imageEntry,
extent: extent, extent: extent,
showOverlay: false,
), ),
), ),
], ],

View file

@ -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<ThumbnailRasterImage> {
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,
),
),
],
);
}
}

View file

@ -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,
),
),
],
),
);
}
}

View file

@ -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<CollectionLens>(context);
return ValueListenableBuilder<Activity>(
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,
);
},
);
}
}

View file

@ -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<ThumbnailRasterImage> {
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,
);
}
}

View file

@ -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,
);
}
}

View file

@ -3,7 +3,6 @@ import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/mime_types.dart'; import 'package:aves/model/mime_types.dart';
import 'package:aves/widgets/album/app_bar.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/empty.dart';
import 'package:aves/widgets/album/grid/list_section_layout.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/list_sliver.dart';
@ -17,17 +16,10 @@ import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
class ThumbnailCollection extends StatelessWidget { class ThumbnailCollection extends StatelessWidget {
final ValueNotifier<PageState> stateNotifier;
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0); final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
final ValueNotifier<double> _tileExtentNotifier = ValueNotifier(0); final ValueNotifier<double> _tileExtentNotifier = ValueNotifier(0);
final GlobalKey _scrollableKey = GlobalKey(); final GlobalKey _scrollableKey = GlobalKey();
ThumbnailCollection({
Key key,
@required this.stateNotifier,
}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( return SafeArea(
@ -77,7 +69,6 @@ class ThumbnailCollection extends StatelessWidget {
physics: collection.isEmpty ? const NeverScrollableScrollPhysics() : null, physics: collection.isEmpty ? const NeverScrollableScrollPhysics() : null,
slivers: [ slivers: [
CollectionAppBar( CollectionAppBar(
stateNotifier: stateNotifier,
appBarHeightNotifier: _appBarHeightNotifier, appBarHeightNotifier: _appBarHeightNotifier,
collection: collection, collection: collection,
), ),