Noo design system rebuild #1

Merged
ayushya merged 31 commits from noo-design-system-rebuild into main 2026-10-01 09:23:58 -04:00
5 changed files with 391 additions and 37 deletions
Showing only changes of commit a8b14b8744 - Show all commits
+17 -9
View File
@@ -358,15 +358,23 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
slots, the rest sit behind "More". See §2 "Selection action bar". slots, the rest sit behind "More". See §2 "Selection action bar".
9. Swipe on a file 9. Swipe on a file
Mobile uses one column of grouped lists, each individually collapsible Mobile is a two-level menu, the way native iOS/Android Settings apps
(tap its label, expanded by default - `NooGroupedList`'s `collapsible` work: the account card stays pinned at the top of a single top-level
param) so the full list can be collapsed down instead of needing a list, and every other section (2-9 above) becomes one tappable
separate way to navigate it; an earlier version had a trailing jump rail `NooSettingsRow` - icon, title, chevron - in a `NooGroupedList` below it.
(one small icon per section, pinned where the scrollbar would sit) Tapping a row pushes a new screen (`NooTopBar`/`NooTopBarBack`) holding
instead, dropped for adding a second, redundant navigation method without just that section's own content full-screen, so no page is ever more
shortening the page. Desktop uses a 2-column grid of cards with a 1px than one category deep and no section needs to be individually
line and radius 20, wide enough to see most sections without scrolling, collapsible any more. Two earlier designs were tried and dropped: a
so it gets neither. trailing jump rail (one small icon per section, pinned where the
scrollbar would sit), and - after that - one long column of every
section inline, each individually collapsible (`NooGroupedList`'s
`collapsible` param) so the page could at least be collapsed down. Both
scrolled the *same* page to or past an anchor; a genuinely separate
pushed screen per category removes the scroll-depth problem outright
instead of just working around it. Desktop is unchanged: a 2-column grid
of cards with a 1px line and radius 20, wide enough to see most sections
without scrolling, so it gets neither a menu nor collapsing.
- **Share sheet / dialog:** sections in this order: - **Share sheet / dialog:** sections in this order:
1. Header: file tile, name, size · folder, and close. 1. Header: file tile, name, size · folder, and close.
2. **Share with people:** an input ("Name, email or group"), then the people with access. The owner comes first; the others each have a permission pill ("Can edit ▾"). 2. **Share with people:** an input ("Name, email or group"), then the people with access. The owner comes first; the others each have a permission pill ("Can edit ▾").
+6 -2
View File
@@ -90,8 +90,12 @@ Gotchas:
- `NooGroupedList` draws dividers by showing `line` through 1px gaps, so each - `NooGroupedList` draws dividers by showing `line` through 1px gaps, so each
child must paint its own surface (`NooSettingsRow` and `NooTabOrderRow` child must paint its own surface (`NooSettingsRow` and `NooTabOrderRow`
do). Its `collapsible`/`initiallyExpanded` params (off by default) make do). Its `collapsible`/`initiallyExpanded` params (off by default) make
`label` a tap target that shows/hides the card - `SettingsSection` is the `label` a tap target that shows/hides the card - no current caller opts
only caller that opts in, for Settings' mobile sections. in (Settings' mobile sections used to, when every section rendered
inline in one long column; now each section is its own pushed screen -
see `account_view.dart`'s doc comment - so there's nothing left to
collapse). The params stay on the component itself since it's otherwise
generic.
- `NooSwipeAction` only reveals its action. The user has to tap the block to - `NooSwipeAction` only reveals its action. The user has to tap the block to
trigger it; a full swipe never deletes. trigger it; a full swipe never deletes.
- Window chrome (macOS traffic lights, the Windows 40px title bar) isn't - Window chrome (macOS traffic lights, the Windows 40px title bar) isn't
+162 -20
View File
@@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../theme/design_tokens.dart'; import '../theme/design_tokens.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/lists/noo_settings_row.dart';
import '../widgets/noo/nav/noo_top_bar.dart'; import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/nav/noo_toolbar.dart'; import '../widgets/noo/nav/noo_toolbar.dart';
import '../widgets/noo/noo_layout.dart'; import '../widgets/noo/noo_layout.dart';
@@ -15,18 +18,32 @@ import '../widgets/settings/settings_tabs.dart';
/// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section /// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section
/// order: account card, accounts, security, file sync, files cache, /// order: account card, accounts, security, file sync, files cache,
/// appearance, tabs, action bar, swipe on a file). One column of /// appearance, tabs, action bar, swipe on a file).
/// [SettingsSection]s (and [SettingsActionBarSection], which has no option ///
/// rows of its own to put in one - just the reorder list) on mobile, each /// Desktop is unchanged: a 2-column grid of cards ([_DesktopGrid]) wide
/// individually collapsible (expanded by default, tap its label to /// enough to see every section at once, so it has no scroll-depth problem
/// collapse - see `SettingsSection`/`NooGroupedList`'s `collapsible` param) /// and needs no menu.
/// so a long Settings screen can be collapsed down rather than needing a ///
/// separate jump rail (an earlier version had one; it added a second, /// Mobile is a two-level menu, the way native iOS/Android Settings apps
/// redundant way to navigate on top of plain scrolling and still didn't /// work: [SettingsAccountCard] (the account summary, not a settings picker)
/// shorten the page). Desktop uses a 2-column grid of cards instead, wide /// stays pinned at the top of a single top-level list ([_MobileMenu]), and
/// enough to see most sections without scrolling, so it gets neither. See /// every other section becomes one tappable [NooSettingsRow] - icon, title,
/// each `widgets/settings/*.dart` file for a section's own content and any /// chevron - in a [NooGroupedList] below it. Tapping a row pushes a new
/// setting that had to be slotted in or grouped under "Advanced appearance". /// [_SettingsCategoryScreen] with its own [NooTopBar]/[NooTopBarBack],
/// containing just that section's content full-screen. This replaces an
/// earlier design where every section rendered inline in one long
/// collapsible-sections column (`_MobileList`, since removed) - and before
/// that, a trailing jump rail (an even earlier version) that scrolled that
/// *same* page to an anchor. Both were rejected: the jump rail added a
/// second, redundant way to navigate on top of plain scrolling without
/// shortening the page, and the collapsible-sections column still left a
/// long page to scroll past even collapsed. A genuinely separate pushed
/// screen per category removes the scroll-depth problem outright, so
/// neither a jump rail nor per-section collapsing is needed any more - see
/// [SettingsSection]'s doc comment for how that reflects in its mobile
/// layout. See each `widgets/settings/*.dart` file for a section's own
/// content and any setting that had to be slotted in or grouped under
/// "Advanced appearance".
class AccountView extends StatelessWidget { class AccountView extends StatelessWidget {
const AccountView({super.key}); const AccountView({super.key});
@@ -42,6 +59,49 @@ class AccountView extends StatelessWidget {
SettingsSwipeSection(), SettingsSwipeSection(),
]; ];
static final _categories = <_SettingsCategory>[
_SettingsCategory(
title: 'Accounts',
icon: LucideIcons.users,
builder: (_) => const SettingsAccountsSection(),
),
_SettingsCategory(
title: 'Security',
icon: LucideIcons.lock,
builder: (_) => const SettingsSecuritySection(),
),
_SettingsCategory(
title: 'File sync',
icon: LucideIcons.cloud,
builder: (_) => const SettingsFileSyncSection(),
),
_SettingsCategory(
title: 'Files cache',
icon: LucideIcons.database,
builder: (_) => const SettingsFilesCacheSection(),
),
_SettingsCategory(
title: 'Appearance',
icon: LucideIcons.sunMoon,
builder: (_) => const SettingsAppearanceSection(),
),
_SettingsCategory(
title: 'Tabs',
icon: LucideIcons.layoutGrid,
builder: (_) => const SettingsTabsSection(),
),
_SettingsCategory(
title: 'Action bar',
icon: LucideIcons.slidersHorizontal,
builder: (_) => const SettingsActionBarSection(),
),
_SettingsCategory(
title: 'Swipe on a file',
icon: LucideIcons.chevronsLeftRight,
builder: (_) => const SettingsSwipeSection(),
),
];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
@@ -60,18 +120,49 @@ class AccountView extends StatelessWidget {
top: false, top: false,
child: desktop child: desktop
? _DesktopGrid(sections: _sections) ? _DesktopGrid(sections: _sections)
: _MobileList(sections: _sections), : _MobileMenu(categories: _categories),
), ),
); );
} }
} }
class _MobileList extends StatelessWidget { /// One row of [AccountView]'s mobile top-level menu: a title, a leading
final List<Widget> sections; /// icon (reused from that section's own first/most-representative row, so
const _MobileList({required this.sections}); /// the menu icon and the content the user lands on agree), and a builder
/// for the section content shown on [_SettingsCategoryScreen].
class _SettingsCategory {
final String title;
final IconData icon;
final WidgetBuilder builder;
_SettingsCategory({
required this.title,
required this.icon,
required this.builder,
});
}
/// The mobile top-level Settings list: [SettingsAccountCard] pinned above a
/// single [NooGroupedList] of category rows, one per [AccountView._categories]
/// entry - the menu half of the menu-then-pushed-screen pattern described on
/// [AccountView]'s own doc comment.
class _MobileMenu extends StatelessWidget {
final List<_SettingsCategory> categories;
const _MobileMenu({required this.categories});
void _open(BuildContext context, _SettingsCategory category) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => _SettingsCategoryScreen(category: category),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors;
return ListView( return ListView(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
NooSpace.sm, NooSpace.sm,
@@ -81,15 +172,66 @@ class _MobileList extends StatelessWidget {
), ),
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
children: [ children: [
for (final section in sections) ...[ const SettingsAccountCard(),
section, const SizedBox(height: NooSpace.xl),
const SizedBox(height: NooSpace.xl), NooGroupedList(
], children: [
for (final category in categories)
NooSettingsRow(
icon: category.icon,
label: Text(category.title),
trailing: Icon(
LucideIcons.chevronRight,
size: 18,
color: colors.fg3,
),
onTap: () => _open(context, category),
),
],
),
], ],
); );
} }
} }
/// A pushed, single-category Settings screen: [NooTopBar] titled with the
/// category, a back button, and just that section's own content - the
/// pushed half of [AccountView]'s mobile menu-then-screen pattern. Always
/// built in a mobile-width context (desktop never opens this screen; it
/// shows every section inline in its own grid instead), so the section
/// widgets inside render their normal mobile layout unchanged.
class _SettingsCategoryScreen extends StatelessWidget {
final _SettingsCategory category;
const _SettingsCategoryScreen({required this.category});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Scaffold(
backgroundColor: colors.bg,
appBar: NooTopBar(
style: NooLayout.navStyle(context),
title: category.title,
leading: const NooTopBarBack(),
),
body: SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.fromLTRB(
NooSpace.sm,
NooSpace.sm,
NooSpace.sm,
NooSpace.xxl,
),
physics: const BouncingScrollPhysics(),
children: [category.builder(context)],
),
),
);
}
}
/// Splits the sections between two columns rather than a strict grid, since /// Splits the sections between two columns rather than a strict grid, since
/// each card's content height varies a lot (the tab reorder list and the /// each card's content height varies a lot (the tab reorder list and the
/// accounts list can both run much taller than, say, Security) - a fixed /// accounts list can both run much taller than, say, Security) - a fixed
+8 -6
View File
@@ -5,7 +5,7 @@ import '../noo/noo_layout.dart';
import '../noo/overlays/noo_dialog.dart'; import '../noo/overlays/noo_dialog.dart';
import '../noo/overlays/noo_sheet.dart'; import '../noo/overlays/noo_sheet.dart';
/// One block of Settings (DESIGN_SYSTEM.md 4's 8-part order): a /// One block of Settings (DESIGN_SYSTEM.md 4's 9-part order): a
/// [NooGroupedList] on mobile (label above a radius-20 card), or a titled, /// [NooGroupedList] on mobile (label above a radius-20 card), or a titled,
/// bordered radius-20 card holding a flat row group on desktop /// bordered radius-20 card holding a flat row group on desktop
/// ("Mobile uses one column of grouped lists. Desktop uses a 2-column grid /// ("Mobile uses one column of grouped lists. Desktop uses a 2-column grid
@@ -31,11 +31,13 @@ class SettingsSection extends StatelessWidget {
return NooGroupedList( return NooGroupedList(
label: title, label: title,
footer: subtitle != null ? Text(subtitle!) : null, footer: subtitle != null ? Text(subtitle!) : null,
// Every mobile Settings section is individually collapsible, // Not collapsible: each mobile Settings section now renders on its
// expanded by default - replaces the old trailing jump rail (see // own pushed screen (see `account_view.dart`'s doc comment for the
// `account_view.dart`'s doc comment) as the way to navigate a long // menu-then-pushed-screen pattern), so there's no long single-scroll
// Settings screen quickly. // page left to collapse sections *within* - an earlier design had
collapsible: true, // every section inline in one column and made them individually
// collapsible for exactly that reason; that's gone now that each
// one is already isolated on its own screen.
children: children, children: children,
); );
} }
+198
View File
@@ -0,0 +1,198 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:noo/models/saved_account.dart';
import 'package:noo/providers/connectivity_controller.dart';
import 'package:noo/providers/files_controller.dart';
import 'package:noo/providers/session_controller.dart';
import 'package:noo/providers/settings_controller.dart';
import 'package:noo/providers/sync_status_controller.dart';
import 'package:noo/theme/app_theme.dart';
import 'package:noo/views/account_view.dart';
import 'package:noo/widgets/noo/nav/noo_top_bar.dart';
/// Covers the mobile Settings navigation restructure: a top-level menu of
/// category rows (`account_view.dart`'s `_MobileMenu`) that pushes a
/// single-section screen per row, replacing the old design where every
/// section rendered inline in one long scrolling column. This only covers
/// the menu/push/back mechanics - each section's own content already has
/// (or doesn't need) its own coverage elsewhere.
void main() {
const secureStorageChannel = MethodChannel(
'plugins.it_nomads.com/flutter_secure_storage',
);
const connectivityChannel = MethodChannel(
'dev.fluttercommunity.plus/connectivity',
);
const connectivityStatusChannel = EventChannel(
'dev.fluttercommunity.plus/connectivity_status',
);
const accountId = 'server_example_com__alice';
const passwordKey = 'nc_app_password_$accountId';
const password = 'app-password';
setUpAll(() {
GoogleFonts.config.allowRuntimeFetching = false;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(secureStorageChannel, (call) async {
if (call.method == 'readAll') {
return <String, String>{passwordKey: password};
}
if (call.method == 'read') {
final key = (call.arguments as Map)['key'] as String?;
return key == passwordKey ? password : null;
}
return null;
});
// Offline, like `session_controller_test.dart`'s setup: keeps
// SessionController in a provisional login (no real HTTP calls) and
// stops FilesController/SyncStatusController from starting their
// network-fetch/periodic-refresh machinery, which would otherwise
// leave timers pending forever in a test.
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(connectivityChannel, (call) async {
if (call.method == 'check') return <String>['none'];
return null;
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockStreamHandler(
connectivityStatusChannel,
MockStreamHandler.inline(onListen: (arguments, events) {}),
);
});
setUp(() {
SharedPreferences.setMockInitialValues({
'account_migration_v1_done': true,
'accounts_list': jsonEncode([
const SavedAccount(
id: accountId,
serverUrl: 'https://server.example.com',
username: 'alice',
).toJson(),
]),
'active_account_id': accountId,
});
});
Future<void> pumpSettings(WidgetTester tester) async {
await tester.binding.setSurfaceSize(const Size(400, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => ConnectivityController()),
ChangeNotifierProvider(
create: (context) => SessionController(context.read()),
),
ChangeNotifierProvider(create: (_) => SettingsController()),
ChangeNotifierProvider(
create: (context) => FilesController(context.read()),
),
ChangeNotifierProvider(
create: (context) =>
SyncStatusController(context.read(), context.read()),
),
],
child: MaterialApp(
theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false),
home: const AccountView(),
),
),
);
// Not `pumpAndSettle`: once the saved account is ready,
// FilesController/SyncStatusController start their own periodic
// refresh timers, which `pumpAndSettle` would spin on forever. A
// bounded number of small pumps is enough to flush the async gaps in
// SessionController's prefs restore and the dependent controllers'
// one-shot account-ready reactions (mirrors
// `session_controller_test.dart`'s `pumpUntil` loop).
for (var i = 0; i < 30; i++) {
await tester.pump(const Duration(milliseconds: 10));
}
}
/// Advances exactly far enough to finish a push/pop transition
/// (`MaterialPageRoute`'s default is 300ms) without risking
/// `pumpAndSettle` picking up a pending periodic timer.
Future<void> settleNav(WidgetTester tester) async {
await tester.pump();
for (var i = 0; i < 10; i++) {
await tester.pump(const Duration(milliseconds: 50));
}
}
const categoryTitles = [
'Accounts',
'Security',
'File sync',
'Files cache',
'Appearance',
'Tabs',
'Action bar',
'Swipe on a file',
];
testWidgets(
'shows a top-level menu of category rows, not every section inline',
(tester) async {
await pumpSettings(tester);
// The account card (pinned, not a category row of its own) ...
expect(find.text('alice'), findsOneWidget);
// ... plus exactly one row per section.
for (final title in categoryTitles) {
expect(find.text(title), findsOneWidget);
}
// A section's own content isn't rendered until its row is tapped -
// this is a menu, not the old all-sections-inline column.
expect(
find.text("Require this device's PIN or biometric to open Noo"),
findsNothing,
);
},
);
testWidgets(
'tapping a category row pushes just that section, with a way back',
(tester) async {
await pumpSettings(tester);
await tester.tap(find.text('Security'));
await settleNav(tester);
// Landed on a separate, pushed Security screen: its own content -
// not shown anywhere on the top-level menu - is now visible. (The
// menu screen below it may stay mounted per `PageRoute.maintainState`,
// so this checks for the pushed screen's content rather than the
// menu's absence.)
expect(
find.text("Require this device's PIN or biometric to open Noo"),
findsOneWidget,
);
// At least one `NooTopBarBack` now leads back - the pushed screen's
// own, on top of `AccountView`'s own (for returning to the shell).
expect(find.byType(NooTopBarBack), findsAtLeastNWidgets(1));
// Tapping the topmost back button pops the pushed screen back off,
// taking its content with it - unlike the underlying menu, a popped
// route is actually removed, so this absence check is meaningful.
await tester.tap(find.byType(NooTopBarBack).last);
await settleNav(tester);
expect(
find.text("Require this device's PIN or biometric to open Noo"),
findsNothing,
);
expect(find.text('Security'), findsOneWidget);
expect(find.text('alice'), findsOneWidget);
},
);
}