diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageOpStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageOpStreamHandler.java index cfb47bd65..b21313500 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageOpStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageOpStreamHandler.java @@ -31,6 +31,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler { private List> entryMapList; private String op; + @SuppressWarnings("unchecked") public ImageOpStreamHandler(Activity activity, Object arguments) { this.activity = activity; if (arguments instanceof Map) { diff --git a/lib/model/favourite_repo.dart b/lib/model/favourite_repo.dart index fbe9d08aa..d138de273 100644 --- a/lib/model/favourite_repo.dart +++ b/lib/model/favourite_repo.dart @@ -1,12 +1,15 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/metadata_db.dart'; +import 'package:aves/utils/change_notifier.dart'; final FavouriteRepo favourites = FavouriteRepo._private(); class FavouriteRepo { List _rows = []; + final AChangeNotifier changeNotifier = AChangeNotifier(); + FavouriteRepo._private(); Future init() async { @@ -23,16 +26,31 @@ class FavouriteRepo { final newRows = entries.map(_entryToRow); await metadataDb.addFavourites(newRows); _rows.addAll(newRows); + changeNotifier.notifyListeners(); } Future remove(Iterable entries) async { final removedRows = entries.map(_entryToRow); await metadataDb.removeFavourites(removedRows); removedRows.forEach(_rows.remove); + changeNotifier.notifyListeners(); + } + + Future move(int oldContentId, ImageEntry entry) async { + final oldRow = _rows.firstWhere((row) => row.contentId == oldContentId, orElse: () => null); + if (oldRow != null) { + _rows.remove(oldRow); + + final newRow = _entryToRow(entry); + await metadataDb.updateFavouriteId(oldContentId, newRow); + _rows.add(newRow); + changeNotifier.notifyListeners(); + } } Future clear() async { await metadataDb.clearFavourites(); _rows.clear(); + changeNotifier.notifyListeners(); } } diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 845dec9dc..2fc97e921 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -35,7 +35,6 @@ class ImageEntry { AddressDetails _addressDetails; final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier(); - final ValueNotifier isFavouriteNotifier = ValueNotifier(false); ImageEntry({ this.uri, @@ -52,7 +51,6 @@ class ImageEntry { this.durationMillis, }) { this.path = path; - isFavouriteNotifier.value = isFavourite; } ImageEntry copyWith({ @@ -120,7 +118,6 @@ class ImageEntry { imageChangeNotifier.dispose(); metadataChangeNotifier.dispose(); addressChangeNotifier.dispose(); - isFavouriteNotifier.dispose(); } @override @@ -362,14 +359,12 @@ class ImageEntry { void addToFavourites() { if (!isFavourite) { favourites.add([this]); - isFavouriteNotifier.value = true; } } void removeFromFavourites() { if (isFavourite) { favourites.remove([this]); - isFavouriteNotifier.value = false; } } } diff --git a/lib/widgets/album/grid/list_section_layout.dart b/lib/widgets/album/grid/list_section_layout.dart index f98da68b1..2f321aa33 100644 --- a/lib/widgets/album/grid/list_section_layout.dart +++ b/lib/widgets/album/grid/list_section_layout.dart @@ -22,7 +22,8 @@ class SectionedListLayoutProvider extends StatelessWidget { @required this.tileExtent, @required this.thumbnailBuilder, @required this.child, - }) : columnCount = max((scrollableWidth / tileExtent).round(), TileExtentManager.columnCountMin); + }) : assert(scrollableWidth != 0), + columnCount = max((scrollableWidth / tileExtent).round(), TileExtentManager.columnCountMin); @override Widget build(BuildContext context) { diff --git a/lib/widgets/album/thumbnail_collection.dart b/lib/widgets/album/thumbnail_collection.dart index d300aa16d..bde436329 100644 --- a/lib/widgets/album/thumbnail_collection.dart +++ b/lib/widgets/album/thumbnail_collection.dart @@ -33,6 +33,9 @@ class ThumbnailCollection extends StatelessWidget { builder: (context, mq, child) { final mqSize = mq.item1; final mqHorizontalPadding = mq.item2; + + if (mqSize.isEmpty) return const SizedBox.shrink(); + TileExtentManager.applyTileExtent(mqSize, mqHorizontalPadding, _tileExtentNotifier); final cacheExtent = TileExtentManager.extentMaxForSize(mqSize) * 2; diff --git a/lib/widgets/common/action_delegates/selection_action_delegate.dart b/lib/widgets/common/action_delegates/selection_action_delegate.dart index cb6829d07..b7828404e 100644 --- a/lib/widgets/common/action_delegates/selection_action_delegate.dart +++ b/lib/widgets/common/action_delegates/selection_action_delegate.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'package:aves/model/collection_lens.dart'; +import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/metadata_db.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/image_file_service.dart'; @@ -103,7 +103,7 @@ class SelectionActionDelegate with PermissionAwareMixin { context: context, selection: selection, opStream: ImageFileService.move(selection, copy: copy, destinationAlbum: destinationAlbum), - onDone: (Set processed) { + onDone: (Set processed) async { debugPrint('$runtimeType _moveSelection onDone'); final movedOps = processed.where((e) => e.success); final movedCount = movedOps.length; @@ -130,10 +130,10 @@ class SelectionActionDelegate with PermissionAwareMixin { contentId: newFields['contentId'] as int, )); }); - metadataDb.saveMetadata(movedEntries.map((entry) => entry.catalogMetadata)); - metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails)); + await metadataDb.saveMetadata(movedEntries.map((entry) => entry.catalogMetadata)); + await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails)); } else { - movedOps.forEach((movedOp) { + await Future.forEach(movedOps, (movedOp) async { final sourceUri = movedOp.uri; final newFields = movedOp.newFields; final entry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null); @@ -146,9 +146,9 @@ class SelectionActionDelegate with PermissionAwareMixin { entry.contentId = newContentId; movedEntries.add(entry); - metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata); - metadataDb.updateAddressId(oldContentId, entry.addressDetails); - metadataDb.updateFavouriteId(oldContentId, FavouriteRow(contentId: entry.contentId, path: entry.path)); + await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata); + await metadataDb.updateAddressId(oldContentId, entry.addressDetails); + await favourites.move(oldContentId, entry); } }); } @@ -234,17 +234,17 @@ class SelectionActionDelegate with PermissionAwareMixin { // do not handle completion inside `StreamBuilder` // as it could be called multiple times final onComplete = () => _hideOpReportOverlay().then((_) => onDone(processed)); - opStream.listen(null, onError: (error) => onComplete(), onDone: onComplete); + opStream.listen( + (event) => processed.add(event), + onError: (error) => onComplete(), + onDone: onComplete, + ); _opReportOverlayEntry = OverlayEntry( builder: (context) { return StreamBuilder( stream: opStream, builder: (context, snapshot) { - if (snapshot.hasData) { - processed.add(snapshot.data); - } - Widget child = const SizedBox.shrink(); if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) { final percent = processed.length.toDouble() / selection.length; diff --git a/lib/widgets/common/fx/sweeper.dart b/lib/widgets/common/fx/sweeper.dart index cdb057d3b..53f1ab4a9 100644 --- a/lib/widgets/common/fx/sweeper.dart +++ b/lib/widgets/common/fx/sweeper.dart @@ -68,6 +68,7 @@ class _SweeperState extends State with SingleTickerProviderStateMixin { @override void dispose() { _angleAnimationController.removeStatusListener(_onAnimationStatusChange); + _angleAnimationController.dispose(); _unregisterWidget(widget); super.dispose(); } @@ -114,10 +115,14 @@ class _SweeperState extends State with SingleTickerProviderStateMixin { setState(() {}); await Future.delayed(Duration(milliseconds: (opacityAnimationDurationMillis * timeDilation).toInt())); _isAppearing = false; - _angleAnimationController.reset(); - _angleAnimationController.forward(); + if (mounted) { + _angleAnimationController.reset(); + _angleAnimationController.forward(); + } + } + if (mounted) { + setState(() {}); } - setState(() {}); } } diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/fullscreen/info/basic_section.dart index eb9e720ba..3c6160b0c 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/fullscreen/info/basic_section.dart @@ -1,4 +1,5 @@ import 'package:aves/model/collection_lens.dart'; +import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; @@ -31,7 +32,6 @@ class BasicSection extends StatelessWidget { final showMegaPixels = entry.isPhoto && entry.megaPixels != null && entry.megaPixels > 0; final resolutionText = '${entry.width ?? '?'} × ${entry.height ?? '?'}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}'; - final tags = entry.xmpSubjects..sort(compareAsciiUpperCase); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -44,37 +44,45 @@ class BasicSection extends StatelessWidget { 'URI': entry.uri ?? '?', if (entry.path != null) 'Path': entry.path, }), - ValueListenableBuilder( - valueListenable: entry.isFavouriteNotifier, - builder: (context, isFavourite, child) { - final album = entry.directory; - final filters = [ - if (entry.isVideo) MimeFilter(MimeTypes.ANY_VIDEO), - if (entry.isAnimated) MimeFilter(MimeFilter.animated), - if (isFavourite) FavouriteFilter(), - if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(album)), - ...tags.map((tag) => TagFilter(tag)), - ]..sort(); - if (filters.isEmpty) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: filters - .map((filter) => AvesFilterChip( - filter: filter, - onPressed: onFilter, - )) - .toList(), - ), - ); - }, - ), + _buildChips(), ], ); } + Widget _buildChips() { + final tags = entry.xmpSubjects..sort(compareAsciiUpperCase); + final album = entry.directory; + final filters = [ + if (entry.isVideo) MimeFilter(MimeTypes.ANY_VIDEO), + if (entry.isAnimated) MimeFilter(MimeFilter.animated), + if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(album)), + ...tags.map((tag) => TagFilter(tag)), + ]; + return AnimatedBuilder( + animation: favourites.changeNotifier, + builder: (context, child) { + final effectiveFilters = [ + ...filters, + if (entry.isFavourite) FavouriteFilter(), + ]..sort(); + if (effectiveFilters.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: effectiveFilters + .map((filter) => AvesFilterChip( + filter: filter, + onPressed: onFilter, + )) + .toList(), + ), + ); + }, + ); + } + Map _buildVideoRows() { final rotation = entry.catalogMetadata?.videoRotation; return { diff --git a/lib/widgets/fullscreen/overlay/top.dart b/lib/widgets/fullscreen/overlay/top.dart index ef4ed54d3..9183c7d1a 100644 --- a/lib/widgets/fullscreen/overlay/top.dart +++ b/lib/widgets/fullscreen/overlay/top.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/fx/sweeper.dart'; @@ -63,7 +64,7 @@ class FullscreenTopOverlay extends StatelessWidget { inAppActions: inAppActions, externalAppActions: externalAppActions, scale: scale, - isFavouriteNotifier: entry.isFavouriteNotifier, + entry: entry, onActionSelected: onActionSelected, ); }, @@ -104,7 +105,7 @@ class _TopOverlayRow extends StatelessWidget { final List inAppActions; final List externalAppActions; final Animation scale; - final ValueNotifier isFavouriteNotifier; + final ImageEntry entry; final Function(EntryAction value) onActionSelected; const _TopOverlayRow({ @@ -113,7 +114,7 @@ class _TopOverlayRow extends StatelessWidget { @required this.inAppActions, @required this.externalAppActions, @required this.scale, - @required this.isFavouriteNotifier, + @required this.entry, @required this.onActionSelected, }) : super(key: key); @@ -153,22 +154,9 @@ class _TopOverlayRow extends StatelessWidget { final onPressed = () => onActionSelected?.call(action); switch (action) { case EntryAction.toggleFavourite: - child = ValueListenableBuilder( - valueListenable: isFavouriteNotifier, - builder: (context, isFavourite, child) => Stack( - alignment: Alignment.center, - children: [ - IconButton( - icon: Icon(isFavourite ? AIcons.favouriteActive : AIcons.favourite), - onPressed: onPressed, - tooltip: isFavourite ? 'Remove from favourites' : 'Add to favourites', - ), - Sweeper( - builder: (context) => const Icon(AIcons.favourite, color: Colors.redAccent), - toggledNotifier: isFavouriteNotifier, - ), - ], - ), + child = _FavouriteToggler( + entry: entry, + onPressed: onPressed, ); break; case EntryAction.info: @@ -207,15 +195,10 @@ class _TopOverlayRow extends StatelessWidget { switch (action) { // in app actions case EntryAction.toggleFavourite: - child = isFavouriteNotifier.value - ? const MenuRow( - text: 'Remove from favourites', - icon: AIcons.favouriteActive, - ) - : const MenuRow( - text: 'Add to favourites', - icon: AIcons.favourite, - ); + child = _FavouriteToggler( + entry: entry, + isMenuItem: true, + ); break; case EntryAction.info: case EntryAction.share: @@ -241,3 +224,80 @@ class _TopOverlayRow extends StatelessWidget { ); } } + +class _FavouriteToggler extends StatefulWidget { + final ImageEntry entry; + final bool isMenuItem; + final VoidCallback onPressed; + + const _FavouriteToggler({ + @required this.entry, + this.isMenuItem = false, + this.onPressed, + }); + + @override + _FavouriteTogglerState createState() => _FavouriteTogglerState(); +} + +class _FavouriteTogglerState extends State<_FavouriteToggler> { + final ValueNotifier isFavouriteNotifier = ValueNotifier(null); + + @override + void initState() { + super.initState(); + favourites.changeNotifier.addListener(_onChanged); + _onChanged(); + } + + @override + void didUpdateWidget(_FavouriteToggler oldWidget) { + super.didUpdateWidget(oldWidget); + _onChanged(); + } + + @override + void dispose() { + favourites.changeNotifier.removeListener(_onChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: isFavouriteNotifier, + builder: (context, isFavourite, child) { + if (widget.isMenuItem) { + return isFavourite + ? const MenuRow( + text: 'Remove from favourites', + icon: AIcons.favouriteActive, + ) + : const MenuRow( + text: 'Add to favourites', + icon: AIcons.favourite, + ); + } + return Stack( + alignment: Alignment.center, + children: [ + IconButton( + icon: Icon(isFavourite ? AIcons.favouriteActive : AIcons.favourite), + onPressed: widget.onPressed, + tooltip: isFavourite ? 'Remove from favourites' : 'Add to favourites', + ), + Sweeper( + key: ValueKey(widget.entry), + builder: (context) => const Icon(AIcons.favourite, color: Colors.redAccent), + toggledNotifier: isFavouriteNotifier, + ), + ], + ); + }, + ); + } + + void _onChanged() { + isFavouriteNotifier.value = widget.entry.isFavourite; + } +}