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:
@@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/files_controller.dart';
|
||||
import '../../providers/session_controller.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/core/noo_avatar.dart';
|
||||
import '../noo/core/noo_button.dart';
|
||||
import '../noo/core/noo_chip.dart';
|
||||
import '../noo/core/noo_progress_bar.dart';
|
||||
import '../noo/noo_layout.dart';
|
||||
import '../shell/shell_common.dart';
|
||||
import 'settings_dialogs.dart';
|
||||
|
||||
/// Settings section 1: the account card (DESIGN_SYSTEM.md 4 - "NooAvatar
|
||||
/// 52-56, name, server, storage NooProgressBar"). The quota/quick-actions
|
||||
/// (refresh/log out/remove) aren't in the spec's short recipe but were on
|
||||
/// the pre-rework card and have no other home, so they stay here as a
|
||||
/// button row under the storage meter.
|
||||
class SettingsAccountCard extends StatelessWidget {
|
||||
const SettingsAccountCard({super.key});
|
||||
|
||||
Future<void> _refresh(BuildContext context, FilesController files) async {
|
||||
await files.refreshData();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Refreshed WebDAV data'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Logout keeps the account saved (see [SessionController.logout]) so
|
||||
/// it's harmless/reversible from a one-tap resume on the login screen -
|
||||
/// doesn't need a confirmation dialog the way removing an account does.
|
||||
Future<void> _logout(BuildContext context, SessionController session) async {
|
||||
final navigator = Navigator.of(context);
|
||||
await session.logout();
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
}
|
||||
|
||||
Future<void> _remove(BuildContext context, SessionController session) async {
|
||||
final id = session.activeAccountId;
|
||||
if (id == null) return;
|
||||
await confirmRemoveAccount(
|
||||
context,
|
||||
session,
|
||||
accountId: id,
|
||||
username: session.username,
|
||||
host: serverHost(session.serverUrl),
|
||||
isActive: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final session = context.watch<SessionController>();
|
||||
final files = context.watch<FilesController>();
|
||||
final quota = files.quota;
|
||||
final name = quota?.userName ?? session.username;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(NooSpace.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
border: NooLayout.isDesktop(context) ? Border.all(color: colors.line) : null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
NooAvatar(initials: accountInitial(name), current: true, size: 56),
|
||||
const SizedBox(width: NooSpace.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.cardTitle.copyWith(color: colors.fg1),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
serverHost(session.serverUrl),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.meta.copyWith(color: colors.fg3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: NooSpace.lg),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Storage', style: NooText.label.copyWith(color: colors.fg2)),
|
||||
Text(
|
||||
quota != null ? quotaDetail(quota) : 'Loading…',
|
||||
style: NooText.meta.copyWith(color: colors.fg3),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
NooProgressBar(value: quotaFraction(quota)),
|
||||
if (quota != null && quota.groups.isNotEmpty) ...[
|
||||
const SizedBox(height: NooSpace.md),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [for (final group in quota.groups) NooChip(child: Text(group))],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: NooSpace.md),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
NooButton(
|
||||
variant: NooButtonVariant.secondary,
|
||||
size: NooButtonSize.compact,
|
||||
icon: LucideIcons.refreshCw,
|
||||
onTap: () => _refresh(context, files),
|
||||
child: const Text('Refresh'),
|
||||
),
|
||||
NooButton(
|
||||
variant: NooButtonVariant.secondary,
|
||||
size: NooButtonSize.compact,
|
||||
icon: LucideIcons.logOut,
|
||||
onTap: () => _logout(context, session),
|
||||
child: const Text('Log out'),
|
||||
),
|
||||
NooButton(
|
||||
variant: NooButtonVariant.danger,
|
||||
size: NooButtonSize.compact,
|
||||
icon: LucideIcons.trash2,
|
||||
onTap: () => _remove(context, session),
|
||||
child: const Text('Remove account'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/saved_account.dart';
|
||||
import '../../providers/session_controller.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../../views/login_view.dart';
|
||||
import '../noo/core/noo_avatar.dart';
|
||||
import '../noo/lists/noo_settings_row.dart';
|
||||
import '../shell/shell_common.dart';
|
||||
import 'settings_dialogs.dart';
|
||||
import 'settings_section.dart';
|
||||
|
||||
/// Settings section 2: every saved account (switch/remove), then "Add
|
||||
/// account" (DESIGN_SYSTEM.md 4).
|
||||
class SettingsAccountsSection extends StatelessWidget {
|
||||
const SettingsAccountsSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionController>();
|
||||
|
||||
return SettingsSection(
|
||||
title: 'Accounts',
|
||||
children: [
|
||||
for (final account in session.accounts)
|
||||
_SavedAccountRow(
|
||||
account: account,
|
||||
isActive: account.id == session.activeAccountId,
|
||||
onTap: account.id == session.activeAccountId
|
||||
? null
|
||||
: () => session.switchAccount(account.id),
|
||||
onRemove: () => confirmRemoveAccount(
|
||||
context,
|
||||
session,
|
||||
accountId: account.id,
|
||||
username: account.username,
|
||||
host: serverHost(account.serverUrl),
|
||||
isActive: account.id == session.activeAccountId,
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.userPlus,
|
||||
accent: true,
|
||||
label: const Text('Add account'),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const LoginView(isAddingAccount: true)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved-account row: avatar, name over host, a check on the active one,
|
||||
/// and a remove button - [NooSettingsRow] can't show an avatar leading
|
||||
/// widget (only an [IconData]), so this is its own row built to the same
|
||||
/// proportions (mirrors `shell_common.dart`'s private `_AccountSwitchRow`,
|
||||
/// which the account switcher sheet/dialog uses for the same data).
|
||||
class _SavedAccountRow extends StatelessWidget {
|
||||
final SavedAccount account;
|
||||
final bool isActive;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const _SavedAccountRow({
|
||||
required this.account,
|
||||
required this.isActive,
|
||||
required this.onTap,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Material(
|
||||
color: colors.surface,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md, vertical: 10),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
NooAvatar(initials: accountInitial(account.username), current: isActive, size: 36),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 2,
|
||||
children: [
|
||||
Text(
|
||||
account.username,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.bodyL.copyWith(
|
||||
height: 1.1,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.fg1,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
serverHost(account.serverUrl),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.meta.copyWith(color: colors.fg3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isActive) Icon(LucideIcons.checkCircle, size: 20, color: colors.accentText),
|
||||
GestureDetector(
|
||||
onTap: onRemove,
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle),
|
||||
child: Icon(LucideIcons.trash2, size: 16, color: colors.danger),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
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 are all settings that predate
|
||||
/// the design system and have no place in it (a single fixed accent, no
|
||||
/// per-user color choice). They still work, just demoted into a separate
|
||||
/// "Advanced appearance" group beneath the main card per the rebuild brief,
|
||||
/// rather than dropped - see the handoff report for whether to remove them
|
||||
/// outright.
|
||||
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: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.sunMoon,
|
||||
label: const Text('Theme'),
|
||||
trailing: NooSegmentedControl<ThemeMode>(
|
||||
size: NooSegmentedSize.sm,
|
||||
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'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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' : '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 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../providers/session_controller.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/core/noo_button.dart';
|
||||
import '../noo/noo_layout.dart';
|
||||
import '../noo/overlays/noo_dialog.dart';
|
||||
import '../noo/overlays/noo_overlay_header.dart';
|
||||
import '../noo/overlays/noo_sheet.dart';
|
||||
|
||||
/// Shared by the account card's own quick "Remove account" action and each
|
||||
/// row in the saved-accounts list - same confirm/remove/pop-to-root gate
|
||||
/// either way (mirrors the pre-rework `_confirmRemoveActive`/
|
||||
/// `_AccountsCard._confirmRemove`), just restyled as [showNooDialog]
|
||||
/// (desktop) / [showNooSheet] (mobile) instead of an [AlertDialog].
|
||||
Future<void> confirmRemoveAccount(
|
||||
BuildContext context,
|
||||
SessionController session, {
|
||||
required String accountId,
|
||||
required String username,
|
||||
required String host,
|
||||
required bool isActive,
|
||||
}) async {
|
||||
final hasOtherAccounts = session.accounts.length > 1;
|
||||
final message = isActive && hasOtherAccounts
|
||||
? 'Remove $username ($host)? Another saved account will become active.'
|
||||
: 'Remove $username ($host)? You can add it again later.';
|
||||
|
||||
var confirmed = false;
|
||||
void cancel() => Navigator.pop(context);
|
||||
void confirm() {
|
||||
confirmed = true;
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
Widget buttons(NooColors colors) => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: NooButton(
|
||||
variant: NooButtonVariant.secondary,
|
||||
size: NooButtonSize.card,
|
||||
fullWidth: true,
|
||||
onTap: cancel,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: NooSpace.sm),
|
||||
Expanded(
|
||||
child: NooButton(
|
||||
variant: NooButtonVariant.danger,
|
||||
size: NooButtonSize.card,
|
||||
fullWidth: true,
|
||||
onTap: confirm,
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final colors = context.nooColors;
|
||||
if (NooLayout.isDesktop(context)) {
|
||||
await showNooDialog(
|
||||
context,
|
||||
title: 'Remove account',
|
||||
children: [
|
||||
Text(message, style: NooText.body.copyWith(color: colors.fg2)),
|
||||
buttons(colors),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
await showNooSheet(
|
||||
context,
|
||||
children: [
|
||||
NooOverlayHeader(title: 'Remove account', onClose: cancel),
|
||||
Text(message, style: NooText.body.copyWith(color: colors.fg2)),
|
||||
buttons(colors),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (!confirmed) return;
|
||||
if (!context.mounted) return;
|
||||
final navigator = Navigator.of(context);
|
||||
await session.removeAccount(accountId);
|
||||
// Only when this was the last saved account does isLoggedIn drop to false
|
||||
// and main.dart swap the root route to LoginView underneath - pop back to
|
||||
// it then, rather than leaving Settings stranded on top. Removing a
|
||||
// non-active account, or falling back to another one, both keep the user
|
||||
// logged in, so Settings should just stay put.
|
||||
if (!session.isLoggedIn) {
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/app_tab.dart';
|
||||
import '../../providers/session_controller.dart';
|
||||
import '../../providers/sync_status_controller.dart';
|
||||
import '../../services/sync_service.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../app_tab_view_builder.dart';
|
||||
import '../noo/core/noo_button.dart';
|
||||
import '../noo/core/noo_toggle.dart';
|
||||
import '../noo/lists/noo_settings_row.dart';
|
||||
import '../noo/nav/noo_top_bar.dart';
|
||||
import '../noo/nav/noo_toolbar.dart';
|
||||
import '../noo/noo_layout.dart';
|
||||
import 'settings_section.dart';
|
||||
|
||||
/// Settings section 4: device sync (which folders/files mirror locally,
|
||||
/// notifications, cellular use, and a manual "Sync now").
|
||||
class SettingsFileSyncSection extends StatefulWidget {
|
||||
const SettingsFileSyncSection({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsFileSyncSection> createState() => _SettingsFileSyncSectionState();
|
||||
}
|
||||
|
||||
class _SettingsFileSyncSectionState extends State<SettingsFileSyncSection> {
|
||||
bool _syncingNow = false;
|
||||
|
||||
Future<void> _syncNow(SessionController session, SyncStatusController sync) async {
|
||||
setState(() => _syncingNow = true);
|
||||
try {
|
||||
await SyncService.syncNow(session, sync);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not start sync: $e'), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _syncingNow = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Offline tab has its own chrome-free content (see the shared
|
||||
/// rebuild contract's "division of chrome" - only the shell draws top
|
||||
/// bars for a tab), so opening it as a pushed screen from here needs a
|
||||
/// minimal top bar of its own to stay navigable.
|
||||
void _openOfflineFiles(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => Scaffold(
|
||||
appBar: NooLayout.isDesktop(context)
|
||||
? const NooToolbar(title: 'Offline')
|
||||
: NooTopBar(
|
||||
style: NooLayout.navStyle(context),
|
||||
title: 'Offline',
|
||||
leading: const NooTopBarBack(),
|
||||
),
|
||||
body: buildAppTabView(AppTab.offline, ScrollController()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionController>();
|
||||
final sync = context.watch<SyncStatusController>();
|
||||
final colors = context.nooColors;
|
||||
final folders = sync.syncedPaths;
|
||||
final everything = sync.syncEverything;
|
||||
final hasSyncScope = everything || folders.isNotEmpty;
|
||||
|
||||
return SettingsSection(
|
||||
title: 'File sync',
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.cloud,
|
||||
label: const Text('Sync everything'),
|
||||
subtitle: const Text('Mirror the whole account instead of picking folders'),
|
||||
trailing: NooToggle(checked: everything, onChanged: sync.setSyncEverything),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.hardDriveDownload,
|
||||
label: const Text('View offline files'),
|
||||
subtitle: Text(
|
||||
everything
|
||||
? 'Every folder in this account is synced'
|
||||
: folders.isEmpty
|
||||
? 'Nothing synced yet'
|
||||
: '${folders.length} synced folder${folders.length == 1 ? '' : 's'}/file${folders.length == 1 ? '' : 's'}',
|
||||
),
|
||||
onTap: () => _openOfflineFiles(context),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.bellRing,
|
||||
label: const Text('Background sync notifications'),
|
||||
subtitle: const Text(
|
||||
'Show a notification when a background sync updates files. '
|
||||
'Conflicts and "Sync now" always notify.',
|
||||
),
|
||||
trailing: NooToggle(checked: sync.syncNotifications, onChanged: sync.setSyncNotifications),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.signal,
|
||||
label: const Text('Sync on cellular'),
|
||||
subtitle: const Text('Off = background sync only runs on Wi-Fi'),
|
||||
trailing: NooToggle(checked: sync.syncOnCellular, onChanged: sync.setSyncOnCellular),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.refreshCw,
|
||||
label: const Text('Sync now'),
|
||||
trailing: _syncingNow
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: colors.accentText),
|
||||
)
|
||||
: NooButton(
|
||||
variant: NooButtonVariant.tonal,
|
||||
size: NooButtonSize.compact,
|
||||
disabled: !hasSyncScope,
|
||||
onTap: () => _syncNow(session, sync),
|
||||
child: const Text('Sync now'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/lists/noo_grouped_list.dart';
|
||||
import '../noo/noo_layout.dart';
|
||||
import '../noo/overlays/noo_dialog.dart';
|
||||
import '../noo/overlays/noo_sheet.dart';
|
||||
|
||||
/// One block of Settings (DESIGN_SYSTEM.md 4's 8-part order): a
|
||||
/// [NooGroupedList] on mobile (label above a radius-20 card), or a titled,
|
||||
/// bordered radius-20 card holding a flat row group on desktop
|
||||
/// ("Mobile uses one column of grouped lists. Desktop uses a 2-column grid
|
||||
/// of cards with a 1px line and radius 20."). Shared by every
|
||||
/// `lib/widgets/settings/*.dart` section so both layouts stay in sync from
|
||||
/// one place.
|
||||
class SettingsSection extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final List<Widget> children;
|
||||
|
||||
const SettingsSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
if (!NooLayout.isDesktop(context)) {
|
||||
return NooGroupedList(
|
||||
label: title,
|
||||
footer: subtitle != null ? Text(subtitle!) : null,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
border: Border.all(color: colors.line),
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
),
|
||||
padding: const EdgeInsets.all(NooSpace.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: NooText.cardTitle.copyWith(color: colors.fg1)),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(subtitle!, style: NooText.meta.copyWith(color: colors.fg3)),
|
||||
],
|
||||
const SizedBox(height: NooSpace.md),
|
||||
FlatRowGroup(children: children),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The line-gapped row stack [NooGroupedList] draws internally, without its
|
||||
/// own outer card/label - used for [SettingsSection]'s desktop card body,
|
||||
/// which already supplies the surrounding card chrome, so nesting a second
|
||||
/// radius-20 card there would double up the framing. Also reused directly
|
||||
/// by `settings_tabs.dart`, whose desktop card mixes plain rows with a
|
||||
/// reorderable list under one shared title/border instead of going through
|
||||
/// [SettingsSection] itself.
|
||||
class FlatRowGroup extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
const FlatRowGroup({super.key, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(NooRadii.input),
|
||||
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],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The picker pattern for every "value + chevron" settings row (cache
|
||||
/// policy, swipe actions, default tab...): a titled option list, a
|
||||
/// [NooGroupedList] sheet on mobile or a [NooDialog] on desktop (mirrors
|
||||
/// `files_controls_row.dart`'s sort/filter sheets). Each option row should
|
||||
/// call `Navigator.pop(context)` itself after applying its choice.
|
||||
void showSettingsPicker(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required List<Widget> options,
|
||||
}) {
|
||||
if (NooLayout.isDesktop(context)) {
|
||||
showNooDialog(context, title: title, children: [Column(children: options)]);
|
||||
} else {
|
||||
showNooSheet(context, children: [NooGroupedList(label: title, children: options)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/session_controller.dart';
|
||||
import '../noo/core/noo_toggle.dart';
|
||||
import '../noo/lists/noo_settings_row.dart';
|
||||
import 'settings_section.dart';
|
||||
|
||||
/// Per-platform copy for the login-lock row's label (DESIGN_SYSTEM.md 3's
|
||||
/// "Biometric label" row, both the mobile table and the macOS/Windows
|
||||
/// desktop table). Falls back to generic copy on platforms the spec
|
||||
/// doesn't name (Linux, tests).
|
||||
String biometricLabel(TargetPlatform platform) {
|
||||
switch (platform) {
|
||||
case TargetPlatform.iOS:
|
||||
return 'Lock with Face ID';
|
||||
case TargetPlatform.android:
|
||||
return 'Lock with fingerprint';
|
||||
case TargetPlatform.macOS:
|
||||
return 'Touch ID';
|
||||
case TargetPlatform.windows:
|
||||
return 'Windows Hello';
|
||||
case TargetPlatform.linux:
|
||||
case TargetPlatform.fuchsia:
|
||||
return 'Lock with device credential';
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings section 3: login lock, which gates opening the app, switching
|
||||
/// accounts, and revealing hidden files behind the device's own PIN/
|
||||
/// biometric credential (see `AppLockService` - this app never stores or
|
||||
/// handles a PIN itself).
|
||||
class SettingsSecuritySection extends StatelessWidget {
|
||||
const SettingsSecuritySection({super.key});
|
||||
|
||||
Future<void> _handleLoginLockChanged(
|
||||
BuildContext context,
|
||||
SessionController session,
|
||||
bool value,
|
||||
) async {
|
||||
final success = value ? await session.setupLoginLock() : await session.disableLoginLock();
|
||||
if (!success && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
value
|
||||
? "Could not set up login lock - make sure this device has a PIN, pattern, password, or biometric configured"
|
||||
: 'Could not turn off login lock',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionController>();
|
||||
final platform = Theme.of(context).platform;
|
||||
|
||||
return SettingsSection(
|
||||
title: 'Security',
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.lock,
|
||||
label: Text(biometricLabel(platform)),
|
||||
subtitle: const Text("Require this device's PIN or biometric to open Noo"),
|
||||
trailing: NooToggle(
|
||||
checked: session.loginLockEnabled,
|
||||
onChanged: (value) => _handleLoginLockChanged(context, session, value),
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.arrowLeftRight,
|
||||
label: const Text('Lock account switching'),
|
||||
subtitle: const Text('Unlock to switch between saved accounts'),
|
||||
trailing: NooToggle(
|
||||
checked: session.lockAccountSwitching,
|
||||
onChanged: session.loginLockEnabled ? session.setLockAccountSwitching : null,
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.eyeOff,
|
||||
label: const Text('Lock hidden files'),
|
||||
subtitle: const Text('Unlock to reveal hidden files and folders'),
|
||||
trailing: NooToggle(
|
||||
checked: session.lockHiddenFiles,
|
||||
onChanged: session.loginLockEnabled ? session.setLockHiddenFiles : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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/design_tokens.dart';
|
||||
import '../noo/lists/noo_settings_row.dart';
|
||||
import 'settings_section.dart';
|
||||
|
||||
String _swipeActionLabel(SwipeAction action) {
|
||||
switch (action) {
|
||||
case SwipeAction.none:
|
||||
return 'None';
|
||||
case SwipeAction.favorite:
|
||||
return 'Favorite';
|
||||
case SwipeAction.delete:
|
||||
return 'Delete';
|
||||
case SwipeAction.share:
|
||||
return 'Share';
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings section 8: what swiping a file left/right does in list view.
|
||||
class SettingsSwipeSection extends StatelessWidget {
|
||||
const SettingsSwipeSection({super.key});
|
||||
|
||||
void _openPicker(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required SwipeAction current,
|
||||
required ValueChanged<SwipeAction> onChanged,
|
||||
}) {
|
||||
showSettingsPicker(
|
||||
context,
|
||||
title: title,
|
||||
options: [
|
||||
for (final action in SwipeAction.values)
|
||||
NooSettingsRow(
|
||||
label: Text(_swipeActionLabel(action)),
|
||||
trailing: action == current
|
||||
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText)
|
||||
: null,
|
||||
onTap: () {
|
||||
onChanged(action);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsController>();
|
||||
|
||||
return SettingsSection(
|
||||
title: 'Swipe on a file',
|
||||
subtitle: 'Choose what swiping a file left or right does in list view',
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.chevronsRight,
|
||||
label: const Text('Swipe right'),
|
||||
value: _swipeActionLabel(settings.swipeRightAction),
|
||||
onTap: () => _openPicker(
|
||||
context,
|
||||
title: 'Swipe right',
|
||||
current: settings.swipeRightAction,
|
||||
onChanged: settings.setSwipeRightAction,
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.chevronsLeft,
|
||||
label: const Text('Swipe left'),
|
||||
value: _swipeActionLabel(settings.swipeLeftAction),
|
||||
onTap: () => _openPicker(
|
||||
context,
|
||||
title: 'Swipe left',
|
||||
current: settings.swipeLeftAction,
|
||||
onChanged: settings.setSwipeLeftAction,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/app_tab.dart';
|
||||
import '../../providers/settings_controller.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/core/noo_toggle.dart';
|
||||
import '../noo/lists/noo_settings_row.dart';
|
||||
import '../noo/lists/noo_tab_order_row.dart';
|
||||
import '../noo/noo_layout.dart';
|
||||
import 'settings_section.dart';
|
||||
|
||||
/// Settings section 7: bottom-nav tab order, visibility (pinned = shown, up
|
||||
/// to [maxVisibleTabs]) and default tab.
|
||||
class SettingsTabsSection extends StatelessWidget {
|
||||
const SettingsTabsSection({super.key});
|
||||
|
||||
void _openDefaultTabPicker(BuildContext context, SettingsController settings) {
|
||||
final visible = settings.tabOrder.where((t) => !settings.hiddenTabs.contains(t)).toList();
|
||||
showSettingsPicker(
|
||||
context,
|
||||
title: 'Default tab',
|
||||
options: [
|
||||
for (final tab in visible)
|
||||
NooSettingsRow(
|
||||
icon: tab.icon,
|
||||
label: Text(tab.label),
|
||||
trailing: tab == settings.defaultTab
|
||||
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText)
|
||||
: null,
|
||||
onTap: () {
|
||||
settings.setDefaultTab(tab);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTogglePin(BuildContext context, SettingsController settings, AppTab tab, bool pinned) {
|
||||
// setTabHidden's `hidden` argument is the *new* hidden state, which is
|
||||
// just the tab's current pinned (= visible) state flipped.
|
||||
final error = settings.setTabHidden(tab, pinned);
|
||||
if (error != null && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleReorder(SettingsController settings, int oldIndex, int newIndex) {
|
||||
final order = List<AppTab>.from(settings.tabOrder);
|
||||
final tab = order.removeAt(oldIndex);
|
||||
order.insert(newIndex, tab);
|
||||
settings.setTabOrder(order);
|
||||
}
|
||||
|
||||
static const _subtitle = 'Drag to reorder, pin up to 5 to the bottom bar';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsController>();
|
||||
final desktop = NooLayout.isDesktop(context);
|
||||
|
||||
final optionRows = [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.arrowUpDown,
|
||||
label: const Text('Tap tab to scroll to top'),
|
||||
subtitle: const Text('Tapping the current bottom bar tab scrolls its list back to the top'),
|
||||
trailing: NooToggle(
|
||||
checked: settings.tapTabToScrollTop,
|
||||
onChanged: settings.setTapTabToScrollTop,
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.star,
|
||||
label: const Text('Default tab'),
|
||||
value: settings.defaultTab.label,
|
||||
onTap: () => _openDefaultTabPicker(context, settings),
|
||||
),
|
||||
];
|
||||
final orderList = _TabOrderList(
|
||||
tabs: settings.tabOrder,
|
||||
hiddenTabs: settings.hiddenTabs,
|
||||
flat: desktop,
|
||||
onReorder: (oldIndex, newIndex) => _handleReorder(settings, oldIndex, newIndex),
|
||||
onTogglePin: (tab, pinned) => _handleTogglePin(context, settings, tab, pinned),
|
||||
);
|
||||
|
||||
if (!desktop) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SettingsSection(title: 'Tabs', subtitle: _subtitle, children: optionRows),
|
||||
const SizedBox(height: NooSpace.md),
|
||||
orderList,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: one bordered card holding both the plain option rows and
|
||||
// the reorderable list, so Tabs still reads as a single grid cell
|
||||
// ("a 2-column grid of cards with a 1px line and radius 20") instead of
|
||||
// splitting into two stacked cards the way the mobile layout does.
|
||||
final colors = context.nooColors;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
border: Border.all(color: colors.line),
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
),
|
||||
padding: const EdgeInsets.all(NooSpace.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Tabs', style: NooText.cardTitle.copyWith(color: colors.fg1)),
|
||||
const SizedBox(height: 4),
|
||||
Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)),
|
||||
const SizedBox(height: NooSpace.md),
|
||||
FlatRowGroup(children: optionRows),
|
||||
const SizedBox(height: NooSpace.sm),
|
||||
orderList,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The reorderable [NooTabOrderRow] stack - a radius-20, line-divided card
|
||||
/// like [NooGroupedList] draws, but [NooGroupedList] itself has no
|
||||
/// reordering support, so this rebuilds that same look around a
|
||||
/// [ReorderableListView.builder] instead of wrapping it.
|
||||
class _TabOrderList extends StatelessWidget {
|
||||
final List<AppTab> tabs;
|
||||
final Set<AppTab> hiddenTabs;
|
||||
final void Function(int oldIndex, int newIndex) onReorder;
|
||||
final void Function(AppTab tab, bool pinned) onTogglePin;
|
||||
|
||||
/// True when an ancestor (the desktop card) already supplies the
|
||||
/// surrounding radius-20 border, so this should render just the divided
|
||||
/// rows without its own outer clip/line background.
|
||||
final bool flat;
|
||||
|
||||
const _TabOrderList({
|
||||
required this.tabs,
|
||||
required this.hiddenTabs,
|
||||
required this.onReorder,
|
||||
required this.onTogglePin,
|
||||
this.flat = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final list = ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: tabs.length,
|
||||
onReorderItem: onReorder,
|
||||
itemBuilder: (context, index) {
|
||||
final tab = tabs[index];
|
||||
final pinned = !hiddenTabs.contains(tab);
|
||||
return Padding(
|
||||
key: ValueKey(tab),
|
||||
padding: EdgeInsets.only(top: index > 0 ? 1 : 0),
|
||||
child: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: NooTabOrderRow(
|
||||
icon: tab.icon,
|
||||
label: tab.label,
|
||||
pinned: pinned,
|
||||
onTogglePin: () => onTogglePin(tab, pinned),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(flat ? NooRadii.input : NooRadii.card),
|
||||
child: DecoratedBox(decoration: BoxDecoration(color: colors.line), child: list),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user