This commit is contained in:
Thibault Deckers 2023-01-31 19:10:38 +01:00
parent d5e702266f
commit 32bbee8bfd
12 changed files with 416 additions and 196 deletions

View file

@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
### Added
- Viewer: overlay details expand/collapse on tap
- TV: improved support for Info
### Changed

View file

@ -1,6 +1,7 @@
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/basic/markdown_container.dart';
import 'package:aves/widgets/common/basic/scaffold.dart';
import 'package:aves/widgets/common/behaviour/intents.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -38,11 +39,11 @@ class _PolicyPageState extends State<PolicyPage> {
child: FocusableActionDetector(
autofocus: true,
shortcuts: const {
SingleActivator(LogicalKeyboardKey.arrowUp): _ScrollIntent.up(),
SingleActivator(LogicalKeyboardKey.arrowDown): _ScrollIntent.down(),
SingleActivator(LogicalKeyboardKey.arrowUp): VerticalScrollIntent.up(),
SingleActivator(LogicalKeyboardKey.arrowDown): VerticalScrollIntent.down(),
},
actions: {
_ScrollIntent: CallbackAction<_ScrollIntent>(onInvoke: _onScrollIntent),
VerticalScrollIntent: VerticalScrollIntentAction(scrollController: _scrollController),
},
child: Center(
child: FutureBuilder<String>(
@ -65,38 +66,4 @@ class _PolicyPageState extends State<PolicyPage> {
),
);
}
void _onScrollIntent(_ScrollIntent intent) {
late int factor;
switch (intent.type) {
case _ScrollDirection.up:
factor = -1;
break;
case _ScrollDirection.down:
factor = 1;
break;
}
_scrollController.animateTo(
_scrollController.offset + factor * 150,
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutCubic,
);
}
}
class _ScrollIntent extends Intent {
const _ScrollIntent({
required this.type,
});
const _ScrollIntent.up() : type = _ScrollDirection.up;
const _ScrollIntent.down() : type = _ScrollDirection.down;
final _ScrollDirection type;
}
enum _ScrollDirection {
up,
down,
}

View file

