diff --git a/lib/model/collection_lens.dart b/lib/model/collection_lens.dart index f6c2e12fa..99e1e1194 100644 --- a/lib/model/collection_lens.dart +++ b/lib/model/collection_lens.dart @@ -128,7 +128,7 @@ class CollectionLens with ChangeNotifier { final ub = CollectionSource.getUniqueAlbumName(b, albums); return compareAsciiUpperCase(ua, ub); }; - sections = Map.unmodifiable(SplayTreeMap.from(byAlbum, compare)); + sections = Map.unmodifiable(SplayTreeMap.of(byAlbum, compare)); break; } notifyListeners(); diff --git a/lib/widgets/album/thumbnail_collection.dart b/lib/widgets/album/thumbnail_collection.dart index ed5614ed9..0db415694 100644 --- a/lib/widgets/album/thumbnail_collection.dart +++ b/lib/widgets/album/thumbnail_collection.dart @@ -1,6 +1,7 @@ import 'package:aves/model/collection_lens.dart'; import 'package:aves/widgets/album/collection_scaling.dart'; import 'package:aves/widgets/album/collection_section.dart'; +import 'package:aves/widgets/common/scroll_thumb.dart'; import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -52,7 +53,7 @@ class ThumbnailCollection extends StatelessWidget { if (appBar != null) appBar, if (collection.isEmpty && emptyBuilder != null) SliverFillViewport( - delegate: SliverChildListDelegate( + delegate: SliverChildListDelegate.fixed( [emptyBuilder(context)], ), ), @@ -73,9 +74,9 @@ class ThumbnailCollection extends StatelessWidget { ); return DraggableScrollbar( - heightScrollThumb: 48, + heightScrollThumb: avesScrollThumbHeight, backgroundColor: Colors.white, - scrollThumbBuilder: _thumbArrowBuilder(false), + scrollThumbBuilder: avesScrollThumbBuilder(), controller: _scrollController, padding: EdgeInsets.only( // padding to get scroll thumb below app bar, above nav bar @@ -91,47 +92,4 @@ class ThumbnailCollection extends StatelessWidget { ), ); } - - static ScrollThumbBuilder _thumbArrowBuilder(bool alwaysVisibleScrollThumb) { - return ( - Color backgroundColor, - Animation thumbAnimation, - Animation labelAnimation, - double height, { - Widget labelText, - }) { - final scrollThumb = Container( - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: const BorderRadius.all( - Radius.circular(12.0), - ), - ), - height: height, - margin: const EdgeInsets.only(right: .5), - padding: const EdgeInsets.all(2), - child: ClipPath( - child: Container( - width: 20.0, - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: const BorderRadius.all( - Radius.circular(12.0), - ), - ), - ), - clipper: ArrowClipper(), - ), - ); - - return DraggableScrollbar.buildScrollThumbAndLabel( - scrollThumb: scrollThumb, - backgroundColor: backgroundColor, - thumbAnimation: thumbAnimation, - labelAnimation: labelAnimation, - labelText: labelText, - alwaysVisibleScrollThumb: alwaysVisibleScrollThumb, - ); - }; - } } diff --git a/lib/widgets/common/scroll_thumb.dart b/lib/widgets/common/scroll_thumb.dart new file mode 100644 index 000000000..9f5b3c0b9 --- /dev/null +++ b/lib/widgets/common/scroll_thumb.dart @@ -0,0 +1,47 @@ +import 'package:draggable_scrollbar/draggable_scrollbar.dart'; +import 'package:flutter/material.dart'; + +const double avesScrollThumbHeight = 48; + +ScrollThumbBuilder avesScrollThumbBuilder() { + return ( + Color backgroundColor, + Animation thumbAnimation, + Animation labelAnimation, + double height, { + Widget labelText, + }) { + final scrollThumb = Container( + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: const BorderRadius.all( + Radius.circular(12.0), + ), + ), + height: height, + margin: const EdgeInsets.only(right: .5), + padding: const EdgeInsets.all(2), + child: ClipPath( + child: Container( + width: 20.0, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: const BorderRadius.all( + Radius.circular(12.0), + ), + ), + ), + clipper: ArrowClipper(), + ), + ); + + return DraggableScrollbar.buildScrollThumbAndLabel( + scrollThumb: scrollThumb, + backgroundColor: backgroundColor, + thumbAnimation: thumbAnimation, + labelAnimation: labelAnimation, + labelText: labelText, + alwaysVisibleScrollThumb: false, + ); + }; +} diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/fullscreen/info/basic_section.dart index 1c115bd0f..d6d6e5601 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/fullscreen/info/basic_section.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:aves/model/image_entry.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart'; @@ -19,25 +21,22 @@ class BasicSection extends StatelessWidget { final showMegaPixels = !entry.isVideo && !entry.isGif && entry.megaPixels != null && entry.megaPixels > 0; final resolutionText = '${entry.width ?? '?'} × ${entry.height ?? '?'}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}'; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InfoRow('Title', entry.title ?? '?'), - InfoRow('Date', dateText), - if (entry.isVideo) ..._buildVideoRows(), - InfoRow('Resolution', resolutionText), - InfoRow('Size', entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : '?'), - InfoRow('URI', entry.uri ?? '?'), - if (entry.path != null) InfoRow('Path', entry.path), - ], - ); + return InfoRowGroup({ + 'Title': entry.title ?? '?', + 'Date': dateText, + if (entry.isVideo) ..._buildVideoRows(), + 'Resolution': resolutionText, + 'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : '?', + 'URI': entry.uri ?? '?', + if (entry.path != null) 'Path': entry.path, + }); } - List _buildVideoRows() { + Map _buildVideoRows() { final rotation = entry.catalogMetadata?.videoRotation; - return [ - InfoRow('Duration', entry.durationText), - if (rotation != null) InfoRow('Rotation', '$rotation°'), - ]; + return { + 'Duration': entry.durationText, + if (rotation != null) 'Rotation': '$rotation°', + }; } } diff --git a/lib/widgets/fullscreen/info/info_page.dart b/lib/widgets/fullscreen/info/info_page.dart index 4084cb77b..848f506dc 100644 --- a/lib/widgets/fullscreen/info/info_page.dart +++ b/lib/widgets/fullscreen/info/info_page.dart @@ -44,6 +44,16 @@ class InfoPageState extends State { Widget build(BuildContext context) { const horizontalPadding = EdgeInsets.symmetric(horizontal: 8); + final appBar = SliverAppBar( + leading: IconButton( + icon: const Icon(OMIcons.arrowUpward), + onPressed: _goToImage, + tooltip: 'Back to image', + ), + title: const Text('Info'), + floating: true, + ); + return MediaQueryDataProvider( child: Scaffold( body: SafeArea( @@ -55,72 +65,56 @@ class InfoPageState extends State { final mqWidth = mq.item1; final mqViewInsetsBottom = mq.item2; final split = mqWidth > 400; + final locationAtTop = split && entry.hasGps; + + final locationSection = LocationSection( + entry: entry, + showTitle: !locationAtTop, + visibleNotifier: widget.visibleNotifier, + ); + final basicAndLocationSliver = locationAtTop + ? SliverToBoxAdapter( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: BasicSection(entry: entry)), + const SizedBox(width: 8), + Expanded(child: locationSection), + ], + ), + ) + : SliverList( + delegate: SliverChildListDelegate.fixed( + [ + BasicSection(entry: entry), + locationSection, + ], + ), + ); + final tagSliver = XmpTagSectionSliver( + collection: widget.collection, + entry: entry, + ); + final metadataSliver = MetadataSectionSliver( + entry: entry, + visibleNotifier: widget.visibleNotifier, + ); return CustomScrollView( controller: _scrollController, slivers: [ - SliverAppBar( - leading: IconButton( - icon: const Icon(OMIcons.arrowUpward), - onPressed: _goToImage, - tooltip: 'Back to image', - ), - title: const Text('Info'), - floating: true, - ), - const SliverPadding( - padding: EdgeInsets.only(top: 8), - ), - if (split && entry.hasGps) - SliverPadding( - padding: horizontalPadding, - sliver: SliverToBoxAdapter( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: BasicSection(entry: entry)), - const SizedBox(width: 8), - Expanded( - child: LocationSection( - entry: entry, - showTitle: false, - visibleNotifier: widget.visibleNotifier, - ), - ), - ], - ), - ), - ) - else - SliverPadding( - padding: horizontalPadding, - sliver: SliverList( - delegate: SliverChildListDelegate( - [ - BasicSection(entry: entry), - LocationSection( - entry: entry, - showTitle: true, - visibleNotifier: widget.visibleNotifier, - ), - ], - ), - ), - ), + appBar, SliverPadding( - padding: horizontalPadding, - sliver: XmpTagSectionSliver(collection: widget.collection, entry: entry), + padding: horizontalPadding + const EdgeInsets.only(top: 8), + sliver: basicAndLocationSliver, ), SliverPadding( padding: horizontalPadding, - sliver: MetadataSectionSliver( - entry: entry, - columnCount: split ? 2 : 1, - visibleNotifier: widget.visibleNotifier, - ), + sliver: tagSliver, ), SliverPadding( - padding: EdgeInsets.only(bottom: 8 + mqViewInsetsBottom), + padding: horizontalPadding + EdgeInsets.only(bottom: 8 + mqViewInsetsBottom), + sliver: metadataSliver, ), ], ); @@ -189,24 +183,32 @@ class SectionRow extends StatelessWidget { } } -class InfoRow extends StatelessWidget { - final String label, value; +class InfoRowGroup extends StatelessWidget { + final Map keyValues; - const InfoRow(this.label, this.value); + const InfoRowGroup(this.keyValues); @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: RichText( - text: TextSpan( + if (keyValues.isEmpty) return const SizedBox.shrink(); + final lastKey = keyValues.keys.last; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText.rich( + TextSpan( + children: keyValues.entries + .expand( + (kv) => [ + TextSpan(text: '${kv.key} ', style: const TextStyle(color: Colors.white70, height: 1.7)), + TextSpan(text: '${kv.value}${kv.key == lastKey ? '' : '\n'}'), + ], + ) + .toList(), + ), style: const TextStyle(fontFamily: 'Concourse'), - children: [ - TextSpan(text: '$label ', style: const TextStyle(color: Colors.white70)), - TextSpan(text: value), - ], ), - ), + ], ); } } diff --git a/lib/widgets/fullscreen/info/location_section.dart b/lib/widgets/fullscreen/info/location_section.dart index d0803b77c..675b97e76 100644 --- a/lib/widgets/fullscreen/info/location_section.dart +++ b/lib/widgets/fullscreen/info/location_section.dart @@ -90,7 +90,7 @@ class _LocationSectionState extends State { if (location.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 8), - child: InfoRow('Address', location), + child: InfoRowGroup({'Address': location}), ), ], ); diff --git a/lib/widgets/fullscreen/info/metadata_section.dart b/lib/widgets/fullscreen/info/metadata_section.dart index af900cdeb..c7d65922a 100644 --- a/lib/widgets/fullscreen/info/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata_section.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:collection'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/metadata_service.dart'; @@ -6,17 +7,13 @@ import 'package:aves/utils/color_utils.dart'; import 'package:aves/widgets/common/fx/highlight_decoration.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; -import 'package:tuple/tuple.dart'; class MetadataSectionSliver extends StatefulWidget { final ImageEntry entry; - final int columnCount; final ValueNotifier visibleNotifier; const MetadataSectionSliver({ @required this.entry, - @required this.columnCount, @required this.visibleNotifier, }); @@ -28,8 +25,6 @@ class _MetadataSectionSliverState extends State with Auto List<_MetadataDirectory> _metadata = []; String _loadedMetadataUri; - int get columnCount => widget.columnCount; - bool get isVisible => widget.visibleNotifier.value; static const int maxValueLength = 140; @@ -67,44 +62,50 @@ class _MetadataSectionSliverState extends State with Auto Widget build(BuildContext context) { super.build(context); - final itemCount = _metadata.isEmpty ? 0 : _metadata.length + 1; - final itemBuilder = (context, index) => index == 0 ? const SectionRow('Metadata') : _DirectoryWidget(_metadata[index - 1]); - - // SliverStaggeredGrid is not as efficient as SliverList when there is only one column - return columnCount == 1 - ? SliverList( - delegate: SliverChildBuilderDelegate( - itemBuilder, - childCount: itemCount, - ), + final directoriesWithoutTitle = _metadata.where((dir) => dir.name.isEmpty); + final directoriesWithTitle = _metadata.where((dir) => dir.name.isNotEmpty); + return SliverList( + delegate: SliverChildListDelegate.fixed( + [ + const SectionRow('Metadata'), + ...directoriesWithoutTitle.map((dir) => InfoRowGroup(dir.tags)), + ExpansionPanelList.radio( + expandedHeaderPadding: EdgeInsets.zero, + children: directoriesWithTitle.map((dir) { + return ExpansionPanelRadio( + value: dir.name, + canTapOnHeader: true, + headerBuilder: (BuildContext context, bool isExpanded) { + return ListTile( + title: _DirectoryTitle(dir.name), + ); + }, + body: Container( + alignment: Alignment.topLeft, + padding: const EdgeInsets.all(8), + child: InfoRowGroup(dir.tags), + ), + ); + }).toList(), ) - : SliverStaggeredGrid.countBuilder( - crossAxisCount: columnCount, - staggeredTileBuilder: (index) => StaggeredTile.fit(index == 0 ? columnCount : 1), - itemBuilder: itemBuilder, - itemCount: itemCount, - mainAxisSpacing: 0, - crossAxisSpacing: 8, - ); + ], + ), + ); } Future _getMetadata() async { if (_loadedMetadataUri == widget.entry.uri) return; if (isVisible) { final rawMetadata = await MetadataService.getAllMetadata(widget.entry) ?? {}; - _metadata = rawMetadata.entries.map((kv) { - final String directoryName = kv.key as String ?? ''; - final Map rawTags = kv.value as Map ?? {}; - final List> tags = rawTags.entries - .map((kv) { - final value = kv.value as String ?? ''; - if (value.isEmpty) return null; - final tagName = kv.key as String ?? ''; - return Tuple2(tagName, value.length > maxValueLength ? '${value.substring(0, maxValueLength)}…' : value); - }) - .where((tag) => tag != null) - .toList() - ..sort((a, b) => a.item1.compareTo(b.item1)); + _metadata = rawMetadata.entries.map((dirKV) { + final directoryName = dirKV.key as String ?? ''; + final rawTags = dirKV.value as Map ?? {}; + final tags = SplayTreeMap.of(Map.fromEntries(rawTags.entries.map((tagKV) { + final value = tagKV.value as String ?? ''; + if (value.isEmpty) return null; + final tagName = tagKV.key as String ?? ''; + return MapEntry(tagName, value.length > maxValueLength ? '${value.substring(0, maxValueLength)}…' : value); + }).where((kv) => kv != null))); return _MetadataDirectory(directoryName, tags); }).toList() ..sort((a, b) => a.name.compareTo(b.name)); @@ -120,24 +121,6 @@ class _MetadataSectionSliverState extends State with Auto bool get wantKeepAlive => true; } -class _DirectoryWidget extends StatelessWidget { - final _MetadataDirectory directory; - - const _DirectoryWidget(this.directory); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (directory.name.isNotEmpty) _DirectoryTitle(directory.name), - ...directory.tags.map((tag) => InfoRow(tag.item1, tag.item2)), - const SizedBox(height: 16), - ], - ); - } -} - class _DirectoryTitle extends StatelessWidget { final String name; @@ -173,7 +156,7 @@ class _DirectoryTitle extends StatelessWidget { class _MetadataDirectory { final String name; - final List> tags; + final SplayTreeMap tags; const _MetadataDirectory(this.name, this.tags); } diff --git a/lib/widgets/fullscreen/info/xmp_section.dart b/lib/widgets/fullscreen/info/xmp_section.dart index e5cbfe80d..02b9bdedf 100644 --- a/lib/widgets/fullscreen/info/xmp_section.dart +++ b/lib/widgets/fullscreen/info/xmp_section.dart @@ -20,7 +20,7 @@ class XmpTagSectionSliver extends AnimatedWidget { Widget build(BuildContext context) { final tags = entry.xmpSubjects; return SliverList( - delegate: SliverChildListDelegate( + delegate: SliverChildListDelegate.fixed( tags.isEmpty ? [] : [