#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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue