info: selectable text, use expansion panels for metadata directories

This commit is contained in:
Thibault Deckers 2020-03-19 22:38:07 +09:00
parent 7958fa33eb
commit 0093b715d1
8 changed files with 179 additions and 190 deletions

View file

@ -128,7 +128,7 @@ class CollectionLens with ChangeNotifier {
final ub = CollectionSource.getUniqueAlbumName(b, albums); final ub = CollectionSource.getUniqueAlbumName(b, albums);
return compareAsciiUpperCase(ua, ub); return compareAsciiUpperCase(ua, ub);
}; };
sections = Map.unmodifiable(SplayTreeMap.from(byAlbum, compare)); sections = Map.unmodifiable(SplayTreeMap.of(byAlbum, compare));
break; break;
} }
notifyListeners(); notifyListeners();

View file

@ -1,6 +1,7 @@
import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/collection_lens.dart';
import 'package:aves/widgets/album/collection_scaling.dart'; import 'package:aves/widgets/album/collection_scaling.dart';
import 'package:aves/widgets/album/collection_section.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:draggable_scrollbar/draggable_scrollbar.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -52,7 +53,7 @@ class ThumbnailCollection extends StatelessWidget {
if (appBar != null) appBar, if (appBar != null) appBar,
if (collection.isEmpty && emptyBuilder != null) if (collection.isEmpty && emptyBuilder != null)
SliverFillViewport( SliverFillViewport(
delegate: SliverChildListDelegate( delegate: SliverChildListDelegate.fixed(
[emptyBuilder(context)], [emptyBuilder(context)],
), ),
), ),
@ -73,9 +74,9 @@ class ThumbnailCollection extends StatelessWidget {
); );
return DraggableScrollbar( return DraggableScrollbar(
heightScrollThumb: 48, heightScrollThumb: avesScrollThumbHeight,
backgroundColor: Colors.white, backgroundColor: Colors.white,
scrollThumbBuilder: _thumbArrowBuilder(false), scrollThumbBuilder: avesScrollThumbBuilder(),
controller: _scrollController, controller: _scrollController,
padding: EdgeInsets.only( padding: EdgeInsets.only(
// padding to get scroll thumb below app bar, above nav bar // 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<double> thumbAnimation,
Animation<double> 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,
);
};
}
} }

View file

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

View file

@ -1,3 +1,5 @@
import 'dart:collection';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/file_utils.dart'; import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/fullscreen/info/info_page.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 showMegaPixels = !entry.isVideo && !entry.isGif && entry.megaPixels != null && entry.megaPixels > 0;
final resolutionText = '${entry.width ?? '?'} × ${entry.height ?? '?'}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}'; final resolutionText = '${entry.width ?? '?'} × ${entry.height ?? '?'}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}';
return Column( return InfoRowGroup({
crossAxisAlignment: CrossAxisAlignment.start, 'Title': entry.title ?? '?',
children: [ 'Date': dateText,
InfoRow('Title', entry.title ?? '?'),
InfoRow('Date', dateText),
if (entry.isVideo) ..._buildVideoRows(), if (entry.isVideo) ..._buildVideoRows(),
InfoRow('Resolution', resolutionText), 'Resolution': resolutionText,
InfoRow('Size', entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : '?'), 'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : '?',
InfoRow('URI', entry.uri ?? '?'), 'URI': entry.uri ?? '?',
if (entry.path != null) InfoRow('Path', entry.path), if (entry.path != null) 'Path': entry.path,
], });
);
} }
List<Widget> _buildVideoRows() { Map<String, String> _buildVideoRows() {
final rotation = entry.catalogMetadata?.videoRotation; final rotation = entry.catalogMetadata?.videoRotation;
return [ return {
InfoRow('Duration', entry.durationText), 'Duration': entry.durationText,
if (rotation != null) InfoRow('Rotation', '$rotation°'), if (rotation != null) 'Rotation': '$rotation°',
]; };
} }
} }

View file

