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
@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../../providers/files_controller.dart';
import '../../theme/design_tokens.dart';
import '../noo/lists/noo_settings_row.dart';
import 'settings_section.dart';
String _cachePolicyLabel(CachePolicy policy) {
switch (policy) {
case CachePolicy.never:
return 'Never cache';
case CachePolicy.interval:
return 'Refresh periodically';
case CachePolicy.manual:
return 'Refresh manually only';
}
}
String _cachePolicyDescription(CachePolicy policy) {
switch (policy) {
case CachePolicy.never:
return 'Every visit to a folder fetches it fresh; synced files are '
'checked each time you open the app';
case CachePolicy.interval:
return "Reuse a folder's listing until it's a few minutes old; synced "
'files update in the background on the same schedule';
case CachePolicy.manual:
return "Reuse a folder's listing until you pull to refresh; synced "
'files only update when you pull down or tap Sync now';
}
}
/// Settings section 5: how often folder listings and synced files refresh
/// from the server.
class SettingsFilesCacheSection extends StatelessWidget {
const SettingsFilesCacheSection({super.key});
void _openPolicyPicker(BuildContext context, FilesController files) {
showSettingsPicker(
context,
title: 'Cache policy',
options: [
for (final policy in CachePolicy.values)
NooSettingsRow(
label: Text(_cachePolicyLabel(policy)),
subtitle: Text(_cachePolicyDescription(policy)),
trailing: policy == files.cachePolicy
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText)
: null,
onTap: () {
files.setCachePolicy(policy);
Navigator.pop(context);
},
),
],
);
}
@override
Widget build(BuildContext context) {
final files = context.watch<FilesController>();
return SettingsSection(
title: 'Files cache',
subtitle: 'How often folder listings and synced files refresh from the server',
children: [
NooSettingsRow(
icon: LucideIcons.database,
label: const Text('Cache policy'),
value: _cachePolicyLabel(files.cachePolicy),
onTap: () => _openPolicyPicker(context, files),
),
if (files.cachePolicy == CachePolicy.interval)
_CacheIntervalRow(
minutes: files.cacheIntervalMinutes,
onChanged: files.setCacheIntervalMinutes,
),
],
);
}
}
/// The refresh-interval slider shown under "Cache policy" while
/// [CachePolicy.interval] is selected - not a plain [NooSettingsRow] since
/// it needs a full-width [Slider], but paints its own `surface` background
/// the same way so it sits correctly inside the group's line dividers (see
/// `NooGroupedList`'s doc comment).
class _CacheIntervalRow extends StatelessWidget {
final int minutes;
final ValueChanged<int> onChanged;
const _CacheIntervalRow({required this.minutes, required this.onChanged});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Material(
color: colors.surface,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Refresh interval', style: NooText.bodyL.copyWith(color: colors.fg1)),
Text('$minutes min', style: NooText.meta.copyWith(color: colors.fg3)),
],
),
SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: colors.accent,
inactiveTrackColor: colors.surface3,
thumbColor: colors.accent,
overlayColor: colors.accent.withValues(alpha: 0.12),
),
child: Slider(
value: minutes.toDouble(),
min: 1,
max: 60,
divisions: 59,
label: '$minutes min',
onChanged: (value) => onChanged(value.round()),
),
),
if (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).',
style: NooText.meta.copyWith(color: colors.fg3),
),
),
],
),
),
);
}
}