Refine iOS navigation, media controls, and background sync
Build APK / build (push) Successful in 5m50s
Build APK / build (push) Successful in 5m50s
This commit is contained in:
@@ -50,6 +50,9 @@ class NooButton extends StatefulWidget {
|
||||
/// are responsible for a tooltip/semantic label, since there's no text.
|
||||
final bool iconOnly;
|
||||
|
||||
/// Continuous icon-only to labelled expansion for animated shell actions.
|
||||
final double? expansion;
|
||||
|
||||
const NooButton({
|
||||
super.key,
|
||||
this.variant = NooButtonVariant.primary,
|
||||
@@ -60,6 +63,7 @@ class NooButton extends StatefulWidget {
|
||||
this.disabled = false,
|
||||
this.onTap,
|
||||
this.iconOnly = false,
|
||||
this.expansion,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -108,11 +112,22 @@ class _NooButtonState extends State<NooButton> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.icon != null) Icon(widget.icon, size: s.icon, color: fg),
|
||||
if (widget.icon != null && widget.child != null) SizedBox(width: s.gap),
|
||||
if (widget.child != null)
|
||||
DefaultTextStyle(
|
||||
style: NooText.buttonSm.copyWith(fontSize: s.font, color: fg),
|
||||
child: widget.child!,
|
||||
if (widget.icon != null && widget.child != null)
|
||||
SizedBox(width: s.gap * (widget.expansion ?? 1)),
|
||||
if (widget.child != null && widget.expansion != 0)
|
||||
ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: widget.expansion ?? 1,
|
||||
heightFactor: 1,
|
||||
child: Opacity(
|
||||
opacity: widget.expansion ?? 1,
|
||||
child: DefaultTextStyle(
|
||||
style: NooText.buttonSm.copyWith(fontSize: s.font, color: fg),
|
||||
child: widget.child!,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -135,7 +150,17 @@ class _NooButtonState extends State<NooButton> {
|
||||
width: widget.iconOnly
|
||||
? s.height
|
||||
: (widget.fullWidth ? double.infinity : null),
|
||||
padding: _isTextVariant || widget.iconOnly
|
||||
padding: widget.expansion != null
|
||||
? EdgeInsets.only(
|
||||
left:
|
||||
(s.height - s.icon) / 2 +
|
||||
(s.px - 4 - (s.height - s.icon) / 2) *
|
||||
widget.expansion!,
|
||||
right:
|
||||
(s.height - s.icon) / 2 +
|
||||
(s.px - (s.height - s.icon) / 2) * widget.expansion!,
|
||||
)
|
||||
: _isTextVariant || widget.iconOnly
|
||||
? EdgeInsets.zero
|
||||
: EdgeInsets.only(
|
||||
left: hasLeadingIcon ? s.px - 4 : s.px,
|
||||
|
||||
@@ -23,7 +23,8 @@ const int _kDesktopInlineActions = 4;
|
||||
/// Replaces the bare row of Material `IconButton`s each screen used to
|
||||
/// build ad hoc.
|
||||
///
|
||||
/// A `surface` card (radius 20) - selection reads as a distinct mode, not
|
||||
/// A floating `surface` card (radius 20), with the bottom bar's border
|
||||
/// and shadow - selection reads as a distinct mode, not
|
||||
/// just a row of buttons floating on `bg` - with a close circle, the
|
||||
/// count, and the bulk actions. This sits in the *content* pane's own
|
||||
/// pinned header slot on every platform (the shell's desktop toolbar,
|
||||
@@ -87,6 +88,8 @@ class NooSelectionBar extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
border: Border.all(color: colors.line),
|
||||
boxShadow: const [nooDialogShadow],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
@@ -10,14 +10,17 @@ class NooSettingsRow extends StatelessWidget {
|
||||
final IconData? icon;
|
||||
final Widget label;
|
||||
final Widget? subtitle;
|
||||
|
||||
/// Renders as `value` text + a trailing chevron - mutually exclusive with
|
||||
/// [trailing] (a `NooToggle`, button, badge, etc.).
|
||||
final String? value;
|
||||
final Widget? trailing;
|
||||
final bool danger;
|
||||
|
||||
/// Accent-text action row, e.g. "Add account".
|
||||
final bool accent;
|
||||
final VoidCallback? onTap;
|
||||
final Color? backgroundColor;
|
||||
|
||||
const NooSettingsRow({
|
||||
super.key,
|
||||
@@ -29,12 +32,15 @@ class NooSettingsRow extends StatelessWidget {
|
||||
this.danger = false,
|
||||
this.accent = false,
|
||||
this.onTap,
|
||||
this.backgroundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final color = danger ? colors.danger : (accent ? colors.accentText : colors.fg1);
|
||||
final color = danger
|
||||
? colors.danger
|
||||
: (accent ? colors.accentText : colors.fg1);
|
||||
final minHeight = subtitle != null ? 60.0 : 52.0;
|
||||
|
||||
Widget? trail = trailing;
|
||||
@@ -44,7 +50,11 @@ class NooSettingsRow extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
value!,
|
||||
style: NooText.body.copyWith(fontSize: 15, height: 1, color: colors.fg3),
|
||||
style: NooText.body.copyWith(
|
||||
fontSize: 15,
|
||||
height: 1,
|
||||
color: colors.fg3,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(LucideIcons.chevronRight, size: 18, color: colors.fg3),
|
||||
@@ -53,7 +63,7 @@ class NooSettingsRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
return Material(
|
||||
color: colors.surface,
|
||||
color: backgroundColor ?? colors.surface,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: ConstrainedBox(
|
||||
@@ -66,7 +76,11 @@ class NooSettingsRow extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 20, color: danger || accent ? color : colors.fg2),
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: danger || accent ? color : colors.fg2,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
],
|
||||
Expanded(
|
||||
|
||||
@@ -19,8 +19,8 @@ const double _kAndroidPillHeight = 32;
|
||||
/// what order, is the caller's business; this only draws [destinations].
|
||||
///
|
||||
/// - [NooNavStyle.ios]: surface fill, 1px top `line`, a 50px row (8px top
|
||||
/// padding) of icon 24 over a 10px label. Active: accent-text, 600.
|
||||
/// Idle: fg-3, 500. No ripple.
|
||||
/// padding) of icon 24 over a 10px selected label. Active: accent-text, 600.
|
||||
/// Idle: centered fg-3 icon, no label. No ripple.
|
||||
/// - [NooNavStyle.android]: surface fill, 80px. Every tab shows its 24px
|
||||
/// icon over a 12px label (label only visible - not removed from layout,
|
||||
/// so the row never resizes - once selected). The active icon sits in a
|
||||
@@ -78,6 +78,11 @@ class NooBottomBar extends StatelessWidget {
|
||||
final NooNavDestination? searchDestination;
|
||||
final VoidCallback? onSearchTap;
|
||||
|
||||
/// Bottom-avatar navigation occupies this slot while closed. Search moves
|
||||
/// from the row into the slot as the avatar expands into its popup.
|
||||
final Widget? avatarSatellite;
|
||||
final double avatarMenuProgress;
|
||||
|
||||
const NooBottomBar({
|
||||
super.key,
|
||||
required this.style,
|
||||
@@ -90,6 +95,8 @@ class NooBottomBar extends StatelessWidget {
|
||||
required this.onSelected,
|
||||
this.searchDestination,
|
||||
this.onSearchTap,
|
||||
this.avatarSatellite,
|
||||
this.avatarMenuProgress = 0,
|
||||
});
|
||||
|
||||
/// The row height [build] draws for [style]/[barStyle] - exposed so a
|
||||
@@ -126,7 +133,12 @@ class NooBottomBar extends StatelessWidget {
|
||||
// Attached folds Search into the row itself (last item); floating
|
||||
// gives it a separate satellite circle instead (built below), so the
|
||||
// row builders only ever see it as a trailing item in the former case.
|
||||
final rowSearch = hasSearch && !floating ? searchDestination : null;
|
||||
final rowSearch =
|
||||
hasSearch &&
|
||||
(!floating || avatarSatellite != null) &&
|
||||
(avatarSatellite == null || !floating || avatarMenuProgress < 1)
|
||||
? searchDestination
|
||||
: null;
|
||||
|
||||
final row = ios
|
||||
? _buildIosRow(trailingSearch: rowSearch)
|
||||
@@ -162,7 +174,9 @@ class NooBottomBar extends StatelessWidget {
|
||||
16,
|
||||
NooBottomBar.floatingBottomMargin,
|
||||
),
|
||||
child: hasSearch
|
||||
child: avatarSatellite != null
|
||||
? _withAvatar(pill, barHeight, hasSearch)
|
||||
: hasSearch
|
||||
// A fixed-height SizedBox, not just a Row with
|
||||
// crossAxisAlignment.stretch - the bottomNavigationBar slot
|
||||
// gives this widget a *loose* (unbounded-max) height
|
||||
@@ -202,37 +216,126 @@ class NooBottomBar extends StatelessWidget {
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(height: barHeight, child: row),
|
||||
child: SizedBox(
|
||||
height: barHeight,
|
||||
child: avatarSatellite == null
|
||||
? row
|
||||
: Row(
|
||||
children: [
|
||||
Expanded(child: row),
|
||||
SizedBox(
|
||||
width: barHeight,
|
||||
height: barHeight,
|
||||
child: avatarSatellite,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return frosted ? _blurred(bar, BorderRadius.zero, frostedBlur) : bar;
|
||||
}
|
||||
|
||||
Widget _withAvatar(Widget rail, double height, bool hasSearch) {
|
||||
final satelliteWidth = hasSearch
|
||||
? height
|
||||
: height * (1 - avatarMenuProgress);
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: rail),
|
||||
SizedBox(width: 8 * (hasSearch ? 1 : 1 - avatarMenuProgress)),
|
||||
SizedBox(
|
||||
width: satelliteWidth,
|
||||
height: height,
|
||||
child: ClipRect(
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.centerRight,
|
||||
minWidth: height,
|
||||
maxWidth: height,
|
||||
child: Stack(
|
||||
children: [
|
||||
avatarSatellite!,
|
||||
if (hasSearch && avatarMenuProgress > 0)
|
||||
Opacity(
|
||||
opacity: avatarMenuProgress,
|
||||
child: _SearchSatellite(
|
||||
destination: searchDestination!,
|
||||
onTap: onSearchTap!,
|
||||
size: height,
|
||||
frosted: frosted,
|
||||
frostedBlur: frostedBlur,
|
||||
frostedOpacity: frostedOpacity,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _fill(NooColors colors) => frosted
|
||||
? colors.surface.withValues(alpha: frostedOpacity)
|
||||
: colors.surface;
|
||||
|
||||
double get _rowSearchFraction =>
|
||||
avatarSatellite == null || barStyle == NooBottomBarStyle.attached
|
||||
? 1
|
||||
: 1 - avatarMenuProgress;
|
||||
|
||||
Widget _searchRowItem(Widget child, double width) => SizedBox(
|
||||
width: width * _rowSearchFraction,
|
||||
child: IgnorePointer(
|
||||
ignoring: _rowSearchFraction < .95,
|
||||
child: ClipRect(
|
||||
child: OverflowBox(
|
||||
minWidth: width,
|
||||
maxWidth: width,
|
||||
child: Opacity(opacity: _rowSearchFraction, child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildIosRow({NooNavDestination? trailingSearch}) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < destinations.length; i++)
|
||||
Expanded(
|
||||
child: _IosItem(
|
||||
destination: destinations[i],
|
||||
selected: i == selectedIndex,
|
||||
onTap: () => onSelected(i),
|
||||
),
|
||||
),
|
||||
if (trailingSearch != null)
|
||||
Expanded(
|
||||
child: _IosItem(
|
||||
destination: trailingSearch,
|
||||
selected: false,
|
||||
onTap: onSearchTap!,
|
||||
),
|
||||
),
|
||||
],
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width =
|
||||
constraints.maxWidth /
|
||||
(destinations.length +
|
||||
(trailingSearch == null ? 0 : _rowSearchFraction));
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < destinations.length; i++)
|
||||
SizedBox(
|
||||
width: width,
|
||||
child: _IosItem(
|
||||
destination: destinations[i],
|
||||
selected: i == selectedIndex,
|
||||
selectedLabelOnly: true,
|
||||
onTap: () => onSelected(i),
|
||||
),
|
||||
),
|
||||
if (trailingSearch != null)
|
||||
_searchRowItem(
|
||||
_IosItem(
|
||||
destination: trailingSearch,
|
||||
selected: false,
|
||||
selectedLabelOnly: true,
|
||||
onTap: onSearchTap!,
|
||||
),
|
||||
width,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -242,7 +345,8 @@ class NooBottomBar extends StatelessWidget {
|
||||
NooNavDestination? trailingSearch,
|
||||
}) {
|
||||
final pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop;
|
||||
final itemCount = destinations.length + (trailingSearch != null ? 1 : 0);
|
||||
final itemCount =
|
||||
destinations.length + (trailingSearch != null ? _rowSearchFraction : 0);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final itemWidth = constraints.maxWidth / itemCount;
|
||||
@@ -268,7 +372,8 @@ class NooBottomBar extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < destinations.length; i++)
|
||||
Expanded(
|
||||
SizedBox(
|
||||
width: itemWidth,
|
||||
child: _AndroidItem(
|
||||
destination: destinations[i],
|
||||
selected: i == selectedIndex,
|
||||
@@ -277,13 +382,14 @@ class NooBottomBar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
if (trailingSearch != null)
|
||||
Expanded(
|
||||
child: _AndroidItem(
|
||||
_searchRowItem(
|
||||
_AndroidItem(
|
||||
destination: trailingSearch,
|
||||
selected: false,
|
||||
floating: floating,
|
||||
onTap: onSearchTap!,
|
||||
),
|
||||
itemWidth,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -362,11 +468,13 @@ class _SearchSatellite extends StatelessWidget {
|
||||
class _IosItem extends StatelessWidget {
|
||||
final NooNavDestination destination;
|
||||
final bool selected;
|
||||
final bool selectedLabelOnly;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _IosItem({
|
||||
required this.destination,
|
||||
required this.selected,
|
||||
this.selectedLabelOnly = false,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@@ -377,30 +485,35 @@ class _IosItem extends StatelessWidget {
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: selectedLabelOnly && !selected ? destination.label : null,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Icon(destination.icon, size: 24, color: fg),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
destination.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.navLabel.copyWith(
|
||||
fontSize: 10,
|
||||
height: 1,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: fg,
|
||||
child: selectedLabelOnly && !selected
|
||||
? Center(child: Icon(destination.icon, size: 24, color: fg))
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Icon(destination.icon, size: 24, color: fg),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
destination.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.navLabel.copyWith(
|
||||
fontSize: 10,
|
||||
height: 1,
|
||||
fontWeight: selected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
color: fg,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ enum NooBottomBarStyle { attached, floating }
|
||||
/// as [NooBottomBarStyle]. [drawer] is the original pattern: a `menu`
|
||||
/// icon, top-left of the top bar, opens a left-edge `Drawer`. [avatarMenu]
|
||||
/// drops that icon entirely and repurposes the avatar button - already
|
||||
/// sitting top-right, already a tap target every user already knows - as
|
||||
/// the one entry point instead, opening a dropdown anchored below it (see
|
||||
/// available at the top or bottom by user preference - as the one entry
|
||||
/// point instead, expanding into an anchored navigation card (see
|
||||
/// `showAvatarMenu` in `widgets/avatar_menu.dart`) rather than a drawer
|
||||
/// sliding from the opposite edge. Applies on both platforms, since
|
||||
/// [NooTopBar]'s `onMenu`/avatar wiring is shared chrome, not
|
||||
|
||||
@@ -52,6 +52,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
/// Icons inherit the bar's icon color (accent-text on iOS, fg-1 on
|
||||
/// Android) through [IconTheme].
|
||||
final List<Widget> actions;
|
||||
final bool iosActionsInTitleRow;
|
||||
|
||||
/// iOS only: the search field shown below the large title.
|
||||
final Widget? search;
|
||||
@@ -76,6 +77,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
this.leading,
|
||||
this.onMenu,
|
||||
this.actions = const [],
|
||||
this.iosActionsInTitleRow = false,
|
||||
this.search,
|
||||
this.androidTitleTrailing,
|
||||
this.backgroundColor,
|
||||
@@ -86,6 +88,11 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
@override
|
||||
Size get preferredSize {
|
||||
if (!_ios) return const Size.fromHeight(_androidRow);
|
||||
if (iosActionsInTitleRow) {
|
||||
return Size.fromHeight(
|
||||
64 + (search != null ? _iosSearch + _iosSearchBottom : 0),
|
||||
);
|
||||
}
|
||||
return Size.fromHeight(
|
||||
_iosRow +
|
||||
_iosTitleTop +
|
||||
@@ -127,6 +134,50 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
}
|
||||
|
||||
Widget _buildIos(NooColors colors, Widget? lead) {
|
||||
if (iosActionsInTitleRow) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 64,
|
||||
child: Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
start: lead == null ? 16 : 6,
|
||||
end: 6,
|
||||
),
|
||||
child: Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
?lead,
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.largeTitle.copyWith(
|
||||
leadingDistribution: TextLeadingDistribution.even,
|
||||
color: colors.fg1,
|
||||
),
|
||||
),
|
||||
),
|
||||
...actions,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (search != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.md,
|
||||
0,
|
||||
NooSpace.md,
|
||||
_iosSearchBottom,
|
||||
),
|
||||
child: SizedBox(height: _iosSearch, child: search),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
||||
Reference in New Issue
Block a user