@ -44,6 +44,16 @@ class InfoPageState extends State<InfoPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
const horizontalPadding = EdgeInsets.symmetric(horizontal: 8); 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( return MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
body: SafeArea( body: SafeArea(
@ -55,72 +65,56 @@ class InfoPageState extends State<InfoPage> {
final mqWidth = mq.item1; final mqWidth = mq.item1;
final mqViewInsetsBottom = mq.item2; final mqViewInsetsBottom = mq.item2;
final split = mqWidth > 400; final split = mqWidth > 400;
final locationAtTop = split && entry.hasGps;
return CustomScrollView( final locationSection = LocationSection(
controller: _scrollController, entry: entry,
slivers: [ showTitle: !locationAtTop,
SliverAppBar( visibleNotifier: widget.visibleNotifier,
leading: IconButton( );
icon: const Icon(OMIcons.arrowUpward), final basicAndLocationSliver = locationAtTop
onPressed: _goToImage, ? SliverToBoxAdapter(
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( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded(child: BasicSection(entry: entry)), Expanded(child: BasicSection(entry: entry)),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(child: locationSection),
child: LocationSection(
entry: entry,
showTitle: false,
visibleNotifier: widget.visibleNotifier,
),
),
], ],
), ),
),
) )
else : SliverList(
SliverPadding( delegate: SliverChildListDelegate.fixed(
padding: horizontalPadding,
sliver: SliverList(
delegate: SliverChildListDelegate(
[ [
BasicSection(entry: entry), BasicSection(entry: entry),
LocationSection( locationSection,
entry: entry,
showTitle: true,
visibleNotifier: widget.visibleNotifier,
),
], ],
), ),
), );
), final tagSliver = XmpTagSectionSliver(
SliverPadding( collection: widget.collection,
padding: horizontalPadding, entry: entry,
sliver: XmpTagSectionSliver(collection: widget.collection, entry: entry), );
), final metadataSliver = MetadataSectionSliver(
SliverPadding(
padding: horizontalPadding,
sliver: MetadataSectionSliver(
entry: entry, entry: entry,
columnCount: split ? 2 : 1,
visibleNotifier: widget.visibleNotifier, visibleNotifier: widget.visibleNotifier,
), );
return CustomScrollView(
controller: _scrollController,
slivers: [
appBar,
SliverPadding(
padding: horizontalPadding + const EdgeInsets.only(top: 8),
sliver: basicAndLocationSliver,
), ),
SliverPadding( SliverPadding(
padding: EdgeInsets.only(bottom: 8 + mqViewInsetsBottom), padding: horizontalPadding,
sliver: tagSliver,
),
SliverPadding(
padding: horizontalPadding + EdgeInsets.only(bottom: 8 + mqViewInsetsBottom),
sliver: metadataSliver,
), ),
], ],
); );
@ -189,24 +183,32 @@ class SectionRow extends StatelessWidget {
} }
} }
class InfoRow extends StatelessWidget { class InfoRowGroup extends StatelessWidget {
final String label, value; final Map<String, String> keyValues;
const InfoRow(this.label, this.value); const InfoRowGroup(this.keyValues);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( if (keyValues.isEmpty) return const SizedBox.shrink();
padding: const EdgeInsets.symmetric(vertical: 4.0), final lastKey = keyValues.keys.last;
child: RichText( return Column(
text: TextSpan( crossAxisAlignment: CrossAxisAlignment.start,
style: const TextStyle(fontFamily: 'Concourse'),
children: [ children: [
TextSpan(text: '$label ', style: const TextStyle(color: Colors.white70)), SelectableText.rich(
TextSpan(text: value), 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'),
), ),
],
); );
} }
} }

View file

