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);
return compareAsciiUpperCase(ua, ub);
};
sections = Map.unmodifiable(SplayTreeMap.from(byAlbum, compare));
sections = Map.unmodifiable(SplayTreeMap.of(byAlbum, compare));
break;
}
notifyListeners();

View file

@ -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<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/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),
return InfoRowGroup({
'Title': entry.title ?? '?',
'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),
],
);
'Resolution': resolutionText,
'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : '?',
'URI': entry.uri ?? '?',
if (entry.path != null) 'Path': entry.path,
});
}
List<Widget> _buildVideoRows() {
Map<String, String> _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°',
};
}
}

View file

@ -44,6 +44,16 @@ class InfoPageState extends State<InfoPage> {
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<InfoPage> {
final mqWidth = mq.item1;
final mqViewInsetsBottom = mq.item2;
final split = mqWidth > 400;
final locationAtTop = split && entry.hasGps;
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(
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(
entry: entry,
showTitle: false,
visibleNotifier: widget.visibleNotifier,
),
),
Expanded(child: locationSection),
],
),
),
)
else
SliverPadding(
padding: horizontalPadding,
sliver: SliverList(
delegate: SliverChildListDelegate(
: SliverList(
delegate: SliverChildListDelegate.fixed(
[
BasicSection(entry: entry),
LocationSection(
entry: entry,
showTitle: true,
visibleNotifier: widget.visibleNotifier,
),
locationSection,
],
),
),
),
SliverPadding(
padding: horizontalPadding,
sliver: XmpTagSectionSliver(collection: widget.collection, entry: entry),
),
SliverPadding(
padding: horizontalPadding,
sliver: MetadataSectionSliver(
);
final tagSliver = XmpTagSectionSliver(
collection: widget.collection,
entry: entry,
);
final metadataSliver = MetadataSectionSliver(
entry: entry,
columnCount: split ? 2 : 1,
visibleNotifier: widget.visibleNotifier,
),
);
return CustomScrollView(
controller: _scrollController,
slivers: [
appBar,
SliverPadding(
padding: horizontalPadding + const EdgeInsets.only(top: 8),
sliver: basicAndLocationSliver,
),
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 {
final String label, value;
class InfoRowGroup extends StatelessWidget {
final Map<String, String> 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(
style: const TextStyle(fontFamily: 'Concourse'),
if (keyValues.isEmpty) return const SizedBox.shrink();
final lastKey = keyValues.keys.last;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextSpan(text: '$label ', style: const TextStyle(color: Colors.white70)),
TextSpan(text: value),
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'),
),
],
);
}
}

View file

@ -90,7 +90,7 @@ class _LocationSectionState extends State<LocationSection> {
if (location.isNotEmpty)
Padding(
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: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<bool> visibleNotifier;
const MetadataSectionSliver({
@required this.entry,
@required this.columnCount,
@required this.visibleNotifier,
});
@ -28,8 +25,6 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
List<_MetadataDirectory> _metadata = [];
String _loadedMetadataUri;
int get columnCount => widget.columnCount;
bool get isVisible => widget.visibleNotifier.value;
static const int maxValueLength = 140;
@ -67,24 +62,34 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> 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<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 (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<Tuple2<String, String>> tags = rawTags.entries
.map((kv) {
final value = kv.value as String ?? '';
_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 = 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));
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<MetadataSectionSliver> 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<Tuple2<String, String>> tags;
final SplayTreeMap<String, String> tags;
const _MetadataDirectory(this.name, this.tags);
}

View file

@ -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
? []
: [