- FilesControlsRow (and Photos' own controls row) wrapped its Row in a SingleChildScrollView, which gives a child unbounded width - silently breaking the trailing Spacer (a flex child needs bounded width) and leaving the List/Grid toggle sitting right after the filter chip instead of pinned to the row's right edge, "not enough controls to fill the row" or not. Removed the scroll wrapper (two chips and an icon toggle never need it) and the dead scroll-hint plumbing that went with it in files_view.dart. - Photos' All/Photos/Videos chips moved into the filter sheet as a full-width segmented control (matching Settings' Theme control), instead of their own row of chips competing for space with the sort and filter chips. - Settings' Theme row: the System/Light/Dark segmented control now sits on its own row below the label instead of squeezed into a trailing slot beside it, where three icon+label segments were too cramped to read comfortably - same shape as the sort sheet's Ascending/Descending control. - NooBanner (Trash's "Empty trash") and NooSummaryCard (Offline's "Sync now") both move their action onto its own row below the description text instead of squeezed onto the same line, where a longer caption had no room to share with it. - SharesController now caches each scope (with you / by you, which also covers links) after its first fetch instead of re-fetching on every single switch, including switching straight back to a scope already loaded seconds ago. - Shares' per-row overflow menu jumped straight to the unshare confirmation with no menu at all - a link share had no way to copy its own URL short of opening the full Share sheet. It's a real menu now: "Copy link" (link shares only) plus remove/unshare. - Settings gained a trailing jump rail on mobile (one small icon per section) so a specific section can be reached in one tap instead of scrolling the whole list by hand; desktop's 2-column grid doesn't need one. - The media viewer's PageView now disables its own left/right swipe while the current photo is zoomed in, so panning around it doesn't also swipe to the next item. - The PDF preview no longer sets minScale below 1.0: pdfx's PdfViewPinch hard-codes an *infinite* pan boundary whenever minScale < 1 (not an exposed parameter, so there's no way to override it), which let a page be panned arbitrarily far off-screen without ever zooming.
335 lines
10 KiB
Dart
335 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../../providers/settings_controller.dart';
|
|
import '../../theme/app_theme.dart';
|
|
import '../../theme/design_tokens.dart';
|
|
import '../noo/core/noo_segmented_control.dart';
|
|
import '../noo/core/noo_toggle.dart';
|
|
import '../noo/lists/noo_settings_row.dart';
|
|
import '../noo/noo_layout.dart';
|
|
import '../noo/overlays/noo_dialog.dart';
|
|
import '../noo/overlays/noo_sheet.dart';
|
|
import '../seek_bar_painter.dart';
|
|
import 'settings_section.dart';
|
|
|
|
String _seekBarStyleLabel(MediaProgressBarStyle style) {
|
|
switch (style) {
|
|
case MediaProgressBarStyle.classic:
|
|
return 'Default';
|
|
case MediaProgressBarStyle.wavy:
|
|
return 'Wavy';
|
|
case MediaProgressBarStyle.slim:
|
|
return 'Slim';
|
|
case MediaProgressBarStyle.squiggly:
|
|
return 'Squiggly';
|
|
}
|
|
}
|
|
|
|
/// Settings section 6: appearance. Only the theme mode (System/Light/Dark)
|
|
/// is in the design system's own recipe - the accent-color picker, dynamic
|
|
/// color, AMOLED and the video seek-bar style predate the design system, so
|
|
/// they're demoted into a separate "Advanced appearance" group beneath the
|
|
/// main card rather than folded into it. The accent picker itself picks a
|
|
/// [NooColors] variant (see [NooColors.fromSeed]/[NooColors.fromDynamicScheme]):
|
|
/// "Default" is the fixed violet palette, the named swatches retint just
|
|
/// the accent roles, and "Match wallpaper" (the `wallpaper`-icon swatch)
|
|
/// follows Material You dynamic color instead.
|
|
class SettingsAppearanceSection extends StatelessWidget {
|
|
const SettingsAppearanceSection({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final settings = context.watch<SettingsController>();
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SettingsSection(
|
|
title: 'Appearance',
|
|
children: [_ThemeRow(settings: settings)],
|
|
),
|
|
const SizedBox(height: NooSpace.xl),
|
|
SettingsSection(
|
|
title: 'Advanced appearance',
|
|
children: [
|
|
NooSettingsRow(
|
|
icon: LucideIcons.palette,
|
|
label: const Text('Accent color'),
|
|
subtitle: Text(
|
|
settings.useDynamicColor
|
|
? 'Matching your wallpaper'
|
|
: settings.seedColor == AppTheme.defaultAccent
|
|
? 'Default'
|
|
: 'Custom color',
|
|
),
|
|
trailing: _AccentSwatchDot(
|
|
color: settings.useDynamicColor ? null : settings.seedColor,
|
|
),
|
|
onTap: () => _openAccentPicker(context, settings),
|
|
),
|
|
NooSettingsRow(
|
|
icon: LucideIcons.sparkles,
|
|
label: const Text('AMOLED black'),
|
|
subtitle: const Text('Use pure black backgrounds in dark mode'),
|
|
trailing: NooToggle(
|
|
checked: settings.amoledDark,
|
|
onChanged: settings.setAmoledDark,
|
|
),
|
|
),
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The "Theme" row: label on its own line, then the System/Light/Dark
|
|
/// segmented control full-width on the line below - not the usual
|
|
/// label-plus-trailing-control `NooSettingsRow` layout, since three
|
|
/// icon+label segments crammed into a trailing slot next to the row's own
|
|
/// label left every segment too cramped to read comfortably. Matches the
|
|
/// same "label, then a full-width segmented control below it" shape
|
|
/// `_showSortSheet` (Ascending/Descending) and Photos' own filter sheet
|
|
/// (All/Photos/Videos) already use.
|
|
class _ThemeRow extends StatelessWidget {
|
|
final SettingsController settings;
|
|
|
|
const _ThemeRow({required this.settings});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.nooColors;
|
|
return Material(
|
|
color: colors.surface,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: NooSpace.md,
|
|
vertical: 12,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(LucideIcons.sunMoon, size: 20, color: colors.fg2),
|
|
const SizedBox(width: 14),
|
|
Text('Theme', style: NooText.bodyL.copyWith(color: colors.fg1)),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
NooSegmentedControl<ThemeMode>(
|
|
fill: true,
|
|
value: settings.themeMode,
|
|
onChanged: settings.setThemeMode,
|
|
options: const [
|
|
NooSegmentOption(
|
|
value: ThemeMode.system,
|
|
icon: LucideIcons.monitor,
|
|
label: 'System',
|
|
),
|
|
NooSegmentOption(
|
|
value: ThemeMode.light,
|
|
icon: LucideIcons.sun,
|
|
label: 'Light',
|
|
),
|
|
NooSegmentOption(
|
|
value: ThemeMode.dark,
|
|
icon: LucideIcons.moon,
|
|
label: 'Dark',
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The current accent-color choice, shown as a small filled circle in the
|
|
/// "Accent color" row - a wallpaper icon when following the OS's dynamic
|
|
/// color instead of a fixed swatch.
|
|
class _AccentSwatchDot extends StatelessWidget {
|
|
final Color? color;
|
|
const _AccentSwatchDot({this.color});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.nooColors;
|
|
return Container(
|
|
width: 22,
|
|
height: 22,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: color ?? colors.surface3,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: color == null
|
|
? Icon(LucideIcons.wallpaper, size: 12, color: colors.fg2)
|
|
: null,
|
|
);
|
|
}
|
|
}
|
|
|
|
void _openAccentPicker(BuildContext context, SettingsController settings) {
|
|
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),
|
|
),
|
|
for (final color in AppTheme.seedColors)
|
|
_AccentSwatch(
|
|
isSelected: !settings.useDynamicColor && settings.seedColor == color,
|
|
onTap: () {
|
|
settings.setSeedColor(color);
|
|
Navigator.pop(context);
|
|
},
|
|
background: color,
|
|
),
|
|
],
|
|
);
|
|
|
|
final colors = context.nooColors;
|
|
if (NooLayout.isDesktop(context)) {
|
|
showNooDialog(context, title: 'Accent color', children: [grid(colors)]);
|
|
} else {
|
|
showNooSheet(context, children: [grid(colors)]);
|
|
}
|
|
}
|
|
|
|
class _AccentSwatch extends StatelessWidget {
|
|
final bool isSelected;
|
|
final VoidCallback onTap;
|
|
final Color background;
|
|
final Widget? child;
|
|
|
|
const _AccentSwatch({
|
|
required this.isSelected,
|
|
required this.onTap,
|
|
required this.background,
|
|
this.child,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.nooColors;
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
width: 44,
|
|
height: 44,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: background,
|
|
shape: BoxShape.circle,
|
|
border: isSelected ? Border.all(color: colors.fg1, width: 3) : null,
|
|
),
|
|
child: isSelected && child == null
|
|
? const Icon(LucideIcons.check, color: Colors.white, size: 20)
|
|
: child,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _openSeekBarPicker(BuildContext context, SettingsController settings) {
|
|
Widget grid() => GridView.count(
|
|
crossAxisCount: 2,
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
mainAxisSpacing: 12,
|
|
crossAxisSpacing: 12,
|
|
childAspectRatio: 1.35,
|
|
children: [
|
|
for (final style in MediaProgressBarStyle.values)
|
|
_SeekBarStyleOption(
|
|
style: style,
|
|
isSelected: style == settings.mediaProgressBarStyle,
|
|
onTap: () {
|
|
settings.setMediaProgressBarStyle(style);
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
|
|
if (NooLayout.isDesktop(context)) {
|
|
showNooDialog(context, title: 'Seek bar style', children: [grid()]);
|
|
} else {
|
|
showNooSheet(context, children: [grid()]);
|
|
}
|
|
}
|
|
|
|
class _SeekBarStyleOption extends StatelessWidget {
|
|
final MediaProgressBarStyle style;
|
|
final bool isSelected;
|
|
final VoidCallback onTap;
|
|
|
|
const _SeekBarStyleOption({
|
|
required this.style,
|
|
required this.isSelected,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.nooColors;
|
|
return Material(
|
|
color: isSelected ? colors.accentSoft : colors.surface2,
|
|
borderRadius: BorderRadius.circular(NooRadii.input),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(NooRadii.input),
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Expanded(
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: 100,
|
|
height: 28,
|
|
child: SeekBarPreview(
|
|
style: style,
|
|
playedColor: isSelected ? colors.accentText : colors.fg2,
|
|
trackColor: colors.surface3,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_seekBarStyleLabel(style),
|
|
style: NooText.body.copyWith(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: isSelected ? colors.accentText : colors.fg1,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|