@ -90,7 +90,7 @@ class _LocationSectionState extends State<LocationSection> {
if (location.isNotEmpty) if (location.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: InfoRow('Address', location), child: InfoRowGroup({'Address': location}),
), ),
], ],
); );

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/metadata_service.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/common/fx/highlight_decoration.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:flutter/material.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 { class MetadataSectionSliver extends StatefulWidget {
final ImageEntry entry; final ImageEntry entry;
final int columnCount;
final ValueNotifier<bool> visibleNotifier; final ValueNotifier<bool> visibleNotifier;
const MetadataSectionSliver({ const MetadataSectionSliver({
@required this.entry, @required this.entry,
@required this.columnCount,
@required this.visibleNotifier, @required this.visibleNotifier,
}); });
@ -28,8 +25,6 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
List<_MetadataDirectory> _metadata = []; List<_MetadataDirectory> _metadata = [];
String _loadedMetadataUri; String _loadedMetadataUri;
int get columnCount => widget.columnCount;
bool get isVisible => widget.visibleNotifier.value; bool get isVisible => widget.visibleNotifier.value;
static const int maxValueLength = 140; static const int maxValueLength = 140;
@ -67,24 +62,34 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
final itemCount = _metadata.isEmpty ? 0 : _metadata.length + 1; final directoriesWithoutTitle = _metadata.where((dir) => dir.name.isEmpty);
final itemBuilder = (context, index) => index == 0 ? const SectionRow('Metadata') : _DirectoryWidget(_metadata[index - 1]); final directoriesWithTitle = _metadata.where((dir) => dir.name.isNotEmpty);
return SliverList(
// SliverStaggeredGrid is not as efficient as SliverList when there is only one column delegate: SliverChildListDelegate.fixed(
return columnCount == 1 [
? SliverList( const SectionRow('Metadata'),
delegate: SliverChildBuilderDelegate( ...directoriesWithoutTitle.map((dir) => InfoRowGroup(dir.tags)),
itemBuilder, ExpansionPanelList.radio(
childCount: itemCount, expandedHeaderPadding: EdgeInsets.zero,
children: directoriesWithTitle.map<ExpansionPanelRadio>((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,
); );
} }
@ -92,19 +97,15 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
if (_loadedMetadataUri == widget.entry.uri) return; if (_loadedMetadataUri == widget.entry.uri) return;
if (isVisible) { if (isVisible) {
final rawMetadata = await MetadataService.getAllMetadata(widget.entry) ?? {}; final rawMetadata = await MetadataService.getAllMetadata(widget.entry) ?? {};
_metadata = rawMetadata.entries.map((kv) { _metadata = rawMetadata.entries.map((dirKV) {
final String directoryName = kv.key as String ?? ''; final directoryName = dirKV.key as String ?? '';
final Map rawTags = kv.value as Map ?? {}; final rawTags = dirKV.value as Map ?? {};
final List<Tuple2<String, String>> tags = rawTags.entries final tags = SplayTreeMap.of(Map.fromEntries(rawTags.entries.map((tagKV) {
.map((kv) { final value = tagKV.value as String ?? '';
final value = kv.value as String ?? '';
if (value.isEmpty) return null; if (value.isEmpty) return null;
final tagName = kv.key as String ?? ''; final tagName = tagKV.key as String ?? '';
return Tuple2(tagName, value.length > maxValueLength ? '${value.substring(0, maxValueLength)}' : value); return MapEntry(tagName, value.length > maxValueLength ? '${value.substring(0, maxValueLength)}' : value);
}) }).where((kv) => kv != null)));
.where((tag) => tag != null)
.toList()
..sort((a, b) => a.item1.compareTo(b.item1));
return _MetadataDirectory(directoryName, tags); return _MetadataDirectory(directoryName, tags);
}).toList() }).toList()
..sort((a, b) => a.name.compareTo(b.name)); ..sort((a, b) => a.name.compareTo(b.name));
@ -120,24 +121,6 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
bool get wantKeepAlive => true; 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 { class _DirectoryTitle extends StatelessWidget {
final String name; final String name;
@ -173,7 +156,7 @@ class _DirectoryTitle extends StatelessWidget {
class _MetadataDirectory { class _MetadataDirectory {
final String name; final String name;
final List<Tuple2<String, String>> tags; final SplayTreeMap<String, String> tags;
const _MetadataDirectory(this.name, this.tags); const _MetadataDirectory(this.name, this.tags);
} }

View file

@ -20,7 +20,7 @@ class XmpTagSectionSliver extends AnimatedWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tags = entry.xmpSubjects; final tags = entry.xmpSubjects;
return SliverList( return SliverList(
delegate: SliverChildListDelegate( delegate: SliverChildListDelegate.fixed(
tags.isEmpty tags.isEmpty
? [] ? []
: [ : [