Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
813c613249 | ||
|
|
c0a67f3efe | ||
|
|
5fbac333d5 |
@@ -29,6 +29,13 @@ widgets. Key points:
|
|||||||
whole app in `main.dart`. When available and `useDynamicColor` is on, the
|
whole app in `main.dart`. When available and `useDynamicColor` is on, the
|
||||||
OS-provided `ColorScheme` wins over the seed color — always thread both
|
OS-provided `ColorScheme` wins over the seed color — always thread both
|
||||||
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
|
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
|
||||||
|
`NooColors.fromDynamicScheme` keeps `bg` on the OS `surface` but picks
|
||||||
|
`surface` (cards, selection bar, rows) from the container roles so it
|
||||||
|
stays lighter than `bg`: `surfaceContainerLowest` (white) in light,
|
||||||
|
`surfaceContainer` in dark. The dynamic_color package seeds the container
|
||||||
|
roles from the primary color rather than the OS palette, and in light mode
|
||||||
|
`surfaceContainerLow` is slightly *darker* than the OS `surface`, which
|
||||||
|
made cards blend into the page ("the selection bar has no background").
|
||||||
- **Font**: Inter via `google_fonts`, applied through
|
- **Font**: Inter via `google_fonts`, applied through
|
||||||
`GoogleFonts.interTextTheme(...)`. Font files ship in
|
`GoogleFonts.interTextTheme(...)`. Font files ship in
|
||||||
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
|
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 930 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 19 KiB |
@@ -139,11 +139,20 @@ class NooColors extends ThemeExtension<NooColors> {
|
|||||||
/// paints it, not painted directly - using it as-is for a sheet/dialog's
|
/// paints it, not painted directly - using it as-is for a sheet/dialog's
|
||||||
/// `barrierColor` hid the screen behind completely instead of dimming it,
|
/// `barrierColor` hid the screen behind completely instead of dimming it,
|
||||||
/// so this uses the fixed palette's already-translucent scrim instead.
|
/// so this uses the fixed palette's already-translucent scrim instead.
|
||||||
|
///
|
||||||
|
/// `surface` (cards, bars, rows) has to read as lighter than `bg`, like
|
||||||
|
/// the fixed palettes' white-on-beige / lifted-on-near-black. In light
|
||||||
|
/// mode `surfaceContainerLow` is a hair *darker* than the OS-provided
|
||||||
|
/// `surface` that [bg] uses, so cards (the selection bar, list rows)
|
||||||
|
/// vanished into the page; `surfaceContainerLowest` (white) doesn't. Dark
|
||||||
|
/// mode uses `surfaceContainer` rather than `...Low` for a bigger step
|
||||||
|
/// off the page.
|
||||||
factory NooColors.fromDynamicScheme(ColorScheme cs) {
|
factory NooColors.fromDynamicScheme(ColorScheme cs) {
|
||||||
final fixed = cs.brightness == Brightness.dark ? dark : light;
|
final dark = cs.brightness == Brightness.dark;
|
||||||
|
final fixed = dark ? NooColors.dark : NooColors.light;
|
||||||
return NooColors(
|
return NooColors(
|
||||||
bg: cs.surface,
|
bg: cs.surface,
|
||||||
surface: cs.surfaceContainerLow,
|
surface: dark ? cs.surfaceContainer : cs.surfaceContainerLowest,
|
||||||
surface2: cs.surfaceContainerHigh,
|
surface2: cs.surfaceContainerHigh,
|
||||||
surface3: cs.surfaceContainerHighest,
|
surface3: cs.surfaceContainerHighest,
|
||||||
line: cs.outlineVariant,
|
line: cs.outlineVariant,
|
||||||
|
|||||||
@@ -522,32 +522,36 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
// Same "icon always, label only when selected" pill as
|
// Same "icon always, label only when selected" pill as
|
||||||
// Files' own type filter - not a fully-labelled track, so
|
// Files' own type filter - not a fully-labelled track, so
|
||||||
// the two screens' filter sheets look and behave the same.
|
// the two screens' filter sheets look and behave the same.
|
||||||
NooSegmentedControl<_PhotoTypeFilter>(
|
FilterSection(
|
||||||
fill: true,
|
icon: LucideIcons.layoutGrid,
|
||||||
onSurface: true,
|
title: 'Type',
|
||||||
labelOnlySelected: true,
|
child: NooSegmentedControl<_PhotoTypeFilter>(
|
||||||
value: _typeFilter,
|
fill: true,
|
||||||
onChanged: (filter) => setState(() {
|
onSurface: true,
|
||||||
_typeFilter = filter;
|
labelOnlySelected: true,
|
||||||
setSheetState(() {});
|
value: _typeFilter,
|
||||||
}),
|
onChanged: (filter) => setState(() {
|
||||||
options: const [
|
_typeFilter = filter;
|
||||||
NooSegmentOption(
|
setSheetState(() {});
|
||||||
value: _PhotoTypeFilter.all,
|
}),
|
||||||
icon: LucideIcons.layoutGrid,
|
options: const [
|
||||||
label: 'All',
|
NooSegmentOption(
|
||||||
),
|
value: _PhotoTypeFilter.all,
|
||||||
NooSegmentOption(
|
icon: LucideIcons.layoutGrid,
|
||||||
value: _PhotoTypeFilter.image,
|
label: 'All',
|
||||||
icon: LucideIcons.image,
|
),
|
||||||
label: 'Photos',
|
NooSegmentOption(
|
||||||
),
|
value: _PhotoTypeFilter.image,
|
||||||
NooSegmentOption(
|
icon: LucideIcons.image,
|
||||||
value: _PhotoTypeFilter.video,
|
label: 'Photos',
|
||||||
icon: LucideIcons.film,
|
),
|
||||||
label: 'Videos',
|
NooSegmentOption(
|
||||||
),
|
value: _PhotoTypeFilter.video,
|
||||||
],
|
icon: LucideIcons.film,
|
||||||
|
label: 'Videos',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
NooGroupedList(
|
NooGroupedList(
|
||||||
|
|||||||
@@ -171,29 +171,33 @@ class FilesControlsRow extends StatelessWidget {
|
|||||||
// Same "icon always, label only when selected" pill as
|
// Same "icon always, label only when selected" pill as
|
||||||
// Photos' own type filter below - not a checkmark list, so
|
// Photos' own type filter below - not a checkmark list, so
|
||||||
// the two screens' filter sheets look and behave the same.
|
// the two screens' filter sheets look and behave the same.
|
||||||
NooSegmentedControl<FilesTypeFilter>(
|
FilterSection(
|
||||||
fill: true,
|
icon: LucideIcons.layoutGrid,
|
||||||
onSurface: true,
|
title: 'Type',
|
||||||
labelOnlySelected: true,
|
child: NooSegmentedControl<FilesTypeFilter>(
|
||||||
value: files.filesTypeFilter,
|
fill: true,
|
||||||
onChanged: files.setFilesTypeFilter,
|
onSurface: true,
|
||||||
options: const [
|
labelOnlySelected: true,
|
||||||
NooSegmentOption(
|
value: files.filesTypeFilter,
|
||||||
value: FilesTypeFilter.all,
|
onChanged: files.setFilesTypeFilter,
|
||||||
icon: LucideIcons.layoutGrid,
|
options: const [
|
||||||
label: 'All',
|
NooSegmentOption(
|
||||||
),
|
value: FilesTypeFilter.all,
|
||||||
NooSegmentOption(
|
icon: LucideIcons.layoutGrid,
|
||||||
value: FilesTypeFilter.filesOnly,
|
label: 'All',
|
||||||
icon: LucideIcons.file,
|
),
|
||||||
label: 'Files',
|
NooSegmentOption(
|
||||||
),
|
value: FilesTypeFilter.filesOnly,
|
||||||
NooSegmentOption(
|
icon: LucideIcons.file,
|
||||||
value: FilesTypeFilter.foldersOnly,
|
label: 'Files',
|
||||||
icon: LucideIcons.folder,
|
),
|
||||||
label: 'Folders',
|
NooSegmentOption(
|
||||||
),
|
value: FilesTypeFilter.foldersOnly,
|
||||||
],
|
icon: LucideIcons.folder,
|
||||||
|
label: 'Folders',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
HiddenFilesFilterRow(
|
HiddenFilesFilterRow(
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class HiddenFilesFilterRow extends StatelessWidget {
|
|||||||
NooSegmentOption(
|
NooSegmentOption(
|
||||||
value: HiddenFilesFilter.include,
|
value: HiddenFilesFilter.include,
|
||||||
icon: LucideIcons.layers,
|
icon: LucideIcons.layers,
|
||||||
label: 'All + hidden',
|
label: 'All',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -80,13 +80,48 @@ class StorageScopeRow extends StatelessWidget {
|
|||||||
NooSegmentOption(
|
NooSegmentOption(
|
||||||
value: StorageScope.all,
|
value: StorageScope.all,
|
||||||
icon: LucideIcons.layers,
|
icon: LucideIcons.layers,
|
||||||
label: 'All + external',
|
label: 'All',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An icon + title line above [child] - the shape every row of the Files/
|
||||||
|
/// Photos filter sheets shares, including their type (All/Files/Folders)
|
||||||
|
/// control, which lives in its own file.
|
||||||
|
class FilterSection extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const FilterSection({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: colors.fg2),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(title, style: NooText.bodyL.copyWith(color: colors.fg1)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _FilterModeRow<T> extends StatelessWidget {
|
class _FilterModeRow<T> extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
@@ -104,27 +139,17 @@ class _FilterModeRow<T> extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = context.nooColors;
|
return FilterSection(
|
||||||
return Column(
|
icon: icon,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
title: title,
|
||||||
children: [
|
child: NooSegmentedControl<T>(
|
||||||
Row(
|
fill: true,
|
||||||
children: [
|
onSurface: true,
|
||||||
Icon(icon, size: 18, color: colors.fg2),
|
labelOnlySelected: true,
|
||||||
const SizedBox(width: 10),
|
value: value,
|
||||||
Text(title, style: NooText.bodyL.copyWith(color: colors.fg1)),
|
onChanged: onChanged,
|
||||||
],
|
options: options,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
|
||||||
NooSegmentedControl<T>(
|
|
||||||
fill: true,
|
|
||||||
onSurface: true,
|
|
||||||
labelOnlySelected: true,
|
|
||||||
value: value,
|
|
||||||
onChanged: onChanged,
|
|
||||||
options: options,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ void main() {
|
|||||||
await tester.tap(find.byIcon(LucideIcons.layers));
|
await tester.tap(find.byIcon(LucideIcons.layers));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(value, HiddenFilesFilter.include);
|
expect(value, HiddenFilesFilter.include);
|
||||||
expect(find.text('All + hidden'), findsOneWidget);
|
expect(find.text('All'), findsOneWidget);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('StorageScopeRow', () {
|
group('StorageScopeRow', () {
|
||||||
testNooWidgets('offers cloud only / only external / all + external', (
|
testNooWidgets('offers cloud only / only external / all', (
|
||||||
tester,
|
tester,
|
||||||
theme,
|
theme,
|
||||||
c,
|
c,
|
||||||
@@ -70,7 +70,7 @@ void main() {
|
|||||||
await tester.tap(find.byIcon(LucideIcons.layers));
|
await tester.tap(find.byIcon(LucideIcons.layers));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(value, StorageScope.all);
|
expect(value, StorageScope.all);
|
||||||
expect(find.text('All + external'), findsOneWidget);
|
expect(find.text('All'), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,30 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('NooColors.fromDynamicScheme surfaces', () {
|
||||||
|
for (final brightness in Brightness.values) {
|
||||||
|
test('cards are clearly lighter than the page (${brightness.name})', () {
|
||||||
|
// The OS palette overrides `surface` (what `bg` maps to) but not the
|
||||||
|
// seeded container roles - mimic that mismatch.
|
||||||
|
final seeded = ColorScheme.fromSeed(
|
||||||
|
seedColor: const Color(0xFF3F6B8F),
|
||||||
|
brightness: brightness,
|
||||||
|
);
|
||||||
|
final os = seeded.copyWith(
|
||||||
|
surface: brightness == Brightness.light
|
||||||
|
? const Color(0xFFF7F9FC)
|
||||||
|
: const Color(0xFF101417),
|
||||||
|
);
|
||||||
|
final c = NooColors.fromDynamicScheme(os);
|
||||||
|
// WCAG-style contrast ratio between the card and the page.
|
||||||
|
final hi = c.surface.computeLuminance();
|
||||||
|
final lo = c.bg.computeLuminance();
|
||||||
|
expect(hi, greaterThan(lo));
|
||||||
|
expect((hi + 0.05) / (lo + 0.05), greaterThan(1.04));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
group('NooAvatar', () {
|
group('NooAvatar', () {
|
||||||
testNooWidgets('current user uses accent-soft/accent-text', (
|
testNooWidgets('current user uses accent-soft/accent-text', (
|
||||||
tester,
|
tester,
|
||||||
|
|||||||