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
+13 -2
View File
@@ -24,6 +24,7 @@ Widget buildAppTabView(
AppTab tab,
ScrollController controller, {
PreferredSizeWidget? topBar,
ValueChanged<bool>? onSelectionChanged,
}) {
switch (tab) {
case AppTab.files:
@@ -33,11 +34,20 @@ Widget buildAppTabView(
key: const ValueKey('files'),
scrollController: controller,
topBar: topBar,
onSelectionChanged: onSelectionChanged,
);
case AppTab.photos:
return PhotosView(scrollController: controller, topBar: topBar);
return PhotosView(
scrollController: controller,
topBar: topBar,
onSelectionChanged: onSelectionChanged,
);
case AppTab.favorites:
return FavoritesView(scrollController: controller, topBar: topBar);
return FavoritesView(
scrollController: controller,
topBar: topBar,
onSelectionChanged: onSelectionChanged,
);
case AppTab.activity:
return ActivityView(scrollController: controller, topBar: topBar);
case AppTab.trash:
@@ -52,6 +62,7 @@ Widget buildAppTabView(
scrollController: controller,
offline: true,
topBar: topBar,
onSelectionChanged: onSelectionChanged,
);
}
}
+77 -17
View File
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/app_tab.dart';
import '../providers/settings_controller.dart';
import 'avatar_menu.dart';
import 'create_menu.dart';
import 'noo/nav/noo_top_bar.dart';
import 'noo/core/noo_button.dart';
import '../theme/design_tokens.dart';
import 'shell/shell_common.dart';
/// The shell's mobile top bar (DESIGN_SYSTEM.md 3, "Mobile"): every regular
@@ -11,9 +14,9 @@ import 'shell/shell_common.dart';
/// tabs built their own via `SyncedHeaderScaffold`, which the rework drops).
/// Built on [NooTopBar]:
///
/// - iOS: `menu` leading, `plus` (Files only - no tab besides Files has a
/// create/upload flow) and the account avatar trailing, then the large
/// title, then an inline search field that opens [SearchView].
/// - iOS: large title, Upload button and account avatar in one row, then
/// an inline search field that opens [SearchView]. Upload shares Android's
/// Auto/Collapsed/Expanded preference.
/// - Android: `menu`, title, avatar actions. Upload has no top-bar icon
/// here - it's the [NooFab] `MainShellView` shows instead. When search
/// isn't in the bottom bar, an inline search-field-styled launcher
@@ -37,6 +40,8 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
final AppTab tab;
final bool searchInBottomBar;
final NooNavMenuStyle navMenuStyle;
final AvatarPosition avatarPosition;
final FabStyle uploadButtonStyle;
const AppTopBar({
super.key,
@@ -44,15 +49,23 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
required this.tab,
this.searchInBottomBar = false,
this.navMenuStyle = NooNavMenuStyle.drawer,
this.avatarPosition = AvatarPosition.top,
this.uploadButtonStyle = FabStyle.auto,
});
bool get _ios => style == NooNavStyle.ios;
bool get _showSearch => !searchInBottomBar;
bool get _uploadCollapsed => switch (uploadButtonStyle) {
FabStyle.auto => tab != AppTab.files && tab != AppTab.photos,
FabStyle.mini => true,
FabStyle.expanded => false,
};
@override
Size get preferredSize => NooTopBar(
style: style,
title: tab.label,
iosActionsInTitleRow: true,
// A throwaway placeholder purely to make preferredSize's "is there a
// search slot" check match what build() actually shows - never
// inserted into the tree, so its content doesn't matter.
@@ -64,6 +77,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
return NooTopBar(
style: style,
title: tab.label,
iosActionsInTitleRow: true,
onMenu: navMenuStyle == NooNavMenuStyle.drawer
? () => Scaffold.of(context).openDrawer()
: null,
@@ -74,23 +88,69 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
? const ShellSearchLauncher()
: null,
actions: [
if (_ios && tab == AppTab.files)
NooTopBarButton(
icon: LucideIcons.plus,
tooltip: 'Create',
onTap: () => showCreateMenu(context),
if (_ios)
Tooltip(
message: 'Upload',
child: Semantics(
button: true,
label: 'Upload',
child: Builder(
builder: (context) {
Widget button(double progress) => NooButton(
variant: NooButtonVariant.tonal,
size: NooButtonSize.compact,
icon: LucideIcons.plus,
expansion: progress,
onTap: () => showCreateMenu(context),
child: const Text('Upload'),
);
final scope = context
.dependOnInheritedWidgetOfExactType<
UploadButtonAnimation
>();
if (scope != null) return button(scope.progress);
return TweenAnimationBuilder<double>(
tween: Tween(end: _uploadCollapsed ? 0 : 1),
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: NooMotion.base,
curve: NooMotion.ease,
builder: (context, progress, _) => button(progress),
);
},
),
),
),
if (navMenuStyle != NooNavMenuStyle.avatarMenu ||
avatarPosition == AvatarPosition.top)
AvatarNavigationAnchor(
child: Builder(
builder: (anchorContext) => ShellAvatarButton(
hitBox: _ios ? 44 : 48,
onTap: navMenuStyle == NooNavMenuStyle.avatarMenu
? () => showAvatarMenu(anchorContext)
: null,
label: navMenuStyle == NooNavMenuStyle.avatarMenu
? 'Menu'
: 'Accounts',
),
),
),
ShellAvatarButton(
hitBox: _ios ? 44 : 48,
onTap: navMenuStyle == NooNavMenuStyle.avatarMenu
? () => showAvatarMenu(context)
: null,
label: navMenuStyle == NooNavMenuStyle.avatarMenu
? 'Menu'
: 'Accounts',
),
],
search: _ios && _showSearch ? const ShellSearchLauncher(ios: true) : null,
);
}
}
/// Shares one transition across the shell's separately mounted tab headers.
class UploadButtonAnimation extends InheritedWidget {
final double progress;
const UploadButtonAnimation({
super.key,
required this.progress,
required super.child,
});
@override
bool updateShouldNotify(UploadButtonAnimation oldWidget) =>
progress != oldWidget.progress;
}
+622 -240
View File
@@ -1,3 +1,4 @@
import 'dart:ui' show ImageFilter, lerpDouble;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
@@ -6,53 +7,83 @@ import '../providers/session_controller.dart';
import '../providers/settings_controller.dart';
import '../providers/trash_controller.dart';
import '../theme/design_tokens.dart';
import 'noo/core/noo_avatar.dart';
import '../views/login_view.dart';
import 'noo/core/noo_avatar.dart';
import 'noo/core/noo_badge.dart';
import 'noo/nav/noo_bottom_bar.dart';
import 'noo/noo_layout.dart';
import 'noo/lists/noo_settings_row.dart';
import 'shell/shell_common.dart';
/// The dropdown [ShellAvatarButton] opens when
/// [SettingsController.navMenuStyle] is [NooNavMenuStyle.avatarMenu] -
/// everything [AppDrawer] holds beyond its storage bar (hidden tabs,
/// Settings), reached from the avatar instead of a hamburger-triggered
/// left `Drawer`. Built with `showGeneralDialog` rather than a `Drawer`/
/// `showNooSheet`/`showNooDialog` - this needs a transparent (non-dimming)
/// barrier and a card anchored under the top bar rather than a modal
/// sheet/dialog, and there's no existing anchored-popup primitive in this
/// app to reuse (`PopupMenuButton`'s own width doesn't stretch to the full
/// content column the way this needs to).
Future<void> showAvatarMenu(BuildContext context) {
return showGeneralDialog<void>(
/// The shell supplies the expanding-card presentation. Standalone top bars
/// retain a dialog fallback (for screens outside the shell).
Future<void> showAvatarMenu(BuildContext context) async {
final scope = AvatarNavigationScope.maybeOf(context);
if (scope != null) {
scope._host.openFrom(context);
return;
}
await showGeneralDialog<void>(
context: context,
barrierColor: Colors.transparent,
barrierDismissible: true,
barrierLabel: 'Close menu',
transitionDuration: NooMotion.fast,
pageBuilder: (context, _, _) => const _AvatarMenuContent(),
transitionDuration: NooMotion.base,
pageBuilder: (context, _, _) => Align(
alignment: Alignment.topCenter,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
child: _StandaloneAvatarCard(),
),
),
),
transitionBuilder: (context, animation, _, child) => FadeTransition(
opacity: animation,
child: ScaleTransition(
alignment: Alignment.topRight,
scale: Tween<double>(
begin: 0.96,
end: 1,
).animate(CurvedAnimation(parent: animation, curve: NooMotion.ease)),
scale: Tween(begin: .88, end: 1.0).animate(
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
),
child: child,
),
),
);
}
class _AvatarMenuContent extends StatefulWidget {
const _AvatarMenuContent();
class _StandaloneAvatarCard extends StatefulWidget {
@override
State<_AvatarMenuContent> createState() => _AvatarMenuContentState();
State<_StandaloneAvatarCard> createState() => _StandaloneAvatarCardState();
}
class _AvatarMenuContentState extends State<_AvatarMenuContent> {
bool _expanded = false;
class _StandaloneAvatarCardState extends State<_StandaloneAvatarCard> {
bool expanded = false;
@override
Widget build(BuildContext context) => AvatarMenuCard(
expanded: expanded,
onExpand: () => setState(() => expanded = !expanded),
onClose: () => Navigator.pop(context),
);
}
/// Account controls and hidden-tab navigation, shared by both card positions.
class AvatarMenuCard extends StatelessWidget {
final bool bottom;
final bool expanded;
final bool movingAvatar;
final bool showHeaderAvatar;
final VoidCallback onExpand;
final VoidCallback onClose;
const AvatarMenuCard({
super.key,
this.bottom = false,
this.movingAvatar = false,
this.showHeaderAvatar = true,
required this.expanded,
required this.onExpand,
required this.onClose,
});
@override
Widget build(BuildContext context) {
@@ -60,226 +91,310 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
final session = context.watch<SessionController>();
final settings = context.watch<SettingsController>();
final trashCount = context.watch<TrashController>().items.length;
final hiddenTabs = settings.tabOrder
.where((t) => settings.hiddenTabs.contains(t))
.toList();
void closeAndOpenSettings() {
Navigator.pop(context);
void settingsTap() {
onClose();
openSettings(context);
}
return Align(
alignment: Alignment.topCenter,
// Just the status-bar inset, not the top bar's own height on top of
// it - the card covers the top bar (title included) rather than
// sitting below it, so opening it reads as the avatar growing into
// this instead of a separate element appearing underneath the row
// it came from.
child: SafeArea(
bottom: false,
child: Padding(
// Same side inset as the Files list's rows (`NooSpace.sm`), so the
// card is as wide as the list beneath it.
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
child: SizedBox(
width: double.infinity,
child: Container(
// No `color`/`clipBehavior` here - a `Container` with both a
// `border` and `clipBehavior` set paints the border as part
// of its *outer* decoration, then paints its (clipped) child
// on top right up to that same boundary with no gap for the
// border's own stroke width to show through. An opaque child
// touching that edge - every row below has its own full-bleed
// `Material` fill - then paints straight over the inner half
// of the border, which read as the border going missing
// specifically wherever an opaque row sits (every corner but
// the two by the header, which has no opaque fill of its
// own). The 1px `Padding` + inset `ClipRRect` below keeps the
// clipped, filled content entirely inside the border's own
// stroke instead of racing it for the same pixels.
decoration: BoxDecoration(
border: Border.all(color: colors.line),
borderRadius: BorderRadius.circular(NooRadii.card),
// `nooDialogShadow` alone is a wide, soft, fairly faint
// shadow - built for a desktop dialog with plenty of room
// to fall off into. On a small card over a dark theme's
// near-black `bg`, that falloff is too gradual to read as
// elevation at all (a dark shadow needs real density close
// to the edge to be visible against an already-dark
// backdrop). A second, tighter, more opaque contact shadow
// underneath it gives an immediate value-step right at the
// card's edge in both themes, with the soft one still
// doing the wider ambient falloff on top.
boxShadow: const [
BoxShadow(
color: Color(0x40000000),
blurRadius: 12,
offset: Offset(0, 4),
),
nooDialogShadow,
],
),
child: Padding(
padding: const EdgeInsets.all(1),
child: ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card - 1),
child: Material(
color: colors.surface,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Avatar on the right, name/host on the left - mirrors the
// top bar's own right-aligned avatar exactly, so this
// header reads as a continuation of the button that
// opened it rather than a disconnected card.
InkWell(
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: const EdgeInsets.all(NooSpace.md),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
session.username,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.bodyL.copyWith(
fontWeight: FontWeight.w600,
color: colors.fg1,
),
),
const SizedBox(height: 2),
Text(
serverHost(session.serverUrl),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(
color: colors.fg3,
),
),
],
),
),
const SizedBox(width: 12),
NooAvatar(
initials: accountInitial(session.username),
current: true,
size: 40,
),
const SizedBox(width: 8),
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colors.fg1.withValues(alpha: 0.08),
),
child: Icon(
_expanded
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 20,
color: colors.fg2,
),
),
],
),
),
),
AnimatedSize(
duration: NooMotion.fast,
curve: NooMotion.ease,
alignment: Alignment.topCenter,
child: _expanded
? Column(
children: [
for (final account in session.accounts)
if (account.id != session.activeAccountId)
_OtherAccountRow(
name: account.username,
host: serverHost(account.serverUrl),
onTap: () {
Navigator.pop(context);
session.switchAccount(account.id);
},
),
Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.md,
0,
NooSpace.md,
NooSpace.md,
),
child: Row(
spacing: 8,
children: [
Expanded(
child: _AccountButton(
icon: LucideIcons.userPlus,
label: 'Add Account',
onTap: () {
final nav = Navigator.of(
context,
);
nav.pop();
nav.push(
MaterialPageRoute(
builder: (_) =>
const LoginView(
isAddingAccount: true,
),
),
);
},
),
),
Expanded(
child: _AccountButton(
icon: LucideIcons.users,
label: 'Manage Accounts',
onTap: closeAndOpenSettings,
),
),
],
),
),
],
)
: const SizedBox(width: double.infinity),
),
if (hiddenTabs.isNotEmpty) ...[
Divider(height: 1, color: colors.line),
for (final tab in hiddenTabs)
NooSettingsRow(
icon: tab.icon,
label: Text(tab.label),
trailing: tab == AppTab.trash && trashCount > 0
? NooBadge(
tone: NooBadgeTone.accent,
child: Text('$trashCount'),
)
: null,
onTap: () {
Navigator.pop(context);
settings.requestTab(tab);
},
),
],
Divider(height: 1, color: colors.line),
NooSettingsRow(
icon: LucideIcons.settings,
label: const Text('Settings'),
onTap: closeAndOpenSettings,
),
],
final header = InkWell(
onTap: onExpand,
child: Padding(
padding: const EdgeInsets.all(NooSpace.md),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
session.username,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.bodyL.copyWith(
fontWeight: FontWeight.w600,
color: colors.fg1,
),
),
const SizedBox(height: 2),
Text(
serverHost(session.serverUrl),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(color: colors.fg3),
),
],
),
),
Icon(
expanded ? LucideIcons.chevronUp : LucideIcons.chevronDown,
size: 20,
color: colors.fg2,
),
if (showHeaderAvatar) const SizedBox(width: 12),
if (showHeaderAvatar)
movingAvatar
? const SizedBox.square(dimension: 32)
: NooAvatar(
initials: accountInitial(session.username),
current: true,
size: 32,
),
],
),
),
);
final attached = bottom && !showHeaderAvatar;
final accountSection = AnimatedSize(
duration: NooMotion.base,
curve: Curves.easeOutCubic,
alignment: bottom ? Alignment.bottomCenter : Alignment.topCenter,
child: expanded
? Column(
children: [
for (final account in session.accounts)
if (account.id != session.activeAccountId)
_OtherAccountRow(
name: account.username,
host: serverHost(account.serverUrl),
onTap: () {
onClose();
session.switchAccount(account.id);
},
),
Padding(
padding: const EdgeInsets.all(NooSpace.md),
child: Row(
spacing: 8,
children: [
Expanded(
child: _AccountButton(
icon: LucideIcons.userPlus,
label: 'Add Account',
onTap: () {
final nav = Navigator.of(context);
onClose();
nav.push(
MaterialPageRoute(
builder: (_) =>
const LoginView(isAddingAccount: true),
),
);
},
),
),
Expanded(
child: _AccountButton(
icon: LucideIcons.users,
label: 'Manage Accounts',
onTap: settingsTap,
),
),
],
),
),
],
)
: const SizedBox(width: double.infinity),
);
final rows = Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!attached) accountSection,
for (final tab in settings.tabOrder.where(settings.hiddenTabs.contains))
NooSettingsRow(
backgroundColor: Colors.transparent,
icon: tab.icon,
label: Text(tab.label),
trailing: tab == AppTab.trash && trashCount > 0
? NooBadge(
tone: NooBadgeTone.accent,
child: Text('$trashCount'),
)
: null,
onTap: () {
onClose();
settings.requestTab(tab);
},
),
Divider(height: 1, color: colors.line),
NooSettingsRow(
backgroundColor: Colors.transparent,
icon: LucideIcons.settings,
label: const Text('Settings'),
onTap: settingsTap,
),
if (attached) ...[header, accountSection],
],
);
final content = SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.all(1),
child: ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card - 1),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!bottom) header,
Flexible(
fit: FlexFit.loose,
child: SingleChildScrollView(child: rows),
),
if (bottom && !attached) header,
],
),
),
),
),
);
return movingAvatar
? content
: _AvatarSurface(
radius: BorderRadius.circular(NooRadii.card),
child: content,
);
}
}
class _AvatarSurface extends StatelessWidget {
final BorderRadius radius;
final Widget child;
final bool connected;
const _AvatarSurface({
required this.radius,
required this.child,
this.connected = false,
});
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsController>();
final colors = context.nooColors;
final frosted = settings.bottomBarFrosted;
final surface = DecoratedBox(
decoration: BoxDecoration(
color: frosted
? colors.surface.withValues(alpha: settings.bottomBarFrostedOpacity)
: colors.surface,
border: connected
? null
: Border.all(color: colors.line),
borderRadius: radius,
),
child: Padding(padding: const EdgeInsets.all(1), child: child),
);
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: radius,
boxShadow: frosted || connected ? null : const [nooDialogShadow],
),
child: ClipRRect(
borderRadius: radius,
child: frosted
? BackdropFilter(
filter: ImageFilter.blur(
sigmaX: settings.bottomBarFrostedBlur,
sigmaY: settings.bottomBarFrostedBlur,
),
child: surface,
)
: surface,
),
);
}
}
class AvatarNavigationScope extends InheritedNotifier<AnimationController> {
final _AvatarNavigationHostState _host;
AvatarNavigationScope._({
required _AvatarNavigationHostState host,
required super.child,
}) : _host = host,
super(notifier: host.animation);
static AvatarNavigationScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<AvatarNavigationScope>();
double get progress =>
Curves.easeInOutCubicEmphasized.transform(_host.animation.value);
bool get bottom => _host.bottom;
void close() => _host.close();
@override
bool updateShouldNotify(AvatarNavigationScope oldWidget) => true;
}
/// Keeps the top avatar's layout slot while the single animated avatar moves
/// into the card. Its own context gives us the real screen-space origin.
class AvatarNavigationAnchor extends StatelessWidget {
final Widget child;
const AvatarNavigationAnchor({super.key, required this.child});
@override
Widget build(BuildContext context) {
final scope = AvatarNavigationScope.maybeOf(context);
return IgnorePointer(
ignoring: scope != null && scope.progress > 0,
child: ClipRect(
child: Align(
alignment: Alignment.centerRight,
widthFactor: 1 - (scope?.progress ?? 0),
child: Opacity(
opacity: scope != null && scope.progress > 0 ? 0 : 1,
child: child,
),
),
),
);
}
}
/// Only the content pane moves; the bottom navigation stays anchored.
class AvatarNavigationBody extends StatelessWidget {
final Widget child;
const AvatarNavigationBody({super.key, required this.child});
@override
Widget build(BuildContext context) {
final scope = AvatarNavigationScope.maybeOf(context);
return Padding(
padding: EdgeInsets.only(
top: scope == null || scope.bottom
? 0
: (scope._host.cardHeight + 12) * scope.progress,
),
child: child,
);
}
}
class AvatarNavigationSatellite extends StatelessWidget {
final double size;
const AvatarNavigationSatellite({super.key, required this.size});
@override
Widget build(BuildContext context) {
final scope = AvatarNavigationScope.maybeOf(context);
if (context.watch<SettingsController>().bottomBarStyle ==
NooBottomBarStyle.attached) {
return Builder(
builder: (anchorContext) => ShellAvatarButton(
hitBox: size,
label: 'Menu',
onTap: () => showAvatarMenu(anchorContext),
),
);
}
return IgnorePointer(
ignoring: scope != null && scope.progress > 0,
child: Opacity(
opacity: scope != null && scope.progress > 0 ? 0 : 1,
child: SizedBox(
width: size,
height: size,
child: _AvatarSurface(
radius: BorderRadius.circular(size / 2),
child: Builder(
builder: (anchorContext) => ShellAvatarButton(
hitBox: size,
label: 'Menu',
onTap: () => showAvatarMenu(anchorContext),
),
),
),
@@ -289,6 +404,273 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
}
}
class AvatarNavigationHost extends StatefulWidget {
final Widget child;
const AvatarNavigationHost({super.key, required this.child});
@override
State<AvatarNavigationHost> createState() => _AvatarNavigationHostState();
}
class _AvatarNavigationHostState extends State<AvatarNavigationHost>
with SingleTickerProviderStateMixin {
late final AnimationController animation = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 420),
reverseDuration: const Duration(milliseconds: 320),
);
final GlobalKey measureKey = GlobalKey();
Rect? origin;
bool bottom = false;
bool expanded = false;
double cardHeight = 150;
bool measuring = false;
void measure() {
if (measuring) return;
measuring = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
measuring = false;
if (!mounted) return;
final box = measureKey.currentContext?.findRenderObject() as RenderBox?;
if (box != null && (box.size.height - cardHeight).abs() > .5) {
setState(() => cardHeight = box.size.height);
}
});
}
void openFrom(BuildContext context) {
if (animation.value > 0) {
close();
return;
}
final settings = context.read<SettingsController>();
final box = context.findRenderObject() as RenderBox?;
if (box == null) return;
final hostBox = this.context.findRenderObject() as RenderBox;
final center = hostBox.globalToLocal(
box.localToGlobal(box.size.center(Offset.zero)),
);
setState(() {
bottom = settings.avatarPosition == AvatarPosition.bottom;
final diameter = bottom
? NooBottomBar.rowHeight(
NooLayout.navStyle(context),
settings.bottomBarStyle,
)
: 32.0;
origin = Rect.fromCenter(
center: center,
width: diameter,
height: diameter,
);
expanded = false;
});
animation.duration = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 420);
animation.forward();
}
void close() {
animation.reverseDuration = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 320);
animation.reverse();
}
@override
void dispose() {
animation.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsController>();
final session = context.watch<SessionController>();
final enabled = settings.navMenuStyle == NooNavMenuStyle.avatarMenu;
return AvatarNavigationScope._(
host: this,
child: AnimatedBuilder(
animation: animation,
builder: (context, _) {
final progress = Curves.easeInOutCubicEmphasized.transform(
animation.value,
);
return PopScope(
canPop: animation.value == 0,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) close();
},
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final height = constraints.maxHeight;
final safeTop = MediaQuery.viewPaddingOf(context).top;
final attached =
bottom &&
settings.bottomBarStyle == NooBottomBarStyle.attached;
final bottomEdge = bottom && origin != null
? origin!.center.dy -
NooBottomBar.rowHeight(
NooLayout.navStyle(context),
settings.bottomBarStyle,
) /
2 -
(attached ? 0 : 12)
: height;
final maxHeight = (bottomEdge - safeTop - 24).clamp(
72.0,
height * .8,
);
measure();
Widget card() => ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: AvatarMenuCard(
bottom: bottom,
movingAvatar: true,
showHeaderAvatar: !attached,
expanded: expanded,
onExpand: () => setState(() => expanded = !expanded),
onClose: close,
),
);
final target = Rect.fromLTWH(
attached ? 0 : 12,
bottom ? bottomEdge - cardHeight : safeTop + 8,
attached ? width : width - 24,
cardHeight,
);
final rect = Rect.lerp(
attached
? Rect.fromLTWH(0, bottomEdge, width, 0)
: origin ?? target,
target,
progress,
)!;
final radius = attached
? BorderRadius.vertical(
top: Radius.circular(NooRadii.card * progress),
)
: BorderRadius.circular(
lerpDouble(
(origin?.width ?? 32) / 2,
NooRadii.card,
progress,
)!,
);
final avatarTarget = Rect.fromLTWH(
target.right - 49,
bottom ? target.bottom - 49 : target.top + 17,
32,
32,
);
final avatar = Rect.lerp(
origin == null
? avatarTarget
: Rect.fromCenter(
center: origin!.center,
width: 32,
height: 32,
),
avatarTarget,
progress,
)!;
return Stack(
children: [
widget.child,
if (enabled)
Positioned(
left: attached ? 0 : 12,
right: attached ? 0 : 12,
top: safeTop,
child: Offstage(
child:
NotificationListener<
SizeChangedLayoutNotification
>(
onNotification: (_) {
measure();
return false;
},
child: SizeChangedLayoutNotifier(
child: KeyedSubtree(
key: measureKey,
child: card(),
),
),
),
),
),
if (enabled && progress > 0 && origin != null) ...[
Positioned.fill(
bottom: bottom ? height - bottomEdge : 0,
child: Semantics(
label: 'Close menu',
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: close,
child: const ColoredBox(color: Colors.transparent),
),
),
),
Positioned.fromRect(
rect: rect,
child: _AvatarSurface(
radius: radius,
connected: attached,
child: ClipRRect(
borderRadius: radius,
child: OverflowBox(
alignment: bottom
? Alignment.bottomRight
: Alignment.topRight,
minWidth: target.width,
maxWidth: target.width,
minHeight: target.height,
maxHeight: target.height,
child: IgnorePointer(
ignoring: progress < .95,
child: Opacity(
opacity: ((progress - .3) / .7).clamp(
0.0,
1.0,
),
child: card(),
),
),
),
),
),
),
if (!attached)
Positioned.fromRect(
rect: avatar,
child: Semantics(
label: 'Close menu',
button: true,
child: GestureDetector(
onTap: close,
child: NooAvatar(
initials: accountInitial(session.username),
current: true,
size: 32,
),
),
),
),
],
],
);
},
),
);
},
),
);
}
}
class _OtherAccountRow extends StatelessWidget {
final String name;
final String host;
@@ -333,7 +715,7 @@ class _OtherAccountRow extends StatelessWidget {
),
),
const SizedBox(width: 12),
NooAvatar(initials: accountInitial(name), current: false, size: 40),
NooAvatar(initials: accountInitial(name), current: false, size: 32),
],
),
),
+21 -2
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/app_tab.dart';
import 'noo/nav/noo_bottom_bar.dart';
import 'avatar_menu.dart';
/// Adapts the app's [AppTab] model onto [NooBottomBar]'s generic
/// [NooNavDestination] list, so `MainShellView` only deals in tabs and
@@ -23,6 +24,7 @@ class BottomNavBar extends StatelessWidget {
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
final VoidCallback? onSearchTap;
final bool avatarInBottomBar;
const BottomNavBar({
super.key,
@@ -35,6 +37,7 @@ class BottomNavBar extends StatelessWidget {
required this.selectedIndex,
required this.onDestinationSelected,
this.onSearchTap,
this.avatarInBottomBar = false,
});
@override
@@ -50,11 +53,27 @@ class BottomNavBar extends StatelessWidget {
NooNavDestination(icon: tab.icon, label: tab.label),
],
selectedIndex: selectedIndex,
onSelected: onDestinationSelected,
onSelected: (index) {
AvatarNavigationScope.maybeOf(context)?.close();
onDestinationSelected(index);
},
searchDestination: onSearchTap == null
? null
: const NooNavDestination(icon: LucideIcons.search, label: 'Search'),
onSearchTap: onSearchTap,
onSearchTap: onSearchTap == null
? null
: () {
AvatarNavigationScope.maybeOf(context)?.close();
onSearchTap!();
},
avatarSatellite: avatarInBottomBar
? AvatarNavigationSatellite(
size: NooBottomBar.rowHeight(style, barStyle),
)
: null,
avatarMenuProgress: avatarInBottomBar
? AvatarNavigationScope.maybeOf(context)?.progress ?? 0
: 0,
);
}
}
+32 -30
View File
@@ -1,18 +1,11 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/settings_controller.dart';
import '../theme/design_tokens.dart';
/// The blurred/translucent chrome for the media viewer's overlay bars
/// (`file_viewer_screen.dart`'s top bar, `MediaActionBar`, the video
/// transport row) - the one deliberate exception to the design system's
/// flat, no-shadow product UI (DESIGN_SYSTEM.md 1.4), since this chrome
/// floats over photo/video content rather than over the app's own
/// surfaces. No shadow, no outline: [color] defaults to the app's own
/// `surface` token (so this panel reads as light or dark to match the
/// active theme, like the rest of the media viewer, instead of a fixed
/// dark tone regardless of theme) at a higher-than-usual [opacity], since
/// a light tint needs denser coverage than a near-black one did to stay
/// legible over arbitrary photo/video brightness underneath.
/// Media viewer chrome that shares the navigation frost preference.
/// Turning frosting off produces an opaque themed surface without blur.
class FrostedGlassContainer extends StatelessWidget {
final Widget child;
final double borderRadius;
@@ -20,14 +13,12 @@ class FrostedGlassContainer extends StatelessWidget {
/// Overrides [borderRadius] for non-uniform corners (e.g. top-only).
final BorderRadius? radius;
/// Blur sigma for the backdrop filter. Defaults to a fixed value; pass an
/// explicit value (e.g. from user settings) to make it adjustable.
final double blurSigma;
/// Blur sigma for the backdrop filter. Defaults to the shared frost setting.
final double? blurSigma;
/// Opacity (0-1) of the tonal fill behind the blur. Defaults to a fixed
/// value; pass an explicit value (e.g. from user settings) to make it
/// adjustable.
final double opacity;
/// Opacity (0-1) of the tonal fill behind the blur. Defaults to the shared frost setting.
final double? opacity;
final bool? frosted;
/// The tint under the blur. Defaults to [NooColors.surface] (resolved at
/// build time, so it always matches the active theme) - pass an explicit
@@ -39,8 +30,9 @@ class FrostedGlassContainer extends StatelessWidget {
required this.child,
this.borderRadius = 0,
this.radius,
this.blurSigma = 20,
this.opacity = 0.8,
this.blurSigma,
this.opacity,
this.frosted,
this.color,
});
@@ -48,18 +40,28 @@ class FrostedGlassContainer extends StatelessWidget {
Widget build(BuildContext context) {
final shape = radius ?? BorderRadius.circular(borderRadius);
final tint = color ?? context.nooColors.surface;
final settings = context.watch<SettingsController?>();
final enabled = frosted ?? settings?.bottomBarFrosted ?? false;
final sigma = blurSigma ?? settings?.bottomBarFrostedBlur ?? 20;
final fill = Container(
decoration: BoxDecoration(
color: enabled
? tint.withValues(
alpha: opacity ?? settings?.bottomBarFrostedOpacity ?? 0.72,
)
: tint,
borderRadius: shape,
),
child: child,
);
return ClipRRect(
borderRadius: shape,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
child: Container(
decoration: BoxDecoration(
color: tint.withValues(alpha: opacity),
borderRadius: shape,
),
child: child,
),
),
child: enabled
? BackdropFilter(
filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
child: fill,
)
: fill,
);
}
}
+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,
+30
View File
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
/// Pins selection actions above the shell navigation. Scaffold supplies its
/// navigation footprint through MediaQuery padding when the body extends
/// behind navigation; otherwise the body already ends above it.
class SelectionBarOverlay extends StatelessWidget {
static const clearance = 64.0;
final Widget child;
final Widget? bar;
const SelectionBarOverlay({super.key, required this.child, this.bar});
@override
Widget build(BuildContext context) {
if (bar == null) return child;
return Stack(
fit: StackFit.expand,
children: [
child,
Positioned(
left: 0,
right: 0,
bottom: MediaQuery.paddingOf(context).bottom + 8,
child: SizedBox(height: 56, child: bar),
),
],
);
}
}
+34 -11
View File
@@ -4,8 +4,10 @@ import '../../models/selection_action.dart';
import '../../providers/settings_controller.dart';
import '../../theme/design_tokens.dart';
import '../noo/lists/noo_tab_order_row.dart';
import '../noo/lists/noo_settings_row.dart';
import '../noo/lists/noo_grouped_list.dart';
import '../noo/core/noo_segmented_control.dart';
import '../noo/noo_layout.dart';
import 'settings_section.dart';
/// Settings section: the priority order bulk actions (favorite, share,
/// download, ...) show in on the Files/Photos/Favorites multi-select
@@ -13,7 +15,7 @@ import 'settings_section.dart';
/// which ones land in `NooSelectionBar`'s fixed inline slots (3 mobile / 4
/// desktop, see its own doc comment) vs. behind its trailing "More" -
/// there's no pin/hide concept here the way Tabs has, so this is just a
/// reorder list, not a whole section of its own option rows.
/// position choice followed by a reorder list.
class SettingsActionBarSection extends StatelessWidget {
const SettingsActionBarSection({super.key});
@@ -33,18 +35,30 @@ class SettingsActionBarSection extends StatelessWidget {
final desktop = NooLayout.isDesktop(context);
final orderList = _ActionOrderList(
rounded: desktop,
order: settings.selectionActionOrder,
onReorder: (oldIndex, newIndex) =>
_handleReorder(settings, oldIndex, newIndex),
);
final positionRow = NooSettingsRow(
label: const Text('Position'),
trailing: NooSegmentedControl<SelectionBarPosition>(
onSurface: true,
value: settings.selectionBarPosition,
onChanged: settings.setSelectionBarPosition,
options: const [
NooSegmentOption(value: SelectionBarPosition.top, label: 'Top'),
NooSegmentOption(value: SelectionBarPosition.bottom, label: 'Bottom'),
],
),
);
final colors = context.nooColors;
if (!desktop) {
return SettingsSection(
title: 'Action bar',
subtitle: _subtitle,
children: [orderList],
return NooGroupedList(
footer: const Text(_subtitle),
children: [positionRow, orderList],
);
}
@@ -65,6 +79,8 @@ class SettingsActionBarSection extends StatelessWidget {
const SizedBox(height: 4),
Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)),
const SizedBox(height: NooSpace.md),
positionRow,
const SizedBox(height: NooSpace.md),
orderList,
],
),
@@ -79,8 +95,13 @@ class SettingsActionBarSection extends StatelessWidget {
class _ActionOrderList extends StatelessWidget {
final List<SelectionActionKind> order;
final void Function(int oldIndex, int newIndex) onReorder;
final bool rounded;
const _ActionOrderList({required this.order, required this.onReorder});
const _ActionOrderList({
required this.order,
required this.onReorder,
required this.rounded,
});
@override
Widget build(BuildContext context) {
@@ -105,12 +126,14 @@ class _ActionOrderList extends StatelessWidget {
);
},
);
final rows = DecoratedBox(
decoration: BoxDecoration(color: colors.line),
child: list,
);
if (!rounded) return rows;
return ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card),
child: DecoratedBox(
decoration: BoxDecoration(color: colors.line),
child: list,
),
child: rows,
);
}
}
+54 -22
View File
@@ -56,9 +56,9 @@ class SettingsAppearanceSection extends StatelessWidget {
_BottomBarStyleRow(settings: settings),
NooSettingsRow(
icon: LucideIcons.glassWater,
label: const Text('Frosted glass bottom bar'),
label: const Text('Frosted glass'),
subtitle: const Text(
'A translucent, blurred bottom bar - content scrolls behind it',
'Blurred bottom navigation, avatar menus and media viewer panels',
),
trailing: NooToggle(
checked: settings.bottomBarFrosted,
@@ -67,6 +67,23 @@ class SettingsAppearanceSection extends StatelessWidget {
),
if (settings.bottomBarFrosted) _FrostedGlassRow(settings: settings),
_NavMenuStyleRow(settings: settings),
if (settings.navMenuStyle == NooNavMenuStyle.avatarMenu)
NooSettingsRow(
icon: LucideIcons.userRound,
label: const Text('Avatar position'),
trailing: NooSegmentedControl<AvatarPosition>(
onSurface: true,
value: settings.avatarPosition,
onChanged: settings.setAvatarPosition,
options: const [
NooSegmentOption(value: AvatarPosition.top, label: 'Top'),
NooSegmentOption(
value: AvatarPosition.bottom,
label: 'Bottom',
),
],
),
),
NooSettingsRow(
icon: LucideIcons.search,
label: const Text('Search in bottom bar'),
@@ -96,14 +113,19 @@ class SettingsAppearanceSection extends StatelessWidget {
icon: LucideIcons.palette,
label: const Text('Accent color'),
subtitle: Text(
settings.useDynamicColor
settings.useDynamicColor &&
Theme.of(context).platform != TargetPlatform.iOS
? 'Matching your wallpaper'
: settings.seedColor == AppTheme.defaultAccent
? 'Default'
: 'Custom color',
),
trailing: _AccentSwatchDot(
color: settings.useDynamicColor ? null : settings.seedColor,
color:
settings.useDynamicColor &&
Theme.of(context).platform != TargetPlatform.iOS
? null
: settings.seedColor,
),
onTap: () => _openAccentPicker(context, settings),
),
@@ -116,15 +138,16 @@ class SettingsAppearanceSection extends StatelessWidget {
onChanged: settings.setAmoledDark,
),
),
NooSettingsRow(
icon: LucideIcons.waves,
label: const Text('Seek bar style'),
subtitle: const Text(
'The progress bar style used when playing videos',
if (Theme.of(context).platform != TargetPlatform.iOS)
NooSettingsRow(
icon: LucideIcons.waves,
label: const Text('Seek bar style'),
subtitle: const Text(
'The progress bar style used when playing videos',
),
value: _seekBarStyleLabel(settings.mediaProgressBarStyle),
onTap: () => _openSeekBarPicker(context, settings),
),
value: _seekBarStyleLabel(settings.mediaProgressBarStyle),
onTap: () => _openSeekBarPicker(context, settings),
),
],
),
],
@@ -168,6 +191,7 @@ class _ThemeRow extends StatelessWidget {
const SizedBox(height: 12),
NooSegmentedControl<ThemeMode>(
fill: true,
onSurface: true,
value: settings.themeMode,
onChanged: settings.setThemeMode,
options: const [
@@ -231,6 +255,7 @@ class _BottomBarStyleRow extends StatelessWidget {
const SizedBox(height: 12),
NooSegmentedControl<NooBottomBarStyle>(
fill: true,
onSurface: true,
value: settings.bottomBarStyle,
onChanged: settings.setBottomBarStyle,
options: const [
@@ -287,6 +312,7 @@ class _FrostedGlassRow extends StatelessWidget {
const SizedBox(height: 12),
NooSegmentedControl<FrostedGlassPreset?>(
fill: true,
onSurface: true,
// No segment selected once the sliders no longer match a preset.
value: settings.frostedPreset,
onChanged: (preset) {
@@ -402,6 +428,7 @@ class _NavMenuStyleRow extends StatelessWidget {
const SizedBox(height: 12),
NooSegmentedControl<NooNavMenuStyle>(
fill: true,
onSurface: true,
value: settings.navMenuStyle,
onChanged: settings.setNavMenuStyle,
options: const [
@@ -455,6 +482,7 @@ class _FabStyleRow extends StatelessWidget {
const SizedBox(height: 12),
NooSegmentedControl<FabStyle>(
fill: true,
onSurface: true,
value: settings.fabStyle,
onChanged: settings.setFabStyle,
options: const [
@@ -508,22 +536,26 @@ class _AccentSwatchDot extends StatelessWidget {
}
void _openAccentPicker(BuildContext context, SettingsController settings) {
final useDynamicColor =
settings.useDynamicColor &&
Theme.of(context).platform != TargetPlatform.iOS;
Widget grid(NooColors colors) => Wrap(
spacing: 14,
runSpacing: 14,
children: [
_AccentSwatch(
isSelected: settings.useDynamicColor,
onTap: () {
settings.setUseDynamicColor(true);
Navigator.pop(context);
},
background: colors.surface3,
child: Icon(LucideIcons.wallpaper, color: colors.fg2, size: 20),
),
if (Theme.of(context).platform != TargetPlatform.iOS)
_AccentSwatch(
isSelected: settings.useDynamicColor,
onTap: () {
settings.setUseDynamicColor(true);
Navigator.pop(context);
},
background: colors.surface3,
child: Icon(LucideIcons.wallpaper, color: colors.fg2, size: 20),
),
for (final color in AppTheme.seedColors)
_AccentSwatch(
isSelected: !settings.useDynamicColor && settings.seedColor == color,
isSelected: !useDynamicColor && settings.seedColor == color,
onTap: () {
settings.setSeedColor(color);
Navigator.pop(context);
@@ -125,12 +125,16 @@ class _CacheIntervalRow extends StatelessWidget {
onChanged: (value) => onChanged(value.round()),
),
),
if (minutes < 15)
if (Theme.of(context).platform == TargetPlatform.iOS ||
(Theme.of(context).platform == TargetPlatform.android && minutes < 15))
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'While the app is closed, synced files refresh at most every '
'15 min (an Android limit).',
Theme.of(context).platform == TargetPlatform.iOS
? 'iOS decides when background sync runs; the refresh '
'interval is a request, not a guaranteed schedule.'
: 'While the app is closed, synced files refresh at most every '
'15 min (an Android limit).',
style: NooText.meta.copyWith(color: colors.fg3),
),
),
+10 -3
View File
@@ -10,8 +10,13 @@ import '../theme/design_tokens.dart';
class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
final double height;
final Widget child;
final bool floating;
const StickyHeaderDelegate({required this.height, required this.child});
const StickyHeaderDelegate({
required this.height,
required this.child,
this.floating = false,
});
@override
double get minExtent => height;
@@ -38,7 +43,7 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
// Colors.transparent` guards against the same M3 elevation-tint
// behavior even if this ever gets a non-zero elevation.
return Material(
color: context.nooColors.bg,
color: floating ? Colors.transparent : context.nooColors.bg,
surfaceTintColor: Colors.transparent,
child: child,
);
@@ -46,6 +51,8 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
@override
bool shouldRebuild(covariant StickyHeaderDelegate oldDelegate) {
return oldDelegate.height != height || oldDelegate.child != child;
return oldDelegate.height != height ||
oldDelegate.child != child ||
oldDelegate.floating != floating;
}
}
+2 -8
View File
@@ -4,14 +4,8 @@ import '../../theme/design_tokens.dart';
import '../frosted_glass_container.dart';
import 'viewer_icon_button.dart';
/// The bottom panel overlaid on the media viewer: an optional transport row
/// ([transportControls], video only) above the action row
/// (share/favorite/open/download/delete/details) - one continuous flush,
/// full-width `FrostedGlassContainer` panel, not two separate floating
/// pills. Kept on that blurred chrome as a deliberate exception to the
/// design system's flat product UI (see `file_viewer_screen.dart`'s
/// build() comment for why), unlike the now-flat bottom nav bar
/// (`bottom_nav_bar.dart`) this screen sits above.
/// A full-width media panel containing transport, actions and details.
/// Its solid/frosted surface follows the shared appearance preference.
class MediaActionBar extends StatelessWidget {
final bool isFavorite;
final bool isBusy;
+63 -6
View File
@@ -1,6 +1,7 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
@@ -198,12 +199,14 @@ class VideoTransportControls extends StatelessWidget {
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: WavySeekBar(
controller: controller,
playedColor: fg,
trackColor: fg.withValues(alpha: 0.3),
style: progressBarStyle,
),
child: Theme.of(context).platform == TargetPlatform.iOS
? IosSeekBar(controller: controller, color: fg)
: WavySeekBar(
controller: controller,
playedColor: fg,
trackColor: fg.withValues(alpha: 0.3),
style: progressBarStyle,
),
),
],
),
@@ -213,6 +216,60 @@ class VideoTransportControls extends StatelessWidget {
}
}
class IosSeekBar extends StatefulWidget {
final VideoPlayerController controller;
final Color color;
const IosSeekBar({super.key, required this.controller, required this.color});
@override
State<IosSeekBar> createState() => _IosSeekBarState();
}
class _IosSeekBarState extends State<IosSeekBar> {
double? _dragRatio;
bool _resumeAfterScrubbing = false;
@override
Widget build(BuildContext context) {
final value = widget.controller.value;
final durationMs = value.duration.inMilliseconds;
final ratio =
(_dragRatio ??
(durationMs > 0
? value.position.inMilliseconds / durationMs
: 0.0))
.clamp(0.0, 1.0);
return SizedBox(
width: double.infinity,
child: CupertinoSlider(
value: ratio,
activeColor: widget.color,
thumbColor: CupertinoColors.white,
onChangeStart: durationMs <= 0
? null
: (_) {
_resumeAfterScrubbing = value.isPlaying;
if (_resumeAfterScrubbing) widget.controller.pause();
},
onChanged: durationMs <= 0
? null
: (ratio) {
setState(() => _dragRatio = ratio);
widget.controller.seekTo(value.duration * ratio);
},
onChangeEnd: durationMs <= 0
? null
: (ratio) async {
await widget.controller.seekTo(value.duration * ratio);
if (!mounted) return;
setState(() => _dragRatio = null);
if (_resumeAfterScrubbing) widget.controller.play();
},
),
);
}
}
/// A Material You "expressive" wavy seek bar: the played portion of the
/// track animates as a travelling sine wave while playing and settles flat
/// when paused, matching the Android 16+ media player style. Drag or tap