@ -441,7 +441,7 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge
@override
Widget build(BuildContext context) {
final scrollView = _buildScrollView(widget.appBar, widget.collection);
return _buildDraggableScrollView(scrollView, widget.collection);
return settings.useTvLayout ? scrollView : _buildDraggableScrollView(scrollView, widget.collection);
}
Widget _buildDraggableScrollView(ScrollView scrollView, CollectionLens collection) {

View file

@ -0,0 +1,44 @@
import 'package:flutter/widgets.dart';
class VerticalScrollIntent extends Intent {
const VerticalScrollIntent({
required this.type,
});
const VerticalScrollIntent.up() : type = VerticalScrollDirection.up;
const VerticalScrollIntent.down() : type = VerticalScrollDirection.down;
final VerticalScrollDirection type;
}
enum VerticalScrollDirection {
up,
down,
}
class VerticalScrollIntentAction extends CallbackAction<VerticalScrollIntent> {
VerticalScrollIntentAction({
required ScrollController scrollController,
}) : super(onInvoke: (intent) => _onScrollIntent(intent, scrollController));
static void _onScrollIntent(
VerticalScrollIntent intent,
ScrollController scrollController,
) {
late int factor;
switch (intent.type) {
case VerticalScrollDirection.up:
factor = -1;
break;
case VerticalScrollDirection.down:
factor = 1;
break;
}
scrollController.animateTo(
scrollController.offset + factor * 150,
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutCubic,
);
}
}

View file

@ -620,7 +620,7 @@ class _FilterScrollView<T extends CollectionFilter> extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scrollView = _buildScrollView(context);
return _buildDraggableScrollView(scrollView);
return settings.useTvLayout ? scrollView : _buildDraggableScrollView(scrollView);
}
Widget _buildDraggableScrollView(ScrollView scrollView) {

View file

@ -97,49 +97,8 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
primary: false,
),
),
Expanded(
child: ValueListenableBuilder<int>(
valueListenable: _tvSelectedIndexNotifier,
builder: (context, selectedIndex, child) {
final rail = NavigationRail(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
extended: true,
destinations: sections
.map((section) => NavigationRailDestination(
icon: section.icon(context),
label: Text(section.title(context)),
))
.toList(),
selectedIndex: selectedIndex,
onDestinationSelected: (index) => _tvSelectedIndexNotifier.value = index,
minExtendedWidth: TvRail.minExtendedWidth,
);
return LayoutBuilder(
builder: (context, constraints) {
return Row(
children: [
SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: IntrinsicHeight(child: rail),
),
),
Expanded(
child: MediaQuery.removePadding(
context: context,
removeLeft: !context.isRtl,
removeRight: context.isRtl,
child: _SettingsSectionBody(
loader: Future.value(sections[selectedIndex].tiles(context)),
),
),
),
],
);
},
);
},
),
const Expanded(
child: _TvRail(),
),
],
),
@ -359,3 +318,68 @@ class _SettingsSectionBody extends StatelessWidget {
);
}
}
class _TvRail extends StatefulWidget {
const _TvRail();
@override
State<_TvRail> createState() => _TvRailState();
}
class _TvRailState extends State<_TvRail> {
final ValueNotifier<int> _indexNotifier = ValueNotifier(0);
@override
void dispose() {
_indexNotifier.dispose();
super.dispose();
}
static final List<SettingsSection> sections = _SettingsPageState.sections;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: _indexNotifier,
builder: (context, selectedIndex, child) {
final rail = NavigationRail(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
extended: true,
destinations: sections
.map((section) => NavigationRailDestination(
icon: section.icon(context),
label: Text(section.title(context)),
))
.toList(),
selectedIndex: selectedIndex,
onDestinationSelected: (index) => _indexNotifier.value = index,
minExtendedWidth: TvRail.minExtendedWidth,
);
return LayoutBuilder(
builder: (context, constraints) {
return Row(
children: [
SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: IntrinsicHeight(child: rail),
),
),
Expanded(
child: MediaQuery.removePadding(
context: context,
removeLeft: !context.isRtl,
removeRight: context.isRtl,
child: _SettingsSectionBody(
loader: Future.value(sections[selectedIndex].tiles(context)),
),
),
),
],
);
},
);
},
);
}
}

View file

