Refine iOS navigation, media controls, and background sync
Build APK / build (push) Successful in 5m50s

This commit is contained in:
Ayushya Amitabh
2026-10-06 23:18:50 -04:00
parent c5b46fd493
commit ce2273c9a6
39 changed files with 2309 additions and 647 deletions
+31 -6
View File
@@ -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,
+4 -1
View File
@@ -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: [
+18 -4
View File
@@ -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(
+161 -48
View File
@@ -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,
),
),
],
),
),
],
),
),
),
);
}
+2 -2
View File
@@ -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
+51
View File
@@ -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,