#437 tv: grid headers, settings rail
This commit is contained in:
parent
52c59e2cc5
commit
737dac8da1
5 changed files with 182 additions and 112 deletions
|
@ -91,7 +91,7 @@ class CollectionSectionHeader extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||||
headerExtent = max(headerExtent, SectionHeader.leadingSize.height * textScaleFactor) + SectionHeader.padding.vertical;
|
headerExtent = max(headerExtent, SectionHeader.leadingSize.height * textScaleFactor) + SectionHeader.padding.vertical + SectionHeader.margin.vertical;
|
||||||
return headerExtent;
|
return headerExtent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:aves/model/device.dart';
|
||||||
import 'package:aves/model/selection.dart';
|
import 'package:aves/model/selection.dart';
|
||||||
import 'package:aves/model/source/section_keys.dart';
|
import 'package:aves/model/source/section_keys.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
|
@ -25,17 +26,42 @@ class SectionHeader<T> extends StatelessWidget {
|
||||||
});
|
});
|
||||||
|
|
||||||
static const leadingSize = Size(48, 32);
|
static const leadingSize = Size(48, 32);
|
||||||
static const padding = EdgeInsets.all(16);
|
static const margin = EdgeInsets.symmetric(vertical: 0, horizontal: 8);
|
||||||
|
static const padding = EdgeInsets.symmetric(vertical: 16, horizontal: 8);
|
||||||
static const widgetSpanAlignment = PlaceholderAlignment.middle;
|
static const widgetSpanAlignment = PlaceholderAlignment.middle;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
Widget child = _buildContent(context);
|
||||||
|
if (device.isTelevision) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
child = Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: InkResponse(
|
||||||
|
onTap: _onTap(context),
|
||||||
|
onHover: (_) {},
|
||||||
|
highlightShape: BoxShape.rectangle,
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(123)),
|
||||||
|
containedInkWell: true,
|
||||||
|
splashColor: colors.primary.withOpacity(0.12),
|
||||||
|
hoverColor: colors.primary.withOpacity(0.04),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
return Container(
|
return Container(
|
||||||
alignment: AlignmentDirectional.centerStart,
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
margin: margin,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(BuildContext context) {
|
||||||
|
return Container(
|
||||||
padding: padding,
|
padding: padding,
|
||||||
constraints: BoxConstraints(minHeight: leadingSize.height),
|
constraints: BoxConstraints(minHeight: leadingSize.height),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: selectable ? () => _toggleSectionSelection(context) : null,
|
onTap: _onTap(context),
|
||||||
onLongPress: selectable
|
onLongPress: selectable
|
||||||
? () {
|
? () {
|
||||||
final selection = context.read<Selection<T>>();
|
final selection = context.read<Selection<T>>();
|
||||||
|
@ -63,7 +89,7 @@ class SectionHeader<T> extends StatelessWidget {
|
||||||
child: leading,
|
child: leading,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onPressed: selectable ? () => _toggleSectionSelection(context) : null,
|
onPressed: _onTap(context),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextSpan(
|
TextSpan(
|
||||||
|
@ -85,6 +111,8 @@ class SectionHeader<T> extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VoidCallback? _onTap(BuildContext context) => selectable ? () => _toggleSectionSelection(context) : null;
|
||||||
|
|
||||||
List<T> _getSectionEntries(BuildContext context) => context.read<SectionedListLayout<T>>().sections[sectionKey] ?? [];
|
List<T> _getSectionEntries(BuildContext context) => context.read<SectionedListLayout<T>>().sections[sectionKey] ?? [];
|
||||||
|
|
||||||
void _toggleSectionSelection(BuildContext context) {
|
void _toggleSectionSelection(BuildContext context) {
|
||||||
|
@ -107,7 +135,7 @@ class SectionHeader<T> extends StatelessWidget {
|
||||||
bool hasTrailing = false,
|
bool hasTrailing = false,
|
||||||
}) {
|
}) {
|
||||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||||
final maxContentWidth = maxWidth - SectionHeader.padding.horizontal;
|
final maxContentWidth = maxWidth - (SectionHeader.padding.horizontal + SectionHeader.margin.horizontal);
|
||||||
final para = RenderParagraph(
|
final para = RenderParagraph(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
children: [
|
children: [
|
||||||
|
@ -159,7 +187,10 @@ class _SectionSelectableLeading<T> extends StatelessWidget {
|
||||||
)
|
)
|
||||||
: _buildBrowsing(context);
|
: _buildBrowsing(context);
|
||||||
|
|
||||||
return AnimatedSwitcher(
|
return FocusTraversalGroup(
|
||||||
|
descendantsAreFocusable: false,
|
||||||
|
descendantsAreTraversable: false,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
duration: Durations.sectionHeaderAnimation,
|
duration: Durations.sectionHeaderAnimation,
|
||||||
switchInCurve: Curves.easeInOut,
|
switchInCurve: Curves.easeInOut,
|
||||||
switchOutCurve: Curves.easeInOut,
|
switchOutCurve: Curves.easeInOut,
|
||||||
|
@ -180,6 +211,7 @@ class _SectionSelectableLeading<T> extends StatelessWidget {
|
||||||
return transition;
|
return transition;
|
||||||
},
|
},
|
||||||
child: child,
|
child: child,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,6 @@ class FilterChipSectionHeader<T> extends StatelessWidget {
|
||||||
|
|
||||||
static double getPreferredHeight(BuildContext context) {
|
static double getPreferredHeight(BuildContext context) {
|
||||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||||
return SectionHeader.leadingSize.height * textScaleFactor + SectionHeader.padding.vertical;
|
return SectionHeader.leadingSize.height * textScaleFactor + SectionHeader.padding.vertical + SectionHeader.margin.vertical;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -85,6 +85,7 @@ class _TvRailState extends State<TvRail> {
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
header,
|
header,
|
||||||
|
const SizedBox(height: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
|
|
@ -15,7 +15,6 @@ import 'package:aves/widgets/common/basic/insets.dart';
|
||||||
import 'package:aves/widgets/common/basic/menu.dart';
|
import 'package:aves/widgets/common/basic/menu.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/extensions/media_query.dart';
|
import 'package:aves/widgets/common/extensions/media_query.dart';
|
||||||
import 'package:aves/widgets/common/identity/highlight_title.dart';
|
|
||||||
import 'package:aves/widgets/common/search/route.dart';
|
import 'package:aves/widgets/common/search/route.dart';
|
||||||
import 'package:aves/widgets/navigation/tv_rail.dart';
|
import 'package:aves/widgets/navigation/tv_rail.dart';
|
||||||
import 'package:aves/widgets/settings/accessibility/accessibility.dart';
|
import 'package:aves/widgets/settings/accessibility/accessibility.dart';
|
||||||
|
@ -48,7 +47,7 @@ class SettingsPage extends StatefulWidget {
|
||||||
|
|
||||||
class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
|
class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
|
||||||
final ValueNotifier<String?> _expandedNotifier = ValueNotifier(null);
|
final ValueNotifier<String?> _expandedNotifier = ValueNotifier(null);
|
||||||
Future<List<List<Widget> Function(BuildContext)?>>? _tvSettingsLoader;
|
final ValueNotifier<int> _tvSelectedIndexNotifier = ValueNotifier(0);
|
||||||
|
|
||||||
static final List<SettingsSection> sections = [
|
static final List<SettingsSection> sections = [
|
||||||
NavigationSection(),
|
NavigationSection(),
|
||||||
|
@ -62,11 +61,14 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
void dispose() {
|
||||||
if (device.isTelevision) {
|
_expandedNotifier.dispose();
|
||||||
_initTvSettings(context);
|
_tvSelectedIndexNotifier.dispose();
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final appBarTitle = Text(context.l10n.settingsPageTitle);
|
final appBarTitle = Text(context.l10n.settingsPageTitle);
|
||||||
|
|
||||||
if (device.isTelevision) {
|
if (device.isTelevision) {
|
||||||
|
@ -75,23 +77,53 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
|
||||||
children: [
|
children: [
|
||||||
const TvRail(),
|
const TvRail(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FutureBuilder<List<List<Widget> Function(BuildContext)?>>(
|
child: Column(
|
||||||
future: _tvSettingsLoader,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
final loaders = snapshot.data;
|
|
||||||
if (loaders == null) return const SizedBox();
|
|
||||||
|
|
||||||
return _buildListView(
|
|
||||||
children: [
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
AppBar(
|
AppBar(
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
title: appBarTitle,
|
title: appBarTitle,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
...loaders.whereNotNull().expand((builder) => builder(context)),
|
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,
|
||||||
|
);
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
SingleChildScrollView(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
|
child: IntrinsicHeight(child: rail),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _SettingsSectionBody(
|
||||||
|
loader: Future.value(sections[selectedIndex].tiles(context)),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -136,75 +168,16 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
|
||||||
body: GestureAreaProtectorStack(
|
body: GestureAreaProtectorStack(
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: _buildListView(
|
child: AnimationLimiter(
|
||||||
|
child: _SettingsListView(
|
||||||
children: sections.map((v) => v.build(context, _expandedNotifier)).toList(),
|
children: sections.map((v) => v.build(context, _expandedNotifier)).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _initTvSettings(BuildContext context) {
|
|
||||||
_tvSettingsLoader ??= Future.wait(sections.map((section) async {
|
|
||||||
final tiles = await section.tiles(context);
|
|
||||||
return (context) {
|
|
||||||
return <Widget>[
|
|
||||||
Padding(
|
|
||||||
// match header layout in Settings page
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 13),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
section.icon(context),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: HighlightTitle(
|
|
||||||
title: section.title(context),
|
|
||||||
showHighlight: false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
...tiles.map((v) => v.build(context)),
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildListView({required List<Widget> children}) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Theme(
|
|
||||||
data: theme.copyWith(
|
|
||||||
textTheme: theme.textTheme.copyWith(
|
|
||||||
// dense style font for tile subtitles, without modifying title font
|
|
||||||
bodyMedium: const TextStyle(fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: AnimationLimiter(
|
|
||||||
child: Selector<MediaQueryData, double>(
|
|
||||||
selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom),
|
|
||||||
builder: (context, mqPaddingBottom, child) {
|
|
||||||
final durations = context.watch<DurationsData>();
|
|
||||||
return ListView(
|
|
||||||
padding: const EdgeInsets.all(8) + EdgeInsets.only(bottom: mqPaddingBottom),
|
|
||||||
children: AnimationConfiguration.toStaggeredList(
|
|
||||||
duration: durations.staggeredAnimation,
|
|
||||||
delay: durations.staggeredAnimationDelay * timeDilation,
|
|
||||||
childAnimationBuilder: (child) => SlideAnimation(
|
|
||||||
verticalOffset: 50.0,
|
|
||||||
child: FadeInAnimation(
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
children: children,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static const String exportVersionKey = 'version';
|
static const String exportVersionKey = 'version';
|
||||||
static const int exportVersion = 1;
|
static const int exportVersion = 1;
|
||||||
|
@ -304,3 +277,67 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SettingsListView extends StatelessWidget {
|
||||||
|
final List<Widget> children;
|
||||||
|
|
||||||
|
const _SettingsListView({
|
||||||
|
super.key,
|
||||||
|
required this.children,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Theme(
|
||||||
|
data: theme.copyWith(
|
||||||
|
textTheme: theme.textTheme.copyWith(
|
||||||
|
// dense style font for tile subtitles, without modifying title font
|
||||||
|
bodyMedium: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Selector<MediaQueryData, double>(
|
||||||
|
selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom),
|
||||||
|
builder: (context, mqPaddingBottom, child) {
|
||||||
|
final durations = context.watch<DurationsData>();
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(8) + EdgeInsets.only(bottom: mqPaddingBottom),
|
||||||
|
children: AnimationConfiguration.toStaggeredList(
|
||||||
|
duration: durations.staggeredAnimation,
|
||||||
|
delay: durations.staggeredAnimationDelay * timeDilation,
|
||||||
|
childAnimationBuilder: (child) => SlideAnimation(
|
||||||
|
verticalOffset: 50.0,
|
||||||
|
child: FadeInAnimation(
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: children,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsSectionBody extends StatelessWidget {
|
||||||
|
final Future<List<SettingsTile>> loader;
|
||||||
|
|
||||||
|
const _SettingsSectionBody({required this.loader});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FutureBuilder<List<SettingsTile>>(
|
||||||
|
future: loader,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final tiles = snapshot.data;
|
||||||
|
if (tiles == null) return const SizedBox();
|
||||||
|
|
||||||
|
return _SettingsListView(
|
||||||
|
key: ValueKey(loader),
|
||||||
|
children: tiles.map((v) => v.build(context)).toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue