Rebuild UI on the Noo design system

Replaces the Material 3 screens with the Noo design system: warm
neutrals, one violet accent, pill controls, Schibsted Grotesk/
Instrument Sans, Lucide icons, no gradients/shadows.

- New lib/widgets/noo/ component kit (core, lists, files, media, nav,
  overlays) plus lib/theme/design_tokens.dart for the color/type/space/
  radius/motion tokens.
- Rebuilt the app shell (top/bottom bars, drawer, desktop sidebar and
  toolbar), every tab (Files/Offline, Photos, Favorites, Recent,
  Activity, Trash, Shares), Settings, the lock and login screens, the
  media viewer, search, the details/share sheets, and the share-upload
  and move/copy destination pickers.
- Added a design canvas (linked from DESIGN_SYSTEM.md) covering the
  screens the spec didn't already describe, with matching Android and
  iOS chrome; wrote up the approved recipes into DESIGN_SYSTEM.md §4.
- Removed now-dead legacy widgets (media_grid_tile, swipeable_item,
  sync_status_badge, selectable_thumbnail) and updated
  architecture.md/styling.md/standards.md to describe the new
  structure and component/testing conventions.
- Added widget tests for the noo/ component kit.

This is a UI-only rework: no provider/model/service behavior changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 01:51:58 -04:00
co-authored by Claude Opus 5.5
parent 51682a901d
commit 56784ef212
125 changed files with 15263 additions and 6804 deletions
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Inline info card with an optional text action, e.g. Trash's retention
/// notice + "Empty trash" (Noo Design System project,
/// `components/lists/Banner.jsx`, mobile variant).
class NooBanner extends StatelessWidget {
final IconData icon;
final Widget child;
final String? actionLabel;
final VoidCallback? onAction;
final bool actionIsDanger;
const NooBanner({
super.key,
this.icon = LucideIcons.info,
required this.child,
this.actionLabel,
this.onAction,
this.actionIsDanger = true,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(NooRadii.card)),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(icon, size: 20, color: colors.fg2),
const SizedBox(width: 12),
Expanded(
child: DefaultTextStyle(
style: NooText.body.copyWith(color: colors.fg2),
child: child,
),
),
if (actionLabel != null)
GestureDetector(
onTap: onAction,
child: Text(
actionLabel!,
style: NooText.buttonSm.copyWith(
fontSize: 14,
color: actionIsDanger ? colors.danger : colors.accentText,
),
),
),
],
),
);
}
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
/// Radius-20 surface card of rows with 1px line dividers and an optional
/// label above it (Noo Design System project,
/// `components/lists/GroupedList.jsx`, mobile variant - desktop's bordered/
/// titled card isn't built yet). Each child (`SettingsRow`, `FileRow`, ...)
/// paints its own `surface` background; this only supplies the 1px `line`
/// gaps between them and the rounded clip. Stack groups 18-20px apart.
class NooGroupedList extends StatelessWidget {
final String? label;
/// Right-aligned secondary text next to [label].
final String? aside;
final List<Widget> children;
final Widget? footer;
const NooGroupedList({
super.key,
this.label,
this.aside,
required this.children,
this.footer,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (label != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label!, style: NooText.label.copyWith(color: colors.fg2)),
if (aside != null)
Text(
aside!,
style: NooText.body.copyWith(
fontSize: 13,
height: 1,
fontWeight: FontWeight.w500,
color: colors.fg3,
),
),
],
),
),
if (label != null) const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card),
child: DecoratedBox(
decoration: BoxDecoration(color: colors.line),
child: Column(
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) const SizedBox(height: 1),
children[i],
],
],
),
),
),
if (footer != null) ...[
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: DefaultTextStyle(
style: NooText.meta.copyWith(color: colors.fg3),
child: footer!,
),
),
],
],
);
}
}
+106
View File
@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// 52/60px settings row: icon, label, subtitle, trailing control (Noo
/// Design System project, `components/lists/SettingsRow.jsx`, mobile
/// variant). Goes inside a [NooGroupedList] - it paints its own `surface`
/// background, the list only supplies the line gaps.
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;
const NooSettingsRow({
super.key,
this.icon,
required this.label,
this.subtitle,
this.value,
this.trailing,
this.danger = false,
this.accent = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final color = danger ? colors.danger : (accent ? colors.accentText : colors.fg1);
final minHeight = subtitle != null ? 60.0 : 52.0;
Widget? trail = trailing;
if (trail == null && value != null) {
trail = Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
value!,
style: NooText.body.copyWith(fontSize: 15, height: 1, color: colors.fg3),
),
const SizedBox(width: 4),
Icon(LucideIcons.chevronRight, size: 18, color: colors.fg3),
],
);
}
return Material(
color: colors.surface,
child: InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: minHeight),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: NooSpace.md,
vertical: subtitle != null ? 11 : 0,
),
child: Row(
children: [
if (icon != null) ...[
Icon(icon, size: 20, color: danger || accent ? color : colors.fg2),
const SizedBox(width: 14),
],
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
DefaultTextStyle(
style: NooText.bodyL.copyWith(
height: 1.1,
fontWeight: danger || accent
? (accent ? FontWeight.w600 : FontWeight.w500)
: FontWeight.w400,
color: color,
),
child: label,
),
if (subtitle != null) ...[
const SizedBox(height: 4),
DefaultTextStyle(
style: NooText.meta.copyWith(color: colors.fg3),
child: subtitle!,
),
],
],
),
),
?trail,
],
),
),
),
),
);
}
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
import '../core/noo_progress_bar.dart';
enum NooSummaryCardTone { normal, danger, accent }
/// Stat card with optional progress, meta and action (Offline summary,
/// desktop stats) - Noo Design System project,
/// `components/lists/SummaryCard.jsx`, mobile variant.
class NooSummaryCard extends StatelessWidget {
final String stat;
final String? caption;
/// 0-1; renders a [NooProgressBar].
final double? progress;
final String? meta;
final Widget? action;
final NooSummaryCardTone tone;
const NooSummaryCard({
super.key,
required this.stat,
this.caption,
this.progress,
this.meta,
this.action,
this.tone = NooSummaryCardTone.normal,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final bg = tone == NooSummaryCardTone.danger ? colors.dangerSoft : colors.surface;
final statColor = switch (tone) {
NooSummaryCardTone.danger => colors.danger,
NooSummaryCardTone.accent => colors.accentText,
NooSummaryCardTone.normal => colors.fg1,
};
final capColor = tone == NooSummaryCardTone.danger ? colors.danger : colors.fg2;
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(NooRadii.card)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(stat, style: NooText.stat.copyWith(color: statColor)),
if (caption != null) ...[
const SizedBox(height: 6),
Text(
caption!,
style: NooText.body.copyWith(fontSize: 14, height: 1.2, color: capColor),
),
],
],
),
),
?action,
],
),
if (progress != null) ...[
const SizedBox(height: 14),
NooProgressBar(value: progress!),
],
if (meta != null) ...[
const SizedBox(height: 14),
Text(meta!, style: NooText.meta.copyWith(color: colors.fg3)),
],
],
),
);
}
}
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Reorderable tab row with a grip and pin toggle - max 5 pinned (Noo
/// Design System project, `components/lists/TabOrderRow.jsx`, mobile
/// variant).
class NooTabOrderRow extends StatelessWidget {
final IconData icon;
final String label;
final bool pinned;
final VoidCallback? onTogglePin;
const NooTabOrderRow({
super.key,
required this.icon,
required this.label,
this.pinned = false,
this.onTogglePin,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 10),
color: colors.surface,
child: Row(
children: [
Icon(LucideIcons.gripVertical, size: 18, color: colors.fg3),
const SizedBox(width: 12),
Icon(icon, size: 20, color: colors.fg2),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: NooText.bodyL.copyWith(height: 1, color: colors.fg1),
),
),
GestureDetector(
onTap: onTogglePin,
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: pinned ? colors.accentSoft : colors.surface2,
shape: BoxShape.circle,
),
child: Icon(
pinned ? LucideIcons.pin : LucideIcons.pinOff,
size: 16,
color: pinned ? colors.accentText : colors.fg3,
),
),
),
],
),
);
}
}