@ -27,10 +27,11 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class BasicSection extends StatelessWidget {
class BasicSection extends StatefulWidget {
final AvesEntry entry;
final CollectionLens? collection;
final EntryInfoActionDelegate actionDelegate;
final ValueNotifier<bool> isScrollingNotifier;
final ValueNotifier<EntryAction?> isEditingMetadataNotifier;
final FilterCallback onFilter;
@ -39,12 +40,54 @@ class BasicSection extends StatelessWidget {
required this.entry,
this.collection,
required this.actionDelegate,
required this.isScrollingNotifier,
required this.isEditingMetadataNotifier,
required this.onFilter,
});
@override
State<BasicSection> createState() => _BasicSectionState();
}
class _BasicSectionState extends State<BasicSection> {
final FocusNode _chipFocusNode = FocusNode();
CollectionLens? get collection => widget.collection;
EntryInfoActionDelegate get actionDelegate => widget.actionDelegate;
@override
void initState() {
super.initState();
_registerWidget(widget);
_onScrollingChanged();
}
@override
void didUpdateWidget(covariant BasicSection oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_unregisterWidget(widget);
_chipFocusNode.dispose();
super.dispose();
}
void _registerWidget(BasicSection widget) {
widget.isScrollingNotifier.addListener(_onScrollingChanged);
}
void _unregisterWidget(BasicSection widget) {
widget.isScrollingNotifier.removeListener(_onScrollingChanged);
}
@override
Widget build(BuildContext context) {
final entry = widget.entry;
return AnimatedBuilder(
animation: entry.metadataChangeNotifier,
builder: (context, child) {
@ -52,7 +95,12 @@ class BasicSection extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_BasicInfo(entry: entry),
_buildChips(context),
Focus(
focusNode: _chipFocusNode,
skipTraversal: true,
canRequestFocus: false,
child: _buildChips(context),
),
_buildEditButtons(context),
],
);
@ -60,6 +108,7 @@ class BasicSection extends StatelessWidget {
}
Widget _buildChips(BuildContext context) {
final entry = widget.entry;
final tags = entry.tags.toList()..sort(compareAsciiUpperCaseNatural);
final album = entry.directory;
final filters = {
@ -91,7 +140,7 @@ class BasicSection extends StatelessWidget {
children: effectiveFilters
.map((filter) => AvesFilterChip(
filter: filter,
onTap: onFilter,
onTap: widget.onFilter,
))
.toList(),
),
@ -101,6 +150,7 @@ class BasicSection extends StatelessWidget {
}
Widget _buildEditButtons(BuildContext context) {
final entry = widget.entry;
final children = [
EntryAction.editRating,
EntryAction.editTags,
@ -124,8 +174,9 @@ class BasicSection extends StatelessWidget {
}
Widget _buildEditMetadataButton(BuildContext context, EntryAction action) {
final entry = widget.entry;
return ValueListenableBuilder<EntryAction?>(
valueListenable: isEditingMetadataNotifier,
valueListenable: widget.isEditingMetadataNotifier,
builder: (context, editingAction, child) {
final isEditing = editingAction != null;
final onPressed = isEditing ? null : () => actionDelegate.onActionSelected(context, entry, collection, action);
@ -181,6 +232,14 @@ class BasicSection extends StatelessWidget {
},
);
}
void _onScrollingChanged() {
if (!widget.isScrollingNotifier.value) {
// using `autofocus` while scrolling seems to fail for widget built offscreen
// so we give focus to this page when the screen is no longer scrolling
_chipFocusNode.children.firstOrNull?.requestFocus();
}
}
}
class _BasicInfo extends StatefulWidget {

View file

@ -40,50 +40,17 @@ class InfoPage extends StatefulWidget {
}
class _InfoPageState extends State<InfoPage> {
final FocusNode _focusNode = FocusNode();
final ScrollController _scrollController = ScrollController();
bool _scrollStartFromTop = false;
static const splitScreenWidthThreshold = 600;
@override
void initState() {
super.initState();
_registerWidget(widget);
_onScrollingChanged();
}
@override
void didUpdateWidget(covariant InfoPage oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_unregisterWidget(widget);
_focusNode.dispose();
_scrollController.dispose();
super.dispose();
}
void _registerWidget(InfoPage widget) {
widget.isScrollingNotifier.addListener(_onScrollingChanged);
}
void _unregisterWidget(InfoPage widget) {
widget.isScrollingNotifier.removeListener(_onScrollingChanged);
}
void _onScrollingChanged() {
if (!widget.isScrollingNotifier.value) {
// using `autofocus` while scrolling seems to fail for widget built offscreen
// so we give focus to this page when the screen is no longer scrolling
_focusNode.requestFocus();
}
}
@override
Widget build(BuildContext context) {
return AvesScaffold(
@ -104,8 +71,6 @@ class _InfoPageState extends State<InfoPage> {
final targetEntry = pageEntry ?? mainEntry;
return EmbeddedDataOpener(
entry: targetEntry,
child: Focus(
focusNode: _focusNode,
child: _InfoPageContent(
collection: widget.collection,
entry: targetEntry,
@ -114,7 +79,6 @@ class _InfoPageState extends State<InfoPage> {
split: mqWidth > splitScreenWidthThreshold,
goToViewer: _goToViewer,
),
),
);
}
@ -240,6 +204,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
entry: entry,
collection: collection,
actionDelegate: _actionDelegate,
isScrollingNotifier: widget.isScrollingNotifier,
isEditingMetadataNotifier: _isEditingMetadataNotifier,
onFilter: _onFilter,
);

View file

@ -40,16 +40,60 @@ class MetadataDirTile extends StatelessWidget {
var tags = dir.tags;
if (tags.isEmpty) return const SizedBox();
final dirName = dir.name;
if (dirName == MetadataDirectory.xmpDirectory) {
return XmpDirTile(
entry: entry,
return AvesExpansionTile(
title: title,
allTags: dir.allTags,
tags: tags,
highlightColor: getTitleColor(context, dir),
expandedNotifier: expandedDirectoryNotifier,
initiallyExpanded: initiallyExpanded,
children: [
MetadataDirTileBody(
entry: entry,
dir: dir,
showThumbnails: showThumbnails,
),
],
);
}
static Color getTitleColor(BuildContext context, MetadataDirectory dir) {
final dirName = dir.name;
if (dirName == MetadataDirectory.xmpDirectory) {
return context.select<AvesColorsData, Color>((v) => v.xmp);
} else {
final colors = context.watch<AvesColorsData>();
return dir.color ?? colors.fromBrandColor(BrandColors.get(dirName)) ?? colors.fromString(dirName);
}
}
}
class MetadataDirTileBody extends StatelessWidget {
final AvesEntry entry;
final MetadataDirectory dir;
final bool showThumbnails;
const MetadataDirTileBody({
super.key,
required this.entry,
required this.dir,
this.showThumbnails = true,
});
@override
Widget build(BuildContext context) {
var tags = dir.tags;
late final List<Widget> children;
final dirName = dir.name;
if (dirName == MetadataDirectory.xmpDirectory) {
children = [
Padding(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: XmpDirTileBody(
allTags: dir.allTags,
tags: tags,
),
),
];
} else {
Map<String, InfoValueSpanBuilder>? linkHandlers;
switch (dirName) {
@ -67,13 +111,7 @@ class MetadataDirTile extends StatelessWidget {
break;
}
final colors = context.watch<AvesColorsData>();
return AvesExpansionTile(
title: title,
highlightColor: dir.color ?? colors.fromBrandColor(BrandColors.get(dirName)) ?? colors.fromString(dirName),
expandedNotifier: expandedDirectoryNotifier,
initiallyExpanded: initiallyExpanded,
children: [
children = [
if (showThumbnails && dirName == MetadataDirectory.exifThumbnailDirectory) MetadataThumbnails(entry: entry),
Padding(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
@ -83,9 +121,13 @@ class MetadataDirTile extends StatelessWidget {
spanBuilders: linkHandlers,
),
),
],
);
];
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
);
}
static Map<String, InfoValueSpanBuilder> getSvgLinkHandlers(SplayTreeMap<String, String> tags) {

View file

@ -2,11 +2,14 @@ import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_info.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/identity/buttons/outlined_button.dart';
import 'package:aves/widgets/viewer/info/common.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart';
import 'package:aves/widgets/viewer/info/metadata/tv_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
@ -92,7 +95,24 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> {
child: child,
),
),
children: [
children: settings.useTvLayout
? [
AvesOutlinedButton(
label: MaterialLocalizations.of(context).moreButtonTooltip,
onPressed: () {
Navigator.maybeOf(context)?.push(
MaterialPageRoute(
settings: const RouteSettings(name: TvMetadataPage.routeName),
builder: (context) => TvMetadataPage(
entry: entry,
metadata: metadata,
),
),
);
},
),
]
: [
const SectionRow(icon: AIcons.info),
...metadata.entries.map((kv) => MetadataDirTile(
entry: entry,

View file

@ -0,0 +1,123 @@
import 'package:aves/model/entry.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/basic/scaffold.dart';
import 'package:aves/widgets/common/behaviour/intents.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/navigation/tv_rail.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class TvMetadataPage extends StatefulWidget {
static const routeName = '/info/metadata';
final AvesEntry entry;
final Map<String, MetadataDirectory> metadata;
const TvMetadataPage({
super.key,
required this.entry,
required this.metadata,
});
@override
State<TvMetadataPage> createState() => _TvMetadataPageState();
}
class _TvMetadataPageState extends State<TvMetadataPage> {
final ValueNotifier<int> _railIndexNotifier = ValueNotifier(0);
final FocusNode _railFocusNode = FocusNode();
final ScrollController _detailsScrollController = ScrollController();
Map<String, MetadataDirectory> get metadata => widget.metadata;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_railFocusNode.children.firstOrNull?.requestFocus();
});
}
@override
void dispose() {
_railIndexNotifier.dispose();
_railFocusNode.dispose();
_detailsScrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AvesScaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: Text(context.l10n.viewerInfoPageTitle),
),
body: ValueListenableBuilder<int>(
valueListenable: _railIndexNotifier,
builder: (context, selectedIndex, child) {
final titles = metadata.keys.toList();
final selectedDir = metadata[titles[selectedIndex]];
if (selectedDir == null) return const SizedBox();
final rail = NavigationRail(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
extended: true,
destinations: titles.mapIndexed((i, title) {
final dir = metadata[titles[i]]!;
final color = MetadataDirTile.getTitleColor(context, dir);
return NavigationRailDestination(
icon: Icon(AIcons.disc, color: color),
label: Text(title),
);
}).toList(),
selectedIndex: selectedIndex,
onDestinationSelected: (index) => _railIndexNotifier.value = index,
minExtendedWidth: TvRail.minExtendedWidth,
);
return SafeArea(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 16),
Focus(
focusNode: _railFocusNode,
skipTraversal: true,
canRequestFocus: false,
child: SingleChildScrollView(
child: IntrinsicHeight(
child: rail,
),
),
),
Expanded(
child: FocusableActionDetector(
shortcuts: const {
SingleActivator(LogicalKeyboardKey.arrowUp): VerticalScrollIntent.up(),
SingleActivator(LogicalKeyboardKey.arrowDown): VerticalScrollIntent.down(),
},
actions: {
VerticalScrollIntent: VerticalScrollIntentAction(scrollController: _detailsScrollController),
},
child: SingleChildScrollView(
controller: _detailsScrollController,
padding: const EdgeInsets.all(16),
child: MetadataDirTileBody(
entry: widget.entry,
dir: selectedDir,
),
),
),
),
],
),
);
},
),
);
}
}

View file

@ -1,41 +1,27 @@
import 'dart:collection';
import 'dart:convert';
import 'package:aves/model/entry.dart';
import 'package:aves/theme/colors.dart';
import 'package:aves/utils/xmp_utils.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class XmpDirTile extends StatefulWidget {
final AvesEntry entry;
final String title;
class XmpDirTileBody extends StatefulWidget {
final SplayTreeMap<String, String> allTags, tags;
final ValueNotifier<String?>? expandedNotifier;
final bool initiallyExpanded;
const XmpDirTile({
const XmpDirTileBody({
super.key,
required this.entry,
required this.title,
required this.allTags,
required this.tags,
required this.expandedNotifier,
required this.initiallyExpanded,
});
@override
State<XmpDirTile> createState() => _XmpDirTileState();
State<XmpDirTileBody> createState() => _XmpDirTileBodyState();
}
class _XmpDirTileState extends State<XmpDirTile> {
class _XmpDirTileBodyState extends State<XmpDirTileBody> {
late final Map<String, String> _schemaRegistryPrefixes, _tags;
AvesEntry get entry => widget.entry;
static const schemaRegistryPrefixesKey = 'schemaRegistryPrefixes';
@override
@ -60,21 +46,10 @@ class _XmpDirTileState extends State<XmpDirTile> {
return XmpNamespace.create(_schemaRegistryPrefixes, nsPrefix, rawProps);
}).toList()
..sort((a, b) => compareAsciiUpperCase(a.displayTitle, b.displayTitle));
return AvesExpansionTile(
// title may contain parent to distinguish multiple XMP directories
title: widget.title,
highlightColor: context.select<AvesColorsData, Color>((v) => v.xmp),
expandedNotifier: widget.expandedNotifier,
initiallyExpanded: widget.initiallyExpanded,
children: [
Padding(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: Column(
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: sections.expand((section) => section.buildNamespaceSection(context)).toList(),
),
),
],
);
}
}