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
|
### Added
|
||||||
|
|
||||||
- Viewer: overlay details expand/collapse on tap
|
- Viewer: overlay details expand/collapse on tap
|
||||||
|
- TV: improved support for Info
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/widgets/common/basic/markdown_container.dart';
|
import 'package:aves/widgets/common/basic/markdown_container.dart';
|
||||||
import 'package:aves/widgets/common/basic/scaffold.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/common/extensions/build_context.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
@ -38,11 +39,11 @@ class _PolicyPageState extends State<PolicyPage> {
|
||||||
child: FocusableActionDetector(
|
child: FocusableActionDetector(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
shortcuts: const {
|
shortcuts: const {
|
||||||
SingleActivator(LogicalKeyboardKey.arrowUp): _ScrollIntent.up(),
|
SingleActivator(LogicalKeyboardKey.arrowUp): VerticalScrollIntent.up(),
|
||||||
SingleActivator(LogicalKeyboardKey.arrowDown): _ScrollIntent.down(),
|
SingleActivator(LogicalKeyboardKey.arrowDown): VerticalScrollIntent.down(),
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
_ScrollIntent: CallbackAction<_ScrollIntent>(onInvoke: _onScrollIntent),
|
VerticalScrollIntent: VerticalScrollIntentAction(scrollController: _scrollController),
|
||||||
},
|
},
|
||||||
child: Center(
|
child: Center(
|
||||||
child: FutureBuilder<String>(
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scrollView = _buildScrollView(widget.appBar, widget.collection);
|
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) {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scrollView = _buildScrollView(context);
|
final scrollView = _buildScrollView(context);
|
||||||
return _buildDraggableScrollView(scrollView);
|
return settings.useTvLayout ? scrollView : _buildDraggableScrollView(scrollView);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDraggableScrollView(ScrollView scrollView) {
|
Widget _buildDraggableScrollView(ScrollView scrollView) {
|
||||||
|
|
|
@ -97,49 +97,8 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
|
||||||
primary: false,
|
primary: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
const Expanded(
|
||||||
child: ValueListenableBuilder<int>(
|
child: _TvRail(),
|
||||||
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)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
@ -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:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class BasicSection extends StatelessWidget {
|
class BasicSection extends StatefulWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
final CollectionLens? collection;
|
final CollectionLens? collection;
|
||||||
final EntryInfoActionDelegate actionDelegate;
|
final EntryInfoActionDelegate actionDelegate;
|
||||||
|
final ValueNotifier<bool> isScrollingNotifier;
|
||||||
final ValueNotifier<EntryAction?> isEditingMetadataNotifier;
|
final ValueNotifier<EntryAction?> isEditingMetadataNotifier;
|
||||||
final FilterCallback onFilter;
|
final FilterCallback onFilter;
|
||||||
|
|
||||||
|
@ -39,12 +40,54 @@ class BasicSection extends StatelessWidget {
|
||||||
required this.entry,
|
required this.entry,
|
||||||
this.collection,
|
this.collection,
|
||||||
required this.actionDelegate,
|
required this.actionDelegate,
|
||||||
|
required this.isScrollingNotifier,
|
||||||
required this.isEditingMetadataNotifier,
|
required this.isEditingMetadataNotifier,
|
||||||
required this.onFilter,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final entry = widget.entry;
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: entry.metadataChangeNotifier,
|
animation: entry.metadataChangeNotifier,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
|
@ -52,7 +95,12 @@ class BasicSection extends StatelessWidget {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_BasicInfo(entry: entry),
|
_BasicInfo(entry: entry),
|
||||||
_buildChips(context),
|
Focus(
|
||||||
|
focusNode: _chipFocusNode,
|
||||||
|
skipTraversal: true,
|
||||||
|
canRequestFocus: false,
|
||||||
|
child: _buildChips(context),
|
||||||
|
),
|
||||||
_buildEditButtons(context),
|
_buildEditButtons(context),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
@ -60,6 +108,7 @@ class BasicSection extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildChips(BuildContext context) {
|
Widget _buildChips(BuildContext context) {
|
||||||
|
final entry = widget.entry;
|
||||||
final tags = entry.tags.toList()..sort(compareAsciiUpperCaseNatural);
|
final tags = entry.tags.toList()..sort(compareAsciiUpperCaseNatural);
|
||||||
final album = entry.directory;
|
final album = entry.directory;
|
||||||
final filters = {
|
final filters = {
|
||||||
|
@ -91,7 +140,7 @@ class BasicSection extends StatelessWidget {
|
||||||
children: effectiveFilters
|
children: effectiveFilters
|
||||||
.map((filter) => AvesFilterChip(
|
.map((filter) => AvesFilterChip(
|
||||||
filter: filter,
|
filter: filter,
|
||||||
onTap: onFilter,
|
onTap: widget.onFilter,
|
||||||
))
|
))
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
|
@ -101,6 +150,7 @@ class BasicSection extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEditButtons(BuildContext context) {
|
Widget _buildEditButtons(BuildContext context) {
|
||||||
|
final entry = widget.entry;
|
||||||
final children = [
|
final children = [
|
||||||
EntryAction.editRating,
|
EntryAction.editRating,
|
||||||
EntryAction.editTags,
|
EntryAction.editTags,
|
||||||
|
@ -124,8 +174,9 @@ class BasicSection extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEditMetadataButton(BuildContext context, EntryAction action) {
|
Widget _buildEditMetadataButton(BuildContext context, EntryAction action) {
|
||||||
|
final entry = widget.entry;
|
||||||
return ValueListenableBuilder<EntryAction?>(
|
return ValueListenableBuilder<EntryAction?>(
|
||||||
valueListenable: isEditingMetadataNotifier,
|
valueListenable: widget.isEditingMetadataNotifier,
|
||||||
builder: (context, editingAction, child) {
|
builder: (context, editingAction, child) {
|
||||||
final isEditing = editingAction != null;
|
final isEditing = editingAction != null;
|
||||||
final onPressed = isEditing ? null : () => actionDelegate.onActionSelected(context, entry, collection, action);
|
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 {
|
class _BasicInfo extends StatefulWidget {
|
||||||
|
|
|
@ -40,50 +40,17 @@ class InfoPage extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InfoPageState extends State<InfoPage> {
|
class _InfoPageState extends State<InfoPage> {
|
||||||
final FocusNode _focusNode = FocusNode();
|
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
bool _scrollStartFromTop = false;
|
bool _scrollStartFromTop = false;
|
||||||
|
|
||||||
static const splitScreenWidthThreshold = 600;
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_unregisterWidget(widget);
|
|
||||||
_focusNode.dispose();
|
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
super.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AvesScaffold(
|
return AvesScaffold(
|
||||||
|
@ -104,16 +71,13 @@ class _InfoPageState extends State<InfoPage> {
|
||||||
final targetEntry = pageEntry ?? mainEntry;
|
final targetEntry = pageEntry ?? mainEntry;
|
||||||
return EmbeddedDataOpener(
|
return EmbeddedDataOpener(
|
||||||
entry: targetEntry,
|
entry: targetEntry,
|
||||||
child: Focus(
|
child: _InfoPageContent(
|
||||||
focusNode: _focusNode,
|
collection: widget.collection,
|
||||||
child: _InfoPageContent(
|
entry: targetEntry,
|
||||||
collection: widget.collection,
|
isScrollingNotifier: widget.isScrollingNotifier,
|
||||||
entry: targetEntry,
|
scrollController: _scrollController,
|
||||||
isScrollingNotifier: widget.isScrollingNotifier,
|
split: mqWidth > splitScreenWidthThreshold,
|
||||||
scrollController: _scrollController,
|
goToViewer: _goToViewer,
|
||||||
split: mqWidth > splitScreenWidthThreshold,
|
|
||||||
goToViewer: _goToViewer,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -240,6 +204,7 @@ class _InfoPageContentState extends State<_InfoPageContent> {
|
||||||
entry: entry,
|
entry: entry,
|
||||||
collection: collection,
|
collection: collection,
|
||||||
actionDelegate: _actionDelegate,
|
actionDelegate: _actionDelegate,
|
||||||
|
isScrollingNotifier: widget.isScrollingNotifier,
|
||||||
isEditingMetadataNotifier: _isEditingMetadataNotifier,
|
isEditingMetadataNotifier: _isEditingMetadataNotifier,
|
||||||
onFilter: _onFilter,
|
onFilter: _onFilter,
|
||||||
);
|
);
|
||||||
|
|
|
@ -40,16 +40,60 @@ class MetadataDirTile extends StatelessWidget {
|
||||||
var tags = dir.tags;
|
var tags = dir.tags;
|
||||||
if (tags.isEmpty) return const SizedBox();
|
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;
|
final dirName = dir.name;
|
||||||
if (dirName == MetadataDirectory.xmpDirectory) {
|
if (dirName == MetadataDirectory.xmpDirectory) {
|
||||||
return XmpDirTile(
|
return context.select<AvesColorsData, Color>((v) => v.xmp);
|
||||||
entry: entry,
|
} else {
|
||||||
title: title,
|
final colors = context.watch<AvesColorsData>();
|
||||||
allTags: dir.allTags,
|
return dir.color ?? colors.fromBrandColor(BrandColors.get(dirName)) ?? colors.fromString(dirName);
|
||||||
tags: tags,
|
}
|
||||||
expandedNotifier: expandedDirectoryNotifier,
|
}
|
||||||
initiallyExpanded: initiallyExpanded,
|
}
|
||||||
);
|
|
||||||
|
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 {
|
} else {
|
||||||
Map<String, InfoValueSpanBuilder>? linkHandlers;
|
Map<String, InfoValueSpanBuilder>? linkHandlers;
|
||||||
switch (dirName) {
|
switch (dirName) {
|
||||||
|
@ -67,25 +111,23 @@ class MetadataDirTile extends StatelessWidget {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
final colors = context.watch<AvesColorsData>();
|
children = [
|
||||||
return AvesExpansionTile(
|
if (showThumbnails && dirName == MetadataDirectory.exifThumbnailDirectory) MetadataThumbnails(entry: entry),
|
||||||
title: title,
|
Padding(
|
||||||
highlightColor: dir.color ?? colors.fromBrandColor(BrandColors.get(dirName)) ?? colors.fromString(dirName),
|
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||||
expandedNotifier: expandedDirectoryNotifier,
|
child: InfoRowGroup(
|
||||||
initiallyExpanded: initiallyExpanded,
|
info: tags,
|
||||||
children: [
|
maxValueLength: Constants.infoGroupMaxValueLength,
|
||||||
if (showThumbnails && dirName == MetadataDirectory.exifThumbnailDirectory) MetadataThumbnails(entry: entry),
|
spanBuilders: linkHandlers,
|
||||||
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) {
|
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.dart';
|
||||||
import 'package:aves/model/entry_info.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/durations.dart';
|
||||||
import 'package:aves/theme/icons.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/common.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/metadata_dir.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/metadata_dir_tile.dart';
|
||||||
|
import 'package:aves/widgets/viewer/info/metadata/tv_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||||
|
@ -92,15 +95,32 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> {
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
children: [
|
children: settings.useTvLayout
|
||||||
const SectionRow(icon: AIcons.info),
|
? [
|
||||||
...metadata.entries.map((kv) => MetadataDirTile(
|
AvesOutlinedButton(
|
||||||
entry: entry,
|
label: MaterialLocalizations.of(context).moreButtonTooltip,
|
||||||
title: kv.key,
|
onPressed: () {
|
||||||
dir: kv.value,
|
Navigator.maybeOf(context)?.push(
|
||||||
expandedDirectoryNotifier: _expandedDirectoryNotifier,
|
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:collection';
|
||||||
import 'dart:convert';
|
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/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:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
class XmpDirTile extends StatefulWidget {
|
class XmpDirTileBody extends StatefulWidget {
|
||||||
final AvesEntry entry;
|
|
||||||
final String title;
|
|
||||||
final SplayTreeMap<String, String> allTags, tags;
|
final SplayTreeMap<String, String> allTags, tags;
|
||||||
final ValueNotifier<String?>? expandedNotifier;
|
|
||||||
final bool initiallyExpanded;
|
|
||||||
|
|
||||||
const XmpDirTile({
|
const XmpDirTileBody({
|
||||||
super.key,
|
super.key,
|
||||||
required this.entry,
|
|
||||||
required this.title,
|
|
||||||
required this.allTags,
|
required this.allTags,
|
||||||
required this.tags,
|
required this.tags,
|
||||||
required this.expandedNotifier,
|
|
||||||
required this.initiallyExpanded,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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;
|
late final Map<String, String> _schemaRegistryPrefixes, _tags;
|
||||||
|
|
||||||
AvesEntry get entry => widget.entry;
|
|
||||||
|
|
||||||
static const schemaRegistryPrefixesKey = 'schemaRegistryPrefixes';
|
static const schemaRegistryPrefixesKey = 'schemaRegistryPrefixes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -60,21 +46,10 @@ class _XmpDirTileState extends State<XmpDirTile> {
|
||||||
return XmpNamespace.create(_schemaRegistryPrefixes, nsPrefix, rawProps);
|
return XmpNamespace.create(_schemaRegistryPrefixes, nsPrefix, rawProps);
|
||||||
}).toList()
|
}).toList()
|
||||||
..sort((a, b) => compareAsciiUpperCase(a.displayTitle, b.displayTitle));
|
..sort((a, b) => compareAsciiUpperCase(a.displayTitle, b.displayTitle));
|
||||||
return AvesExpansionTile(
|
|
||||||
// title may contain parent to distinguish multiple XMP directories
|
return Column(
|
||||||
title: widget.title,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
highlightColor: context.select<AvesColorsData, Color>((v) => v.xmp),
|
children: sections.expand((section) => section.buildNamespaceSection(context)).toList(),
|
||||||
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(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue