info: selectable text, use expansion panels for metadata directories
This commit is contained in:
parent
7958fa33eb
commit
0093b715d1
8 changed files with 179 additions and 190 deletions
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
47
lib/widgets/common/scroll_thumb.dart
Normal file
47
lib/widgets/common/scroll_thumb.dart
Normal 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,
|
||||
);
|
||||
};
|
||||
}
|
|
@ -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<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°',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
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<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(
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
@ -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,44 +62,50 @@ 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,
|
||||
);
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _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<Tuple2<String, String>> 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<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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
? []
|
||||
: [
|
||||
|
|
Loading…
Reference in a new issue