selection: switch mode, add/remove items
This commit is contained in:
parent
b228fcf55d
commit
fb9f297b4b
11 changed files with 450 additions and 348 deletions
|
@ -16,6 +16,7 @@ class CollectionLens with ChangeNotifier {
|
|||
GroupFactor groupFactor;
|
||||
SortFactor sortFactor;
|
||||
final AChangeNotifier filterChangeNotifier = AChangeNotifier();
|
||||
final AChangeNotifier selectionChangeNotifier = AChangeNotifier();
|
||||
|
||||
List<ImageEntry> _filteredEntries;
|
||||
List<StreamSubscription> _subscriptions = [];
|
||||
|
@ -194,8 +195,52 @@ class CollectionLens with ChangeNotifier {
|
|||
_applySort();
|
||||
_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 GroupFactor { album, month, day }
|
||||
|
||||
enum Activity { browse, select }
|
||||
|
|
|
@ -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<PageState> stateNotifier;
|
||||
final ValueNotifier<double> 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<CollectionAppBar> with SingleTickerProviderStateMixin {
|
||||
final TextEditingController _searchFieldController = TextEditingController();
|
||||
|
||||
AnimationController _browseToSearchAnimation;
|
||||
|
||||
ValueNotifier<PageState> get stateNotifier => widget.stateNotifier;
|
||||
AnimationController _browseToSelectAnimation;
|
||||
|
||||
CollectionLens get collection => widget.collection;
|
||||
|
||||
|
@ -43,7 +37,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> 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<CollectionAppBar> 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<PageState>(
|
||||
valueListenable: stateNotifier,
|
||||
builder: (context, state, child) {
|
||||
return ValueListenableBuilder<Activity>(
|
||||
valueListenable: collection.activityNotifier,
|
||||
builder: (context, activity, child) {
|
||||
return AnimatedBuilder(
|
||||
animation: collection.filterChangeNotifier,
|
||||
builder: (context, child) => SliverAppBar(
|
||||
|
@ -98,20 +92,17 @@ class _CollectionAppBarState extends State<CollectionAppBar> 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<CollectionAppBar> 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<Widget> _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<CollectionAction>(
|
||||
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<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 {
|
||||
// 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<CollectionAppBar> 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<CollectionAppBar> with SingleTickerPr
|
|||
}
|
||||
}
|
||||
|
||||
class SearchField extends StatelessWidget {
|
||||
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 }
|
||||
enum CollectionAction { select, stats, groupByAlbum, groupByMonth, groupByDay, sortByDate, sortBySize, sortByName }
|
||||
|
|
|
@ -9,9 +9,7 @@ import 'package:provider/provider.dart';
|
|||
class CollectionPage extends StatelessWidget {
|
||||
final CollectionLens collection;
|
||||
|
||||
final ValueNotifier<PageState> _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 }
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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<ScaleOverlay> {
|
|||
child: DecoratedThumbnail(
|
||||
entry: widget.imageEntry,
|
||||
extent: extent,
|
||||
showOverlay: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
68
lib/widgets/album/thumbnail/decorated.dart
Normal file
68
lib/widgets/album/thumbnail/decorated.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
89
lib/widgets/album/thumbnail/overlay.dart
Normal file
89
lib/widgets/album/thumbnail/overlay.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
89
lib/widgets/album/thumbnail/raster.dart
Normal file
89
lib/widgets/album/thumbnail/raster.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
43
lib/widgets/album/thumbnail/vector.dart
Normal file
43
lib/widgets/album/thumbnail/vector.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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<PageState> stateNotifier;
|
||||
|
||||
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
|
||||
final ValueNotifier<double> _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,
|
||||
),
|
||||
|
|
Loading…
Reference in a new issue