tv: info
This commit is contained in:
parent
d5e702266f
commit
32bbee8bfd
12 changed files with 416 additions and 196 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
44
lib/widgets/common/behaviour/intents.dart
Normal file
44
lib/widgets/common/behaviour/intents.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,16 +71,13 @@ class _InfoPageState extends State<InfoPage> {
|
|||
final targetEntry = pageEntry ?? mainEntry;
|
||||
return EmbeddedDataOpener(
|
||||
entry: targetEntry,
|
||||
child: Focus(
|
||||
focusNode: _focusNode,
|
||||
child: _InfoPageContent(
|
||||
collection: widget.collection,
|
||||
entry: targetEntry,
|
||||
isScrollingNotifier: widget.isScrollingNotifier,
|
||||
scrollController: _scrollController,
|
||||
split: mqWidth > splitScreenWidthThreshold,
|
||||
goToViewer: _goToViewer,
|
||||
),
|
||||
child: _InfoPageContent(
|
||||
collection: widget.collection,
|
||||
entry: targetEntry,
|
||||
isScrollingNotifier: widget.isScrollingNotifier,
|
||||
scrollController: _scrollController,
|
||||
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,
|
||||
);
|
||||
|
|
|
@ -40,16 +40,60 @@ class MetadataDirTile extends StatelessWidget {
|
|||
var tags = dir.tags;
|
||||
if (tags.isEmpty) return const SizedBox();
|
||||
|
||||
return AvesExpansionTile(
|
||||
title: title,
|
||||
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 XmpDirTile(
|
||||
entry: entry,
|
||||
title: title,
|
||||
allTags: dir.allTags,
|
||||
tags: tags,
|
||||
expandedNotifier: expandedDirectoryNotifier,
|
||||
initiallyExpanded: initiallyExpanded,
|
||||
);
|
||||
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,25 +111,23 @@ 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: [
|
||||
if (showThumbnails && dirName == MetadataDirectory.exifThumbnailDirectory) MetadataThumbnails(entry: entry),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
child: InfoRowGroup(
|
||||
info: tags,
|
||||
maxValueLength: Constants.infoGroupMaxValueLength,
|
||||
spanBuilders: linkHandlers,
|
||||
),
|
||||
children = [
|
||||
if (showThumbnails && dirName == MetadataDirectory.exifThumbnailDirectory) MetadataThumbnails(entry: entry),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
child: InfoRowGroup(
|
||||
info: tags,
|
||||
maxValueLength: Constants.infoGroupMaxValueLength,
|
||||
spanBuilders: linkHandlers,
|
||||
),
|
||||
],
|
||||
);
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, InfoValueSpanBuilder> getSvgLinkHandlers(SplayTreeMap<String, String> tags) {
|
||||
|
|
|
@ -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,15 +95,32 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> {
|
|||
child: child,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
const SectionRow(icon: AIcons.info),
|
||||
...metadata.entries.map((kv) => MetadataDirTile(
|
||||
entry: entry,
|
||||
title: kv.key,
|
||||
dir: kv.value,
|
||||
expandedDirectoryNotifier: _expandedDirectoryNotifier,
|
||||
)),
|
||||
],
|
||||
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,
|
||||
title: kv.key,
|
||||
dir: kv.value,
|
||||
expandedDirectoryNotifier: _expandedDirectoryNotifier,
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
123
lib/widgets/viewer/info/metadata/tv_page.dart
Normal file
123
lib/widgets/viewer/info/metadata/tv_page.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: sections.expand((section) => section.buildNamespaceSection(context)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: sections.expand((section) => section.buildNamespaceSection(context)).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue