#437 tv: grid headers, settings rail

This commit is contained in:
Thibault Deckers 2022-12-14 12:30:04 +01:00
parent 52c59e2cc5
commit 737dac8da1
5 changed files with 182 additions and 112 deletions

View file

@ -91,7 +91,7 @@ class CollectionSectionHeader extends StatelessWidget {
}
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;
}
}

View file

@ -1,3 +1,4 @@
import 'package:aves/model/device.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/theme/durations.dart';
@ -25,17 +26,42 @@ class SectionHeader<T> extends StatelessWidget {
});
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;
@override
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(
alignment: AlignmentDirectional.centerStart,
margin: margin,
child: child,
);
}
Widget _buildContent(BuildContext context) {
return Container(
padding: padding,
constraints: BoxConstraints(minHeight: leadingSize.height),
child: GestureDetector(
onTap: selectable ? () => _toggleSectionSelection(context) : null,
onTap: _onTap(context),
onLongPress: selectable
? () {
final selection = context.read<Selection<T>>();
@ -63,7 +89,7 @@ class SectionHeader<T> extends StatelessWidget {
child: leading,
)
: null,
onPressed: selectable ? () => _toggleSectionSelection(context) : null,
onPressed: _onTap(context),
),
),
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] ?? [];
void _toggleSectionSelection(BuildContext context) {
@ -107,7 +135,7 @@ class SectionHeader<T> extends StatelessWidget {
bool hasTrailing = false,
}) {
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
final maxContentWidth = maxWidth - SectionHeader.padding.horizontal;
final maxContentWidth = maxWidth - (SectionHeader.padding.horizontal + SectionHeader.margin.horizontal);
final para = RenderParagraph(
TextSpan(
children: [
@ -159,27 +187,31 @@ class _SectionSelectableLeading<T> extends StatelessWidget {
)
: _buildBrowsing(context);
return AnimatedSwitcher(
duration: Durations.sectionHeaderAnimation,
switchInCurve: Curves.easeInOut,
switchOutCurve: Curves.easeInOut,
transitionBuilder: (child, animation) {
Widget transition = ScaleTransition(
scale: animation,
child: child,
);
if (browsingBuilder == null) {
// when switching with a header that has no icon,
// we also transition the size for a smooth push to the text
transition = SizeTransition(
axis: Axis.horizontal,
sizeFactor: animation,
child: transition,
return FocusTraversalGroup(
descendantsAreFocusable: false,
descendantsAreTraversable: false,
child: AnimatedSwitcher(
duration: Durations.sectionHeaderAnimation,
switchInCurve: Curves.easeInOut,
switchOutCurve: Curves.easeInOut,
transitionBuilder: (child, animation) {
Widget transition = ScaleTransition(
scale: animation,
child: child,
);
}
return transition;
},
child: child,
if (browsingBuilder == null) {
// when switching with a header that has no icon,
// we also transition the size for a smooth push to the text
transition = SizeTransition(
axis: Axis.horizontal,
sizeFactor: animation,
child: transition,
);
}
return transition;
},
child: child,
),
);
}

View file

@ -24,6 +24,6 @@ class FilterChipSectionHeader<T> extends StatelessWidget {
static double getPreferredHeight(BuildContext 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;
}
}

View file

@ -85,6 +85,7 @@ class _TvRailState extends State<TvRail> {
children: [
const SizedBox(height: 8),
header,
const SizedBox(height: 4),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {

View file

@ -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/extensions/build_context.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/navigation/tv_rail.dart';
import 'package:aves/widgets/settings/accessibility/accessibility.dart';
@ -48,7 +47,7 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
final ValueNotifier<String?> _expandedNotifier = ValueNotifier(null);
Future<List<List<Widget> Function(BuildContext)?>>? _tvSettingsLoader;
final ValueNotifier<int> _tvSelectedIndexNotifier = ValueNotifier(0);
static final List<SettingsSection> sections = [
NavigationSection(),
@ -62,11 +61,14 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
];
@override
Widget build(BuildContext context) {
if (device.isTelevision) {
_initTvSettings(context);
}
void dispose() {
_expandedNotifier.dispose();
_tvSelectedIndexNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final appBarTitle = Text(context.l10n.settingsPageTitle);
if (device.isTelevision) {
@ -75,23 +77,53 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
children: [
const TvRail(),
Expanded(
child: FutureBuilder<List<List<Widget> Function(BuildContext)?>>(
future: _tvSettingsLoader,
builder: (context, snapshot) {
final loaders = snapshot.data;
if (loaders == null) return const SizedBox();
return _buildListView(
children: [
AppBar(
automaticallyImplyLeading: false,
title: appBarTitle,
elevation: 0,
),
...loaders.whereNotNull().expand((builder) => builder(context)),
],
);
},
child: Column(
children: [
const SizedBox(height: 8),
AppBar(
automaticallyImplyLeading: false,
title: appBarTitle,
elevation: 0,
),
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,8 +168,10 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
body: GestureAreaProtectorStack(
child: SafeArea(
bottom: false,
child: _buildListView(
children: sections.map((v) => v.build(context, _expandedNotifier)).toList(),
child: AnimationLimiter(
child: _SettingsListView(
children: sections.map((v) => v.build(context, _expandedNotifier)).toList(),
),
),
),
),
@ -145,67 +179,6 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
}
}
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 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(),
);
},
);
}
}