import 'dart:async'; import 'package:aves/main.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/utils/durations.dart'; import 'package:aves/widgets/album/filter_bar.dart'; import 'package:aves/widgets/album/search/search_delegate.dart'; import 'package:aves/widgets/common/action_delegates/selection_action_delegate.dart'; import 'package:aves/widgets/common/data_providers/media_store_collection_provider.dart'; import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/icons.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:flutter/scheduler.dart'; import 'package:intl/intl.dart'; import 'package:pedantic/pedantic.dart'; class CollectionAppBar extends StatefulWidget { final ValueNotifier appBarHeightNotifier; final CollectionLens collection; const CollectionAppBar({ Key key, @required this.appBarHeightNotifier, @required this.collection, }) : super(key: key); @override _CollectionAppBarState createState() => _CollectionAppBarState(); } class _CollectionAppBarState extends State with SingleTickerProviderStateMixin { final TextEditingController _searchFieldController = TextEditingController(); SelectionActionDelegate _actionDelegate; AnimationController _browseToSelectAnimation; CollectionLens get collection => widget.collection; bool get hasFilters => collection.filters.isNotEmpty; @override void initState() { super.initState(); _actionDelegate = SelectionActionDelegate( collection: collection, ); _browseToSelectAnimation = AnimationController( duration: Durations.iconAnimation, vsync: this, ); _registerWidget(widget); WidgetsBinding.instance.addPostFrameCallback((_) => _updateHeight()); } @override void didUpdateWidget(CollectionAppBar oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); } @override void dispose() { _unregisterWidget(widget); _browseToSelectAnimation.dispose(); _searchFieldController.dispose(); super.dispose(); } void _registerWidget(CollectionAppBar widget) { widget.collection.activityNotifier.addListener(_onActivityChange); widget.collection.filterChangeNotifier.addListener(_updateHeight); } void _unregisterWidget(CollectionAppBar widget) { widget.collection.activityNotifier.removeListener(_onActivityChange); widget.collection.filterChangeNotifier.removeListener(_updateHeight); } @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: collection.activityNotifier, builder: (context, activity, child) { return AnimatedBuilder( animation: collection.filterChangeNotifier, builder: (context, child) => SliverAppBar( titleSpacing: 0, leading: _buildAppBarLeading(), title: _buildAppBarTitle(), actions: _buildActions(), bottom: hasFilters ? FilterBar( filters: collection.filters, onPressed: collection.removeFilter, ) : null, floating: true, ), ); }, ); } Widget _buildAppBarLeading() { VoidCallback onPressed; String tooltip; if (collection.isBrowsing) { onPressed = Scaffold.of(context).openDrawer; tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip; } else if (collection.isSelecting) { onPressed = () { collection.clearSelection(); collection.browse(); }; tooltip = MaterialLocalizations.of(context).backButtonTooltip; } return IconButton( icon: AnimatedIcon( icon: AnimatedIcons.menu_arrow, progress: _browseToSelectAnimation, ), onPressed: onPressed, tooltip: tooltip, ); } Widget _buildAppBarTitle() { if (collection.isBrowsing) { Widget title = Text(AvesApp.mode == AppMode.pick ? 'Select' : 'Aves'); if (AvesApp.mode == AppMode.main) { title = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ title, ValueListenableBuilder( valueListenable: collection.source.stateNotifier, builder: (context, sourceState, child) { return AnimatedSwitcher( duration: Durations.appBarTitleAnimation, transitionBuilder: (child, animation) => FadeTransition( opacity: animation, child: SizeTransition( sizeFactor: animation, child: child, ), ), child: sourceState == SourceState.ready ? const SizedBox.shrink() : SourceStateSubtitle( source: collection.source, ), ); }, ), ], ); } 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: title, ), ); } else if (collection.isSelecting) { return AnimatedBuilder( animation: collection.selectionChangeNotifier, builder: (context, child) { final count = collection.selection.length; return Text(Intl.plural(count, zero: 'Select items', one: '${count} item', other: '${count} items')); }, ); } return null; } List _buildActions() { return [ if (collection.isBrowsing) IconButton( icon: const Icon(AIcons.search), onPressed: _goToSearch, ), if (collection.isSelecting) ...EntryActions.selection.map((action) => AnimatedBuilder( animation: collection.selectionChangeNotifier, builder: (context, child) { return IconButton( icon: Icon(action.getIcon()), onPressed: collection.selection.isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action), tooltip: action.getText(), ); }, )), Builder( builder: (context) => PopupMenuButton( itemBuilder: (context) { final hasSelection = collection.selection.isNotEmpty; return [ ..._buildSortMenuItems(), ..._buildGroupMenuItems(), if (collection.isBrowsing) ...[ if (AvesApp.mode == AppMode.main) if (kDebugMode) const PopupMenuItem( value: CollectionAction.refresh, child: MenuRow(text: 'Refresh', icon: AIcons.refresh), ), const PopupMenuItem( value: CollectionAction.select, child: MenuRow(text: 'Select', icon: AIcons.select), ), const PopupMenuItem( value: CollectionAction.stats, child: MenuRow(text: 'Stats', icon: AIcons.stats), ), ], if (collection.isSelecting) ...[ PopupMenuItem( value: CollectionAction.copy, enabled: hasSelection, child: const MenuRow(text: 'Copy to album'), ), PopupMenuItem( value: CollectionAction.move, enabled: hasSelection, child: const MenuRow(text: 'Move to album'), ), PopupMenuItem( value: CollectionAction.refreshMetadata, enabled: hasSelection, child: const MenuRow(text: 'Refresh metadata'), ), const PopupMenuDivider(), const PopupMenuItem( value: CollectionAction.selectAll, child: MenuRow(text: 'Select all'), ), PopupMenuItem( value: CollectionAction.selectNone, enabled: hasSelection, child: const MenuRow(text: 'Select none'), ), ] ]; }, onSelected: _onCollectionActionSelected, ), ), ]; } 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 _onActivityChange() { if (collection.isSelecting) { _browseToSelectAnimation.forward(); } else { _browseToSelectAnimation.reverse(); _searchFieldController.clear(); } } void _updateHeight() { widget.appBarHeightNotifier.value = kToolbarHeight + (hasFilters ? FilterBar.preferredHeight : 0); } void _onCollectionActionSelected(CollectionAction action) async { // wait for the popup menu to hide before proceeding with the action await Future.delayed(Durations.popupMenuAnimation * timeDilation); switch (action) { case CollectionAction.copy: case CollectionAction.move: case CollectionAction.refreshMetadata: _actionDelegate.onCollectionActionSelected(context, action); break; case CollectionAction.refresh: final source = collection.source; if (source is MediaStoreSource) { source.clearEntries(); unawaited(source.refresh()); } break; case CollectionAction.select: collection.select(); break; case CollectionAction.selectAll: collection.addToSelection(collection.sortedEntries); break; case CollectionAction.selectNone: collection.clearSelection(); break; case CollectionAction.stats: unawaited(_goToStats()); break; case CollectionAction.groupByAlbum: settings.collectionGroupFactor = GroupFactor.album; collection.group(GroupFactor.album); break; case CollectionAction.groupByMonth: settings.collectionGroupFactor = GroupFactor.month; collection.group(GroupFactor.month); break; case CollectionAction.groupByDay: settings.collectionGroupFactor = GroupFactor.day; collection.group(GroupFactor.day); break; case CollectionAction.sortByDate: settings.collectionSortFactor = SortFactor.date; collection.sort(SortFactor.date); break; case CollectionAction.sortBySize: settings.collectionSortFactor = SortFactor.size; collection.sort(SortFactor.size); break; case CollectionAction.sortByName: settings.collectionSortFactor = SortFactor.name; collection.sort(SortFactor.name); break; } } void _goToSearch() { showSearch( context: context, delegate: ImageSearchDelegate(collection.source, collection.addFilter), ); } Future _goToStats() { return Navigator.push( context, MaterialPageRoute( builder: (context) => StatsPage( collection: collection, ), ), ); } } enum CollectionAction { copy, move, refresh, refreshMetadata, select, selectAll, selectNone, stats, groupByAlbum, groupByMonth, groupByDay, sortByDate, sortBySize, sortByName, } class SourceStateSubtitle extends StatefulWidget { final CollectionSource source; const SourceStateSubtitle({@required this.source}); @override _SourceStateSubtitleState createState() => _SourceStateSubtitleState(); } class _SourceStateSubtitleState extends State { Timer _progressTimer; CollectionSource get source => widget.source; SourceState get sourceState => source.stateNotifier.value; List get entries => source.rawEntries; @override void initState() { super.initState(); _progressTimer = Timer.periodic(Durations.appBarProgressTimerInterval, (_) => setState(() {})); } @override void dispose() { _progressTimer.cancel(); super.dispose(); } @override Widget build(BuildContext context) { String subtitle; double progress; switch (sourceState) { case SourceState.loading: subtitle = 'Loading'; break; case SourceState.cataloguing: subtitle = 'Cataloguing'; progress = entries.where((entry) => entry.isCatalogued).length.toDouble() / entries.length; break; case SourceState.locating: subtitle = 'Locating'; final entriesToLocate = entries.where((entry) => entry.hasGps).toList(); progress = entriesToLocate.where((entry) => entry.isLocated).length.toDouble() / entriesToLocate.length; break; case SourceState.ready: default: break; } final subtitleStyle = Theme.of(context).textTheme.caption; return subtitle == null ? const SizedBox.shrink() : Row( mainAxisSize: MainAxisSize.min, children: [ Text(subtitle, style: subtitleStyle), if (progress != null && progress > 0) ...[ const SizedBox(width: 8), Text( NumberFormat.percentPattern().format(progress), style: subtitleStyle.copyWith(color: Colors.white30), ), ] ], ); } }