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
+243 -98
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -22,12 +23,24 @@ import 'providers/trash_controller.dart';
import 'services/pick_intent_service.dart';
import 'services/share_intent_service.dart';
import 'theme/app_theme.dart';
import 'theme/design_tokens.dart';
import 'views/lock_screen_view.dart';
import 'views/login_view.dart';
import 'views/search_view.dart';
import 'views/share_upload_view.dart';
import 'widgets/app_drawer.dart';
import 'widgets/app_tab_view_builder.dart';
import 'widgets/floating_bottom_bar.dart';
import 'widgets/app_top_bar.dart';
import 'widgets/bottom_nav_bar.dart';
import 'widgets/create_menu.dart';
import 'widgets/noo/core/noo_avatar.dart';
import 'widgets/noo/core/noo_button.dart';
import 'widgets/noo/core/noo_fab.dart';
import 'widgets/noo/nav/noo_nav_style.dart';
import 'widgets/noo/nav/noo_sidebar.dart';
import 'widgets/noo/nav/noo_toolbar.dart';
import 'widgets/noo/noo_layout.dart';
import 'widgets/noo/overlays/noo_dialog.dart';
import 'widgets/shell/shell_common.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
@@ -143,21 +156,19 @@ class _SplashView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final colors = context.nooColors;
return Scaffold(
backgroundColor: colors.bg,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// The monochrome adaptive-icon layer is a plain white silhouette
// on transparent, meant to be tinted rather than shown as-is -
// srcIn recolors it to the theme's foreground so it reads
// correctly in both light and dark mode.
// srcIn recolors it to fg-1 so it reads correctly in both light
// and dark mode.
ColorFiltered(
colorFilter: ColorFilter.mode(
colorScheme.onSurface,
BlendMode.srcIn,
),
colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn),
child: Image.asset(
'assets/icon/app_icon_monochrome.png',
width: 72,
@@ -170,7 +181,7 @@ class _SplashView extends StatelessWidget {
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: colorScheme.primary,
color: colors.accent,
),
),
],
@@ -246,26 +257,34 @@ class _MainShellViewState extends State<MainShellView> {
if (status.isGranted || status.isPermanentlyDenied) return;
if (!mounted) return;
final shouldRequest = await showDialog<bool>(
context: context,
final colors = context.nooColors;
final shouldRequest = await showNooDialog<bool>(
context,
title: 'Allow notifications?',
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('Allow notifications?'),
content: const Text(
children: [
Text(
'Noo shows upload and download progress as a notification, so '
'you can track transfers that keep running in the background.',
style: NooText.body.copyWith(color: colors.fg2),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Not now'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Allow'),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
spacing: 10,
children: [
NooButton(
variant: NooButtonVariant.secondary,
onTap: () => Navigator.of(context).pop(false),
child: const Text('Not now'),
),
NooButton(
variant: NooButtonVariant.primary,
onTap: () => Navigator.of(context).pop(true),
child: const Text('Allow'),
),
],
),
],
);
if (shouldRequest == true) {
await Permission.notification.request();
@@ -294,9 +313,15 @@ class _MainShellViewState extends State<MainShellView> {
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final settings = context.watch<SettingsController>();
final pick = context.watch<PickController>();
final connectivity = context.watch<ConnectivityController>();
final session = context.watch<SessionController>();
final quota = context.watch<FilesController>().quota;
// Cheap and already loaded once the Trash tab has been visited this
// session - not worth adding a fetch just to populate a nav badge.
final trashCount = context.watch<TrashController>().items.length;
// A one-shot request (e.g. tapping a search result) to switch tabs -
// consumed here so it only fires once, then cleared after this frame
@@ -317,24 +342,184 @@ class _MainShellViewState extends State<MainShellView> {
// user's own hidden/reordered tab settings rather than respecting
// them, since picking is a separate mode from normal browsing. With
// no network at all, every other tab would just show its own loading
// spinner forever/an error state, so the bottom nav collapses to just
// Offline - the one tab that works without a connection (see
// spinner forever/an error state, so the nav collapses to just Offline
// - the one tab that works without a connection (see
// `FilesView(offline: true)`/`ConnectivityController`).
final visible = pickRequest != null
final overrideActive = pickRequest != null || connectivity.isOffline;
final pinnedTabs = pickRequest != null
? [AppTab.files, AppTab.photos]
: connectivity.isOffline
? [AppTab.offline]
: settings.visibleTabs;
final selectedTab = visible.contains(_currentTab)
? _currentTab
: (visible.isNotEmpty ? visible.first : AppTab.files);
final selectedIndex = visible
.indexOf(selectedTab)
.clamp(0, visible.isEmpty ? 0 : visible.length - 1);
// The drawer/sidebar's "More" section - hidden while an override above
// is active, same as the drawer itself being unavailable during pick.
final hiddenTabs = overrideActive
? const <AppTab>[]
: settings.tabOrder
.where((t) => settings.hiddenTabs.contains(t))
.toList();
final navItems = visible
.map((tab) => FloatingNavItem(label: tab.label, icon: tab.icon))
.toList();
// A tab opened from "More" isn't one of the pinned destinations, but
// should stay shown and selected (with no destination highlighted in
// the bottom bar/sidebar) until the user taps a pinned or another
// "More" tab.
final displayTabs = !overrideActive && !pinnedTabs.contains(_currentTab)
? [...pinnedTabs, _currentTab]
: pinnedTabs;
final selectedTab = displayTabs.contains(_currentTab)
? _currentTab
: (displayTabs.isNotEmpty ? displayTabs.first : AppTab.files);
final selectedIndex = displayTabs
.indexOf(selectedTab)
.clamp(0, displayTabs.isEmpty ? 0 : displayTabs.length - 1);
final pinnedIndex = pinnedTabs.indexOf(selectedTab);
void selectTab(AppTab tab) {
if (tab == _currentTab) return;
setState(() => _currentTab = tab);
}
String? trashBadge(AppTab tab) =>
tab == AppTab.trash && trashCount > 0 ? '$trashCount' : null;
// Each tab renders its own sticky selection toolbar inline (right under
// its sort/filter row) instead of the shell swapping in a shared one,
// so the nav chrome stays put and usable regardless of selection state.
final tabStack = Stack(
children: [
IndexedStack(
index: selectedIndex,
children: displayTabs
.map((tab) => buildAppTabView(tab, _scrollControllers[tab]!))
.toList(),
),
if (pick.isDownloadingForPick) const _PickingProgressOverlay(),
],
);
final canUpload =
pickRequest == null &&
(selectedTab == AppTab.files || selectedTab == AppTab.photos);
Widget scaffold;
if (NooLayout.isDesktop(context)) {
scaffold = Scaffold(
backgroundColor: colors.bg,
body: Row(
children: [
NooSidebar(
account: NooSidebarAccount(
avatar: NooAvatar(
initials: accountInitial(session.username),
current: true,
size: 32,
),
name: session.username,
subtitle: serverHost(session.serverUrl),
onTap: () => showAccountSwitcher(context),
),
items: [
for (final tab in pinnedTabs)
NooSidebarItem(
icon: tab.icon,
label: tab.label,
count: trashBadge(tab),
selected: tab == selectedTab,
onTap: () => selectTab(tab),
),
if (hiddenTabs.isNotEmpty) const NooSidebarDivider(),
for (final tab in hiddenTabs)
NooSidebarItem(
icon: tab.icon,
label: tab.label,
count: trashBadge(tab),
selected: tab == selectedTab,
onTap: () => selectTab(tab),
),
],
storage: NooSidebarStorage(
value: quotaFraction(quota),
detail: quotaDetail(quota),
),
settings: NooSidebarItem(
icon: LucideIcons.settings,
label: 'Settings',
onTap: () => openSettings(context),
),
),
Expanded(
child: Column(
children: [
NooToolbar(
title: selectedTab.label,
search: const ShellSearchLauncher(onSurface: true),
actions: [
if (canUpload)
NooButton(
icon: LucideIcons.upload,
onTap: () => showCreateMenu(context),
child: const Text('Upload'),
),
],
),
Expanded(
child: ColoredBox(color: colors.surface, child: tabStack),
),
],
),
),
],
),
);
} else {
final navStyle = NooLayout.navStyle(context);
scaffold = Scaffold(
backgroundColor: colors.bg,
drawerScrimColor: colors.scrim,
appBar: pickRequest == null
? AppTopBar(style: navStyle, tab: selectedTab)
: null,
drawer: pickRequest == null ? const AppDrawer() : null,
// Android-only extended Upload FAB - iOS uses the top bar's `plus`
// instead (see AppTopBar).
floatingActionButton: canUpload && navStyle == NooNavStyle.android
? NooFab(onTap: () => showCreateMenu(context))
: null,
body: tabStack,
bottomNavigationBar: BottomNavBar(
style: navStyle,
tabs: pinnedTabs,
selectedIndex: pinnedIndex,
onDestinationSelected: (index) {
final tappedTab = pinnedTabs[index];
if (tappedTab == _currentTab) {
if (settings.tapTabToScrollTop) {
final controller = _scrollControllers[tappedTab];
// `hasClients` only means a position is attached - not that
// it's finished its first layout. Calling animateTo before
// that throws deep inside Flutter's ballistic-scroll code
// (min/maxScrollExtent are still null), and since this
// fires synchronously from the nav bar's tap handler, the
// exception corrupts that tab's scroll view instead of
// just being logged - a real crash seen in the wild
// (Files rendering empty after re-tapping its own tab).
if (controller != null &&
controller.hasClients &&
controller.position.hasContentDimensions) {
controller.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
}
return;
}
selectTab(tappedTab);
},
),
);
}
return PopScope(
// While picking, system back cancels the pick (and tells the caller
@@ -344,53 +529,7 @@ class _MainShellViewState extends State<MainShellView> {
onPopInvokedWithResult: (didPop, result) {
if (!didPop && pickRequest != null) pick.cancelPick();
},
child: Scaffold(
body: Stack(
children: [
IndexedStack(
index: selectedIndex,
children: visible
.map((tab) => buildAppTabView(tab, _scrollControllers[tab]!))
.toList(),
),
if (pick.isDownloadingForPick) const _PickingProgressOverlay(),
// Each tab now renders its own sticky selection toolbar inline
// (right under its sort/filter row) instead of this shell
// swapping in a shared floating bar - the bottom nav stays put
// and usable regardless of selection state.
FloatingBottomNavBar(
selectedIndex: selectedIndex,
items: navItems,
opacity: settings.bottomBarOpacity,
blurSigma: settings.bottomBarBlur,
onDestinationSelected: (index) {
final tappedTab = visible[index];
if (tappedTab == _currentTab) {
if (settings.tapTabToScrollTop) {
final controller = _scrollControllers[tappedTab];
if (controller != null && controller.hasClients) {
controller.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
}
return;
}
setState(() {
_currentTab = tappedTab;
});
},
onSearchTap: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SearchView()));
},
),
],
),
),
child: scaffold,
);
}
}
@@ -403,21 +542,27 @@ class _PickingProgressOverlay extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Positioned.fill(
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.35),
child: const Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Preparing file...'),
],
),
color: colors.scrim,
child: Center(
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(NooRadii.dialog),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: colors.accent),
const SizedBox(height: 16),
Text(
'Preparing file…',
style: NooText.body.copyWith(color: colors.fg1),
),
],
),
),
),
+11 -9
View File
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
/// Every destination the bottom nav bar can show. Order here is only the
/// fallback default — actual order/visibility/default-tab are user
@@ -41,24 +41,26 @@ extension AppTabInfo on AppTab {
}
}
// Lucide icon mapping per DESIGN_SYSTEM.md 1.5 - the Lucide icon slug is
// named in a comment where it doesn't match the Dart identifier.
IconData get icon {
switch (this) {
case AppTab.files:
return Icons.folder_rounded;
return LucideIcons.folder;
case AppTab.photos:
return Icons.photo_library_rounded;
return LucideIcons.images;
case AppTab.favorites:
return Icons.favorite_rounded;
return LucideIcons.star;
case AppTab.activity:
return Icons.electric_bolt_rounded;
return LucideIcons.activity;
case AppTab.trash:
return Icons.delete_outline_rounded;
return LucideIcons.trash2; // trash-2
case AppTab.shares:
return Icons.groups_rounded;
return LucideIcons.share2; // share-2
case AppTab.recent:
return Symbols.search_activity_rounded;
return LucideIcons.clock;
case AppTab.offline:
return Icons.offline_pin_rounded;
return LucideIcons.hardDriveDownload; // hard-drive-download
}
}
}
-21
View File
@@ -23,8 +23,6 @@ class SettingsController extends ChangeNotifier {
static const _prefThemeMode = 'ui_theme_mode';
static const _prefUseDynamicColor = 'ui_use_dynamic_color';
static const _prefSeedColor = 'ui_seed_color';
static const _prefBottomBarOpacity = 'ui_bottom_bar_opacity';
static const _prefBottomBarBlur = 'ui_bottom_bar_blur';
static const _prefTabOrder = 'ui_tab_order';
static const _prefHiddenTabs = 'ui_hidden_tabs';
static const _prefDefaultTab = 'ui_default_tab';
@@ -43,8 +41,6 @@ class SettingsController extends ChangeNotifier {
bool _amoledDark = false;
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
double _bottomBarOpacity = 0.55;
double _bottomBarBlur = 28;
bool _tapTabToScrollTop = true;
List<AppTab> _tabOrder = AppTab.values.toList();
@@ -82,8 +78,6 @@ class SettingsController extends ChangeNotifier {
bool get useDynamicColor => _useDynamicColor;
bool get amoledDark => _amoledDark;
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
double get bottomBarOpacity => _bottomBarOpacity;
double get bottomBarBlur => _bottomBarBlur;
bool get tapTabToScrollTop => _tapTabToScrollTop;
/// Every tab in the user's configured order, including hidden ones — used
@@ -122,9 +116,6 @@ class SettingsController extends ChangeNotifier {
}
final seedColorValue = prefs.getInt(_prefSeedColor);
if (seedColorValue != null) _seedColor = Color(seedColorValue);
_bottomBarOpacity =
prefs.getDouble(_prefBottomBarOpacity) ?? _bottomBarOpacity;
_bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur;
_tapTabToScrollTop =
prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop;
@@ -223,18 +214,6 @@ class SettingsController extends ChangeNotifier {
);
}
void setBottomBarOpacity(double value) {
_bottomBarOpacity = value;
notifyListeners();
_prefsFuture.then((p) => p.setDouble(_prefBottomBarOpacity, value));
}
void setBottomBarBlur(double value) {
_bottomBarBlur = value;
notifyListeners();
_prefsFuture.then((p) => p.setDouble(_prefBottomBarBlur, value));
}
void setTapTabToScrollTop(bool value) {
_tapTabToScrollTop = value;
notifyListeners();
+57 -26
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'design_tokens.dart';
class AppTheme {
static const Color defaultNextcloudBlue = Color(0xFF0082C9);
@@ -30,6 +31,49 @@ class AppTheme {
Color(0xFF2E7D32), // Emerald Green
];
// Design-system typefaces (DESIGN_SYSTEM.md 1.3): Schibsted Grotesk for
// headings, Instrument Sans for UI/body text. Applied over Flutter's
// default TextTheme slots app-wide - screens not yet reworked to the
// design system's own named type scale (`NooText`) still benefit from
// the new typefaces immediately instead of staying on Inter until their
// turn comes.
static TextTheme _textTheme(Brightness brightness) {
final base = brightness == Brightness.light
? ThemeData.light().textTheme
: ThemeData.dark().textTheme;
return GoogleFonts.instrumentSansTextTheme(base).copyWith(
displayLarge: GoogleFonts.schibstedGrotesk(
textStyle: base.displayLarge,
fontWeight: FontWeight.w600,
),
displayMedium: GoogleFonts.schibstedGrotesk(
textStyle: base.displayMedium,
fontWeight: FontWeight.w600,
),
displaySmall: GoogleFonts.schibstedGrotesk(
textStyle: base.displaySmall,
fontWeight: FontWeight.w600,
),
headlineLarge: GoogleFonts.schibstedGrotesk(
textStyle: base.headlineLarge,
fontWeight: FontWeight.w600,
),
headlineMedium: GoogleFonts.schibstedGrotesk(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
headlineSmall: GoogleFonts.schibstedGrotesk(
textStyle: base.headlineSmall,
fontWeight: FontWeight.w600,
),
titleLarge: GoogleFonts.schibstedGrotesk(
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
titleMedium: GoogleFonts.schibstedGrotesk(fontWeight: FontWeight.w500),
);
}
static ThemeData light(
Color seedColor, {
ColorScheme? dynamicScheme,
@@ -41,26 +85,19 @@ class AppTheme {
seedColor: seedColor,
brightness: Brightness.light,
);
final nooColors = (useDynamicColor && dynamicScheme != null)
? NooColors.fromDynamicScheme(dynamicScheme)
: NooColors.light;
return ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: colorScheme,
extensions: [nooColors],
scaffoldBackgroundColor: colorScheme.surface,
pageTransitionsTheme: _pageTransitionsTheme,
sliderTheme: _sliderTheme,
textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme)
.copyWith(
headlineMedium: GoogleFonts.inter(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
titleLarge: GoogleFonts.inter(
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
titleMedium: GoogleFonts.inter(fontWeight: FontWeight.w500),
),
textTheme: _textTheme(Brightness.light),
cardTheme: CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
@@ -71,7 +108,7 @@ class AppTheme {
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: GoogleFonts.inter(
titleTextStyle: GoogleFonts.schibstedGrotesk(
fontSize: 22,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
@@ -92,6 +129,9 @@ class AppTheme {
seedColor: seedColor,
brightness: Brightness.dark,
);
var nooColors = (useDynamicColor && dynamicScheme != null)
? NooColors.fromDynamicScheme(dynamicScheme)
: NooColors.dark;
if (amoled) {
// True OLED black across every surface tone Material 3 hands out -
@@ -106,27 +146,18 @@ class AppTheme {
surfaceContainerHighest: Colors.black,
surfaceDim: Colors.black,
);
nooColors = nooColors.withAmoled();
}
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: colorScheme,
extensions: [nooColors],
scaffoldBackgroundColor: colorScheme.surface,
pageTransitionsTheme: _pageTransitionsTheme,
sliderTheme: _sliderTheme,
textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme)
.copyWith(
headlineMedium: GoogleFonts.inter(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
titleLarge: GoogleFonts.inter(
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
titleMedium: GoogleFonts.inter(fontWeight: FontWeight.w500),
),
textTheme: _textTheme(Brightness.dark),
cardTheme: CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
@@ -137,7 +168,7 @@ class AppTheme {
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: GoogleFonts.inter(
titleTextStyle: GoogleFonts.schibstedGrotesk(
fontSize: 22,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
+428
View File
@@ -0,0 +1,428 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// Semantic color tokens from `.claude/context/design-system/DESIGN_SYSTEM.md`
/// section 1.1 - read that file before changing any value here. Registered
/// on [ThemeData.extensions], so call sites reach these via
/// `Theme.of(context).extension<NooColors>()!` (or a `context.nooColors`
/// getter, once one exists) instead of the Material [ColorScheme] roles.
///
/// Two independent sources feed this, matching the app's color settings:
/// the fixed [light]/[dark] palettes below (the design system's own
/// default look), or [NooColors.fromDynamicScheme] when the user has
/// Material You dynamic color turned on - there's no third option (no
/// custom accent picker), since the design system explicitly rules out
/// "add new colors". [withAmoled] layers on top of either source when the
/// user's AMOLED toggle is on.
@immutable
class NooColors extends ThemeExtension<NooColors> {
final Color bg;
final Color surface;
final Color surface2;
final Color surface3;
final Color line;
final Color fg1;
final Color fg2;
final Color fg3;
final Color accent;
final Color accentText;
final Color accentSoft;
final Color danger;
final Color dangerSoft;
final Color dangerFill;
final Color success;
final Color successSoft;
final Color warning;
final Color warningSoft;
final Color info;
final Color infoSoft;
final Color scrim;
const NooColors({
required this.bg,
required this.surface,
required this.surface2,
required this.surface3,
required this.line,
required this.fg1,
required this.fg2,
required this.fg3,
required this.accent,
required this.accentText,
required this.accentSoft,
required this.danger,
required this.dangerSoft,
required this.dangerFill,
required this.success,
required this.successSoft,
required this.warning,
required this.warningSoft,
required this.info,
required this.infoSoft,
required this.scrim,
});
static const light = NooColors(
bg: Color(0xFFF3EFE6),
surface: Color(0xFFFFFFFF),
surface2: Color(0xFFF6F5F2),
surface3: Color(0xFFEAE8E2),
line: Color(0xFFEAE8E2),
fg1: Color(0xFF0E0D0B),
fg2: Color(0xFF5A5852),
fg3: Color(0xFF7C7A72),
accent: Color(0xFF8D0DE3),
accentText: Color(0xFF8D0DE3),
accentSoft: Color(0xFFECDFFF),
danger: Color(0xFFA8202A),
dangerSoft: Color(0xFFFDE7E7),
dangerFill: Color(0xFFA8202A),
success: Color(0xFF3E7A23),
successSoft: Color(0xFFEAF5DF),
warning: Color(0xFF8C5A0E),
warningSoft: Color(0xFFFBEFD0),
info: Color(0xFF2940A8),
infoSoft: Color(0xFFE6ECFE),
scrim: Color(0x730E0D0B),
);
static const dark = NooColors(
bg: Color(0xFF0E0D0B),
surface: Color(0xFF25241F),
surface2: Color(0xFF3D3C38),
surface3: Color(0xFF5A5852),
line: Color(0xFF3D3C38),
fg1: Color(0xFFF3EFE6),
fg2: Color(0xFFBDBAB1),
fg3: Color(0xFF9E9B92),
accent: Color(0xFF8D0DE3),
accentText: Color(0xFFCBA5FD),
accentSoft: Color(0xFF2F0454),
danger: Color(0xFFF49E9E),
dangerSoft: Color(0xFF560F18),
dangerFill: Color(0xFFA8202A),
success: Color(0xFFB7DD9F),
successSoft: Color(0xFF1E3F10),
warning: Color(0xFFF0C26C),
warningSoft: Color(0xFF4A3208),
info: Color(0xFFB6C3FB),
infoSoft: Color(0xFF1C2B73),
scrim: Color(0x99000000),
);
/// Avatar/photo-placeholder fills (`DESIGN_SYSTEM.md` 1.1) - a fixed
/// secondary palette, same in light and dark, always paired with
/// [avatarTextColor]. Content-identity colors, not theme, so they don't
/// change with brightness or dynamic color (same reasoning as file-type
/// tinting in `styling.md`).
static const avatarPalette = [
Color(0xFF8DCDE2),
Color(0xFF96B0FD),
Color(0xFFE5D8BD),
Color(0xFFEEEDB3),
Color(0xFFDA9AC7),
Color(0xFFA7D296),
Color(0xFFE9A6A7),
Color(0xFF85D1BD),
Color(0xFFCBA5FD),
];
static const avatarTextColor = Color(0xFF0E0D0B);
/// Maps a Material You dynamic [ColorScheme] onto these roles for users
/// who've turned dynamic color on, instead of the fixed [light]/[dark]
/// palettes. `success`/`successSoft`/`warning`/`warningSoft`/`info`/
/// `infoSoft` have no Material dynamic equivalent and are content-identity
/// colors regardless (file-type tiles) rather than brand colors, so they
/// always come from the fixed palette matching [cs]'s brightness.
factory NooColors.fromDynamicScheme(ColorScheme cs) {
final fixed = cs.brightness == Brightness.dark ? dark : light;
return NooColors(
bg: cs.surface,
surface: cs.surfaceContainerLow,
surface2: cs.surfaceContainerHigh,
surface3: cs.surfaceContainerHighest,
line: cs.outlineVariant,
fg1: cs.onSurface,
fg2: cs.onSurfaceVariant,
fg3: cs.onSurfaceVariant,
accent: cs.primary,
accentText: cs.primary,
accentSoft: cs.primaryContainer,
danger: cs.error,
dangerSoft: cs.errorContainer,
dangerFill: cs.error,
success: fixed.success,
successSoft: fixed.successSoft,
warning: fixed.warning,
warningSoft: fixed.warningSoft,
info: fixed.info,
infoSoft: fixed.infoSoft,
scrim: cs.scrim,
);
}
/// True OLED black across every surface tone - not just [bg] - so cards,
/// bars and sheets all go black too instead of the dark palette's warm
/// dark-grey tones. Mirrors the AMOLED handling this replaces in
/// `AppTheme.dark` (see git history), just against these tokens instead
/// of Material [ColorScheme] roles.
NooColors withAmoled() => copyWith(
bg: Colors.black,
surface: Colors.black,
surface2: Colors.black,
surface3: Colors.black,
);
@override
NooColors copyWith({
Color? bg,
Color? surface,
Color? surface2,
Color? surface3,
Color? line,
Color? fg1,
Color? fg2,
Color? fg3,
Color? accent,
Color? accentText,
Color? accentSoft,
Color? danger,
Color? dangerSoft,
Color? dangerFill,
Color? success,
Color? successSoft,
Color? warning,
Color? warningSoft,
Color? info,
Color? infoSoft,
Color? scrim,
}) {
return NooColors(
bg: bg ?? this.bg,
surface: surface ?? this.surface,
surface2: surface2 ?? this.surface2,
surface3: surface3 ?? this.surface3,
line: line ?? this.line,
fg1: fg1 ?? this.fg1,
fg2: fg2 ?? this.fg2,
fg3: fg3 ?? this.fg3,
accent: accent ?? this.accent,
accentText: accentText ?? this.accentText,
accentSoft: accentSoft ?? this.accentSoft,
danger: danger ?? this.danger,
dangerSoft: dangerSoft ?? this.dangerSoft,
dangerFill: dangerFill ?? this.dangerFill,
success: success ?? this.success,
successSoft: successSoft ?? this.successSoft,
warning: warning ?? this.warning,
warningSoft: warningSoft ?? this.warningSoft,
info: info ?? this.info,
infoSoft: infoSoft ?? this.infoSoft,
scrim: scrim ?? this.scrim,
);
}
@override
NooColors lerp(ThemeExtension<NooColors>? other, double t) {
if (other is! NooColors) return this;
return NooColors(
bg: Color.lerp(bg, other.bg, t)!,
surface: Color.lerp(surface, other.surface, t)!,
surface2: Color.lerp(surface2, other.surface2, t)!,
surface3: Color.lerp(surface3, other.surface3, t)!,
line: Color.lerp(line, other.line, t)!,
fg1: Color.lerp(fg1, other.fg1, t)!,
fg2: Color.lerp(fg2, other.fg2, t)!,
fg3: Color.lerp(fg3, other.fg3, t)!,
accent: Color.lerp(accent, other.accent, t)!,
accentText: Color.lerp(accentText, other.accentText, t)!,
accentSoft: Color.lerp(accentSoft, other.accentSoft, t)!,
danger: Color.lerp(danger, other.danger, t)!,
dangerSoft: Color.lerp(dangerSoft, other.dangerSoft, t)!,
dangerFill: Color.lerp(dangerFill, other.dangerFill, t)!,
success: Color.lerp(success, other.success, t)!,
successSoft: Color.lerp(successSoft, other.successSoft, t)!,
warning: Color.lerp(warning, other.warning, t)!,
warningSoft: Color.lerp(warningSoft, other.warningSoft, t)!,
info: Color.lerp(info, other.info, t)!,
infoSoft: Color.lerp(infoSoft, other.infoSoft, t)!,
scrim: Color.lerp(scrim, other.scrim, t)!,
);
}
}
/// Reads the app's [NooColors] - registered on every [ThemeData] built by
/// `AppTheme`, so this is never actually null in a running app.
extension NooColorsContext on BuildContext {
NooColors get nooColors => Theme.of(this).extension<NooColors>()!;
}
/// The 4px spacing scale from the Noo Design System project's
/// `tokens/spacing.css` (claude.ai/design, project id
/// bb69c00d-f400-445c-9683-355668c0bfe1 - the authoritative source now;
/// `.claude/context/design-system/` is the earlier, less precise mockup
/// export).
class NooSpace {
const NooSpace._();
static const double xxs = 4;
static const double xs = 8;
static const double sm = 12;
static const double smd = 14;
static const double md = 16;
static const double lg = 20;
static const double xl = 24;
static const double xxl = 32;
}
/// Corner radii from `tokens/spacing.css`. `pill` covers every button,
/// chip, segmented control, toggle, search field and badge.
class NooRadii {
const NooRadii._();
static const double pill = 999;
static const double card = 20;
static const double gridCard = 18;
static const double input = 14;
static const double sidebarItem = 12;
static const double sheetTop = 28;
static const double dialog = 24;
static const double dropdown = 10;
static const double screenCornerAndroid = 36;
static const double screenCornerIOS = 48;
static const double windowMac = 12;
static const double windowWin = 8;
}
/// Fixed heights/widths from `tokens/spacing.css` - button sizes, row
/// heights, the desktop sidebar width. Named to match the component that
/// uses each one (`NooButton`'s `size` picks from the `button*` set).
class NooSizes {
const NooSizes._();
static const double buttonCta = 52;
static const double buttonCard = 44;
static const double buttonField = 40;
static const double buttonToolbar = 36;
static const double buttonCompact = 32;
static const double buttonXs = 28;
static const double rowMobile = 64;
static const double rowDesktop = 52;
static const double settingsRow = 52;
static const double sidebarItem = 38;
static const double toolbar = 64;
static const double sidebarWidth = 256;
}
/// Motion constants from `tokens/spacing.css`'s `--ease`/`--dur-*`/
/// `--press-scale`.
class NooMotion {
const NooMotion._();
static const Curve ease = Cubic(0.7, 0, 0.15, 1);
static const Duration fast = Duration(milliseconds: 180);
static const Duration base = Duration(milliseconds: 280);
static const Duration slow = Duration(milliseconds: 480);
static const double pressScale = 0.98;
}
/// The one shadow the design system allows, from `tokens/spacing.css`'s
/// `--shadow-dialog` - desktop dialogs only. Nothing else in the app should
/// use a `BoxShadow`.
const nooDialogShadow = BoxShadow(
color: Color(0x241E002F), // rgba(30,0,47,.14)
blurRadius: 48,
offset: Offset(0, 24),
);
/// The named type scale from `DESIGN_SYSTEM.md` 1.3 - mobile sizes only for
/// now (desktop rework hasn't started). Schibsted Grotesk for headings,
/// Instrument Sans for UI text, per the spec. Color is deliberately not set
/// here - callers pull it from [NooColors] since the same style (e.g.
/// `meta`) is reused across surfaces with different foreground colors.
class NooText {
const NooText._();
static TextStyle get largeTitle => GoogleFonts.schibstedGrotesk(
fontSize: 34,
height: 0.9,
fontWeight: FontWeight.w600,
letterSpacing: -34 * 0.03,
);
static TextStyle get stat => GoogleFonts.schibstedGrotesk(
fontSize: 32,
height: 0.9,
fontWeight: FontWeight.w600,
letterSpacing: -32 * 0.03,
);
static TextStyle get title => GoogleFonts.schibstedGrotesk(
fontSize: 22,
fontWeight: FontWeight.w500,
letterSpacing: -22 * 0.02,
);
static TextStyle get cardTitle => GoogleFonts.schibstedGrotesk(
fontSize: 20,
fontWeight: FontWeight.w500,
letterSpacing: -20 * 0.02,
);
/// Desktop `GroupedList` title / share dialog headings - 17px, between
/// [cardTitle] and [groupHeading].
static TextStyle get sectionTitle => GoogleFonts.schibstedGrotesk(
fontSize: 17,
fontWeight: FontWeight.w500,
letterSpacing: -17 * 0.02,
);
static TextStyle get groupHeading => GoogleFonts.schibstedGrotesk(
fontSize: 18,
fontWeight: FontWeight.w500,
letterSpacing: -18 * 0.02,
);
static TextStyle get bodyL => GoogleFonts.instrumentSans(
fontSize: 16,
height: 1.2,
fontWeight: FontWeight.w400,
);
static TextStyle get body => GoogleFonts.instrumentSans(
fontSize: 15,
height: 1.3,
fontWeight: FontWeight.w400,
);
static TextStyle get label => GoogleFonts.instrumentSans(
fontSize: 13,
fontWeight: FontWeight.w600,
);
static TextStyle get meta => GoogleFonts.instrumentSans(
fontSize: 13,
height: 1.2,
fontWeight: FontWeight.w400,
);
/// Bottom-nav label, idle state (Android: 12/500 fg-2 - see
/// `Mobile Screen.dc.html`'s `aNav` block). Use [navLabelActive] for the
/// selected destination.
static TextStyle get navLabel =>
GoogleFonts.instrumentSans(fontSize: 12, fontWeight: FontWeight.w500);
/// Bottom-nav label, active state (Android: 12/600 fg-1, not accent-text -
/// only the icon's pill uses accent-soft/accent-text).
static TextStyle get navLabelActive =>
GoogleFonts.instrumentSans(fontSize: 12, fontWeight: FontWeight.w600);
static TextStyle get button =>
GoogleFonts.instrumentSans(fontSize: 15, fontWeight: FontWeight.w600);
static TextStyle get buttonSm =>
GoogleFonts.instrumentSans(fontSize: 14, fontWeight: FontWeight.w600);
static const mono = TextStyle(
fontFamily: 'monospace',
fontFamilyFallback: ['ui-monospace', 'Menlo'],
fontSize: 14,
height: 1,
);
}
File diff suppressed because it is too large Load Diff
+99 -120
View File
@@ -1,11 +1,31 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/nextcloud_item.dart';
import '../providers/files_controller.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart';
import '../providers/session_controller.dart';
import '../theme/design_tokens.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/media/noo_activity_item.dart';
import '../widgets/noo/media/noo_photo_tile.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/tabs/tab_day_groups.dart';
import '../widgets/tabs/tab_state_slivers.dart';
/// The whole-account activity feed - lives on [FilesController] (fetched
/// alongside the current Files folder) rather than its own controller, see
/// `FilesController.refreshData`. Grouped by calendar day per
/// `DESIGN_SYSTEM.md` §4; desktop centers the feed at 760px.
///
/// [NextcloudActivity] only carries a rendered `title`/`subject` string
/// pair plus an `author` username - the server response has no structured
/// actor/verb/object breakdown or file reference, so the bold "actor"
/// segment is the author and the rest of the sentence (already fully
/// formed server-side) rides as one plain run rather than being split
/// further, and there's no reliable file to show in the trailing file
/// tile slot - see this screen's rebuild report for what a richer feed
/// would need from the service layer.
class ActivityView extends StatelessWidget {
final ScrollController scrollController;
@@ -13,129 +33,88 @@ class ActivityView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final files = context.watch<FilesController>();
final session = context.watch<SessionController>();
final activities = files.activities;
final isDesktop = NooLayout.isDesktop(context);
final groups = groupByCalendarDay<NextcloudActivity>(
activities,
(a) => a.timestamp,
);
Widget feed;
if (files.isLoading && activities.isEmpty) {
feed = tabLoadingSliver;
} else if (activities.isEmpty) {
feed = tabEmptySliver(
context,
icon: LucideIcons.activity,
message: 'No recent activity',
);
} else {
feed = SliverPadding(
padding: EdgeInsets.symmetric(horizontal: NooLayout.gutter(context)),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final group = groups[index];
return Padding(
padding: const EdgeInsets.only(bottom: 18),
child: NooGroupedList(
label: group.label,
children: [
for (final act in group.items)
Material(
color: colors.surface,
child: NooActivityItem(
actor: act.author,
verb: act.title,
tail: act.subject.isNotEmpty && act.subject != act.title
? act.subject
: null,
time: DateFormat.jm().format(act.timestamp),
avatarColor: NooPhotoTile.paletteColor(act.author.hashCode),
currentUser:
act.author.toLowerCase() == session.username.toLowerCase(),
),
),
],
),
);
}, childCount: groups.length),
),
);
}
final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: 16)),
if (files.isLoading && activities.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (activities.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'No recent activity',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final act = activities[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.2),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
act.icon,
color: colorScheme.onPrimaryContainer,
size: 20,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
act.title,
style: theme.textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
const SizedBox(width: 8),
Text(
DateFormat.jm().format(act.timestamp),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
const SizedBox(height: 4),
Text(
act.subject,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
const SizedBox(height: 6),
Text(
'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w500,
fontSize: 11,
),
),
],
),
),
],
),
),
);
}, childCount: activities.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
feed,
...tabBottomInsetSlivers,
];
return SyncedHeaderScaffold(
scrollController: scrollController,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
contentSlivers: contentSlivers,
final scrollView = CustomScrollView(
controller: scrollController,
slivers: contentSlivers,
);
return ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: () => context.read<FilesController>().refreshData(),
// "Desktop limits it to 760px wide" (DESIGN_SYSTEM.md §4) - centered
// rather than left-aligned, so a wide window doesn't stretch the
// feed's short sentences edge to edge.
child: isDesktop
? Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: scrollView,
),
)
: scrollView,
),
);
}
}
+264 -277
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/app_tab.dart';
@@ -13,16 +13,22 @@ import '../providers/item_operations.dart';
import '../providers/session_controller.dart';
import '../providers/settings_controller.dart';
import '../services/download_service.dart';
import '../theme/design_tokens.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/item_icon.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/segmented_icon_toggle.dart';
import '../widgets/selectable_thumbnail.dart';
import '../widgets/files_controls_row.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/files/noo_file_tile.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/lists/noo_settings_row.dart';
import '../widgets/noo/media/noo_grid_card.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/noo/core/noo_button.dart';
import '../widgets/share_sheet.dart';
import '../widgets/sort_menu_button.dart';
import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.dart';
import '../widgets/synced_header_scaffold.dart' show formatBytes;
import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.dart';
@@ -35,9 +41,11 @@ import 'move_copy_destination_picker.dart';
/// wide list also means the breadcrumb-vs-content mismatch a filter
/// toggle had - the trail showing wherever Files was last browsing while
/// the content showed something else entirely - simply can't happen).
/// Shares Files' own sort/hidden/storage-scope/grid-list display prefs
/// (via `FilesController.applyFilesDisplayPrefs`) rather than a separate
/// parallel settings dimension.
/// Shares Files' own sort/hidden/type-filter/storage-scope/grid-list
/// display prefs (via `FilesController.applyFilesDisplayPrefs`, and
/// `FilesControlsRow` for the controls themselves) rather than a separate
/// parallel settings dimension. Renders content only - the shell owns the
/// top bar, bottom bar, drawer and FAB.
class FavoritesView extends StatefulWidget {
final ScrollController scrollController;
@@ -79,7 +87,14 @@ class _FavoritesViewState extends State<FavoritesView>
/// scroll (row already fits).
Future<void> _playScrollHint(ScrollController scrollController) async {
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted || !scrollController.hasClients) return;
// `hasClients` only means a position is attached, not that it's
// finished its first layout - reading maxScrollExtent before that
// throws (min/maxScrollExtent are still null internally).
if (!mounted ||
!scrollController.hasClients ||
!scrollController.position.hasContentDimensions) {
return;
}
final maxExtent = scrollController.position.maxScrollExtent;
if (maxExtent <= 0) return;
final double peak = maxExtent < 36 ? maxExtent : 36;
@@ -348,174 +363,87 @@ class _FavoritesViewState extends State<FavoritesView>
);
}
Widget _buildControlsRow(FilesController files) {
return SizedBox(
height: 44,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
/// A single item's actions (the same list the multi-select bar offers,
/// just for one item) - mirrors `FilesView._showItemActionsSheet`.
void _showItemActionsSheet(BuildContext context, NextcloudItem item) {
final actions = _buildSelectionActions([item]);
showNooSheet(
context,
children: [
NooGroupedList(
children: [
IconButton(
icon: Icon(
files.filesSortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
for (final action in actions)
NooSettingsRow(
icon: action.icon,
label: Text(action.label),
onTap: () {
Navigator.pop(context);
action.onTap();
},
),
visualDensity: VisualDensity.compact,
tooltip: files.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: files.toggleFilesSortOrder,
),
SizedBox(
width: 130,
child: SortMenuButton(
field: files.filesSortField,
onChanged: files.setFilesSortField,
),
),
ToggleIconButton(
icon: files.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: files.showHiddenFiles,
onTap: () => files.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: files.storageScope == StorageScope.cloud,
onTap: () => files.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: files.storageScope == StorageScope.external,
onTap: () => files.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
),
const SizedBox(width: 8),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !files.isGridView,
onTap: () => files.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: files.isGridView,
onTap: () => files.setGridView(true),
tooltip: 'Grid view',
),
],
),
],
),
),
],
);
}
Widget _buildListTile(
/// A real image/video thumbnail for [item]'s file tile/card, sized so
/// `NooFileTile`'s `FittedBox` (which needs a concretely-sized child to
/// scale) always has one to work with; null when there's nothing to show
/// (falls back to the kind's soft-color tile).
Widget? _thumbnailFor(
BuildContext context,
NextcloudItem item,
List<NextcloudItem> siblings,
) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final session = context.watch<SessionController>();
final isSelected = _selectedIds.contains(item.id);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.5)
: colorScheme.surfaceContainerLow,
child: InkWell(
onTap: () {
if (_isSelecting) {
_toggleSelection(item);
} else {
_openFavorite(context, item, siblings);
}
},
onLongPress: () => _toggleSelection(item),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
SelectableThumbnail(
isSelected: isSelected,
size: 44,
checkmarkSize: 24,
child: ItemThumbnail(
item: item,
service: session.service,
size: 44,
borderRadius: 12,
iconSize: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
item.isFolder
? 'Folder'
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
),
),
),
);
}
Widget _buildGridCard(
BuildContext context,
NextcloudItem item,
List<NextcloudItem> siblings,
) {
final theme = Theme.of(context);
final session = context.watch<SessionController>();
final isSelected = _selectedIds.contains(item.id);
NextcloudItem item, {
required double extent,
}) {
final isMedia =
(item.type == NextcloudItemType.image ||
item.type == NextcloudItemType.video) &&
item.previewUrl != null;
final iconColor = getIconColor(context, item.type);
if (!isMedia) return null;
final session = context.watch<SessionController>();
return Image.network(
item.previewUrl!,
width: extent,
height: extent,
headers: session.service?.authHeaders,
fit: BoxFit.cover,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) => const SizedBox.shrink(),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Material(
color: theme.colorScheme.surfaceContainerLow,
child: InkWell(
String _metaFor(NextcloudItem item) {
return item.isFolder
? 'Folder'
: '${formatBytes(item.size)} · ${DateFormat.yMMMd().format(item.lastModified)}';
}
Widget _buildRow(
BuildContext context,
NextcloudItem item,
List<NextcloudItem> siblings,
) {
final isSelected = _selectedIds.contains(item.id);
final kind = NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card),
child: NooFileRow(
kind: kind,
name: item.name,
meta: _metaFor(item),
favorite: item.isFavorite,
iosStyle: NooLayout.iosStyle(context),
selected: isSelected,
thumbnail: _thumbnailFor(context, item, extent: NooSizes.rowMobile),
onTap: () {
if (_isSelecting) {
_toggleSelection(item);
@@ -524,66 +452,87 @@ class _FavoritesViewState extends State<FavoritesView>
}
},
onLongPress: () => _toggleSelection(item),
child: SelectableThumbnail(
isSelected: isSelected,
checkmarkSize: 32,
child: isMedia
? Image.network(
item.previewUrl!,
headers: session.service?.authHeaders,
fit: BoxFit.cover,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) => Container(
color: theme.colorScheme.secondaryContainer,
child: Icon(
getItemIcon(item.type),
color: theme.colorScheme.onSecondaryContainer,
size: 32,
),
),
)
: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
getItemIcon(item.type),
color: iconColor,
size: 24,
),
),
Text(
item.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
],
),
),
),
onMore: () => _showItemActionsSheet(context, item),
),
),
);
}
Widget _buildTableRow(
BuildContext context,
NextcloudItem item,
List<NextcloudItem> siblings,
) {
final isSelected = _selectedIds.contains(item.id);
final kind = NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
);
return NooFileTableRow(
kind: kind,
name: item.name,
col2: item.isFolder ? null : DateFormat.yMMMd().format(item.lastModified),
col3: item.isFolder ? null : formatBytes(item.size),
favorite: item.isFavorite,
selected: isSelected,
thumbnail: _thumbnailFor(context, item, extent: NooFileTileSize.desktop.extent),
onTap: () {
if (_isSelecting) {
_toggleSelection(item);
} else {
_openFavorite(context, item, siblings);
}
},
// Desktop has no long-press gesture; right-click is this row's
// equivalent entry point into multi-select.
onSecondaryTap: () => _toggleSelection(item),
onMore: () => _showItemActionsSheet(context, item),
);
}
Widget _buildGridCard(
BuildContext context,
NextcloudItem item,
List<NextcloudItem> siblings,
bool isDesktop,
) {
final colors = context.nooColors;
final isSelected = _selectedIds.contains(item.id);
final kind = NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
);
final thumbnailHeight = isDesktop ? 118.0 : 104.0;
return NooGridCard(
name: item.name,
meta: item.isFolder ? 'Folder' : formatBytes(item.size),
placeholderColor: kind.background(colors),
icon: kind.icon,
iconColor: kind.foreground(colors),
thumbnailHeight: thumbnailHeight,
thumbnail: _thumbnailFor(context, item, extent: thumbnailHeight),
selected: isSelected,
verticalOverflowIcon: !NooLayout.iosStyle(context),
onTap: () {
if (_isSelecting) {
_toggleSelection(item);
} else {
_openFavorite(context, item, siblings);
}
},
onLongPress: () => _toggleSelection(item),
onMore: () => _showItemActionsSheet(context, item),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final isDesktop = NooLayout.isDesktop(context);
final favoritesController = context.watch<FavoritesController>();
final filesController = context.watch<FilesController>();
@@ -600,18 +549,26 @@ class _FavoritesViewState extends State<FavoritesView>
.where((i) => _selectedIds.contains(i.id))
.toList();
final controlsRow = Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.sm,
NooSpace.sm,
NooSpace.sm,
NooSpace.xs,
),
child: FilesControlsRow(folderPath: filesController.currentFolderPath),
);
final contentSlivers = <Widget>[
SliverPersistentHeader(
pinned: !_isSelecting,
delegate: StickyHeaderDelegate(
height: 60,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: _buildControlsRow(filesController),
),
height: _isSelecting ? 56 : 64,
child: _isSelecting
? _buildSelectionBar(context, selectedItems)
: controlsRow,
),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (favoritesController.isLoading && favorites.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
@@ -626,32 +583,25 @@ class _FavoritesViewState extends State<FavoritesView>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
Icon(LucideIcons.circleAlert, size: 56, color: colors.danger),
const SizedBox(height: 16),
Text(
'Could not load favorites',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
style: NooText.cardTitle.copyWith(color: colors.danger),
),
const SizedBox(height: 8),
Text(
favoritesController.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: NooText.body.copyWith(color: colors.fg3),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: favoritesController.fetchAll,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
NooButton(
variant: NooButtonVariant.secondary,
size: NooButtonSize.field,
icon: LucideIcons.refreshCw,
onTap: favoritesController.fetchAll,
child: const Text('Retry'),
),
],
),
@@ -665,17 +615,11 @@ class _FavoritesViewState extends State<FavoritesView>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.favorite_border_rounded,
size: 64,
color: colorScheme.outlineVariant,
),
Icon(LucideIcons.star, size: 56, color: colors.fg3),
const SizedBox(height: 12),
Text(
'No favorites yet',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: NooText.cardTitle.copyWith(color: colors.fg2),
),
],
),
@@ -683,30 +627,70 @@ class _FavoritesViewState extends State<FavoritesView>
)
else if (filesController.isGridView)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
padding: EdgeInsets.symmetric(
horizontal: NooLayout.gutter(context),
vertical: NooSpace.xs,
),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.1,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isDesktop ? 5 : 2,
crossAxisSpacing: isDesktop ? 16 : 10,
mainAxisSpacing: isDesktop ? 16 : 10,
childAspectRatio: isDesktop ? 0.92 : 0.85,
),
delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(context, favorites[index], siblings);
return _buildGridCard(context, favorites[index], siblings, isDesktop);
}, childCount: favorites.length),
),
)
else if (isDesktop)
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: NooLayout.gutter(context)),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == 0) {
return NooFileTableHeader(
col2Label: 'Modified',
col3Label: 'Size',
sortColumn: switch (filesController.filesSortField) {
FileSortField.name => 0,
FileSortField.dateModified => 1,
FileSortField.size => 2,
FileSortField.dateCreated => null,
},
sortAscending: filesController.filesSortAscending,
onSort: (column) {
final field = [
FileSortField.name,
FileSortField.dateModified,
FileSortField.size,
][column];
if (field == filesController.filesSortField) {
filesController.toggleFilesSortOrder();
} else {
filesController.setFilesSortField(field);
}
},
);
}
return _buildTableRow(
context,
favorites[index - 1],
siblings,
);
}, childCount: favorites.length + 1),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
padding: EdgeInsets.symmetric(horizontal: NooLayout.gutter(context)),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return _buildListTile(context, favorites[index], siblings);
return _buildRow(context, favorites[index], siblings);
}, childCount: favorites.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
];
return PopScope(
@@ -714,28 +698,30 @@ class _FavoritesViewState extends State<FavoritesView>
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelecting) _clearSelection();
},
child: SyncedHeaderScaffold(
scrollController: widget.scrollController,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: favoritesController.fetchAll,
selectionBar: _isSelecting
? _buildSelectionBar(context, theme, selectedItems)
: null,
contentSlivers: contentSlivers,
child: ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: favoritesController.fetchAll,
child: CustomScrollView(
controller: widget.scrollController,
slivers: contentSlivers,
),
),
),
);
}
/// Replaces the top bar entirely while selecting (see
/// `SyncedHeaderScaffold.selectionBar`) - mirrors Files/Photos' identical
/// selection bar.
/// Replaces the controls row's own sticky slot while selecting - mirrors
/// Files/Photos' identical selection bar.
Widget _buildSelectionBar(
BuildContext context,
ThemeData theme,
List<NextcloudItem> selectedItems,
) {
final colors = context.nooColors;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
child: Row(
children: [
SizedBox(
@@ -746,7 +732,7 @@ class _FavoritesViewState extends State<FavoritesView>
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
icon: Icon(LucideIcons.x, size: 20, color: colors.fg1),
tooltip: 'Cancel selection',
onPressed: _clearSelection,
visualDensity: VisualDensity.compact,
@@ -754,8 +740,9 @@ class _FavoritesViewState extends State<FavoritesView>
const SizedBox(width: 8),
Text(
'${selectedItems.length} selected',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
style: NooText.cardTitle.copyWith(
fontSize: 17,
color: colors.fg1,
),
),
],
@@ -771,7 +758,7 @@ class _FavoritesViewState extends State<FavoritesView>
children: [
for (final action in _buildSelectionActions(selectedItems))
IconButton(
icon: Icon(action.icon, size: 20),
icon: Icon(action.icon, size: 20, color: colors.fg1),
tooltip: action.label,
onPressed: action.onTap,
visualDensity: VisualDensity.compact,
File diff suppressed because it is too large Load Diff
+660 -468
View File
File diff suppressed because it is too large Load Diff
+15 -28
View File
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../providers/session_controller.dart';
import '../theme/design_tokens.dart';
import '../widgets/noo/core/noo_button.dart';
/// Shown in place of the main shell whenever [SessionController.needsUnlock] is
/// true - a fresh app launch with login lock configured, or returning to the
@@ -48,10 +51,10 @@ class _LockScreenViewState extends State<LockScreenView>
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
return Scaffold(
backgroundColor: colors.bg,
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
@@ -63,10 +66,7 @@ class _LockScreenViewState extends State<LockScreenView>
// silhouette on transparent, meant to be recolored rather
// than shown as-is.
ColorFiltered(
colorFilter: ColorFilter.mode(
colorScheme.onSurface,
BlendMode.srcIn,
),
colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn),
child: Image.asset(
'assets/icon/app_icon_monochrome.png',
width: 80,
@@ -77,35 +77,22 @@ class _LockScreenViewState extends State<LockScreenView>
Text(
'Noo is locked',
textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
style: NooText.title.copyWith(color: colors.fg1),
),
const SizedBox(height: 8),
Text(
'Unlock with your PIN or biometrics to continue',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: NooText.body.copyWith(color: colors.fg3),
),
const SizedBox(height: 32),
SizedBox(
height: 54,
child: FilledButton.icon(
onPressed: _authenticating ? null : _attemptUnlock,
icon: _authenticating
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: colorScheme.onPrimary,
),
)
: const Icon(Icons.lock_open_rounded),
label: Text(_authenticating ? 'Unlocking…' : 'Unlock'),
),
NooButton(
variant: NooButtonVariant.primary,
size: NooButtonSize.cta,
icon: LucideIcons.lockOpen,
disabled: _authenticating,
onTap: _attemptUnlock,
child: Text(_authenticating ? 'Unlocking…' : 'Unlock'),
),
],
),
+177 -247
View File
@@ -1,13 +1,22 @@
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 '../widgets/noo/core/noo_avatar.dart';
import '../widgets/noo/core/noo_button.dart';
import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/noo/overlays/noo_text_field.dart';
import '../widgets/settings/settings_dialogs.dart';
import '../widgets/shell/shell_common.dart';
import 'login_webview_view.dart';
class LoginView extends StatefulWidget {
/// True when this is pushed from Settings ("Add account") on top of an
/// already-logged-in session, rather than shown as the app's root screen
/// with no account yet. Adds an AppBar/back affordance and auto-pops once
/// with no account yet. Adds a top bar/back affordance and auto-pops once
/// the new account becomes active.
final bool isAddingAccount;
@@ -18,9 +27,9 @@ class LoginView extends StatefulWidget {
}
class _LoginViewState extends State<LoginView> {
final _formKey = GlobalKey<FormState>();
final _urlController = TextEditingController();
String? _originalActiveAccountId;
String? _localError;
bool _popped = false;
bool _webViewPushed = false;
@@ -39,12 +48,14 @@ class _LoginViewState extends State<LoginView> {
}
void _handleContinue() {
if (!_formKey.currentState!.validate()) return;
final url = _urlController.text.trim();
if (url.isEmpty) {
setState(() => _localError = 'Please enter your Nextcloud server address');
return;
}
setState(() => _localError = null);
FocusScope.of(context).unfocus();
context.read<SessionController>().startLoginFlow(
_urlController.text.trim(),
addAccount: widget.isAddingAccount,
);
context.read<SessionController>().startLoginFlow(url, addAccount: widget.isAddingAccount);
}
/// Resumes a saved account with one tap (see [SessionController.switchAccount]).
@@ -68,47 +79,25 @@ class _LoginViewState extends State<LoginView> {
);
}
/// Mirrors AccountView's `_AccountsCard._confirmRemove` - same
/// destructive-action gate, just reached from the login screen instead of
/// Settings.
/// Mirrors Settings' own account removal - same confirm/remove gate, just
/// reached from the login screen instead of the saved-accounts list.
Future<void> _confirmRemoveAccount(SavedAccount account) async {
final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Remove Account'),
content: Text(
'Remove ${account.username} ($host)? You can add it again later.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Remove'),
),
],
);
},
await confirmRemoveAccount(
context,
context.read<SessionController>(),
accountId: account.id,
username: account.username,
host: serverHost(account.serverUrl),
isActive: false,
);
if (confirmed != true || !mounted) return;
await context.read<SessionController>().removeAccount(account.id);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final session = context.watch<SessionController>();
final isAwaitingBrowser =
session.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
final isAwaitingBrowser = session.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
final isInitiating = session.loginFlowStatus == LoginFlowStatus.initiating;
// A new/refreshed account has just become active - pop back to
@@ -128,9 +117,7 @@ class _LoginViewState extends State<LoginView> {
// SessionController.startLoginFlow's doc comment for why. Push it the
// moment there's a URL to show; _webViewPushed resets once that route
// pops (cancelled or done) so a retry after cancelling pushes it again.
if (!_webViewPushed &&
isAwaitingBrowser &&
session.pendingLoginUrl != null) {
if (!_webViewPushed && isAwaitingBrowser && session.pendingLoginUrl != null) {
_webViewPushed = true;
final url = session.pendingLoginUrl!;
WidgetsBinding.instance.addPostFrameCallback((_) async {
@@ -154,9 +141,17 @@ class _LoginViewState extends State<LoginView> {
});
}
final errorMessage =
_localError ?? (session.loginFlowStatus == LoginFlowStatus.error ? session.errorMessage : null);
final body = Scaffold(
backgroundColor: colors.bg,
appBar: widget.isAddingAccount
? AppBar(title: const Text('Add Account'))
? NooTopBar(
style: NooLayout.navStyle(context),
title: 'Add account',
leading: const NooTopBarBack(),
)
: null,
body: SafeArea(
child: Center(
@@ -172,8 +167,7 @@ class _LoginViewState extends State<LoginView> {
// deleting it, specifically so it can be resumed
// from here with one tap - no need to repeat
// Login Flow v2.
if (!widget.isAddingAccount &&
session.accounts.isNotEmpty) ...[
if (!widget.isAddingAccount && session.accounts.isNotEmpty) ...[
_SavedAccountsSection(
accounts: session.accounts,
onSelect: (account) => _continueAsAccount(account),
@@ -182,33 +176,21 @@ class _LoginViewState extends State<LoginView> {
const SizedBox(height: 28),
Row(
children: [
const Expanded(child: Divider()),
Expanded(child: Divider(color: colors.line)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
'or',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
child: Text('or', style: NooText.meta.copyWith(color: colors.fg3)),
),
const Expanded(child: Divider()),
Expanded(child: Divider(color: colors.line)),
],
),
const SizedBox(height: 28),
],
_ServerForm(
formKey: _formKey,
urlController: _urlController,
isLoading:
isInitiating || session.isLoading || isAwaitingBrowser,
errorMessage:
session.loginFlowStatus == LoginFlowStatus.error
? session.errorMessage
: null,
isLoading: isInitiating || session.isLoading || isAwaitingBrowser,
errorMessage: errorMessage,
onContinue: _handleContinue,
theme: theme,
colorScheme: colorScheme,
),
],
),
@@ -236,157 +218,94 @@ class _LoginViewState extends State<LoginView> {
}
class _ServerForm extends StatelessWidget {
final GlobalKey<FormState> formKey;
final TextEditingController urlController;
final bool isLoading;
final String? errorMessage;
final VoidCallback onContinue;
final ThemeData theme;
final ColorScheme colorScheme;
const _ServerForm({
required this.formKey,
required this.urlController,
required this.isLoading,
required this.errorMessage,
required this.onContinue,
required this.theme,
required this.colorScheme,
});
@override
Widget build(BuildContext context) {
return Form(
key: formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
// Same monochrome-tinted treatment as the splash/lock screens
// (see main.dart's _SplashView) - the asset is a plain white
// silhouette on transparent, meant to be recolored rather than
// shown as-is. Used everywhere the app shows its own icon
// in-app, rather than the full-color launcher icon.
child: ColorFiltered(
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurface,
BlendMode.srcIn,
),
child: Image.asset(
'assets/icon/app_icon_monochrome.png',
width: 80,
height: 80,
),
),
final colors = context.nooColors;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
// Same monochrome-tinted treatment as the splash/lock screens
// (see main.dart's _SplashView) - the asset is a plain white
// silhouette on transparent, meant to be recolored rather than
// shown as-is. Used everywhere the app shows its own icon
// in-app, rather than the full-color launcher icon.
child: ColorFiltered(
colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn),
child: Image.asset('assets/icon/app_icon_monochrome.png', width: 80, height: 80),
),
const SizedBox(height: 24),
Text(
'Noo',
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
),
const SizedBox(height: 24),
Text('Noo', textAlign: TextAlign.center, style: NooText.largeTitle.copyWith(color: colors.fg1)),
const SizedBox(height: 6),
Text(
'Connect to your self-hosted server',
textAlign: TextAlign.center,
style: NooText.body.copyWith(color: colors.fg3),
),
const SizedBox(height: 36),
if (errorMessage != null)
Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colors.dangerSoft,
borderRadius: BorderRadius.circular(NooRadii.input),
),
),
const SizedBox(height: 6),
Text(
'Connect to your self-hosted server',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 36),
if (errorMessage != null)
Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Icon(
Icons.error_outline_rounded,
color: colorScheme.onErrorContainer,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
errorMessage!,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.w500,
),
child: Row(
children: [
Icon(LucideIcons.circleAlert, color: colors.danger, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
errorMessage!,
style: NooText.body.copyWith(
fontSize: 13,
fontWeight: FontWeight.w500,
color: colors.danger,
),
),
],
),
),
TextFormField(
controller: urlController,
keyboardType: TextInputType.url,
autofillHints: const [AutofillHints.url],
decoration: InputDecoration(
labelText: 'Server Address',
hintText: 'cloud.example.com',
prefixIcon: const Icon(Icons.dns_outlined),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
),
filled: true,
fillColor: colorScheme.surfaceContainerLow,
),
validator: (val) {
if (val == null || val.trim().isEmpty) {
return 'Please enter your Nextcloud server address';
}
return null;
},
onFieldSubmitted: (_) => onContinue(),
),
const SizedBox(height: 28),
SizedBox(
height: 54,
child: FilledButton(
onPressed: isLoading ? null : onContinue,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(27),
),
),
child: isLoading
? SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: colorScheme.onPrimary,
),
)
: const Text(
'Continue',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(height: 24),
Text(
"You'll finish signing in on the page that opens. This app never sees your password.",
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
NooTextField(
controller: urlController,
placeholder: 'cloud.example.com',
leadingIcon: LucideIcons.server,
keyboardType: TextInputType.url,
onSubmitted: (_) => onContinue(),
),
const SizedBox(height: 28),
NooButton(
variant: NooButtonVariant.primary,
size: NooButtonSize.cta,
fullWidth: true,
disabled: isLoading,
onTap: onContinue,
child: Text(isLoading ? 'Connecting…' : 'Continue'),
),
const SizedBox(height: 24),
Text(
"You'll finish signing in on the page that opens. This app never sees your password.",
textAlign: TextAlign.center,
style: NooText.meta.copyWith(color: colors.fg3, fontSize: 11),
),
],
);
}
}
@@ -398,44 +317,35 @@ class _SavedAccountsSection extends StatelessWidget {
final ValueChanged<SavedAccount> onSelect;
final ValueChanged<SavedAccount> onRemove;
const _SavedAccountsSection({
required this.accounts,
required this.onSelect,
required this.onRemove,
});
const _SavedAccountsSection({required this.accounts, required this.onSelect, required this.onRemove});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text(
'Continue as',
style: theme.textTheme.labelLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
child: Text('Continue as', style: NooText.label.copyWith(color: colors.fg2)),
),
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
for (final account in accounts) ...[
_SavedAccountRow(
account: account,
onTap: () => onSelect(account),
onRemove: () => onRemove(account),
),
if (account != accounts.last)
const Divider(height: 1, indent: 16, endIndent: 16),
ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card),
child: DecoratedBox(
decoration: BoxDecoration(color: colors.line),
child: Column(
children: [
for (var i = 0; i < accounts.length; i++) ...[
if (i > 0) const SizedBox(height: 1),
_SavedAccountRow(
account: accounts[i],
onTap: () => onSelect(accounts[i]),
onRemove: () => onRemove(accounts[i]),
),
],
],
],
),
),
),
],
@@ -448,40 +358,60 @@ class _SavedAccountRow extends StatelessWidget {
final VoidCallback onTap;
final VoidCallback onRemove;
const _SavedAccountRow({
required this.account,
required this.onTap,
required this.onRemove,
});
const _SavedAccountRow({required this.account, required this.onTap, required this.onRemove});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl;
final initial = account.username.isNotEmpty
? account.username[0].toUpperCase()
: '?';
final colors = context.nooColors;
return ListTile(
leading: CircleAvatar(
backgroundColor: colorScheme.primary,
child: Text(
initial,
style: TextStyle(
color: colorScheme.onPrimary,
fontWeight: FontWeight.bold,
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), 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),
),
],
),
),
GestureDetector(
onTap: onRemove,
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle),
child: Icon(LucideIcons.x, size: 16, color: colors.fg2),
),
),
],
),
),
),
title: Text(account.username),
subtitle: Text(host),
trailing: IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: 'Remove account',
visualDensity: VisualDensity.compact,
onPressed: onRemove,
),
onTap: onTap,
);
}
}
+17 -6
View File
@@ -1,7 +1,11 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../providers/session_controller.dart';
import '../theme/design_tokens.dart';
import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/noo_layout.dart';
/// Every Login Flow v2 page - first login or an additional account -
/// rendered in Flutter's own WebView rather than a Chrome Custom Tab or
@@ -63,6 +67,7 @@ class _LoginWebViewViewState extends State<LoginWebViewView> {
@override
Widget build(BuildContext context) {
final session = context.watch<SessionController>();
final colors = context.nooColors;
// The login flow finished (success or error) or was cancelled
// elsewhere - close this screen automatically rather than leaving it
@@ -81,18 +86,24 @@ class _LoginWebViewViewState extends State<LoginWebViewView> {
if (!didPop) _cancel();
},
child: Scaffold(
appBar: AppBar(
title: const Text('Sign in'),
leading: IconButton(
icon: const Icon(Icons.close_rounded),
backgroundColor: colors.bg,
appBar: NooTopBar(
style: NooLayout.navStyle(context),
title: 'Log in',
leading: NooTopBarButton(
icon: LucideIcons.x,
tooltip: 'Cancel',
onPressed: _cancel,
onTap: _cancel,
),
),
body: Stack(
children: [
WebViewWidget(controller: _controller),
if (_pageLoading) const LinearProgressIndicator(),
if (_pageLoading)
LinearProgressIndicator(
color: colors.accent,
backgroundColor: colors.surface3,
),
],
),
),
+152 -309
View File
@@ -1,21 +1,22 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../models/move_copy_result.dart';
import '../models/nextcloud_item.dart';
import '../providers/files_controller.dart';
import '../providers/item_operations.dart';
import '../providers/session_controller.dart';
import '../theme/design_tokens.dart';
import '../widgets/breadcrumbs.dart';
import '../widgets/item_icon.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/move_copy_conflict_sheet.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/segmented_icon_toggle.dart';
import '../widgets/sort_menu_button.dart';
import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.dart';
import '../widgets/noo/core/noo_button.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/nav/noo_toolbar.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/tabs/tab_state_slivers.dart';
/// Destination-folder browser for moving/copying [items] (a multi-select
/// batch from Files or Photos). Visually mirrors `ShareUploadView`'s
@@ -175,352 +176,194 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
).pop(MoveCopyResult(succeeded: succeeded, failed: failed));
}
Widget _buildControlsRow(FilesController files) {
return SizedBox(
height: 44,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
IconButton(
icon: Icon(
files.filesSortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: files.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: files.toggleFilesSortOrder,
),
SizedBox(
width: 130,
child: SortMenuButton(
field: files.filesSortField,
onChanged: files.setFilesSortField,
),
),
ToggleIconButton(
icon: files.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: files.showHiddenFiles,
onTap: () => files.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: files.storageScope == StorageScope.cloud,
onTap: () => files.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: files.storageScope == StorageScope.external,
onTap: () => files.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
),
const SizedBox(width: 8),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !files.isGridView,
onTap: () => files.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: files.isGridView,
onTap: () => files.setGridView(true),
tooltip: 'Grid view',
),
],
),
],
// Only folders are valid Move/Copy destinations, so the listing - unlike
// Files' own - never shows plain files at all.
Widget _buildRow(NextcloudItem item, int index, int count) {
final colors = context.nooColors;
final row = NooFileRow(
kind: NooFileKind.folder,
name: item.name,
meta: 'Folder',
iosStyle: NooLayout.iosStyle(context),
onTap: () => _navigateToFolder(item.path),
);
final isFirst = index == 0;
final isLast = index == count - 1;
return Column(
children: [
ClipRRect(
borderRadius: BorderRadius.vertical(
top: isFirst ? const Radius.circular(NooRadii.card) : Radius.zero,
bottom: isLast ? const Radius.circular(NooRadii.card) : Radius.zero,
),
child: row,
),
),
if (!isLast) Container(height: 1, color: colors.line),
],
);
}
// Only folders are valid Move/Copy destinations - files still show (so
// the listing matches what the Files tab itself would show for this
// folder) but are visually dimmed and inert, same treatment as
// ShareUploadView's own destination browser.
Widget _buildListTile(NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final session = context.watch<SessionController>();
final isFolder = item.isFolder;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Opacity(
opacity: isFolder ? 1 : 0.5,
child: Material(
color: colorScheme.surfaceContainerLow,
child: InkWell(
onTap: isFolder ? () => _navigateToFolder(item.path) : null,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
ItemThumbnail(
item: item,
service: session.service,
size: 44,
borderRadius: 12,
iconSize: 22,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
isFolder
? 'Folder'
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (isFolder) const Icon(Icons.chevron_right_rounded),
],
),
),
),
),
),
),
);
}
Widget _buildGridCard(NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isFolder = item.isFolder;
final iconColor = getIconColor(context, item.type);
return Opacity(
opacity: isFolder ? 1 : 0.5,
child: Material(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: isFolder ? () => _navigateToFolder(item.path) : null,
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
getItemIcon(item.type),
color: iconColor,
size: 24,
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
const SizedBox(height: 2),
Text(
isFolder ? 'Folder' : formatBytes(item.size),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
],
),
),
),
),
Widget _buildDesktopRow(NextcloudItem item) {
return NooFileTableRow(
kind: NooFileKind.folder,
name: item.name,
onTap: () => _navigateToFolder(item.path),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final desktop = NooLayout.isDesktop(context);
final files = context.watch<FilesController>();
final ops = context.read<ItemOperations>();
final hasBreadcrumbs = _pathStack.length > 1;
final currentLabel = _currentPath == '/'
? 'Home'
: _currentPath.split('/').where((s) => s.isNotEmpty).last;
final items = files.applyFilesDisplayPrefs(_rawItems);
final folders = files
.applyFilesDisplayPrefs(_rawItems)
.where((item) => item.isFolder)
.toList();
final invalidDestination = _destinationIsInvalid();
final gutter = NooLayout.gutter(context);
final verb = widget.copy ? 'Copying' : 'Moving';
final itemCount = widget.items.length;
final controlsColumn = Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildControlsRow(files),
if (hasBreadcrumbs) ...[
const SizedBox(height: 10),
SizedBox(
height: 32,
child: Breadcrumbs(
pathStack: _pathStack,
onTap: _navigateToPathIndex,
),
),
],
],
),
);
final contentSlivers = <Widget>[
SliverPersistentHeader(
pinned: true,
delegate: StickyHeaderDelegate(
height: hasBreadcrumbs ? 114 : 72,
child: controlsColumn,
final slivers = <Widget>[
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(gutter, NooSpace.md, gutter, NooSpace.sm),
child: hasBreadcrumbs
? SizedBox(
height: 32,
child: Breadcrumbs(
pathStack: _pathStack,
onTap: _navigateToPathIndex,
),
)
: const SizedBox.shrink(),
),
),
if (_isLoading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
tabLoadingSliver
else if (folders.isEmpty)
tabEmptySliver(
context,
icon: LucideIcons.folder,
message: 'No folders here',
)
else if (items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'Folder is empty',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
)
else if (files.isGridView)
else if (desktop)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.1,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildDesktopRow(folders[index]),
childCount: folders.length,
),
delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(items[index]);
}, childCount: items.length),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return _buildListTile(items[index]);
}, childCount: items.length),
delegate: SliverChildBuilderDelegate(
(context, index) => _buildRow(folders[index], index, folders.length),
childCount: folders.length,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.xl)),
];
final title = widget.copy ? 'Copy to...' : 'Move to...';
return PopScope(
canPop: _pathStack.length <= 1,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) _navigateUp();
},
child: Scaffold(
body: SyncedHeaderScaffold(
scrollController: _scrollController,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
contentSlivers: contentSlivers,
backgroundColor: colors.bg,
appBar: desktop
? NooToolbar(
title: title,
actions: [
const MoreTabsButton(),
const SizedBox(width: NooSpace.xs),
NooButton(
variant: NooButtonVariant.secondary,
size: NooButtonSize.toolbar,
onTap: () => Navigator.maybePop(context),
child: const Text('Cancel'),
),
],
)
: NooTopBar(
style: NooLayout.navStyle(context),
title: title,
leading: const NooTopBarBack(label: 'Cancel'),
// Stacks a hidden tab's own screen on top rather than
// jumping the shell there, so it doesn't abandon this
// picker - safe to keep, unlike ShareUploadView's chrome.
actions: const [MoreTabsButton()],
),
body: SafeArea(
top: false,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: () => _fetch(_currentPath),
child: CustomScrollView(controller: _scrollController, slivers: slivers),
),
),
bottomNavigationBar: Material(
color: colorScheme.surfaceContainerHigh,
elevation: 8,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
bottomNavigationBar: DecoratedBox(
decoration: BoxDecoration(
color: colors.surface,
border: Border(top: BorderSide(color: colors.line)),
),
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
padding: const EdgeInsets.fromLTRB(
NooSpace.lg,
NooSpace.md,
NooSpace.lg,
NooSpace.md,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (invalidDestination) ...[
Text(
"Can't ${widget.copy ? 'copy' : 'move'} a folder into "
"itself or one of its own subfolders",
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
Text(
invalidDestination
? "Can't ${widget.copy ? 'copy' : 'move'} a folder into "
"itself or one of its own subfolders"
: '$verb $itemCount item${itemCount == 1 ? '' : 's'}',
style: NooText.meta.copyWith(
color: invalidDestination ? colors.danger : colors.fg3,
),
const SizedBox(height: 8),
],
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: invalidDestination || _isSubmitting
? null
: () => _confirm(ops),
icon: _isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
widget.copy
? Icons.copy_rounded
: Icons.drive_file_move_rounded,
),
const SizedBox(height: NooSpace.sm),
NooButton(
variant: NooButtonVariant.primary,
size: NooButtonSize.cta,
fullWidth: true,
disabled: invalidDestination || _isSubmitting,
icon: _isSubmitting
? null
: (widget.copy ? LucideIcons.copy : LucideIcons.folderInput),
onTap: invalidDestination || _isSubmitting
? null
: () => _confirm(ops),
child: _isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
label: Text(
'${widget.copy ? 'Copy' : 'Move'} ${widget.items.length} '
'item(s) to $currentLabel',
),
),
)
: Text(widget.copy ? 'Copy here' : 'Move here'),
),
],
),
+365 -228
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/nextcloud_item.dart';
@@ -11,18 +12,42 @@ import '../providers/photos_controller.dart';
import '../providers/pick_controller.dart';
import '../providers/session_controller.dart';
import '../services/download_service.dart';
import '../theme/design_tokens.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/segmented_icon_toggle.dart';
import '../widgets/selectable_thumbnail.dart';
import '../widgets/noo/core/noo_button.dart';
import '../widgets/noo/core/noo_chip.dart';
import '../widgets/noo/core/noo_segmented_control.dart';
import '../widgets/noo/core/noo_toggle.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/lists/noo_settings_row.dart';
import '../widgets/noo/media/noo_photo_group.dart';
import '../widgets/noo/media/noo_photo_tile.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/share_sheet.dart';
import '../widgets/sort_menu_button.dart';
import '../widgets/sort_menu_button.dart' show sortFieldLabel;
import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.dart';
import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.dart';
/// The type chips PhotosController actually has data for (DESIGN_SYSTEM.md
/// section 4 also lists "Camera", but there's no camera-upload/EXIF source
/// to distinguish that from any other photo - see the view's doc comment).
enum _PhotoTypeFilter { all, image, video }
/// A contiguous run of [PhotosController.items] that shares a calendar
/// month, in whatever order the controller already sorted them - see
/// [_PhotosViewState._groupByMonth].
class _MonthGroup {
final DateTime month;
final List<NextcloudItem> items;
_MonthGroup(this.month, this.items);
}
/// The Photos tab: every image/video across the account, grouped by month
/// (DESIGN_SYSTEM.md section 4). Renders content only - the shell
/// (`MainShellView`) owns the top bar, bottom bar, drawer and FAB.
class PhotosView extends StatefulWidget {
final ScrollController scrollController;
@@ -39,6 +64,12 @@ class _PhotosViewState extends State<PhotosView>
final ScrollController _selectionActionsScrollController = ScrollController();
final List<AnimationController> _scrollHintControllers = [];
// Session-local only (not one of PhotosController's persisted display
// prefs) - a pure narrowing of the already-fetched/filtered/sorted list,
// same way the type chips narrow Files' listing without touching the
// controller.
_PhotoTypeFilter _typeFilter = _PhotoTypeFilter.all;
bool get _isSelecting => _selectedIds.isNotEmpty;
void _toggleSelection(NextcloudItem item) {
@@ -64,7 +95,14 @@ class _PhotosViewState extends State<PhotosView>
/// starts. No-ops if there's nothing to scroll (row already fits).
Future<void> _playScrollHint(ScrollController scrollController) async {
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted || !scrollController.hasClients) return;
// `hasClients` only means a position is attached, not that it's
// finished its first layout - reading maxScrollExtent before that
// throws (min/maxScrollExtent are still null internally).
if (!mounted ||
!scrollController.hasClients ||
!scrollController.position.hasContentDimensions) {
return;
}
final maxExtent = scrollController.position.maxScrollExtent;
if (maxExtent <= 0) return;
final double peak = maxExtent < 36 ? maxExtent : 36;
@@ -195,10 +233,39 @@ class _PhotosViewState extends State<PhotosView>
];
}
/// Splits an already-filtered/sorted list into contiguous month runs
/// (`dateCreated`) in list order - deliberately not a global re-sort by
/// date, so a "Name"/"Size" sort chip still governs the order shown
/// (see the group header row it feeds).
List<_MonthGroup> _groupByMonth(List<NextcloudItem> items) {
final groups = <_MonthGroup>[];
for (final item in items) {
final d = item.dateCreated;
final month = DateTime(d.year, d.month);
if (groups.isNotEmpty && groups.last.month == month) {
groups.last.items.add(item);
} else {
groups.add(_MonthGroup(month, [item]));
}
}
return groups;
}
bool _matchesTypeFilter(NextcloudItem item) {
switch (_typeFilter) {
case _PhotoTypeFilter.all:
return true;
case _PhotoTypeFilter.image:
return item.type == NextcloudItemType.image;
case _PhotoTypeFilter.video:
return item.type == NextcloudItemType.video;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final isDesktop = NooLayout.isDesktop(context);
final photosController = context.watch<PhotosController>();
final filesController = context.watch<FilesController>();
final pick = context.watch<PickController>();
@@ -210,89 +277,44 @@ class _PhotosViewState extends State<PhotosView>
);
}
final photos = photosController.items;
final selectedItems = photos
final allPhotos = photosController.items;
final visiblePhotos = allPhotos.where(_matchesTypeFilter).toList();
// Selection tracks the full (favorites/hidden-filtered) list, not just
// the type-filtered view, so switching the type chip mid-selection
// never silently drops a selected item from the bulk actions.
final selectedItems = allPhotos
.where((i) => _selectedIds.contains(i.id))
.toList();
final groups = _groupByMonth(visiblePhotos);
final controlsRow = Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: SizedBox(
height: 44,
child: Row(
children: [
IconButton(
icon: Icon(
photosController.sortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: photosController.sortAscending
? 'Ascending'
: 'Descending',
onPressed: photosController.toggleSortOrder,
),
Expanded(
child: SortMenuButton(
field: photosController.sortField,
onChanged: photosController.setSortField,
),
),
ToggleIconButton(
icon: photosController.showFavoritesOnly
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
isSelected: photosController.showFavoritesOnly,
onTap: photosController.toggleFavoritesFilter,
tooltip: 'Favorites only',
),
const SizedBox(width: 4),
ToggleIconButton(
icon: photosController.showHidden
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: photosController.showHidden,
onTap: () => photosController.toggleShowHidden(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: filesController.storageScope == StorageScope.cloud,
onTap: () =>
filesController.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected:
filesController.storageScope == StorageScope.external,
onTap: () =>
filesController.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
),
],
),
),
final controlsRow = _buildControlsRow(
context,
photosController,
filesController,
);
final List<Widget> contentSlivers = [
// Sticky while browsing; once selecting, the selection bar takes over
// the very top of the screen instead (see `selectionBar` below), so
// this is free to scroll away rather than staying pinned under it.
// the same slot instead.
SliverPersistentHeader(
pinned: !_isSelecting,
delegate: StickyHeaderDelegate(height: 60, child: controlsRow),
delegate: StickyHeaderDelegate(
height: _isSelecting ? 56 : 64,
child: _isSelecting
? _buildSelectionBar(context, pick, selectedItems)
: Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.sm,
NooSpace.sm,
NooSpace.sm,
NooSpace.xs,
),
child: controlsRow,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (photosController.isLoading && photos.isEmpty)
if (photosController.isLoading && allPhotos.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
@@ -306,85 +328,82 @@ class _PhotosViewState extends State<PhotosView>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
Icon(LucideIcons.circleAlert, size: 56, color: colors.danger),
const SizedBox(height: 16),
Text(
'Could not load photos',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
style: NooText.cardTitle.copyWith(color: colors.danger),
),
const SizedBox(height: 8),
Text(
photosController.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: NooText.body.copyWith(color: colors.fg3),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: photosController.fetchAllMedia,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
NooButton(
variant: NooButtonVariant.secondary,
size: NooButtonSize.field,
icon: LucideIcons.refreshCw,
onTap: photosController.fetchAllMedia,
child: const Text('Retry'),
),
],
),
),
),
)
else if (photos.isEmpty)
else if (visiblePhotos.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.photo_library_outlined,
size: 64,
color: colorScheme.outlineVariant,
),
Icon(LucideIcons.image, size: 56, color: colors.fg3),
const SizedBox(height: 12),
Text(
'No photos found in Nextcloud library',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
allPhotos.isEmpty
? 'No photos found in Nextcloud library'
: 'No photos match this filter',
style: NooText.cardTitle.copyWith(color: colors.fg2),
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.0,
for (final group in groups) ...[
SliverToBoxAdapter(
child: NooPhotoGroupHeader(
title: DateFormat('MMMM yyyy').format(group.month),
count:
'${group.items.length} ${group.items.length == 1 ? 'item' : 'items'}',
padding: EdgeInsets.fromLTRB(
NooLayout.gutter(context),
NooSpace.lg,
NooLayout.gutter(context),
NooSpace.sm,
),
),
delegate: SliverChildBuilderDelegate((context, index) {
final photo = photos[index];
return _buildPhotoTile(context, photo);
}, childCount: photos.length),
),
),
NooPhotoGrid(
itemCount: group.items.length,
columns: isDesktop
? NooPhotoGrid.desktopColumns
: NooPhotoGrid.mobileColumns,
gap: isDesktop ? NooPhotoGrid.desktopGap : NooPhotoGrid.mobileGap,
padding: EdgeInsets.symmetric(
horizontal: isDesktop ? NooLayout.gutter(context) : 0,
),
itemBuilder: (context, index) =>
_buildPhotoTile(context, group.items[index], visiblePhotos),
),
],
// Fixed clearance so the last row isn't hidden behind the floating
// nav bar, regardless of grid length.
const SliverToBoxAdapter(child: SizedBox(height: 100)),
// For a short grid this stretches the white card's background down to
// the screen edge (matching the empty state above); for a long grid
// that already fills the viewport it contributes nothing extra.
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
];
return PopScope(
@@ -392,29 +411,181 @@ class _PhotosViewState extends State<PhotosView>
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelecting) _clearSelection();
},
child: SyncedHeaderScaffold(
scrollController: widget.scrollController,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: photosController.fetchAllMedia,
selectionBar: _isSelecting
? _buildSelectionBar(context, theme, pick, selectedItems)
: null,
contentSlivers: contentSlivers,
child: ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: photosController.fetchAllMedia,
child: CustomScrollView(
controller: widget.scrollController,
slivers: contentSlivers,
),
),
),
);
}
/// Replaces the top bar entirely while selecting (see
/// `SyncedHeaderScaffold.selectionBar`) - a close button, the "N
/// selected" count, and the horizontally-scrollable bulk actions.
/// Type chips (All/Photos/Videos - "Camera" from the spec has no data
/// source, see the view's doc comment), a sort chip and a filter chip for
/// favorites-only/hidden/external-storage, mirroring `FilesControlsRow`'s
/// layout/pattern.
Widget _buildControlsRow(
BuildContext context,
PhotosController photos,
FilesController files,
) {
final filtersActive =
photos.showFavoritesOnly ||
photos.showHidden ||
files.storageScope != StorageScope.cloud;
return SizedBox(
height: 44,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
NooChip(
icon: photos.sortAscending
? LucideIcons.arrowUp
: LucideIcons.arrowDown,
onTap: () => _showSortSheet(context, photos),
child: Text(sortFieldLabel(photos.sortField)),
),
const SizedBox(width: 8),
NooChip(
selected: _typeFilter == _PhotoTypeFilter.all,
onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.all),
child: const Text('All'),
),
const SizedBox(width: 8),
NooChip(
selected: _typeFilter == _PhotoTypeFilter.image,
onTap: () =>
setState(() => _typeFilter = _PhotoTypeFilter.image),
child: const Text('Photos'),
),
const SizedBox(width: 8),
NooChip(
selected: _typeFilter == _PhotoTypeFilter.video,
onTap: () =>
setState(() => _typeFilter = _PhotoTypeFilter.video),
child: const Text('Videos'),
),
const SizedBox(width: 8),
NooChip(
icon: LucideIcons.filter,
trailing: NooChipTrailing.menu,
selected: filtersActive,
onTap: () => _showFilterSheet(context, photos, files),
child: const Text('Filter'),
),
],
),
),
);
}
void _showSortSheet(BuildContext context, PhotosController photos) {
showNooSheet(
context,
children: [
NooSegmentedControl<bool>(
fill: true,
value: photos.sortAscending,
onChanged: (asc) {
if (asc != photos.sortAscending) photos.toggleSortOrder();
},
options: const [
NooSegmentOption(
value: true,
icon: LucideIcons.arrowUp,
label: 'Ascending',
),
NooSegmentOption(
value: false,
icon: LucideIcons.arrowDown,
label: 'Descending',
),
],
),
NooGroupedList(
children: [
for (final field in FileSortField.values)
NooSettingsRow(
label: Text(sortFieldLabel(field)),
trailing: field == photos.sortField
? Icon(
LucideIcons.check,
size: 18,
color: context.nooColors.accentText,
)
: null,
onTap: () {
photos.setSortField(field);
Navigator.pop(context);
},
),
],
),
],
);
}
void _showFilterSheet(
BuildContext context,
PhotosController photos,
FilesController files,
) {
showNooSheet(
context,
children: [
NooGroupedList(
children: [
NooSettingsRow(
icon: LucideIcons.heart,
label: const Text('Favorites only'),
trailing: NooToggle(
checked: photos.showFavoritesOnly,
onChanged: (_) => photos.toggleFavoritesFilter(),
),
),
NooSettingsRow(
icon: LucideIcons.eye,
label: const Text('Show hidden files'),
trailing: NooToggle(
checked: photos.showHidden,
onChanged: (_) => photos.toggleShowHidden(),
),
),
NooSettingsRow(
icon: LucideIcons.hardDrive,
label: const Text('External storage'),
trailing: NooToggle(
checked: files.storageScope == StorageScope.external,
onChanged: (external) => files.setStorageScope(
external ? StorageScope.external : StorageScope.cloud,
),
),
),
],
),
],
);
}
/// Replaces the controls row's own sticky slot while selecting - a close
/// button, the "N selected" count, and the horizontally-scrollable bulk
/// actions. Mirrors `FilesView`'s identical selection bar.
Widget _buildSelectionBar(
BuildContext context,
ThemeData theme,
PickController pick,
List<NextcloudItem> selectedItems,
) {
final colors = context.nooColors;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
child: Row(
children: [
SizedBox(
@@ -425,7 +596,7 @@ class _PhotosViewState extends State<PhotosView>
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
icon: Icon(LucideIcons.x, size: 20, color: colors.fg1),
tooltip: 'Cancel selection',
onPressed: _clearSelection,
visualDensity: VisualDensity.compact,
@@ -433,8 +604,9 @@ class _PhotosViewState extends State<PhotosView>
const SizedBox(width: 8),
Text(
'${selectedItems.length} selected',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
style: NooText.cardTitle.copyWith(
fontSize: 17,
color: colors.fg1,
),
),
],
@@ -447,7 +619,7 @@ class _PhotosViewState extends State<PhotosView>
scrollDirection: Axis.horizontal,
// Left-aligned (not anchored to the trailing edge) so the
// first action's left edge sits at a fixed spot - lining up
// with the controls row's own first icon directly below it -
// with the controls row's own first chip directly below it -
// regardless of how many actions there are.
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -457,7 +629,7 @@ class _PhotosViewState extends State<PhotosView>
selectedItems,
))
IconButton(
icon: Icon(action.icon, size: 20),
icon: Icon(action.icon, size: 20, color: colors.fg1),
tooltip: action.label,
onPressed: action.onTap,
visualDensity: VisualDensity.compact,
@@ -471,117 +643,82 @@ class _PhotosViewState extends State<PhotosView>
);
}
Widget _buildPhotoTile(BuildContext context, NextcloudItem photo) {
final colorScheme = Theme.of(context).colorScheme;
Widget _buildPhotoTile(
BuildContext context,
NextcloudItem photo,
List<NextcloudItem> siblings,
) {
final pick = context.watch<PickController>();
final session = context.watch<SessionController>();
final isSelected = _selectedIds.contains(photo.id);
final isVideo = photo.type == NextcloudItemType.video;
// No border radius on the tile itself — the outer ClipRRect below is
// the only place that rounds it, so a swipe doesn't reveal a stray
// floating rounded corner mid-drag (see the same fix in files_view).
final tile = GestureDetector(
return NooPhotoTile(
selected: isSelected,
selectionMode: _isSelecting,
// No real duration source (no video-metadata extraction anywhere in
// the app) - an empty string still marks it as a video and shows the
// icon-only badge (DESIGN_SYSTEM.md: "else icon-only").
videoDuration: isVideo ? '' : null,
placeholderColor: NooPhotoTile.paletteColor(photo.id.hashCode),
onTap: () {
if (pick.isPicking) {
_handlePickTap(context, pick, photo);
} else if (_isSelecting) {
_toggleSelection(photo);
} else {
_openLightbox(context, photo);
_openLightbox(context, photo, siblings);
}
},
onLongPress: pick.isPicking && !pick.pickRequest!.allowMultiple
? null
: () => _toggleSelection(photo),
child: ColoredBox(
color: colorScheme.surfaceContainerHigh,
child: SelectableThumbnail(
isSelected: isSelected,
checkmarkSize: 32,
child: Stack(
fit: StackFit.expand,
children: [
photo.previewUrl != null
? LayoutBuilder(
builder: (context, constraints) {
// Decode at the tile's actual rendered size rather
// than the full 500x500 preview the server returns —
// cheaper to decode/cache while scrolling a grid.
final cachePixels =
(constraints.maxWidth *
MediaQuery.of(context).devicePixelRatio)
.round();
return Image.network(
photo.previewUrl!,
headers: session.service?.authHeaders,
fit: BoxFit.cover,
cacheWidth: cachePixels,
cacheHeight: cachePixels,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) =>
_buildFallbackTile(context, photo),
);
},
)
: _buildFallbackTile(context, photo),
if (photo.type == NextcloudItemType.video)
Positioned(
bottom: 8,
right: 8,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 16,
),
),
),
if (photo.isFavorite)
const Positioned(
top: 8,
right: 8,
child: Icon(
Icons.favorite_rounded,
color: Colors.red,
size: 18,
),
),
],
),
),
),
child: photo.previewUrl != null
? LayoutBuilder(
builder: (context, constraints) {
// Decode at the tile's actual rendered size rather than the
// full 500x500 preview the server returns - cheaper to
// decode/cache while scrolling a grid.
final cachePixels =
(constraints.maxWidth *
MediaQuery.of(context).devicePixelRatio)
.round();
return Image.network(
photo.previewUrl!,
headers: session.service?.authHeaders,
fit: BoxFit.cover,
cacheWidth: cachePixels,
cacheHeight: cachePixels,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) =>
_buildFallbackTile(context, photo),
);
},
)
: _buildFallbackTile(context, photo),
);
return ClipRRect(borderRadius: BorderRadius.circular(16), child: tile);
}
Widget _buildFallbackTile(BuildContext context, NextcloudItem photo) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
color: colorScheme.secondaryContainer,
final colors = context.nooColors;
final kind = NooFileKind.from(name: photo.name, mimeType: photo.mimeType);
return ColoredBox(
color: kind.background(colors),
child: Center(
child: Icon(
photo.type == NextcloudItemType.video
? Icons.movie_rounded
: Icons.image_rounded,
color: colorScheme.onSecondaryContainer,
size: 32,
),
child: Icon(kind.icon, color: kind.foreground(colors), size: 32),
),
);
}
void _openLightbox(BuildContext context, NextcloudItem photo) {
final photos = context.read<PhotosController>().items;
void _openLightbox(
BuildContext context,
NextcloudItem photo,
List<NextcloudItem> siblings,
) {
Navigator.push(
context,
FileViewerScreen.route(item: photo, siblings: photos),
FileViewerScreen.route(item: photo, siblings: siblings),
);
}
+173 -198
View File
@@ -1,54 +1,29 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/nextcloud_item.dart';
import '../providers/recent_controller.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart';
import '../theme/design_tokens.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/lists/noo_settings_row.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/share_sheet.dart';
import '../widgets/tabs/tab_day_groups.dart';
import '../widgets/tabs/tab_location.dart';
import '../widgets/tabs/tab_state_slivers.dart';
import 'file_viewer_screen.dart';
IconData _recentItemIcon(NextcloudItemType type) {
switch (type) {
case NextcloudItemType.folder:
return Icons.folder_rounded;
case NextcloudItemType.image:
return Icons.image_rounded;
case NextcloudItemType.video:
return Icons.movie_rounded;
case NextcloudItemType.audio:
return Icons.audiotrack_rounded;
case NextcloudItemType.document:
return Icons.description_rounded;
case NextcloudItemType.archive:
return Icons.folder_zip_rounded;
case NextcloudItemType.file:
return Icons.insert_drive_file_rounded;
}
}
Color _recentItemIconColor(BuildContext context, NextcloudItemType type) {
final colorScheme = Theme.of(context).colorScheme;
switch (type) {
case NextcloudItemType.folder:
return colorScheme.primary;
case NextcloudItemType.image:
return Colors.amber.shade700;
case NextcloudItemType.video:
return Colors.deepOrange.shade600;
case NextcloudItemType.audio:
return Colors.purple.shade600;
case NextcloudItemType.document:
return Colors.blue.shade700;
case NextcloudItemType.archive:
return Colors.teal.shade700;
case NextcloudItemType.file:
return colorScheme.outline;
}
}
/// Recently modified files across the whole account (not folders), newest
/// first — see [RecentController.fetchAll].
/// first - see [RecentController.fetchAll]. Grouped into Today/Yesterday/
/// This week/Earlier per `DESIGN_SYSTEM.md` §4; meta is "Modified {time} ·
/// {location}" since the server only ever reports a modification, not a
/// distinct action per entry.
class RecentView extends StatefulWidget {
final ScrollController scrollController;
@@ -63,8 +38,7 @@ class _RecentViewState extends State<RecentView> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final recent = context.watch<RecentController>();
if (!_requested) {
@@ -73,174 +47,164 @@ class _RecentViewState extends State<RecentView> {
}
final items = recent.items;
final isDesktop = NooLayout.isDesktop(context);
final groups = groupByRecentBucket<NextcloudItem>(
items,
(item) => item.lastModified,
);
final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: 16)),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
if (recent.isLoading && items.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
tabLoadingSliver
else if (recent.errorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Could not load recent files',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
recent.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: recent.fetchAll,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
],
),
),
),
tabErrorSliver(
context,
title: 'Could not load recent files',
message: recent.errorMessage!,
onRetry: recent.fetchAll,
)
else if (items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.history_rounded,
size: 64,
color: colorScheme.outlineVariant,
),
const SizedBox(height: 12),
Text(
'No recent files',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
tabEmptySliver(
context,
icon: LucideIcons.clock,
message: 'No recent files',
)
else if (isDesktop)
_buildDesktopTable(context, groups)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = items[index];
return _buildTile(context, item);
}, childCount: items.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
_buildMobileGroups(context, groups),
...tabBottomInsetSlivers,
];
return SyncedHeaderScaffold(
scrollController: widget.scrollController,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: recent.fetchAll,
contentSlivers: contentSlivers,
);
}
Widget _buildTile(BuildContext context, NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final iconColor = _recentItemIconColor(context, item.type);
final folderPath = item.path.substring(
0,
item.path.length - item.name.length,
);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => _openFile(context, item),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_recentItemIcon(item.type),
color: iconColor,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
folderPath.isEmpty ? '/' : folderPath,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
DateFormat.yMMMd().add_jm().format(item.lastModified),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
),
return ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: recent.fetchAll,
child: CustomScrollView(
controller: widget.scrollController,
slivers: contentSlivers,
),
),
);
}
Widget _buildMobileGroups(
BuildContext context,
List<TabDayGroup<NextcloudItem>> groups,
) {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final group = groups[index];
return Padding(
padding: EdgeInsets.only(bottom: index == groups.length - 1 ? 0 : 18),
child: NooGroupedList(
label: group.label,
children: [
for (final item in group.items) _buildRow(context, item),
],
),
);
}, childCount: groups.length),
),
);
}
Widget _buildDesktopTable(
BuildContext context,
List<TabDayGroup<NextcloudItem>> groups,
) {
return SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.xl,
vertical: NooSpace.xs,
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final group = groups[index];
return Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TabGroupLabel(group.label),
const NooFileTableHeader(col2Label: 'Modified', col3Label: 'Location'),
for (final item in group.items) _buildDesktopRow(context, item),
],
),
);
}, childCount: groups.length),
),
);
}
Widget _buildRow(BuildContext context, NextcloudItem item) {
return NooFileRow(
kind: NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
),
name: item.name,
meta: 'Modified ${_metaTime(item.lastModified)} · ${_location(item)}',
favorite: item.isFavorite,
iosStyle: NooLayout.iosStyle(context),
onTap: () => _openFile(context, item),
onMore: () => _showItemSheet(context, item),
);
}
Widget _buildDesktopRow(BuildContext context, NextcloudItem item) {
return NooFileTableRow(
kind: NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
),
name: item.name,
col2: _metaTime(item.lastModified),
col3: _location(item),
favorite: item.isFavorite,
onTap: () => _openFile(context, item),
onMore: () => _showItemSheet(context, item),
);
}
void _showItemSheet(BuildContext context, NextcloudItem item) {
showNooSheet(
context,
children: [
NooGroupedList(
children: [
NooSettingsRow(
icon: LucideIcons.info,
label: const Text('Details'),
onTap: () {
Navigator.pop(context);
DetailsSheet.show(context, item);
},
),
NooSettingsRow(
icon: LucideIcons.share2,
label: const Text('Share'),
onTap: () {
Navigator.pop(context);
ShareSheet.show(context, item);
},
),
],
),
],
);
}
/// Preserves the original screen's exact tap behavior: always opens the
/// media viewer (the server's recent-files search only ever returns
/// files, never folders, so there's no folder-navigation case to handle).
void _openFile(BuildContext context, NextcloudItem item) {
final recent = context.read<RecentController>();
final siblings = recent.items.where((i) => i.isMedia).toList();
@@ -250,3 +214,14 @@ class _RecentViewState extends State<RecentView> {
);
}
}
String _metaTime(DateTime dt) {
final now = DateTime.now();
if (dt.year == now.year && dt.month == now.month && dt.day == now.day) {
return DateFormat.jm().format(dt);
}
if (dt.year == now.year) return DateFormat.MMMd().format(dt);
return DateFormat.yMMMd().format(dt);
}
String _location(NextcloudItem item) => tabLocationLabel(item.path, item.name);
+236 -89
View File
@@ -1,14 +1,29 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:path/path.dart' as p;
import 'package:provider/provider.dart';
import '../models/app_tab.dart';
import '../models/nextcloud_item.dart';
import '../providers/files_controller.dart';
import '../providers/settings_controller.dart';
import '../theme/design_tokens.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/core/noo_search_field.dart';
import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/nav/noo_toolbar.dart';
import '../widgets/noo/noo_layout.dart';
import 'file_viewer_screen.dart';
/// Full-text file search, pushed from the shell's search entry point
/// (`openSearch` in `widgets/shell/shell_common.dart`). Debounces input,
/// then lists matches as file rows - tapping one jumps to the result on the
/// Files tab (see [_openResult]). Per DESIGN_SYSTEM.md §4 "Search".
class SearchView extends StatefulWidget {
const SearchView({super.key});
@@ -74,25 +89,6 @@ class _SearchViewState extends State<SearchView> {
}
}
IconData _iconFor(NextcloudItemType type) {
switch (type) {
case NextcloudItemType.folder:
return Icons.folder_rounded;
case NextcloudItemType.image:
return Icons.image_rounded;
case NextcloudItemType.video:
return Icons.movie_rounded;
case NextcloudItemType.audio:
return Icons.audiotrack_rounded;
case NextcloudItemType.document:
return Icons.description_rounded;
case NextcloudItemType.archive:
return Icons.folder_zip_rounded;
case NextcloudItemType.file:
return Icons.insert_drive_file_rounded;
}
}
/// Navigates the Files tab to [item] (its own folder if it's a folder,
/// its parent folder otherwise - rebuilding the whole breadcrumb trail
/// from root rather than assuming it's under wherever the user was
@@ -113,98 +109,249 @@ class _SearchViewState extends State<SearchView> {
}
}
void _clear() {
_controller.clear();
_focusNode.requestFocus();
_onChanged('');
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final desktop = NooLayout.isDesktop(context);
return Scaffold(
body: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: SearchBar(
controller: _controller,
focusNode: _focusNode,
autoFocus: true,
hintText: 'Search your files...',
elevation: const WidgetStatePropertyAll(0),
onChanged: _onChanged,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.pop(context),
backgroundColor: colors.bg,
appBar: desktop
? NooToolbar(
titleWidget: _buildField(onSurface: true),
actions: [
IconTheme.merge(
data: IconThemeData(color: colors.fg1, size: 24),
child: NooTopBarButton(
icon: LucideIcons.x,
tooltip: 'Close',
onTap: () => Navigator.pop(context),
),
),
trailing: [
if (_controller.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () {
_controller.clear();
_onChanged('');
},
],
)
: null,
body: SafeArea(
top: !desktop,
child: desktop
? _buildBody(context, desktop)
: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.xs,
NooSpace.xs,
NooSpace.md,
NooSpace.xs,
),
child: Row(
children: [
IconTheme.merge(
data: IconThemeData(color: colors.fg1, size: 24),
child: NooTopBarButton(
icon: LucideIcons.arrowLeft,
tooltip: 'Back',
onTap: () => Navigator.pop(context),
),
),
Expanded(child: _buildField(onSurface: false)),
],
),
),
Expanded(child: _buildBody(context, desktop)),
],
),
),
);
}
Widget _buildField({required bool onSurface}) {
// The pill `NooSearchField`, not the inset-panel `NooTextField` - this
// is a search field (DESIGN_SYSTEM.md's "Search field" component,
// radius 999 outside the one iOS-inline exception), not a form input.
// `ios: false` matches the Android shape we're building against now;
// revisit once the iOS large-title/inline-field entry point is built.
return NooSearchField(
controller: _controller,
focusNode: _focusNode,
autofocus: true,
placeholder: 'Search files',
onChanged: _onChanged,
onSurface: onSurface,
ios: false,
trailing: _controller.text.isEmpty
? null
: _ClearFieldButton(onTap: _clear),
);
}
Widget _buildBody(BuildContext context, bool desktop) {
final colors = context.nooColors;
if (_isSearching) {
return Center(child: CircularProgressIndicator(color: colors.accent));
}
if (_error != null) {
return _buildMessage(
context,
icon: LucideIcons.circleAlert,
iconColor: colors.danger,
title: 'Couldn\'t search',
message: _error,
);
}
if (!_hasSearched) {
return _buildMessage(
context,
icon: LucideIcons.search,
iconColor: colors.fg3,
title: 'Search your files',
message: 'Find files and folders by name.',
);
}
if (_results.isEmpty) {
return _buildMessage(
context,
icon: LucideIcons.searchX,
iconColor: colors.fg3,
title: 'No results found',
);
}
return desktop ? _buildDesktopResults(context) : _buildMobileResults(context);
}
Widget _buildMessage(
BuildContext context, {
required IconData icon,
required Color iconColor,
required String title,
String? message,
}) {
final colors = context.nooColors;
return Center(
child: Padding(
padding: const EdgeInsets.all(NooSpace.xl),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 56, color: iconColor),
const SizedBox(height: NooSpace.sm),
Text(
title,
textAlign: TextAlign.center,
style: NooText.cardTitle.copyWith(color: colors.fg2),
),
Expanded(child: _buildBody(context, colorScheme, theme)),
if (message != null) ...[
const SizedBox(height: NooSpace.xxs),
Text(
message,
textAlign: TextAlign.center,
style: NooText.body.copyWith(color: colors.fg3),
),
],
],
),
),
);
}
Widget _buildBody(
BuildContext context,
ColorScheme colorScheme,
ThemeData theme,
) {
if (_isSearching) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
_error!,
textAlign: TextAlign.center,
style: TextStyle(color: colorScheme.error),
),
),
);
}
if (!_hasSearched) {
return const SizedBox.shrink();
}
if (_results.isEmpty) {
return Center(
child: Text(
'No results found',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
);
}
Widget _buildMobileResults(BuildContext context) {
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.fromLTRB(
NooSpace.sm,
NooSpace.xs,
NooSpace.sm,
NooSpace.xl,
),
physics: const BouncingScrollPhysics(),
itemCount: _results.length,
itemBuilder: (context, index) {
final item = _results[index];
return ListTile(
leading: Icon(_iconFor(item.type), color: colorScheme.primary),
title: Text(item.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
item.isFolder
? item.path
: DateFormat.yMMMd().format(item.lastModified),
maxLines: 1,
overflow: TextOverflow.ellipsis,
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card),
child: NooFileRow(
kind: NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
),
name: item.name,
meta: _mobileMeta(item),
favorite: item.isFavorite,
iosStyle: NooLayout.iosStyle(context),
onTap: () => _openResult(item),
),
),
onTap: () => _openResult(item),
);
},
);
}
Widget _buildDesktopResults(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.xl,
vertical: NooSpace.sm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const NooFileTableHeader(col2Label: 'Modified', col3Label: 'Location'),
for (final item in _results)
NooFileTableRow(
kind: NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
),
name: item.name,
col2: DateFormat.yMMMd().format(item.lastModified),
col3: item.isFolder ? item.path : p.dirname(item.path),
favorite: item.isFavorite,
onTap: () => _openResult(item),
),
],
),
);
}
/// Mirrors the pre-rework screen's exact copy: a folder result shows its
/// own full path (there's no separate "location" field to fall back to),
/// a file result shows when it was last modified.
String _mobileMeta(NextcloudItem item) =>
item.isFolder ? item.path : DateFormat.yMMMd().format(item.lastModified);
}
/// Small round "clear" button for [NooTextField.trailing] - clears the
/// query and puts focus back in the field, same as the old `SearchBar`'s
/// trailing close icon.
class _ClearFieldButton extends StatelessWidget {
final VoidCallback onTap;
const _ClearFieldButton({required this.onTap});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return SizedBox.square(
dimension: 32,
child: Material(
type: MaterialType.transparency,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Icon(LucideIcons.x, size: 16, color: colors.fg3),
),
),
);
}
}
+150 -349
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../models/app_tab.dart';
import '../models/nextcloud_item.dart';
@@ -9,24 +8,31 @@ import '../providers/session_controller.dart';
import '../providers/settings_controller.dart';
import '../services/share_intent_service.dart';
import '../services/upload_service.dart';
import '../theme/design_tokens.dart';
import '../widgets/breadcrumbs.dart';
import '../widgets/item_icon.dart';
import '../widgets/marquee_title.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/segmented_icon_toggle.dart';
import '../widgets/sort_menu_button.dart';
import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.dart';
import '../widgets/noo/core/noo_button.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/nav/noo_toolbar.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/shell/shell_common.dart';
import '../widgets/tabs/tab_state_slivers.dart';
/// Shown when another app shares one or more files to Noo (Android's
/// "Share to..." sheet). Lets the user browse to a destination folder,
/// mirroring the Files tab's own controls/filters/listing so this feels
/// like the same browser rather than a stripped-down picker, then hands the
/// actual prepare+upload off to [UploadService] - a real Android foreground
/// reusing `FilesController`'s shared folder-browsing state so this feels
/// like the same browser Files itself uses, then hands the actual
/// prepare+upload off to [UploadService] - a real Android foreground
/// service (see `ShareUploadService.kt`'s doc comment), not something this
/// screen or even the app needs to stay open for. [files] only ever carry
/// cheap Uri metadata (see [ShareIntentService]'s doc comment); that
/// service is the only thing that ever reads their actual bytes.
/// service is the only thing that ever reads their actual bytes. Pushed
/// via `Navigator` from outside `MainShellView` (a cold share-intent
/// launch, or Files' own "+" -> "Upload file"), so - unlike a tab - it
/// builds its own complete top chrome rather than relying on the shell's.
class ShareUploadView extends StatefulWidget {
final List<SharedFileRef> files;
@@ -86,243 +92,48 @@ class _ShareUploadViewState extends State<ShareUploadView> {
);
}
Widget _buildControlsRow(FilesController files) {
return SizedBox(
height: 44,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
IconButton(
icon: Icon(
files.filesSortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: files.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: files.toggleFilesSortOrder,
),
SizedBox(
width: 130,
child: SortMenuButton(
field: files.filesSortField,
onChanged: files.setFilesSortField,
),
),
ToggleIconButton(
icon: files.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: files.showHiddenFiles,
onTap: () => files.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: files.storageScope == StorageScope.cloud,
onTap: () => files.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: files.storageScope == StorageScope.external,
onTap: () => files.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
),
const SizedBox(width: 8),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.select_all_rounded,
isSelected: files.filesTypeFilter == FilesTypeFilter.all,
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all),
tooltip: 'Files & folders',
),
ToggleIconButton(
icon: Icons.insert_drive_file_outlined,
isSelected:
files.filesTypeFilter == FilesTypeFilter.filesOnly,
onTap: () =>
files.setFilesTypeFilter(FilesTypeFilter.filesOnly),
tooltip: 'Files only',
),
ToggleIconButton(
icon: Icons.folder_outlined,
isSelected:
files.filesTypeFilter == FilesTypeFilter.foldersOnly,
onTap: () =>
files.setFilesTypeFilter(FilesTypeFilter.foldersOnly),
tooltip: 'Folders only',
),
],
),
const SizedBox(width: 8),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !files.isGridView,
onTap: () => files.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: files.isGridView,
onTap: () => files.setGridView(true),
tooltip: 'Grid view',
),
],
),
],
// Only folders are valid upload destinations, so the listing - unlike
// Files' own - never shows plain files at all.
Widget _buildRow(BuildContext context, NextcloudItem item, int index, int count) {
final colors = context.nooColors;
final files = context.read<FilesController>();
final row = NooFileRow(
kind: NooFileKind.folder,
name: item.name,
meta: 'Folder',
iosStyle: NooLayout.iosStyle(context),
onTap: () => files.navigateToFolder(item.path),
);
final isFirst = index == 0;
final isLast = index == count - 1;
return Column(
children: [
ClipRRect(
borderRadius: BorderRadius.vertical(
top: isFirst ? const Radius.circular(NooRadii.card) : Radius.zero,
bottom: isLast ? const Radius.circular(NooRadii.card) : Radius.zero,
),
child: row,
),
),
if (!isLast) Container(height: 1, color: colors.line),
],
);
}
// Only folders are valid upload destinations - files still show (so the
// listing matches what the Files tab itself would show for this folder)
// but are visually dimmed and inert rather than hidden outright.
Widget _buildListTile(BuildContext context, NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
Widget _buildDesktopRow(BuildContext context, NextcloudItem item) {
final files = context.read<FilesController>();
final session = context.watch<SessionController>();
final isFolder = item.isFolder;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Opacity(
opacity: isFolder ? 1 : 0.5,
child: Material(
color: colorScheme.surfaceContainerLow,
child: InkWell(
onTap: isFolder ? () => files.navigateToFolder(item.path) : null,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
ItemThumbnail(
item: item,
service: session.service,
size: 44,
borderRadius: 12,
iconSize: 22,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
isFolder
? 'Folder'
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (isFolder) const Icon(Icons.chevron_right_rounded),
],
),
),
),
),
),
),
);
}
Widget _buildGridCard(BuildContext context, NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final files = context.read<FilesController>();
final isFolder = item.isFolder;
final iconColor = getIconColor(context, item.type);
return Opacity(
opacity: isFolder ? 1 : 0.5,
child: Material(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: isFolder ? () => files.navigateToFolder(item.path) : null,
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
getItemIcon(item.type),
color: iconColor,
size: 24,
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
const SizedBox(height: 2),
Text(
isFolder ? 'Folder' : formatBytes(item.size),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
],
),
),
),
),
return NooFileTableRow(
kind: NooFileKind.folder,
name: item.name,
onTap: () => files.navigateToFolder(item.path),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final desktop = NooLayout.isDesktop(context);
final files = context.watch<FilesController>();
final session = context.watch<SessionController>();
final hasBreadcrumbs = files.pathStack.length > 1;
@@ -330,131 +141,124 @@ class _ShareUploadViewState extends State<ShareUploadView> {
final currentLabel = currentPath == '/'
? 'Home'
: currentPath.split('/').where((s) => s.isNotEmpty).last;
final items = files.items;
final folders = files.items.where((item) => item.isFolder).toList();
final gutter = NooLayout.gutter(context);
// Mirrors FilesView's own controls-row + breadcrumbs sticky header
// exactly (padding, heights) so this reads as the same browser, just
// reached from a share intent instead of the Files tab.
final controlsColumn = Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildControlsRow(files),
if (hasBreadcrumbs) ...[
const SizedBox(height: 10),
SizedBox(
height: 32,
child: Breadcrumbs(
pathStack: files.pathStack,
onTap: (index) => files.navigateToPathIndex(index),
),
),
],
],
),
);
final contentSlivers = <Widget>[
SliverPersistentHeader(
pinned: true,
delegate: StickyHeaderDelegate(
height: hasBreadcrumbs ? 114 : 72,
child: controlsColumn,
final slivers = <Widget>[
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(gutter, NooSpace.md, gutter, NooSpace.sm),
child: hasBreadcrumbs
? SizedBox(
height: 32,
child: Breadcrumbs(
pathStack: files.pathStack,
onTap: (index) => files.navigateToPathIndex(index),
),
)
: const SizedBox.shrink(),
),
),
if (files.isLoading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
tabLoadingSliver
else if (folders.isEmpty)
tabEmptySliver(
context,
icon: LucideIcons.folder,
message: 'No folders here',
)
else if (items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'Folder is empty',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
)
else if (files.isGridView)
else if (desktop)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.1,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildDesktopRow(context, folders[index]),
childCount: folders.length,
),
delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(context, items[index]);
}, childCount: items.length),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return _buildListTile(context, items[index]);
}, childCount: items.length),
delegate: SliverChildBuilderDelegate(
(context, index) =>
_buildRow(context, folders[index], index, folders.length),
childCount: folders.length,
),
),
),
// So the last row isn't hidden behind the bottom "Upload to..." bar.
const SliverToBoxAdapter(child: SizedBox(height: 24)),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.xl)),
];
final title = 'Upload to...';
return Scaffold(
body: SyncedHeaderScaffold(
scrollController: _scrollController,
// Same trailing actions as every other tab except "More tabs" -
// there's nowhere useful for it to go while mid-upload (jumping to
// Trash/Shares/etc. would abandon this destination picker), so the
// top chrome is identical to every other tab minus that one entry.
// Backing out is still the system back gesture/button, same as any
// other pushed screen.
actions: const [ProfileAvatarButton()],
contentSlivers: contentSlivers,
backgroundColor: colors.bg,
appBar: desktop
? NooToolbar(
title: title,
actions: [
NooButton(
variant: NooButtonVariant.secondary,
size: NooButtonSize.toolbar,
onTap: () => Navigator.of(context).maybePop(),
child: const Text('Cancel'),
),
],
)
: NooTopBar(
style: NooLayout.navStyle(context),
title: title,
leading: const NooTopBarBack(label: 'Cancel'),
// Same top-bar access as every tab (avatar -> Settings, swipe
// to switch accounts) - nothing else belongs here: jumping to
// another tab mid-upload would abandon this destination
// picker, so there's no "more tabs" entry.
actions: const [ShellAvatarButton()],
),
body: SafeArea(
top: false,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: files.refreshData,
child: CustomScrollView(controller: _scrollController, slivers: slivers),
),
),
// A rounded-top, elevated bar "peeking" up from the bottom edge - the
// uploading-file summary (marqueed if it doesn't fit on one line)
// sits directly above the destination button, both inside the one
// sheet, rather than the summary living up in the scrolling content
// far away from the action it describes.
bottomNavigationBar: Material(
color: colorScheme.surfaceContainerHigh,
elevation: 8,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
// A flat surface bar with a top line, peeking up from the bottom edge -
// the uploading-file summary (marqueed if it doesn't fit on one line)
// sits directly above the destination button.
bottomNavigationBar: DecoratedBox(
decoration: BoxDecoration(
color: colors.surface,
border: Border(top: BorderSide(color: colors.line)),
),
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
padding: const EdgeInsets.fromLTRB(
NooSpace.lg,
NooSpace.md,
NooSpace.lg,
NooSpace.md,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Same pill/chip look and exact same duller background
// color as the filter toggles' shared background
// (SegmentedIconGroup's `surfaceContainerHigh`), so it reads
// as part of the same visual language rather than a new
// accent color. Sized to the text itself (like a real chip)
// up to the row's available width - MarqueeTitle needs a
// concrete (not just loose) width to know whether/how far
// to scroll, so this measures the text once up front rather
// than leaving the chip unconstrained.
// Sized to the text itself (like a real chip) up to the
// row's available width - MarqueeTitle needs a concrete
// (not just loose) width to know whether/how far to scroll,
// so this measures the text once up front rather than
// leaving the chip unconstrained.
LayoutBuilder(
builder: (context, constraints) {
final uploadingText = widget.files.length == 1
? 'Uploading ${widget.files.first.name}'
: 'Uploading ${widget.files.length} files';
final chipTextStyle = theme.textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
final chipTextStyle = NooText.label.copyWith(
color: colors.fg2,
);
const horizontalPadding = 28.0;
final painter = TextPainter(
@@ -470,31 +274,28 @@ class _ShareUploadViewState extends State<ShareUploadView> {
return Container(
width: chipWidth,
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
horizontal: NooSpace.smd,
vertical: NooSpace.xs,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
color: colors.surface2,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: SizedBox(
height: 20,
child: MarqueeTitle(
text: uploadingText,
style: chipTextStyle,
),
child: MarqueeTitle(text: uploadingText, style: chipTextStyle),
),
);
},
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => _uploadHere(session),
icon: const Icon(Icons.upload_rounded),
label: Text('Upload to $currentLabel'),
),
const SizedBox(height: NooSpace.sm),
NooButton(
variant: NooButtonVariant.primary,
size: NooButtonSize.cta,
fullWidth: true,
icon: LucideIcons.upload,
onTap: () => _uploadHere(session),
child: Text('Upload to $currentLabel'),
),
],
),
+158 -207
View File
@@ -1,49 +1,55 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/nextcloud_item.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/app_tab.dart';
import '../models/nextcloud_share.dart';
import '../providers/files_controller.dart';
import '../providers/settings_controller.dart';
import '../providers/shares_controller.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart';
import '../theme/design_tokens.dart';
import '../widgets/noo/core/noo_segmented_control.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/tabs/tab_state_slivers.dart';
IconData _shareItemIcon(NextcloudItemType type) {
switch (type) {
case NextcloudItemType.folder:
return Icons.folder_rounded;
case NextcloudItemType.image:
return Icons.image_rounded;
case NextcloudItemType.video:
return Icons.movie_rounded;
case NextcloudItemType.audio:
return Icons.audiotrack_rounded;
case NextcloudItemType.document:
return Icons.description_rounded;
case NextcloudItemType.archive:
return Icons.folder_zip_rounded;
case NextcloudItemType.file:
return Icons.insert_drive_file_rounded;
}
}
/// Which slice of [SharesController.shares] is on screen - `With you` maps
/// straight onto the controller's own `sharedWithMe: true` fetch, but the
/// controller only ever exposes one boolean scope, so `By you` and `Links`
/// both use its `sharedWithMe: false` fetch and are told apart client-side
/// by [NextcloudShare.shareType] (see `_visibleShares`) - `Links` and
/// `By you` never trigger a re-fetch when switching between each other,
/// only when crossing to/from `With you`.
enum _ShareScope { withYou, byYou, links }
IconData _shareTypeIcon(ShareType type) {
switch (type) {
case ShareType.user:
return Icons.person_rounded;
return LucideIcons.user;
case ShareType.group:
return Icons.groups_rounded;
return LucideIcons.users;
case ShareType.publicLink:
return Icons.link_rounded;
return LucideIcons.link;
case ShareType.email:
return Icons.email_rounded;
return LucideIcons.mail;
case ShareType.federated:
return Icons.public_rounded;
return LucideIcons.globe;
case ShareType.other:
return Icons.share_rounded;
return LucideIcons.share2;
}
}
/// "Can edit" when any write bit (update/create/delete) is set on the OCS
/// share permissions bitmask, "Can view" otherwise - kept to the two
/// broad buckets the row's one-line meta has room for.
String _permissionLabel(int permissions) {
const update = 2, create = 4, delete = 8;
final canWrite = permissions & (update | create | delete) != 0;
return canWrite ? 'Can edit' : 'Can view';
}
class SharesView extends StatefulWidget {
final ScrollController scrollController;
@@ -55,11 +61,13 @@ class SharesView extends StatefulWidget {
class _SharesViewState extends State<SharesView> {
bool _requested = false;
// Mirrors `SharesController.sharedWithMe`'s own default (false, "shared
// by me") so the first fetch this triggers matches what's shown.
_ShareScope _scope = _ShareScope.byYou;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final sharesController = context.watch<SharesController>();
if (!_requested) {
@@ -69,202 +77,147 @@ class _SharesViewState extends State<SharesView> {
);
}
final shares = sharesController.shares;
final shares = _visibleShares(sharesController);
final isDesktop = NooLayout.isDesktop(context);
final List<Widget> contentSlivers = [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: SegmentedButton<bool>(
segments: const [
ButtonSegment(value: false, label: Text('Shared by me')),
ButtonSegment(value: true, label: Text('Shared with me')),
SliverPadding(
padding: EdgeInsets.fromLTRB(
NooLayout.gutter(context),
NooSpace.md,
NooLayout.gutter(context),
0,
),
sliver: SliverToBoxAdapter(
child: NooSegmentedControl<_ShareScope>(
value: _scope,
options: const [
NooSegmentOption(value: _ShareScope.withYou, label: 'With you'),
NooSegmentOption(value: _ShareScope.byYou, label: 'By you'),
NooSegmentOption(value: _ShareScope.links, label: 'Links'),
],
selected: {sharesController.sharedWithMe},
onSelectionChanged: (set) =>
sharesController.setSharedWithMe(set.first),
onChanged: (scope) => _selectScope(sharesController, scope),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
if (sharesController.isLoading && shares.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
tabLoadingSliver
else if (sharesController.errorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Could not load shares',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
sharesController.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: sharesController.fetchAll,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
],
),
),
),
tabErrorSliver(
context,
title: 'Could not load shares',
message: sharesController.errorMessage!,
onRetry: sharesController.fetchAll,
)
else if (shares.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.groups_rounded,
size: 64,
color: colorScheme.outlineVariant,
),
const SizedBox(height: 12),
Text(
sharesController.sharedWithMe
? 'Nothing has been shared with you'
: 'You haven\'t shared anything yet',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
)
tabEmptySliver(context, icon: LucideIcons.share2, message: _emptyMessage())
else if (isDesktop)
_buildDesktopTable(context, shares)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final share = shares[index];
return _buildShareTile(context, share);
}, childCount: shares.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
_buildMobileList(context, shares),
...tabBottomInsetSlivers,
];
return SyncedHeaderScaffold(
scrollController: widget.scrollController,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: sharesController.fetchAll,
contentSlivers: contentSlivers,
return ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: sharesController.fetchAll,
child: CustomScrollView(
controller: widget.scrollController,
slivers: contentSlivers,
),
),
);
}
Widget _buildShareTile(BuildContext context, NextcloudShare share) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final iconColor = colorScheme.primary;
List<NextcloudShare> _visibleShares(SharesController c) {
switch (_scope) {
case _ShareScope.withYou:
return c.shares;
case _ShareScope.byYou:
return c.shares.where((s) => s.shareType != ShareType.publicLink).toList();
case _ShareScope.links:
return c.shares.where((s) => s.shareType == ShareType.publicLink).toList();
}
}
final withWhom = share.sharedWithMe
? 'From ${share.ownerDisplayName}'
: (share.sharedWithDisplayName != null
? 'With ${share.sharedWithDisplayName}'
: 'Shared link');
String _emptyMessage() {
switch (_scope) {
case _ShareScope.withYou:
return 'Nothing has been shared with you';
case _ShareScope.byYou:
return "You haven't shared anything yet";
case _ShareScope.links:
return 'No share links yet';
}
}
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_shareItemIcon(share.itemType),
color: iconColor,
size: 22,
),
void _selectScope(SharesController c, _ShareScope scope) {
setState(() => _scope = scope);
// No-ops (see `SharesController.setSharedWithMe`) when moving between
// `byYou`/`links`, which share the same `sharedWithMe: false` fetch.
c.setSharedWithMe(scope == _ShareScope.withYou);
}
Widget _buildMobileList(BuildContext context, List<NextcloudShare> shares) {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
sliver: SliverToBoxAdapter(
child: NooGroupedList(
children: [
for (final share in shares)
NooFileRow(
kind: NooFileKind.from(name: share.name, isDirectory: share.isFolder),
name: share.name,
meta: '${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}',
iosStyle: NooLayout.iosStyle(context),
onTap: share.isFolder ? () => _openFolder(context, share) : null,
trailing: Icon(_shareTypeIcon(share.shareType), size: 16),
onMore: () => _confirmUnshare(context, share),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
share.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
_shareTypeIcon(share.shareType),
size: 13,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
'$withWhom • ${DateFormat.yMMMd().format(share.sharedAt)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
],
),
),
IconButton(
icon: Icon(Icons.link_off_rounded, color: colorScheme.error),
tooltip: share.sharedWithMe ? 'Remove' : 'Unshare',
onPressed: () => _confirmUnshare(context, share),
),
],
),
],
),
),
);
}
Widget _buildDesktopTable(BuildContext context, List<NextcloudShare> shares) {
return SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.xl,
vertical: NooSpace.xs,
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == 0) {
return const NooFileTableHeader(col2Label: 'Owner', col3Label: 'Permission');
}
final share = shares[index - 1];
return NooFileTableRow(
kind: NooFileKind.from(name: share.name, isDirectory: share.isFolder),
name: share.name,
col2: share.ownerDisplayName,
col3: _permissionLabel(share.permissions),
onTap: share.isFolder ? () => _openFolder(context, share) : null,
onMore: () => _confirmUnshare(context, share),
);
}, childCount: shares.length + 1),
),
);
}
/// Only folders are safely tappable - unlike Recent/Favorites, a share
/// doesn't carry the size/mime-type `FileViewerScreen` needs, so opening
/// a file share here would have to synthesize that data instead of
/// reading it, same as the original screen (no tap action at all).
void _openFolder(BuildContext context, NextcloudShare share) {
context.read<FilesController>().navigateToAbsoluteFolder(share.path);
context.read<SettingsController>().requestTab(AppTab.files);
}
Future<void> _confirmUnshare(
BuildContext context,
NextcloudShare share,
@@ -273,7 +226,7 @@ class _SharesViewState extends State<SharesView> {
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(share.sharedWithMe ? 'Remove Share' : 'Unshare'),
title: Text(share.sharedWithMe ? 'Remove share' : 'Unshare'),
content: Text(
share.sharedWithMe
? 'Remove "${share.name}" shared with you by ${share.ownerDisplayName}?'
@@ -298,9 +251,7 @@ class _SharesViewState extends State<SharesView> {
if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
final success = await context.read<SharesController>().deleteShare(
share,
);
final success = await context.read<SharesController>().deleteShare(share);
messenger.showSnackBar(
SnackBar(
content: Text(
+276 -185
View File
@@ -1,31 +1,28 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/nextcloud_item.dart';
import '../providers/trash_controller.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart';
IconData _trashItemIcon(NextcloudItemType type) {
switch (type) {
case NextcloudItemType.folder:
return Icons.folder_rounded;
case NextcloudItemType.image:
return Icons.image_rounded;
case NextcloudItemType.video:
return Icons.movie_rounded;
case NextcloudItemType.audio:
return Icons.audiotrack_rounded;
case NextcloudItemType.document:
return Icons.description_rounded;
case NextcloudItemType.archive:
return Icons.folder_zip_rounded;
case NextcloudItemType.file:
return Icons.insert_drive_file_rounded;
}
}
import '../theme/design_tokens.dart';
import '../widgets/noo/core/noo_button.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/files/noo_file_tile.dart';
import '../widgets/noo/lists/noo_banner.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/tabs/tab_location.dart';
import '../widgets/tabs/tab_state_slivers.dart';
/// The Trash tab: a retention banner (`NooBanner`, with "Empty trash" as
/// its danger action) then the trashed-items list, per `DESIGN_SYSTEM.md`
/// §4. There's no dedicated "empty trash" endpoint anywhere in
/// [TrashController]/`NextcloudService` - [_emptyTrash] composes it from
/// the existing per-item [TrashController.deleteForever], the same way
/// `FavoritesView._confirmDeleteSelected` builds its bulk delete from a
/// per-item call.
class TrashView extends StatefulWidget {
final ScrollController scrollController;
@@ -40,8 +37,7 @@ class _TrashViewState extends State<TrashView> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final trashController = context.watch<TrashController>();
if (!_requested) {
@@ -52,176 +48,135 @@ class _TrashViewState extends State<TrashView> {
}
final trash = trashController.items;
final isDesktop = NooLayout.isDesktop(context);
final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: 16)),
if (trashController.isLoading && trash.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (trashController.errorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Could not load trash',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
trashController.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: trashController.fetchAll,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
],
),
),
),
)
else if (trash.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.delete_outline_rounded,
size: 64,
color: colorScheme.outlineVariant,
),
const SizedBox(height: 12),
Text(
'Trash is empty',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = trash[index];
return _buildTrashTile(context, item);
}, childCount: trash.length),
SliverPadding(
padding: EdgeInsets.fromLTRB(
NooLayout.gutter(context),
NooSpace.md,
NooLayout.gutter(context),
0,
),
sliver: SliverToBoxAdapter(
child: NooBanner(
actionLabel: trash.isEmpty ? null : 'Empty trash',
onAction: trash.isEmpty ? null : () => _confirmEmptyTrash(context, trash),
child: const Text('Deleted items are kept for 30 days, then removed automatically.'),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
if (trashController.isLoading && trash.isEmpty)
tabLoadingSliver
else if (trashController.errorMessage != null)
tabErrorSliver(
context,
title: 'Could not load trash',
message: trashController.errorMessage!,
onRetry: trashController.fetchAll,
)
else if (trash.isEmpty)
tabEmptySliver(
context,
icon: LucideIcons.trash2,
message: 'Trash is empty',
)
else if (isDesktop)
_buildDesktopTable(context, trash)
else
_buildMobileList(context, trash),
...tabBottomInsetSlivers,
];
return SyncedHeaderScaffold(
scrollController: widget.scrollController,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: trashController.fetchAll,
contentSlivers: contentSlivers,
);
}
Widget _buildTrashTile(BuildContext context, NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final iconColor = colorScheme.outline;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_trashItemIcon(item.type),
color: iconColor,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
item.deletedAt != null
? 'Deleted ${DateFormat.yMMMd().format(item.deletedAt!)}'
: 'Deleted',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.restore_rounded),
tooltip: 'Restore',
color: colorScheme.primary,
onPressed: () => _restore(context, item),
),
IconButton(
icon: Icon(
Icons.delete_forever_rounded,
color: colorScheme.error,
),
tooltip: 'Delete forever',
onPressed: () => _confirmDeleteForever(context, item),
),
],
),
return ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: trashController.fetchAll,
child: CustomScrollView(
controller: widget.scrollController,
slivers: contentSlivers,
),
),
);
}
Widget _buildMobileList(BuildContext context, List<NextcloudItem> trash) {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
sliver: SliverToBoxAdapter(
child: NooGroupedList(
children: [
for (final item in trash)
NooFileRow(
kind: NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
),
name: item.name,
meta: _meta(item),
iosStyle: NooLayout.iosStyle(context),
trailing: SizedBox.square(
dimension: 40,
child: Semantics(
button: true,
label: 'Restore',
child: InkResponse(
onTap: () => _restore(context, item),
radius: 20,
child: const Icon(LucideIcons.rotateCcw),
),
),
),
onMore: () => _confirmDeleteForever(context, item),
),
],
),
),
);
}
Widget _buildDesktopTable(BuildContext context, List<NextcloudItem> trash) {
return SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.xl,
vertical: NooSpace.xs,
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == 0) {
return const NooFileTableHeader(
col2Label: 'Deleted',
col3Label: 'Original location',
);
}
final item = trash[index - 1];
return _TrashDesktopRow(
kind: NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
),
name: item.name,
col2: _deletedLabel(item),
col3: item.originalLocation ?? 'Unknown location',
onRestore: () => _restore(context, item),
onMore: () => _confirmDeleteForever(context, item),
);
}, childCount: trash.length + 1),
),
);
}
String _meta(NextcloudItem item) =>
'Deleted ${_deletedLabel(item)} · ${tabLocationLabel(item.originalLocation ?? item.path, item.name)}';
String _deletedLabel(NextcloudItem item) =>
item.deletedAt != null ? DateFormat.yMMMd().format(item.deletedAt!) : 'recently';
Future<void> _restore(BuildContext context, NextcloudItem item) async {
final messenger = ScaffoldMessenger.of(context);
final success = await context.read<TrashController>().restore(item);
@@ -243,7 +198,7 @@ class _TrashViewState extends State<TrashView> {
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Delete Forever'),
title: const Text('Delete forever'),
content: Text(
'Permanently delete "${item.name}"? This cannot be undone.',
),
@@ -257,7 +212,7 @@ class _TrashViewState extends State<TrashView> {
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Delete Forever'),
child: const Text('Delete forever'),
),
],
);
@@ -276,4 +231,140 @@ class _TrashViewState extends State<TrashView> {
),
);
}
Future<void> _confirmEmptyTrash(
BuildContext context,
List<NextcloudItem> trash,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Empty trash'),
content: Text(
'Permanently delete all ${trash.length} item(s) in trash? This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Empty trash'),
),
],
);
},
);
if (confirmed != true || !context.mounted) return;
final controller = context.read<TrashController>();
final messenger = ScaffoldMessenger.of(context);
var succeeded = 0;
for (final item in trash) {
if (await controller.deleteForever(item)) succeeded++;
}
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text('Deleted $succeeded of ${trash.length} item(s)'),
behavior: SnackBarBehavior.floating,
),
);
}
}
/// Desktop trash row: like `NooFileTableRow`, but its 120px last column
/// holds a tonal "Restore" button plus the overflow button (permanent
/// delete) instead of status icons - `NooFileTableRow` has no slot for
/// screen-specific actions there. Column widths (180/160/120) are matched
/// by hand to `NooFileTableHeader`'s (private in `noo_file_table.dart`) so
/// this still lines up under it; promoting an optional `actions` slot onto
/// `NooFileTableRow` would let this fold back into the shared component.
class _TrashDesktopRow extends StatelessWidget {
final NooFileKind kind;
final String name;
final String col2;
final String col3;
final VoidCallback onRestore;
final VoidCallback onMore;
const _TrashDesktopRow({
required this.kind,
required this.name,
required this.col2,
required this.col3,
required this.onRestore,
required this.onMore,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final metaStyle = NooText.meta.copyWith(color: colors.fg3);
return SizedBox(
height: NooSizes.rowDesktop,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
child: Row(
children: [
Expanded(
child: Row(
children: [
NooFileTile(kind: kind, size: NooFileTileSize.desktop),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
),
const SizedBox(width: NooSpace.md),
],
),
),
SizedBox(
width: 180,
child: Text(col2, maxLines: 1, overflow: TextOverflow.ellipsis, style: metaStyle),
),
SizedBox(
width: 160,
child: Text(col3, maxLines: 1, overflow: TextOverflow.ellipsis, style: metaStyle),
),
SizedBox(
width: 120,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
NooButton(
variant: NooButtonVariant.tonal,
size: NooButtonSize.xs,
onTap: onRestore,
child: const Text('Restore'),
),
const SizedBox(width: 4),
NooOverflowButton(size: 24, onTap: onMore),
],
),
),
),
],
),
),
);
}
}
+83
View File
@@ -0,0 +1,83 @@
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/files_controller.dart';
import '../providers/session_controller.dart';
import '../providers/settings_controller.dart';
import '../providers/trash_controller.dart';
import 'noo/core/noo_avatar.dart';
import 'noo/nav/noo_drawer.dart';
import 'shell/shell_common.dart';
/// The mobile drawer (DESIGN_SYSTEM.md 2 "Drawer" / 3; `drawer` in
/// `Mobile Screen.dc.html`): account block, storage bar, the tabs not
/// pinned to the bottom nav bar ("More"), Settings, and an "Edit tabs"
/// link. Opened the same way as before (menu icon in [AppTopBar]), but
/// built on [NooDrawer] instead of the old Material [Drawer] + ad hoc rows.
///
/// Tapping a "More" tab switches the shell straight to it (rather than
/// pushing it as a standalone screen, like the pre-rework `MoreTabsButton`
/// did) via `SettingsController.requestTab` - the same one-shot mechanism
/// `SearchView`/`FavoritesView`/`ShareUploadView` already use to jump the
/// shell to a tab from outside it, so `MainShellView` needs no extra
/// plumbing to support it.
class AppDrawer extends StatelessWidget {
const AppDrawer({super.key});
@override
Widget build(BuildContext context) {
final session = context.watch<SessionController>();
final settings = context.watch<SettingsController>();
final quota = context.watch<FilesController>().quota;
// Cheap and already loaded when the Trash tab has been visited this
// session - not worth a fetch just to populate a drawer badge.
final trashCount = context.watch<TrashController>().items.length;
final hiddenTabs = settings.tabOrder
.where((t) => settings.hiddenTabs.contains(t))
.toList();
void closeAndOpenSettings() {
Navigator.pop(context);
openSettings(context);
}
return NooDrawer(
account: NooDrawerAccount(
avatar: NooAvatar(
initials: accountInitial(session.username),
current: true,
size: 48,
),
name: session.username,
subtitle: serverHost(session.serverUrl),
onTap: closeAndOpenSettings,
onSwitchAccount: () => showAccountSwitcher(context),
),
storage: NooDrawerStorage(
value: quotaFraction(quota),
label: quotaUsedLabel(quota),
),
moreItems: [
for (final tab in hiddenTabs)
NooDrawerItem(
icon: tab.icon,
label: tab.label,
count: tab == AppTab.trash && trashCount > 0
? '$trashCount'
: null,
onTap: () {
Navigator.pop(context);
settings.requestTab(tab);
},
),
],
settings: NooDrawerItem(
icon: LucideIcons.settings,
label: 'Settings',
onTap: closeAndOpenSettings,
),
footer: NooDrawerLink(onTap: closeAndOpenSettings),
);
}
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/app_tab.dart';
import 'create_menu.dart';
import 'noo/nav/noo_top_bar.dart';
import 'shell/shell_common.dart';
/// The shell's mobile top bar (DESIGN_SYSTEM.md 3, "Mobile"): every regular
/// tab now gets the same chrome (this used to only wrap Files - the other
/// tabs built their own via `SyncedHeaderScaffold`, which the rework drops).
/// Built on [NooTopBar]:
///
/// - iOS: `menu` leading, `plus` (Files only - no tab besides Files has a
/// create/upload flow) and the account avatar trailing, then the large
/// title, then an inline search field that opens [SearchView].
/// - Android: `menu`, title, `search` and avatar actions. Upload has no
/// top-bar icon here - it's the [NooFab] `MainShellView` shows instead.
///
/// [style] is resolved by the caller (`NooLayout.navStyle`) rather than
/// read from context here, since [preferredSize] is a plain getter with no
/// BuildContext - the same reason [NooTopBar] itself takes it as a field.
class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
final NooNavStyle style;
final AppTab tab;
const AppTopBar({super.key, required this.style, required this.tab});
bool get _ios => style == NooNavStyle.ios;
@override
Size get preferredSize => NooTopBar(
style: style,
title: tab.label,
// A throwaway placeholder purely to make preferredSize's "is there a
// search slot" check match what build() actually shows - never
// inserted into the tree, so its content doesn't matter.
search: _ios ? const SizedBox.shrink() : null,
).preferredSize;
@override
Widget build(BuildContext context) {
return NooTopBar(
style: style,
title: tab.label,
onMenu: () => Scaffold.of(context).openDrawer(),
actions: [
if (!_ios)
NooTopBarButton(
icon: LucideIcons.search,
tooltip: 'Search',
onTap: () => openSearch(context),
),
if (_ios && tab == AppTab.files)
NooTopBarButton(
icon: LucideIcons.plus,
tooltip: 'Create',
onTap: () => showCreateMenu(context),
),
ShellAvatarButton(hitBox: _ios ? 44 : 48),
],
search: _ios ? const ShellSearchLauncher(ios: true) : null,
);
}
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import '../models/app_tab.dart';
import 'noo/nav/noo_bottom_bar.dart';
/// Adapts the app's [AppTab] model onto [NooBottomBar]'s generic
/// [NooNavDestination] list, so `MainShellView` only deals in tabs and
/// doesn't need to import `noo/nav` itself. [selectedIndex] may be -1 (no
/// destination highlighted) when the shell is showing a tab opened from the
/// drawer's "More" list that isn't one of the pinned [tabs].
class BottomNavBar extends StatelessWidget {
final NooNavStyle style;
final List<AppTab> tabs;
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
const BottomNavBar({
super.key,
required this.style,
required this.tabs,
required this.selectedIndex,
required this.onDestinationSelected,
});
@override
Widget build(BuildContext context) {
return NooBottomBar(
style: style,
destinations: [
for (final tab in tabs)
NooNavDestination(icon: tab.icon, label: tab.label),
],
selectedIndex: selectedIndex,
onSelected: onDestinationSelected,
);
}
}
+33 -23
View File
@@ -1,7 +1,13 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../theme/design_tokens.dart';
/// Tappable "Home / Folder / Sub-folder" trail shown whenever the user has
/// navigated below the root, in both the Files and Photos tabs.
/// navigated below the root - now only used by `ShareUploadView` and
/// `MoveCopyDestinationPicker` (Files/Photos moved to their own noo-styled
/// `FileBreadcrumbRow`), so this is free to carry noo tokens directly rather
/// than staying on the old Material look. Same behaviour: every crumb but
/// the last is tappable, jumping back to that depth.
class Breadcrumbs extends StatelessWidget {
final List<String> pathStack;
final ValueChanged<int> onTap;
@@ -19,8 +25,7 @@ class Breadcrumbs extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final lastIndex = pathStack.length - 1;
return SingleChildScrollView(
@@ -32,37 +37,42 @@ class Breadcrumbs extends StatelessWidget {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(
Icons.chevron_right_rounded,
size: 16,
color: colorScheme.outlineVariant,
LucideIcons.chevronRight,
size: 14,
color: colors.fg3,
),
),
if (i == lastIndex)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
_labelFor(i),
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
style: NooText.body.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600,
color: colors.fg1,
),
),
)
else
// A chip's whole pill is the tap target (not just the text),
// so the previous plain-text crumbs were fiddly to hit.
ActionChip(
label: Text(_labelFor(i)),
labelStyle: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.primary,
// The whole pill is the tap target, not just the text.
InkWell(
onTap: () => onTap(i),
borderRadius: BorderRadius.circular(NooRadii.pill),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Text(
_labelFor(i),
style: NooText.body.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600,
color: colors.accentText,
),
),
),
onPressed: () => onTap(i),
backgroundColor: colorScheme.surfaceContainerHigh,
side: BorderSide.none,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: 4),
),
],
],
+119
View File
@@ -0,0 +1,119 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../providers/files_controller.dart';
import '../services/share_intent_service.dart';
import '../views/share_upload_view.dart';
import 'noo/core/noo_button.dart';
import 'noo/lists/noo_grouped_list.dart';
import 'noo/lists/noo_settings_row.dart';
import 'noo/overlays/noo_dialog.dart';
import 'noo/overlays/noo_sheet.dart';
import 'noo/overlays/noo_text_field.dart';
/// The Files tab's "+"/FAB action: upload a file or create a folder into
/// the current folder. Shared by `FilesView`'s own create button and
/// `MainShellView`'s FAB (the Noo Design System project's FAB component),
/// since both need to trigger the exact same flow.
void showCreateMenu(BuildContext context) {
showNooSheet(
context,
children: [
NooGroupedList(
children: [
NooSettingsRow(
icon: LucideIcons.upload,
label: const Text('Upload file'),
onTap: () {
Navigator.pop(context);
_pickAndUploadFile(context);
},
),
NooSettingsRow(
icon: LucideIcons.folder,
label: const Text('New folder'),
onTap: () {
Navigator.pop(context);
_showCreateFolderDialog(context);
},
),
],
),
],
);
}
void _showCreateFolderDialog(BuildContext context) {
final files = context.read<FilesController>();
final controller = TextEditingController();
Future<void> submit(BuildContext ctx) async {
final name = controller.text.trim();
if (name.isEmpty) return;
Navigator.pop(ctx);
final success = await files.createFolder(name);
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(success ? 'Created folder $name' : 'Failed to create folder'),
behavior: SnackBarBehavior.floating,
),
);
}
}
showNooDialog<void>(
context,
title: 'New folder',
children: [
NooTextField(
controller: controller,
placeholder: 'e.g. Finance',
autofocus: true,
onSurface: false,
onSubmitted: (_) => submit(context),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
spacing: 10,
children: [
NooButton(
variant: NooButtonVariant.secondary,
onTap: () => Navigator.pop(context),
child: const Text('Cancel'),
),
NooButton(
variant: NooButtonVariant.primary,
onTap: () => submit(context),
child: const Text('Create'),
),
],
),
],
);
}
// Mirrors `main.dart`'s `_handleSharedFiles` exactly, so picking a file via
// "+"/the FAB lands on the same destination-picker screen and background
// foreground-service upload as receiving one via Android's "Share to..."
// sheet does, rather than a separate in-app-only upload path.
Future<void> _pickAndUploadFile(BuildContext context) async {
final picked = await FilePicker.pickFiles();
if (picked.isEmpty) return;
final files = picked
.where((f) => f.path != null)
.map(
(f) => SharedFileRef(
uri: Uri.file(f.path!).toString(),
name: f.name,
size: f.lengthSync(),
),
)
.toList();
if (files.isEmpty || !context.mounted) return;
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => ShareUploadView(files: files)));
}
+44 -92
View File
@@ -1,11 +1,18 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../../models/nextcloud_item.dart';
import '../../providers/item_operations.dart';
import '../../providers/session_controller.dart';
import '../../theme/design_tokens.dart';
import '../noo/lists/noo_grouped_list.dart';
import '../noo/media/noo_activity_item.dart';
import '../noo/media/noo_photo_tile.dart';
/// Per-file activity feed — same list-item look as the account-wide
/// Activity tab (activity_view.dart), scoped to a single item.
/// Per-file activity feed - the same actor/verb/tail mapping and
/// avatar-palette convention as the account-wide Activity tab
/// (`activity_view.dart`), scoped to a single item.
class DetailsActivityTab extends StatefulWidget {
final NextcloudItem item;
@@ -36,107 +43,52 @@ class _DetailsActivityTabState extends State<DetailsActivityTab> {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final session = context.watch<SessionController>();
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
return const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: CircularProgressIndicator()),
);
}
if (_activities.isEmpty) {
return Center(
child: Text(
'No recent activity',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.activity, size: 40, color: colors.fg3),
const SizedBox(height: NooSpace.sm),
Text(
'No recent activity',
style: NooText.cardTitle.copyWith(fontSize: 16, color: colors.fg2),
),
],
),
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
itemCount: _activities.length,
itemBuilder: (context, index) {
final act = _activities[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.2),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
act.icon,
color: colorScheme.onPrimaryContainer,
size: 20,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
act.title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
const SizedBox(width: 8),
Text(
DateFormat.jm().format(act.timestamp),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
const SizedBox(height: 4),
Text(
act.subject,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
const SizedBox(height: 6),
Text(
'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w500,
fontSize: 11,
),
),
],
),
),
],
return NooGroupedList(
children: [
for (final act in _activities)
ColoredBox(
color: colors.surface,
child: NooActivityItem(
actor: act.author,
verb: act.title,
tail: act.subject.isNotEmpty && act.subject != act.title
? act.subject
: null,
time: DateFormat.yMMMd().add_jm().format(act.timestamp),
avatarColor: NooPhotoTile.paletteColor(act.author.hashCode),
currentUser:
act.author.toLowerCase() == session.username.toLowerCase(),
),
),
);
},
],
);
}
}
+47 -37
View File
@@ -1,21 +1,19 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../models/nextcloud_item.dart';
import '../../theme/design_tokens.dart';
import '../noo/lists/noo_grouped_list.dart';
import '../synced_header_scaffold.dart' show formatBytes;
/// Plain metadata tab of the Details sheet — name, type, size, location,
/// and dates. No network calls, so no loading state needed.
/// Plain metadata list of the Details sheet/dialog - name, type, size,
/// location, and dates. No network calls, so no loading state needed.
class DetailsInfoTab extends StatelessWidget {
final NextcloudItem item;
final ScrollController? scrollController;
const DetailsInfoTab({super.key, required this.item, this.scrollController});
const DetailsInfoTab({super.key, required this.item});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final rows = <(String, String)>[
('Name', item.name),
('Type', item.isFolder ? 'Folder' : (item.mimeType ?? 'File')),
@@ -26,39 +24,51 @@ class DetailsInfoTab extends StatelessWidget {
('Favorite', item.isFavorite ? 'Yes' : 'No'),
];
return ListView.separated(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
itemCount: rows.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final (label, value) = rows[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 96,
child: Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
return NooGroupedList(
children: [for (final row in rows) _InfoRow(label: row.$1, value: row.$2)],
);
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
const _InfoRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return ColoredBox(
color: colors.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.md,
vertical: NooSpace.sm,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 96,
child: Text(
label,
style: NooText.body.copyWith(fontSize: 14, color: colors.fg3),
),
Expanded(
child: Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
Expanded(
child: Text(
value,
style: NooText.body.copyWith(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
],
),
);
},
),
],
),
),
);
}
}
+114 -125
View File
@@ -1,160 +1,149 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:path/path.dart' as p;
import '../../models/nextcloud_item.dart';
import '../gradual_bottom_sheet.dart';
import '../../theme/design_tokens.dart';
import '../noo/core/noo_segmented_control.dart';
import '../noo/files/noo_file_kind.dart';
import '../noo/files/noo_file_tile.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';
import '../synced_header_scaffold.dart' show formatBytes;
import 'details_activity_tab.dart';
import 'details_info_tab.dart';
import 'details_versions_tab.dart';
IconData _detailsItemIcon(NextcloudItemType type) {
switch (type) {
case NextcloudItemType.folder:
return Icons.folder_rounded;
case NextcloudItemType.image:
return Icons.image_rounded;
case NextcloudItemType.video:
return Icons.movie_rounded;
case NextcloudItemType.audio:
return Icons.audiotrack_rounded;
case NextcloudItemType.document:
return Icons.description_rounded;
case NextcloudItemType.archive:
return Icons.folder_zip_rounded;
case NextcloudItemType.file:
return Icons.insert_drive_file_rounded;
}
/// The file-type tile every per-item sheet/dialog opens on
/// (`DESIGN_SYSTEM.md` §1.2/§4) - shared by [DetailsSheet] and [ShareSheet]
/// so both overlays for the same item use the same header tile.
Widget detailsFileTile(
NextcloudItem item, {
NooFileTileSize size = NooFileTileSize.header,
}) {
return NooFileTile(
kind: NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
),
size: size,
);
}
/// Reusable "Details" bottom sheet for a single file/folder — Info,
/// Versions, and Activity tabs. Sharing lives in its own sheet
/// ([ShareSheet]), triggered separately from wherever a "Share" action
/// appears. Triggered from long-press selection (exactly one item), the
/// media viewer's action bar, and the "open externally" flow for
/// unsupported file types.
/// The header's meta subtitle - "12.3 KB · Documents" for a file, "Folder"
/// for a folder (`DESIGN_SYSTEM.md` §4: "size · folder").
String detailsMetaLine(NextcloudItem item) {
if (item.isFolder) return 'Folder';
final parent = p.basename(p.dirname(item.path));
final location = parent.isEmpty || parent == '.' || parent == '/'
? 'Home'
: parent;
return '${formatBytes(item.size)} · $location';
}
enum _DetailsTab { info, versions, activity }
/// Reusable "Details" bottom sheet/dialog for a single file/folder - Info,
/// Versions and Activity switched by a segmented control (there's no
/// tab-strip component in the noo kit, so this is the closest fit - see the
/// rebuild report). Sharing lives in its own sheet ([ShareSheet]), triggered
/// separately from wherever a "Share" action appears. Triggered from
/// long-press selection (exactly one item), the media viewer's action bar,
/// and the "open externally" flow for unsupported file types.
///
/// Only used directly on mobile (bundles its own [NooOverlayHeader]); the
/// desktop path in [show] passes [_DetailsBody] straight to [showNooDialog],
/// which renders the header itself via `leading`/`title`/`subtitle`.
class DetailsSheet extends StatelessWidget {
final NextcloudItem item;
const DetailsSheet({super.key, required this.item});
static Future<void> show(BuildContext context, NextcloudItem item) {
return showGradualBottomSheet(
context,
builder: (context, scrollController) => DetailsSheet(item: item),
);
if (NooLayout.isDesktop(context)) {
return showNooDialog(
context,
leading: detailsFileTile(item),
title: item.name,
subtitle: detailsMetaLine(item),
children: [_DetailsBody(item: item)],
);
}
return showNooSheet(context, children: [DetailsSheet(item: item)]);
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Column(
children: [
DetailsHeader(item: item),
// Icon size matches the bottom nav bar's own tab icons
// (FloatingBottomNavBar) rather than TabBar's oversized default,
// so a "tabs" affordance reads the same size everywhere in the
// app.
const TabBar(
tabs: [
Tab(
icon: Icon(Icons.info_outline_rounded, size: 22),
text: 'Info',
),
Tab(
icon: Icon(Icons.history_rounded, size: 22),
text: 'Versions',
),
Tab(
icon: Icon(Icons.electric_bolt_rounded, size: 22),
text: 'Activity',
),
],
),
Expanded(
child: TabBarView(
children: [
DetailsInfoTab(item: item),
DetailsVersionsTab(item: item),
DetailsActivityTab(item: item),
],
),
),
],
),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
NooOverlayHeader(
leading: detailsFileTile(item),
title: item.name,
subtitle: detailsMetaLine(item),
onClose: () => Navigator.pop(context),
),
const SizedBox(height: NooSpace.lg),
_DetailsBody(item: item),
],
);
}
}
/// Icon/name/size/date row shared by [DetailsSheet], the media viewer's
/// collapsed "peek" state (which shows just this, before the user drags the
/// sheet open far enough to reveal the tabs), and [ShareSheet]'s own title
/// (so every per-item bottom sheet opens on the same header instead of
/// [ShareSheet] alone using a bare title `Text`).
class DetailsHeader extends StatelessWidget {
/// The segmented control plus whichever tab's content it selects. A private
/// [StatefulWidget] rather than three fields on [DetailsSheet] so the
/// selection survives independently of how the header/container around it
/// is built (mobile embeds this once, desktop hands it to [showNooDialog]
/// directly).
class _DetailsBody extends StatefulWidget {
final NextcloudItem item;
/// Defaults to this widget's own standalone inset ([DetailsSheet]); a
/// caller whose surrounding scroll view already applies horizontal
/// padding (e.g. [ShareSheet]'s `ListView`) should pass a padding with
/// zero left/right to avoid doubling it up.
final EdgeInsetsGeometry padding;
const _DetailsBody({required this.item});
const DetailsHeader({
super.key,
required this.item,
this.padding = const EdgeInsets.fromLTRB(20, 12, 20, 8),
});
@override
State<_DetailsBody> createState() => _DetailsBodyState();
}
class _DetailsBodyState extends State<_DetailsBody> {
_DetailsTab _tab = _DetailsTab.info;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Padding(
padding: padding,
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
NooSegmentedControl<_DetailsTab>(
onSurface: true,
fill: true,
value: _tab,
onChanged: (tab) => setState(() => _tab = tab),
options: const [
NooSegmentOption(
value: _DetailsTab.info,
icon: LucideIcons.info,
label: 'Info',
),
child: Icon(
_detailsItemIcon(item.type),
color: colorScheme.onPrimaryContainer,
size: 22,
NooSegmentOption(
value: _DetailsTab.versions,
icon: LucideIcons.history,
label: 'Versions',
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
item.isFolder
? DateFormat.yMMMd().format(item.lastModified)
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
NooSegmentOption(
value: _DetailsTab.activity,
icon: LucideIcons.activity,
label: 'Activity',
),
),
],
),
],
),
const SizedBox(height: NooSpace.lg),
switch (_tab) {
_DetailsTab.info => DetailsInfoTab(item: widget.item),
_DetailsTab.versions => DetailsVersionsTab(item: widget.item),
_DetailsTab.activity => DetailsActivityTab(item: widget.item),
},
],
);
}
}
+129 -41
View File
@@ -1,15 +1,20 @@
import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import '../../models/nextcloud_file_version.dart';
import '../../models/nextcloud_item.dart';
import '../../providers/item_operations.dart';
import '../../theme/design_tokens.dart';
import '../noo/files/noo_file_kind.dart';
import '../noo/files/noo_file_tile.dart';
import '../noo/lists/noo_grouped_list.dart';
import '../synced_header_scaffold.dart' show formatBytes;
/// File version history — a synthetic "Current version" row (from the
/// File version history - a synthetic "Current version" row (from the
/// item's own metadata) followed by whatever the DAV versions endpoint
/// returns, each with Restore/Download actions.
class DetailsVersionsTab extends StatefulWidget {
@@ -75,7 +80,7 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
await FileSaver.instance.saveFile(
name: baseName,
filePath: tempPath,
ext: ext,
fileExtension: ext,
);
if (!mounted) return;
messenger.showSnackBar(
@@ -101,11 +106,12 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
return const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: CircularProgressIndicator()),
);
}
final items = [
@@ -117,44 +123,126 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
),
..._versions,
];
final kind = NooFileKind.from(
name: widget.item.name,
mimeType: widget.item.mimeType,
isDirectory: false,
);
return ListView.separated(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
itemCount: items.length,
separatorBuilder: (context, index) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final version = items[index];
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: colorScheme.primaryContainer,
child: Icon(
Icons.history_rounded,
color: colorScheme.onPrimaryContainer,
size: 20,
),
return NooGroupedList(
children: [
for (final version in items)
_VersionRow(
kind: kind,
version: version,
onRestore: () => _restore(version),
onDownload: () => _download(version),
),
title: Text(
version.isCurrent
? 'Current version'
: DateFormat.yMMMd().add_jm().format(version.timestamp),
),
subtitle: Text(
'${DateFormat.yMMMd().format(version.timestamp)} • ${formatBytes(version.size)}',
),
trailing: version.isCurrent
? null
: PopupMenuButton<String>(
onSelected: (value) => value == 'restore'
? _restore(version)
: _download(version),
itemBuilder: (context) => const [
PopupMenuItem(value: 'restore', child: Text('Restore')),
PopupMenuItem(value: 'download', child: Text('Download')),
],
),
);
},
],
);
}
}
class _VersionRow extends StatelessWidget {
final NooFileKind kind;
final NextcloudFileVersion version;
final VoidCallback onRestore;
final VoidCallback onDownload;
const _VersionRow({
required this.kind,
required this.version,
required this.onRestore,
required this.onDownload,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return ColoredBox(
color: colors.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.md,
vertical: NooSpace.sm,
),
child: Row(
children: [
NooFileTile(kind: kind, size: NooFileTileSize.activity),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
version.isCurrent
? 'Current version'
: DateFormat.yMMMd().add_jm().format(version.timestamp),
style: NooText.bodyL.copyWith(
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
const SizedBox(height: 2),
Text(
'${formatBytes(version.size)} · ${DateFormat.yMMMd().format(version.timestamp)}',
style: NooText.meta.copyWith(color: colors.fg3),
),
],
),
),
if (!version.isCurrent) ...[
const SizedBox(width: NooSpace.xs),
_RowIconButton(
icon: LucideIcons.rotateCcw,
tooltip: 'Restore',
onTap: onRestore,
),
const SizedBox(width: NooSpace.xxs),
_RowIconButton(
icon: LucideIcons.download,
tooltip: 'Download',
onTap: onDownload,
),
],
],
),
),
);
}
}
/// A small round icon button matching [NooCloseButton]'s visual language
/// (surface-2 circle) but for an arbitrary action - promote this to `noo/`
/// if another row-level icon action needs the same treatment.
class _RowIconButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback onTap;
const _RowIconButton({
required this.icon,
required this.tooltip,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Tooltip(
message: tooltip,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
width: 32,
height: 32,
alignment: Alignment.center,
decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle),
child: Icon(icon, size: 16, color: colors.fg2),
),
),
);
}
}
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../theme/design_tokens.dart';
/// A noo-styled "Home / Folder / Sub-folder" trail for the Files/Offline
/// tabs - a private restyle of the shared `Breadcrumbs` widget (which other
/// screens still use in its old Material look), so this screen's crumbs use
/// noo tokens without touching that shared file. Same behaviour: every
/// crumb but the last is tappable, jumping back to that depth.
class FileBreadcrumbRow extends StatelessWidget {
final List<String> pathStack;
final ValueChanged<int> onTap;
const FileBreadcrumbRow({
super.key,
required this.pathStack,
required this.onTap,
});
String _labelFor(int index) {
if (index == 0) return 'Home';
final segments = pathStack[index]
.split('/')
.where((s) => s.isNotEmpty)
.toList();
return segments.isEmpty ? 'Home' : segments.last;
}
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final lastIndex = pathStack.length - 1;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var i = 0; i < pathStack.length; i++) ...[
if (i > 0)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(
LucideIcons.chevronRight,
size: 14,
color: colors.fg3,
),
),
if (i == lastIndex)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
_labelFor(i),
style: NooText.body.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600,
color: colors.fg1,
),
),
)
else
// The whole pill is the tap target, not just the text.
InkWell(
onTap: () => onTap(i),
borderRadius: BorderRadius.circular(NooRadii.pill),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Text(
_labelFor(i),
style: NooText.body.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600,
color: colors.accentText,
),
),
),
),
],
],
),
);
}
}
+147 -99
View File
@@ -1,16 +1,37 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../providers/files_controller.dart';
import 'segmented_icon_toggle.dart';
import 'sort_menu_button.dart';
import '../theme/design_tokens.dart';
import 'noo/core/noo_chip.dart';
import 'noo/core/noo_segmented_control.dart';
import 'noo/core/noo_toggle.dart';
import 'noo/lists/noo_grouped_list.dart';
import 'noo/lists/noo_settings_row.dart';
import 'noo/overlays/noo_sheet.dart';
import 'sort_menu_button.dart' show sortFieldLabel;
/// The Files tab's sort/hidden/scope/type-filter/view-mode controls row,
/// shared with the Offline tab so both read and write the exact same
/// [FilesController] prefs. [folderPath] picks which folder's sort applies
/// (the Files tab's current folder, or the folder being browsed offline).
/// [showStorageScope] is off for Offline - a local mirror has no
/// cloud/external distinction.
String _typeFilterLabel(FilesTypeFilter filter) {
switch (filter) {
case FilesTypeFilter.all:
return 'All types';
case FilesTypeFilter.filesOnly:
return 'Files only';
case FilesTypeFilter.foldersOnly:
return 'Folders only';
}
}
/// The Files tab's sort/filter/view-mode row (Noo Design System project's
/// Files screen: a sort chip, a filter chip, a spacer, then the list/grid
/// segmented toggle) - shared with the Offline tab, which reads and writes
/// the exact same [FilesController] prefs. [folderPath] picks which
/// folder's sort applies. [showStorageScope] is off for Offline - a local
/// mirror has no cloud/external distinction.
///
/// Hidden-files and storage-scope, which have no home in the mockup's
/// simple two-chip row, live inside the filter chip's sheet instead of as
/// their own row controls.
class FilesControlsRow extends StatelessWidget {
final String folderPath;
final bool showStorageScope;
@@ -26,6 +47,9 @@ class FilesControlsRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final files = context.watch<FilesController>();
final filtersActive = files.filesTypeFilter != FilesTypeFilter.all ||
files.showHiddenFiles ||
(showStorageScope && files.storageScope != StorageScope.cloud);
return SizedBox(
height: 44,
@@ -34,98 +58,29 @@ class FilesControlsRow extends StatelessWidget {
scrollDirection: Axis.horizontal,
child: Row(
children: [
IconButton(
icon: Icon(
files.sortAscendingFor(folderPath)
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: files.sortAscendingFor(folderPath)
? 'Ascending'
: 'Descending',
onPressed: () => files.toggleSortOrderFor(folderPath),
),
// A plain width, not Expanded - this row scrolls horizontally,
// which gives every control unbounded width to lay out in, so a
// flex child would throw.
SizedBox(
width: 130,
child: SortMenuButton(
field: files.sortFieldFor(folderPath),
onChanged: (field) => files.setSortFieldFor(folderPath, field),
),
),
ToggleIconButton(
icon: files.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: files.showHiddenFiles,
onTap: () => files.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
if (showStorageScope) ...[
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: files.storageScope == StorageScope.cloud,
onTap: () => files.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: files.storageScope == StorageScope.external,
onTap: () => files.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
),
const SizedBox(width: 8),
],
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.select_all_rounded,
isSelected: files.filesTypeFilter == FilesTypeFilter.all,
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all),
tooltip: 'Files & folders',
),
ToggleIconButton(
icon: Icons.insert_drive_file_outlined,
isSelected:
files.filesTypeFilter == FilesTypeFilter.filesOnly,
onTap: () =>
files.setFilesTypeFilter(FilesTypeFilter.filesOnly),
tooltip: 'Files only',
),
ToggleIconButton(
icon: Icons.folder_outlined,
isSelected:
files.filesTypeFilter == FilesTypeFilter.foldersOnly,
onTap: () =>
files.setFilesTypeFilter(FilesTypeFilter.foldersOnly),
tooltip: 'Folders only',
),
],
NooChip(
icon: files.sortAscendingFor(folderPath)
? LucideIcons.arrowUp
: LucideIcons.arrowDown,
onTap: () => _showSortSheet(context, files),
child: Text(sortFieldLabel(files.sortFieldFor(folderPath))),
),
const SizedBox(width: 8),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !files.isGridView,
onTap: () => files.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: files.isGridView,
onTap: () => files.setGridView(true),
tooltip: 'Grid view',
),
NooChip(
icon: LucideIcons.filter,
trailing: NooChipTrailing.menu,
selected: filtersActive,
onTap: () => _showFilterSheet(context, files),
child: Text(_typeFilterLabel(files.filesTypeFilter)),
),
const Spacer(),
NooSegmentedControl<bool>(
iconOnly: true,
value: files.isGridView,
onChanged: files.setGridView,
options: const [
NooSegmentOption(value: false, icon: LucideIcons.list),
NooSegmentOption(value: true, icon: LucideIcons.grid),
],
),
],
@@ -133,4 +88,97 @@ class FilesControlsRow extends StatelessWidget {
),
);
}
void _showSortSheet(BuildContext context, FilesController files) {
showNooSheet(
context,
children: [
NooSegmentedControl<bool>(
fill: true,
value: files.sortAscendingFor(folderPath),
onChanged: (asc) {
if (asc != files.sortAscendingFor(folderPath)) {
files.toggleSortOrderFor(folderPath);
}
},
options: const [
NooSegmentOption(value: true, icon: LucideIcons.arrowUp, label: 'Ascending'),
NooSegmentOption(value: false, icon: LucideIcons.arrowDown, label: 'Descending'),
],
),
NooGroupedList(
children: [
for (final field in FileSortField.values)
NooSettingsRow(
label: Text(sortFieldLabel(field)),
trailing: field == files.sortFieldFor(folderPath)
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText)
: null,
onTap: () {
files.setSortFieldFor(folderPath, field);
Navigator.pop(context);
},
),
],
),
],
);
}
void _showFilterSheet(BuildContext context, FilesController files) {
showNooSheet(
context,
children: [
NooGroupedList(
label: 'Show',
children: [
NooSettingsRow(
label: const Text('Files and folders'),
trailing: files.filesTypeFilter == FilesTypeFilter.all
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText)
: null,
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all),
),
NooSettingsRow(
label: const Text('Files only'),
trailing: files.filesTypeFilter == FilesTypeFilter.filesOnly
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText)
: null,
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.filesOnly),
),
NooSettingsRow(
label: const Text('Folders only'),
trailing: files.filesTypeFilter == FilesTypeFilter.foldersOnly
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText)
: null,
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.foldersOnly),
),
],
),
NooGroupedList(
children: [
NooSettingsRow(
icon: LucideIcons.eye,
label: const Text('Show hidden files'),
trailing: NooToggle(
checked: files.showHiddenFiles,
onChanged: (_) => files.toggleShowHiddenFiles(),
),
),
if (showStorageScope)
NooSettingsRow(
icon: LucideIcons.hardDrive,
label: const Text('External storage'),
trailing: NooToggle(
checked: files.storageScope == StorageScope.external,
onChanged: (external) => files.setStorageScope(
external ? StorageScope.external : StorageScope.cloud,
),
),
),
],
),
],
);
}
}
-151
View File
@@ -1,151 +0,0 @@
import 'package:flutter/material.dart';
import 'frosted_glass_container.dart';
class FloatingNavItem {
final String label;
final IconData icon;
const FloatingNavItem({required this.label, required this.icon});
}
/// A floating pill-shaped bottom navigation bar in the style of Google
/// Photos' 2026 redesign: unselected destinations collapse to just their
/// icon, the selected destination expands into an icon+label capsule, and
/// a detached circular action (search) sits just outside the pill.
class FloatingBottomNavBar extends StatelessWidget {
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
final List<FloatingNavItem> items;
final VoidCallback? onSearchTap;
final double opacity;
final double blurSigma;
const FloatingBottomNavBar({
super.key,
required this.selectedIndex,
required this.onDestinationSelected,
required this.items,
this.onSearchTap,
this.opacity = 0.55,
this.blurSigma = 28,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 20, left: 20, right: 20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildPill(context, colorScheme),
const SizedBox(width: 10),
_buildSearchButton(context, colorScheme),
],
),
),
),
),
);
}
Widget _buildPill(BuildContext context, ColorScheme colorScheme) {
return FrostedGlassContainer(
opacity: opacity,
blurSigma: blurSigma,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
// The selected item's label expands the pill, and with enough
// tabs visible that can outgrow the available width (the
// ConstrainedBox above caps it at 480, but the screen itself may
// be narrower) - wrapped in a scroll view rather than a plain Row
// so it degrades to a swipe instead of overflowing/clipping.
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(items.length, (index) {
final item = items[index];
final isSelected = selectedIndex == index;
return InkWell(
onTap: () => onDestinationSelected(index),
borderRadius: BorderRadius.circular(26),
splashColor: colorScheme.primary.withValues(alpha: 0.12),
highlightColor: Colors.transparent,
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOutCubic,
padding: EdgeInsets.symmetric(
horizontal: isSelected ? 16 : 12,
vertical: 12,
),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: Colors.transparent,
borderRadius: BorderRadius.circular(26),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
item.icon,
size: 22,
color: isSelected
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
),
if (isSelected) ...[
const SizedBox(width: 8),
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: colorScheme.onPrimary,
letterSpacing: 0.1,
),
),
],
],
),
),
);
}),
),
),
),
);
}
Widget _buildSearchButton(BuildContext context, ColorScheme colorScheme) {
return FrostedGlassContainer(
opacity: opacity,
blurSigma: blurSigma,
child: SizedBox(
width: 52,
height: 52,
child: InkWell(
onTap: onSearchTap,
borderRadius: BorderRadius.circular(32),
splashColor: colorScheme.primary.withValues(alpha: 0.12),
child: Icon(
Icons.search_rounded,
color: colorScheme.onSurfaceVariant,
size: 24,
),
),
),
);
}
}
+53 -28
View File
@@ -1,48 +1,73 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/nextcloud_item.dart';
import '../services/nextcloud_service.dart';
import '../theme/design_tokens.dart';
/// The icon/color/thumbnail treatment for a file or folder, shared by any
/// screen that lists [NextcloudItem]s the same way the Files tab does (see
/// `files_view.dart`, and `share_upload_view.dart`'s destination picker).
/// Matches DESIGN_SYSTEM.md 1.2's file-type tile table (`KIND`) - a pdf is
/// its own kind there (distinct from a generic document), which is why
/// these take the whole [NextcloudItem] rather than just its
/// [NextcloudItemType]: the type alone can't tell a pdf from any other
/// document.
bool _isPdf(NextcloudItem item) => item.name.toLowerCase().endsWith('.pdf');
IconData getItemIcon(NextcloudItemType type) {
switch (type) {
IconData getItemIcon(NextcloudItem item) {
switch (item.type) {
case NextcloudItemType.folder:
return Icons.folder_rounded;
return LucideIcons.folder;
case NextcloudItemType.image:
return Icons.image_rounded;
return LucideIcons.image;
case NextcloudItemType.video:
return Icons.movie_rounded;
case NextcloudItemType.audio:
return Icons.audiotrack_rounded;
return LucideIcons.film;
case NextcloudItemType.document:
return Icons.description_rounded;
return LucideIcons.fileText;
case NextcloudItemType.archive:
return Icons.folder_zip_rounded;
return LucideIcons.fileArchive;
case NextcloudItemType.audio:
case NextcloudItemType.file:
return Icons.insert_drive_file_rounded;
return LucideIcons.file;
}
}
Color getIconColor(BuildContext context, NextcloudItemType type) {
final colorScheme = Theme.of(context).colorScheme;
switch (type) {
Color getIconColor(BuildContext context, NextcloudItem item) {
final colors = context.nooColors;
switch (item.type) {
case NextcloudItemType.folder:
return colorScheme.primary;
case NextcloudItemType.image:
return Colors.amber.shade700;
case NextcloudItemType.video:
return Colors.deepOrange.shade600;
case NextcloudItemType.audio:
return Colors.purple.shade600;
return colors.accentText;
case NextcloudItemType.document:
return Colors.blue.shade700;
return _isPdf(item) ? colors.danger : colors.info;
case NextcloudItemType.image:
return colors.info;
case NextcloudItemType.video:
return colors.warning;
case NextcloudItemType.archive:
return Colors.teal.shade700;
case NextcloudItemType.audio:
case NextcloudItemType.file:
return colorScheme.outline;
return colors.fg2;
}
}
/// The tile's background (the soft-tinted square/circle behind the icon) -
/// the other half of the [getIconColor] pair every call site pairs it with.
Color getIconBackground(BuildContext context, NextcloudItem item) {
final colors = context.nooColors;
switch (item.type) {
case NextcloudItemType.folder:
return colors.accentSoft;
case NextcloudItemType.document:
return _isPdf(item) ? colors.dangerSoft : colors.infoSoft;
case NextcloudItemType.image:
return colors.infoSoft;
case NextcloudItemType.video:
return colors.warningSoft;
case NextcloudItemType.archive:
case NextcloudItemType.audio:
case NextcloudItemType.file:
return colors.surface3;
}
}
@@ -74,7 +99,7 @@ class ItemThumbnail extends StatelessWidget {
@override
Widget build(BuildContext context) {
final iconColor = getIconColor(context, item.type);
final iconColor = getIconColor(context, item);
final isMedia =
item.type == NextcloudItemType.image ||
item.type == NextcloudItemType.video;
@@ -91,7 +116,7 @@ class ItemThumbnail extends StatelessWidget {
height: size,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
color: getIconBackground(context, item),
borderRadius: BorderRadius.circular(borderRadius),
),
child: useLocal || (isMedia && item.previewUrl != null)
@@ -116,7 +141,7 @@ class ItemThumbnail extends StatelessWidget {
if (progress == null) return child;
return Center(
child: Icon(
getItemIcon(item.type),
getItemIcon(item),
color: iconColor,
size: iconSize,
),
@@ -124,7 +149,7 @@ class ItemThumbnail extends StatelessWidget {
},
errorBuilder: (context, error, stack) => Center(
child: Icon(
getItemIcon(item.type),
getItemIcon(item),
color: iconColor,
size: iconSize,
),
@@ -142,7 +167,7 @@ class ItemThumbnail extends StatelessWidget {
)
: Center(
child: Icon(
getItemIcon(item.type),
getItemIcon(item),
color: iconColor,
size: iconSize,
),
+45 -66
View File
@@ -1,6 +1,11 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../providers/sync_status_controller.dart';
import '../theme/design_tokens.dart';
import 'noo/lists/noo_grouped_list.dart';
import 'noo/lists/noo_settings_row.dart';
import 'noo/overlays/noo_sheet.dart';
/// The configured sync targets (folders/files) with a "stop syncing"
/// action per target - what used to be Settings' Device Sync card's own
@@ -14,16 +19,7 @@ class ManageSyncedFoldersSheet extends StatelessWidget {
static void show(BuildContext context) {
final sync = context.read<SyncStatusController>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
showDragHandle: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (sheetContext) => ManageSyncedFoldersSheet(sync: sync),
);
showNooSheet(context, children: [ManageSyncedFoldersSheet(sync: sync)]);
}
void _remove(BuildContext context, String path) {
@@ -38,69 +34,52 @@ class ManageSyncedFoldersSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
// AnimatedBuilder, not a one-shot read: removing a target should update
// this list in place without closing the sheet, and showNooSheet's
// `children` is built once by the caller - this widget owns its own
// rebuild instead.
return AnimatedBuilder(
animation: sync,
builder: (context, _) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final everything = sync.syncEverything;
final targets = sync.syncedPaths;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Synced to this device',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
if (everything)
Text(
'Every folder in this account is being synced to this '
'device.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
)
else if (targets.isEmpty)
Text(
'Nothing synced yet - select a folder or file in Files '
'and use "Sync to device" to mirror it here for offline '
'access.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
)
else
Flexible(
child: ListView.separated(
shrinkWrap: true,
itemCount: targets.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final target = targets[index];
return ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.sync_rounded),
title: Text(target),
trailing: IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: 'Stop syncing',
onPressed: () => _remove(context, target),
),
);
},
),
),
],
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Synced to this device',
style: NooText.cardTitle.copyWith(color: colors.fg1),
),
),
const SizedBox(height: NooSpace.sm),
if (everything)
Text(
'Every folder in this account is being synced to this device.',
style: NooText.body.copyWith(color: colors.fg3),
)
else if (targets.isEmpty)
Text(
'Nothing synced yet. Select a folder or file in Files and '
'use "Sync to device" to mirror it here for offline access.',
style: NooText.body.copyWith(color: colors.fg3),
)
else
NooGroupedList(
children: [
for (final target in targets)
NooSettingsRow(
icon: LucideIcons.folderSync,
label: Text(target),
trailing: IconButton(
icon: Icon(LucideIcons.x, size: 18, color: colors.fg3),
tooltip: 'Stop syncing',
onPressed: () => _remove(context, target),
),
),
],
),
],
);
},
);
-98
View File
@@ -1,98 +0,0 @@
import 'package:flutter/material.dart';
import '../models/nextcloud_item.dart';
import 'synced_header_scaffold.dart' show formatBytes;
/// Grid content for an image/video: the actual preview fills the whole card
/// as a background, with the name/size legible over a bottom scrim -
/// matching a Google Photos-style grid instead of a small icon badge.
/// Shared by the Files and Offline tabs; they differ only in where the
/// pixels come from ([imageBuilder] - a server preview vs. the local
/// mirror file) and what to show if that fails ([fallbackBuilder]).
class MediaGridTile extends StatelessWidget {
final NextcloudItem item;
/// Given the pixel width to decode at, returns the image to paint.
/// Decoding straight to the painted size (rather than the full 500x500
/// server preview / full-resolution local file) avoids a real source of
/// scrolling jank.
final ImageProvider Function(int cachePixels) imageBuilder;
final WidgetBuilder fallbackBuilder;
const MediaGridTile({
super.key,
required this.item,
required this.imageBuilder,
required this.fallbackBuilder,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return LayoutBuilder(
builder: (context, constraints) {
final cachePixels =
(constraints.maxWidth * MediaQuery.of(context).devicePixelRatio)
.round();
return Stack(
fit: StackFit.expand,
children: [
Image(
image: imageBuilder(cachePixels),
fit: BoxFit.cover,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) => fallbackBuilder(ctx),
),
if (item.type == NextcloudItemType.video)
const Center(
child: Icon(
Icons.play_circle_fill_rounded,
color: Colors.white,
size: 36,
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.fromLTRB(10, 20, 10, 8),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black87],
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
Text(
formatBytes(item.size),
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white70,
fontSize: 10,
),
),
],
),
),
),
],
);
},
);
}
}
+160 -109
View File
@@ -1,6 +1,15 @@
import 'package:flutter/material.dart';
import '../models/move_copy_result.dart';
import 'gradual_bottom_sheet.dart';
import '../theme/design_tokens.dart';
import 'noo/core/noo_button.dart';
import 'noo/core/noo_segmented_control.dart';
import 'noo/files/noo_file_kind.dart';
import 'noo/files/noo_file_tile.dart';
import 'noo/lists/noo_grouped_list.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';
/// Shown after a Move/Copy batch comes back with `MoveCopyResult.conflicts`
/// non-empty (an item with the same name already exists at the
@@ -8,27 +17,36 @@ import 'gradual_bottom_sheet.dart';
/// per item as they're hit, and lets the user resolve them all the same
/// way (overwrite/keep both) or open a per-item breakdown. Returns the
/// `ConflictChoice` map `ItemOperations.resolveConflicts` expects, or null
/// if dismissed without choosing.
/// if dismissed without choosing. A `showNooDialog` (desktop) / `showNooSheet`
/// (mobile) instead of the old `showGradualBottomSheet` - the content never
/// grows past a couple of screens, so the drag-to-resize sheet that needed
/// isn't worth it here.
class MoveCopyConflictSheet extends StatefulWidget {
final List<MoveCopyConflict> conflicts;
final ScrollController? scrollController;
const MoveCopyConflictSheet({
super.key,
required this.conflicts,
this.scrollController,
});
const MoveCopyConflictSheet({super.key, required this.conflicts});
static Future<Map<String, ConflictChoice>?> show(
BuildContext context,
List<MoveCopyConflict> conflicts,
) {
return showGradualBottomSheet<Map<String, ConflictChoice>>(
final content = MoveCopyConflictSheet(conflicts: conflicts);
final count = conflicts.length;
final title = count == 1
? '1 item already exists'
: '$count items already exist';
if (NooLayout.isDesktop(context)) {
return showNooDialog<Map<String, ConflictChoice>>(
context,
title: title,
subtitle: 'Choose how to handle the conflicts',
children: [content],
);
}
return showNooSheet<Map<String, ConflictChoice>>(
context,
builder: (context, scrollController) => MoveCopyConflictSheet(
conflicts: conflicts,
scrollController: scrollController,
),
children: [content],
);
}
@@ -55,118 +73,151 @@ class _MoveCopyConflictSheetState extends State<MoveCopyConflictSheet> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDesktop = NooLayout.isDesktop(context);
final count = widget.conflicts.length;
return ListView(
controller: widget.scrollController,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
count == 1
? '1 item already exists at the destination'
: '$count items already exist at the destination',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
// The dialog already renders this title/subtitle in its own header
// (see `show` above); the sheet has no header of its own, so it
// needs one here.
if (!isDesktop) ...[
NooOverlayHeader(
title: count == 1
? '1 item already exists'
: '$count items already exist',
subtitle: 'Choose how to handle the conflicts',
onClose: () => Navigator.pop(context),
),
),
const SizedBox(height: 4),
Text(
'Choose how to handle the conflicts.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
const SizedBox(height: NooSpace.lg),
],
if (!_decidingPerItem) ...[
FilledButton(
onPressed: () => _resolveAll(ConflictChoice.overwrite),
NooButton(
variant: NooButtonVariant.primary,
size: NooButtonSize.card,
fullWidth: true,
onTap: () => _resolveAll(ConflictChoice.overwrite),
child: const Text('Overwrite all'),
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () => _resolveAll(ConflictChoice.keepBoth),
const SizedBox(height: NooSpace.xs),
NooButton(
variant: NooButtonVariant.tonal,
size: NooButtonSize.card,
fullWidth: true,
onTap: () => _resolveAll(ConflictChoice.keepBoth),
child: const Text('Keep both (rename)'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => setState(() => _decidingPerItem = true),
const SizedBox(height: NooSpace.xs),
NooButton(
variant: NooButtonVariant.outline,
size: NooButtonSize.card,
fullWidth: true,
onTap: () => setState(() => _decidingPerItem = true),
child: const Text('Decide per item'),
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
const SizedBox(height: NooSpace.lg),
],
for (final conflict in widget.conflicts)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
conflict.item.isFolder
? Icons.folder_rounded
: Icons.insert_drive_file_rounded,
color: colorScheme.onSurfaceVariant,
size: 20,
),
const SizedBox(width: 10),
Expanded(
child: Text(
conflict.item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
),
if (_decidingPerItem) ...[
const SizedBox(height: 6),
SizedBox(
width: double.infinity,
child: SegmentedButton<ConflictChoice>(
segments: const [
ButtonSegment(
value: ConflictChoice.overwrite,
label: Text('Overwrite'),
),
ButtonSegment(
value: ConflictChoice.keepBoth,
label: Text('Keep both'),
),
ButtonSegment(
value: ConflictChoice.skip,
label: Text('Skip'),
),
],
selected: {_perItemChoices[conflict.item.id]!},
showSelectedIcon: false,
onSelectionChanged: (selection) => setState(() {
_perItemChoices[conflict.item.id] = selection.first;
}),
),
),
],
],
),
),
NooGroupedList(
label: _decidingPerItem ? 'Items' : null,
children: [
for (final conflict in widget.conflicts)
_ConflictRow(
conflict: conflict,
decidingPerItem: _decidingPerItem,
choice: _perItemChoices[conflict.item.id]!,
onChoiceChanged: (choice) => setState(() {
_perItemChoices[conflict.item.id] = choice;
}),
),
],
),
if (_decidingPerItem) ...[
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _confirmPerItem,
child: const Text('Confirm'),
),
const SizedBox(height: NooSpace.lg),
NooButton(
variant: NooButtonVariant.primary,
size: NooButtonSize.card,
fullWidth: true,
onTap: _confirmPerItem,
child: const Text('Confirm'),
),
],
],
);
}
}
/// One conflicting item: its file-type tile and name, plus (once "Decide
/// per item" is active) a segmented overwrite/keep both/skip choice.
class _ConflictRow extends StatelessWidget {
final MoveCopyConflict conflict;
final bool decidingPerItem;
final ConflictChoice choice;
final ValueChanged<ConflictChoice> onChoiceChanged;
const _ConflictRow({
required this.conflict,
required this.decidingPerItem,
required this.choice,
required this.onChoiceChanged,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final item = conflict.item;
final kind = NooFileKind.from(name: item.name, isDirectory: item.isFolder);
return Material(
color: colors.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.md,
vertical: NooSpace.sm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
NooFileTile(kind: kind),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.bodyL.copyWith(
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
),
],
),
if (decidingPerItem) ...[
const SizedBox(height: NooSpace.xs),
NooSegmentedControl<ConflictChoice>(
fill: true,
onSurface: true,
value: choice,
onChanged: onChoiceChanged,
options: const [
NooSegmentOption(
value: ConflictChoice.overwrite,
label: 'Overwrite',
),
NooSegmentOption(
value: ConflictChoice.keepBoth,
label: 'Keep both',
),
NooSegmentOption(value: ConflictChoice.skip, label: 'Skip'),
],
),
],
],
),
),
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../theme/design_tokens.dart';
/// Initials circle on a secondary-palette colour; the current user uses
/// accent-soft (Noo Design System project, `components/core/Avatar.jsx`).
/// Sizes: 30-32 in bars, 36 in lists, 48 in the drawer, 52-56 on the
/// account card.
class NooAvatar extends StatelessWidget {
final String? initials;
final double size;
/// One of [NooColors.avatarPalette]; ignored when [current] or [icon] is set.
final Color? color;
final bool current;
/// Show an icon instead of initials (e.g. a group share).
final IconData? icon;
const NooAvatar({
super.key,
this.initials,
this.size = 36,
this.color,
this.current = false,
this.icon,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final Color bg;
final Color fg;
if (current) {
bg = colors.accentSoft;
fg = colors.accentText;
} else if (icon != null) {
bg = colors.surface2;
fg = colors.fg2;
} else {
bg = color ?? NooColors.avatarPalette.first;
fg = NooColors.avatarTextColor;
}
return Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(color: bg, shape: BoxShape.circle),
child: icon != null
? Icon(icon, size: (size * 0.5).round().toDouble(), color: fg)
: Text(
initials ?? '',
style: GoogleFonts.instrumentSans(
fontWeight: FontWeight.w600,
fontSize: (size * 0.36).round().toDouble(),
height: 1,
color: fg,
),
),
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
enum NooBadgeTone { success, danger, accent, warning, info }
/// Small status pill, e.g. "Connected" in settings (Noo Design System
/// project, `components/core/Badge.jsx`).
class NooBadge extends StatelessWidget {
final NooBadgeTone tone;
final Widget child;
const NooBadge({super.key, this.tone = NooBadgeTone.success, required this.child});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final (bg, fg) = switch (tone) {
NooBadgeTone.success => (colors.successSoft, colors.success),
NooBadgeTone.danger => (colors.dangerSoft, colors.danger),
NooBadgeTone.accent => (colors.accentSoft, colors.accentText),
NooBadgeTone.warning => (colors.warningSoft, colors.warning),
NooBadgeTone.info => (colors.infoSoft, colors.info),
};
return Container(
height: 24,
// Centering the text vertically within the fixed 24px height needs
// *some* alignment mechanism, but Container's own `alignment:` (with
// a height but no width) would expand to fill all available
// *bounded* width instead of shrink-wrapping (see NooFab's doc
// comment) - a Row with mainAxisSize.min sidesteps that entirely:
// it shrink-wraps horizontally regardless of incoming constraints,
// while its default crossAxisAlignment.center still centers the
// text within the Container's tight 24px height.
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(NooRadii.pill)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
DefaultTextStyle(
style: NooText.label.copyWith(fontSize: 12, height: 1, color: fg),
child: child,
),
],
),
);
}
}
+143
View File
@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
enum NooButtonVariant { primary, tonal, secondary, danger, outline, text, textDanger }
enum NooButtonSize { cta, card, field, toolbar, compact, xs }
class _SizeSpec {
final double height;
final double px;
final double font;
final double icon;
final double gap;
const _SizeSpec(this.height, this.px, this.font, this.icon, this.gap);
}
const _sizes = {
NooButtonSize.cta: _SizeSpec(52, 20, 16, 20, 10),
NooButtonSize.card: _SizeSpec(44, 18, 15, 16, 8),
NooButtonSize.field: _SizeSpec(40, 16, 14, 16, 6),
NooButtonSize.toolbar: _SizeSpec(36, 16, 14, 16, 8),
NooButtonSize.compact: _SizeSpec(32, 14, 13, 14, 6),
NooButtonSize.xs: _SizeSpec(28, 12, 12, 14, 6),
};
/// The pill button for every action in the design system
/// (Noo Design System project, `components/core/Button.jsx`). Pick
/// [NooButtonVariant] by emphasis and [NooButtonSize] by context - never
/// build a bespoke `ElevatedButton`/`FilledButton` for app chrome; extend
/// this instead.
class NooButton extends StatefulWidget {
final NooButtonVariant variant;
final NooButtonSize size;
final IconData? icon;
final Widget? child;
final bool fullWidth;
final bool disabled;
final VoidCallback? onTap;
const NooButton({
super.key,
this.variant = NooButtonVariant.primary,
this.size = NooButtonSize.toolbar,
this.icon,
this.child,
this.fullWidth = false,
this.disabled = false,
this.onTap,
});
@override
State<NooButton> createState() => _NooButtonState();
}
class _NooButtonState extends State<NooButton> {
bool _down = false;
(Color, Color, BoxBorder?) _colors(NooColors colors) {
switch (widget.variant) {
case NooButtonVariant.primary:
return (colors.accent, Colors.white, null);
case NooButtonVariant.tonal:
return (colors.accentSoft, colors.accentText, null);
case NooButtonVariant.secondary:
return (colors.surface2, colors.fg1, null);
case NooButtonVariant.danger:
return (colors.dangerSoft, colors.danger, null);
case NooButtonVariant.outline:
return (
Colors.transparent,
colors.fg1,
Border.all(color: colors.surface3, width: 1.5),
);
case NooButtonVariant.text:
return (Colors.transparent, colors.accentText, null);
case NooButtonVariant.textDanger:
return (Colors.transparent, colors.danger, null);
}
}
bool get _isTextVariant =>
widget.variant == NooButtonVariant.text ||
widget.variant == NooButtonVariant.textDanger;
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final s = _sizes[widget.size]!;
final (bg, fg, border) = _colors(colors);
final hasLeadingIcon = widget.icon != null && widget.child != null && !_isTextVariant;
final content = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.icon != null) Icon(widget.icon, size: s.icon, color: fg),
if (widget.icon != null && widget.child != null) SizedBox(width: s.gap),
if (widget.child != null)
DefaultTextStyle(
style: NooText.buttonSm.copyWith(fontSize: s.font, color: fg),
child: widget.child!,
),
],
);
return Opacity(
opacity: widget.disabled ? 0.45 : 1,
child: GestureDetector(
onTapDown: widget.disabled ? null : (_) => setState(() => _down = true),
onTapUp: widget.disabled ? null : (_) => setState(() => _down = false),
onTapCancel: widget.disabled ? null : () => setState(() => _down = false),
onTap: widget.disabled ? null : widget.onTap,
child: AnimatedScale(
scale: _down && !widget.disabled ? NooMotion.pressScale : 1,
duration: NooMotion.fast,
curve: NooMotion.ease,
child: Container(
height: s.height,
width: widget.fullWidth ? double.infinity : null,
padding: _isTextVariant
? EdgeInsets.zero
: EdgeInsets.only(
left: hasLeadingIcon ? s.px - 4 : s.px,
right: s.px,
),
// Only align when fullWidth actually expands this Container -
// otherwise (no width, alignment set) Container wraps the child
// in an Align that fills all available *bounded* space instead
// of shrink-wrapping to it, which is what stretched this button
// edge-to-edge instead of sizing to its content (see NooFab's
// doc comment for the same bug).
alignment: widget.fullWidth ? Alignment.center : null,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(NooRadii.pill),
border: border,
),
child: content,
),
),
),
);
}
}
+92
View File
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
enum NooChipTrailing { none, menu, remove }
/// A pill filter/sort chip; selected = accent-soft (Noo Design System
/// project, `components/core/Chip.jsx`).
class NooChip extends StatelessWidget {
final Widget child;
final IconData? icon;
final bool selected;
final NooChipTrailing trailing;
/// filled = mobile (surface fill, 34px); outline = desktop (1px line, 32px)
final bool outline;
final VoidCallback? onTap;
const NooChip({
super.key,
required this.child,
this.icon,
this.selected = false,
this.trailing = NooChipTrailing.none,
this.outline = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final height = outline ? 32.0 : 34.0;
final hasSideContent = icon != null || trailing != NooChipTrailing.none;
Color bg;
Color fg;
FontWeight weight;
Border? border;
if (selected) {
bg = colors.accentSoft;
fg = colors.accentText;
weight = FontWeight.w600;
} else if (outline) {
bg = Colors.transparent;
fg = colors.fg1;
weight = FontWeight.w500;
border = Border.all(color: colors.line, width: 1);
} else {
bg = colors.surface;
fg = colors.fg1;
weight = FontWeight.w500;
}
return GestureDetector(
onTap: onTap,
child: Container(
height: height,
padding: EdgeInsets.symmetric(horizontal: hasSideContent ? 12 : 14),
decoration: BoxDecoration(
color: bg,
border: border,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: outline ? 14 : 16, color: fg),
const SizedBox(width: 6),
],
DefaultTextStyle(
style: NooText.body.copyWith(
fontSize: outline ? 13 : 14,
height: 1,
fontWeight: weight,
color: fg,
),
child: child,
),
if (trailing == NooChipTrailing.menu) ...[
const SizedBox(width: 6),
Icon(LucideIcons.chevronDown, size: 14, color: fg),
],
if (trailing == NooChipTrailing.remove) ...[
const SizedBox(width: 6),
Icon(LucideIcons.x, size: 14, color: fg),
],
],
),
),
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Android-only extended Upload pill, bottom-right of Files and Photos (Noo
/// Design System project, `components/core/FAB.jsx`). 56px tall, accent
/// fill - iOS uses a `plus` in the nav bar instead (not yet built).
class NooFab extends StatelessWidget {
final String label;
final IconData icon;
final VoidCallback? onTap;
const NooFab({super.key, this.label = 'Upload', this.icon = LucideIcons.plus, this.onTap});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Material(
color: colors.accent,
borderRadius: BorderRadius.circular(NooRadii.pill),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(NooRadii.pill),
child: Container(
height: 56,
padding: const EdgeInsets.fromLTRB(16, 0, 20, 0),
// No `alignment:` here - Container without an explicit width
// wraps its child in an Align when alignment is set, and Align
// EXPANDS to fill all available bounded space (not just the
// child's size) unless the incoming constraints are unbounded.
// The Scaffold floating-action-button slot gives bounded
// constraints, so this shipped as a full-width bar instead of a
// compact pill. Row+mainAxisSize.min already sizes correctly on
// its own; the fix is just not adding alignment back here.
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 24, color: Colors.white),
const SizedBox(width: 10),
Text(label, style: NooText.button.copyWith(color: Colors.white)),
],
),
),
),
);
}
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
/// 6px pill meter for storage, cache and offline limits (Noo Design System
/// project, `components/core/ProgressBar.jsx`).
class NooProgressBar extends StatelessWidget {
/// 0-1
final double value;
const NooProgressBar({super.key, required this.value});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.pill),
child: SizedBox(
height: 6,
child: LinearProgressIndicator(
value: value.clamp(0.0, 1.0),
backgroundColor: colors.surface3,
valueColor: AlwaysStoppedAnimation(colors.accent),
),
),
);
}
}
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Pill search input with a leading icon (Noo Design System project,
/// `components/core/SearchField.jsx`).
class NooSearchField extends StatelessWidget {
final String placeholder;
final TextEditingController? controller;
final FocusNode? focusNode;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final bool autofocus;
/// true = iOS inline field (radius 12, 38px, 16px text, below the large
/// title); false = the generic 36px pill used everywhere else (also the
/// Android "expands from a top-bar icon" search field once expanded).
final bool ios;
/// True when placed directly on a `surface` background (uses surface-2
/// fill instead of surface, so it doesn't disappear against it).
final bool onSurface;
final double? width;
/// A trailing widget (e.g. a clear button), shown after the input.
final Widget? trailing;
const NooSearchField({
super.key,
this.placeholder = 'Search',
this.controller,
this.focusNode,
this.onChanged,
this.onSubmitted,
this.autofocus = false,
this.ios = false,
this.onSurface = false,
this.width,
this.trailing,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Container(
width: width,
height: ios ? 38 : 36,
padding: EdgeInsets.symmetric(horizontal: ios ? 10 : 14),
decoration: BoxDecoration(
color: onSurface ? colors.surface2 : colors.surface,
borderRadius: BorderRadius.circular(ios ? 12 : NooRadii.pill),
),
child: Row(
children: [
Icon(LucideIcons.search, size: ios ? 18 : 16, color: colors.fg3),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: controller,
focusNode: focusNode,
onChanged: onChanged,
onSubmitted: onSubmitted,
autofocus: autofocus,
style: NooText.body.copyWith(
fontSize: ios ? 16 : 14,
height: 1,
color: colors.fg1,
),
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: placeholder,
hintStyle: NooText.body.copyWith(
fontSize: ios ? 16 : 14,
height: 1,
color: colors.fg3,
),
),
),
),
?trailing,
],
),
);
}
}
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
class NooSegmentOption<T> {
final T value;
final String? label;
final IconData? icon;
const NooSegmentOption({required this.value, this.label, this.icon});
}
enum NooSegmentedSize { md, sm, xs }
const _heights = {NooSegmentedSize.md: 34.0, NooSegmentedSize.sm: 30.0, NooSegmentedSize.xs: 28.0};
/// Pill track with an accent-soft active segment - List/Grid, Shares scope,
/// Theme (Noo Design System project, `components/core/SegmentedControl.jsx`).
/// Replaces the older `SegmentedIconGroup`/`ToggleIconButton` pair; migrate
/// call sites to this as they're touched rather than adding new ones there.
class NooSegmentedControl<T> extends StatelessWidget {
final List<NooSegmentOption<T>> options;
final T value;
final ValueChanged<T>? onChanged;
/// Use surface on bg, surface-2 on surface.
final bool onSurface;
final NooSegmentedSize size;
final bool fill;
final bool iconOnly;
const NooSegmentedControl({
super.key,
required this.options,
required this.value,
this.onChanged,
this.onSurface = false,
this.size = NooSegmentedSize.md,
this.fill = false,
this.iconOnly = false,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final h = iconOnly ? 28.0 : (_heights[size] ?? 34.0);
final segments = options.map((o) {
final on = o.value == value;
final child = Container(
height: h,
width: iconOnly ? 36 : null,
padding: iconOnly || fill ? EdgeInsets.zero : const EdgeInsets.symmetric(horizontal: 12),
alignment: Alignment.center,
decoration: BoxDecoration(
color: on ? colors.accentSoft : Colors.transparent,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (o.icon != null)
Icon(
o.icon,
size: iconOnly ? (size == NooSegmentedSize.md ? 18 : 16) : 14,
color: on ? colors.accentText : colors.fg2,
),
if (o.icon != null && !iconOnly && o.label != null) const SizedBox(width: 6),
if (!iconOnly && o.label != null)
Text(
o.label!,
style: NooText.body.copyWith(
fontSize: size == NooSegmentedSize.md ? 14 : 13,
height: 1,
fontWeight: on ? FontWeight.w600 : FontWeight.w500,
color: on ? colors.accentText : colors.fg2,
),
),
],
),
);
final tappable = GestureDetector(
onTap: onChanged == null ? null : () => onChanged!(o.value),
child: child,
);
return fill ? Expanded(child: tappable) : tappable;
}).toList();
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: onSurface ? colors.surface2 : colors.surface,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: Row(mainAxisSize: fill ? MainAxisSize.max : MainAxisSize.min, children: segments),
);
}
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
/// On/off switch; accent track on, surface-3 track off, white knob -
/// identical on every platform, never a native iOS/Android switch (Noo
/// Design System project, `components/core/Toggle.jsx`).
class NooToggle extends StatelessWidget {
final bool checked;
final ValueChanged<bool>? onChanged;
final bool desktop;
const NooToggle({
super.key,
required this.checked,
this.onChanged,
this.desktop = false,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final w = desktop ? 44.0 : 48.0;
final h = desktop ? 26.0 : 28.0;
final knob = h - 6;
return GestureDetector(
onTap: onChanged == null ? null : () => onChanged!(!checked),
child: AnimatedContainer(
duration: NooMotion.fast,
curve: NooMotion.ease,
width: w,
height: h,
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: checked ? colors.accent : colors.surface3,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: AnimatedAlign(
duration: NooMotion.fast,
curve: NooMotion.ease,
alignment: checked ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
width: knob,
height: knob,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
),
),
);
}
}
+188
View File
@@ -0,0 +1,188 @@
import 'package:flutter/widgets.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// File-type buckets from `DESIGN_SYSTEM.md` 1.2 (`KIND` in `noo-kit.js`).
/// Drives the tile tint + icon in `NooFileTile`, `NooFileRow` and
/// `NooFileTableRow`. [other] isn't in the spec table - it's the neutral
/// fallback (archive colors, plain `file` icon) for anything the table
/// doesn't cover (audio, binaries, unknown extensions) so those don't
/// masquerade as an archive or a document.
enum NooFileKind {
folder,
pdf,
doc,
sheet,
video,
image,
archive,
other;
/// Lucide icon for this kind.
IconData get icon {
switch (this) {
case NooFileKind.folder:
return LucideIcons.folder;
case NooFileKind.pdf:
case NooFileKind.doc:
return LucideIcons.fileText;
case NooFileKind.sheet:
return LucideIcons.sheet;
case NooFileKind.video:
return LucideIcons.film;
case NooFileKind.image:
return LucideIcons.image;
case NooFileKind.archive:
return LucideIcons.fileArchive;
case NooFileKind.other:
return LucideIcons.file;
}
}
/// Tile background - the soft half of the pair.
Color background(NooColors colors) {
switch (this) {
case NooFileKind.folder:
return colors.accentSoft;
case NooFileKind.pdf:
return colors.dangerSoft;
case NooFileKind.doc:
case NooFileKind.image:
return colors.infoSoft;
case NooFileKind.sheet:
return colors.successSoft;
case NooFileKind.video:
return colors.warningSoft;
case NooFileKind.archive:
case NooFileKind.other:
return colors.surface3;
}
}
/// Icon color - always the strong half of [background]'s pair.
Color foreground(NooColors colors) {
switch (this) {
case NooFileKind.folder:
return colors.accentText;
case NooFileKind.pdf:
return colors.danger;
case NooFileKind.doc:
case NooFileKind.image:
return colors.info;
case NooFileKind.sheet:
return colors.success;
case NooFileKind.video:
return colors.warning;
case NooFileKind.archive:
case NooFileKind.other:
return colors.fg2;
}
}
/// Derives a kind from whatever the caller has: [isDirectory] wins, then
/// [mimeType] (e.g. WebDAV `getcontenttype`), then [name]'s extension.
static NooFileKind from({
String? name,
String? mimeType,
bool isDirectory = false,
}) {
final mime = mimeType?.toLowerCase().trim();
if (isDirectory || mime == 'httpd/unix-directory') {
return NooFileKind.folder;
}
if (mime != null && mime.isNotEmpty) {
final byMime = _fromMime(mime);
if (byMime != null) return byMime;
}
if (name != null) {
final dot = name.lastIndexOf('.');
if (dot >= 0 && dot < name.length - 1) {
final byExt = _extensions[name.substring(dot + 1).toLowerCase()];
if (byExt != null) return byExt;
}
}
return NooFileKind.other;
}
static NooFileKind? _fromMime(String mime) {
if (mime == 'application/pdf') return NooFileKind.pdf;
if (mime.startsWith('image/')) return NooFileKind.image;
if (mime.startsWith('video/')) return NooFileKind.video;
if (mime.contains('spreadsheet') ||
mime.contains('ms-excel') ||
mime == 'text/csv') {
return NooFileKind.sheet;
}
if (mime.contains('zip') ||
mime.contains('compressed') ||
mime.contains('x-tar') ||
mime.contains('x-7z') ||
mime.contains('x-rar') ||
mime.contains('gzip')) {
return NooFileKind.archive;
}
if (mime.startsWith('text/') ||
mime.contains('wordprocessing') ||
mime.contains('msword') ||
mime.contains('opendocument.text') ||
mime.contains('presentation') ||
mime.contains('powerpoint') ||
mime == 'application/rtf') {
return NooFileKind.doc;
}
// Generic types (application/octet-stream etc.) fall through to the
// extension check.
return null;
}
static const _extensions = <String, NooFileKind>{
'pdf': NooFileKind.pdf,
// doc / text
'doc': NooFileKind.doc,
'docx': NooFileKind.doc,
'odt': NooFileKind.doc,
'rtf': NooFileKind.doc,
'txt': NooFileKind.doc,
'md': NooFileKind.doc,
'ppt': NooFileKind.doc,
'pptx': NooFileKind.doc,
'odp': NooFileKind.doc,
'pages': NooFileKind.doc,
// sheet
'xls': NooFileKind.sheet,
'xlsx': NooFileKind.sheet,
'ods': NooFileKind.sheet,
'csv': NooFileKind.sheet,
'numbers': NooFileKind.sheet,
// video
'mp4': NooFileKind.video,
'mov': NooFileKind.video,
'mkv': NooFileKind.video,
'webm': NooFileKind.video,
'avi': NooFileKind.video,
'm4v': NooFileKind.video,
'3gp': NooFileKind.video,
// image
'jpg': NooFileKind.image,
'jpeg': NooFileKind.image,
'png': NooFileKind.image,
'gif': NooFileKind.image,
'webp': NooFileKind.image,
'heic': NooFileKind.image,
'heif': NooFileKind.image,
'bmp': NooFileKind.image,
'svg': NooFileKind.image,
'tif': NooFileKind.image,
'tiff': NooFileKind.image,
'dng': NooFileKind.image,
// archive
'zip': NooFileKind.archive,
'tar': NooFileKind.archive,
'gz': NooFileKind.archive,
'tgz': NooFileKind.archive,
'bz2': NooFileKind.archive,
'xz': NooFileKind.archive,
'7z': NooFileKind.archive,
'rar': NooFileKind.archive,
};
}
+193
View File
@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
import 'noo_file_kind.dart';
import 'noo_file_tile.dart';
import 'noo_status_icon.dart';
/// Mobile 64px file row (`DESIGN_SYSTEM.md` 2, "File row"): 40px tile, name
/// (16/500, one line) over a meta line (`[status][shared] size · modified`,
/// 13 fg-3), an optional favorite star / [trailing] widget, then the
/// overflow button. Paints its own `surface` background (accent-soft when
/// [selected]), so it can sit directly in a list or inside a
/// `NooSwipeAction`.
class NooFileRow extends StatelessWidget {
final NooFileKind kind;
final String name;
/// Formatted size, e.g. "2.4 MB". Joined to [modified] with " · ".
final String? size;
/// Formatted modified time, e.g. "Yesterday".
final String? modified;
/// Replaces the "size · modified" text entirely - use it for the error
/// hint ("Couldn't sync · Tap to retry") or screen-specific meta (Trash's
/// "Deleted 3 days ago").
final String? meta;
/// Leading meta-line icons, drawn in order (spec order is sync status,
/// then shared). When this contains [NooSyncStatus.error] the meta text
/// turns `danger`.
final List<NooSyncStatus> statuses;
/// Shows the accent-text star before the overflow button.
final bool favorite;
/// Screen-specific trailing icon (e.g. `rotate-ccw` in Trash), placed
/// after the favorite star and before the overflow button.
final Widget? trailing;
/// `ellipsis` on iOS, `ellipsis-vertical` everywhere else.
final bool iosStyle;
final bool selected;
/// Real image/video thumbnail for the tile; see [NooFileTile.thumbnail].
final Widget? thumbnail;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
/// Overflow menu tap. The button is hidden when null.
final VoidCallback? onMore;
const NooFileRow({
super.key,
required this.kind,
required this.name,
this.size,
this.modified,
this.meta,
this.statuses = const [],
this.favorite = false,
this.trailing,
this.iosStyle = false,
this.selected = false,
this.thumbnail,
this.onTap,
this.onLongPress,
this.onMore,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final metaText =
meta ??
[
size,
modified,
].whereType<String>().where((s) => s.isNotEmpty).join(' · ');
final hasError = statuses.contains(NooSyncStatus.error);
return Material(
color: selected ? colors.accentSoft : colors.surface,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
child: SizedBox(
height: NooSizes.rowMobile,
child: Padding(
padding: EdgeInsets.only(
left: NooSpace.md,
right: onMore != null ? NooSpace.xxs : NooSpace.md,
),
child: Row(
children: [
NooFileTile(kind: kind, thumbnail: thumbnail),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.bodyL.copyWith(
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
if (statuses.isNotEmpty || metaText.isNotEmpty) ...[
const SizedBox(height: NooSpace.xxs),
Row(
children: [
for (final s in statuses) ...[
NooStatusIcon(s),
const SizedBox(width: NooSpace.xxs),
],
Expanded(
child: Text(
metaText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(
color: hasError ? colors.danger : colors.fg3,
),
),
),
],
),
],
],
),
),
if (favorite) ...[
const SizedBox(width: NooSpace.xs),
Icon(LucideIcons.star, size: 18, color: colors.accentText),
],
if (trailing != null) ...[
const SizedBox(width: NooSpace.xs),
IconTheme.merge(
data: IconThemeData(size: 18, color: colors.fg3),
child: trailing!,
),
],
if (onMore != null)
NooOverflowButton(iosStyle: iosStyle, onTap: onMore),
],
),
),
),
),
);
}
}
/// The 40px round overflow ("more") hit target used at the end of file
/// rows - `ellipsis` when [iosStyle], `ellipsis-vertical` otherwise, 20px
/// in fg-3.
class NooOverflowButton extends StatelessWidget {
final bool iosStyle;
final double size;
final VoidCallback? onTap;
const NooOverflowButton({
super.key,
this.iosStyle = false,
this.size = 40,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return SizedBox.square(
dimension: size,
child: Material(
type: MaterialType.transparency,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Icon(
iosStyle ? LucideIcons.ellipsis : LucideIcons.ellipsisVertical,
size: size >= 40 ? 20 : 18,
color: colors.fg3,
),
),
),
);
}
}
+243
View File
@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
import 'noo_file_kind.dart';
import 'noo_file_row.dart';
import 'noo_file_tile.dart';
import 'noo_status_icon.dart';
/// Fixed widths of the desktop file table's grid
/// (`minmax(0,1fr) 180px 160px 120px`, `DESIGN_SYSTEM.md` 2 "File row").
/// The first column takes the remaining width.
const double _col2Width = 180;
const double _col3Width = 160;
const double _col4Width = 120;
/// Inset shared by header and rows so their columns line up; also the
/// padding inside a selected row's r12 fill.
const double _tableInset = NooSpace.sm;
/// Lays out the four table columns. Shared by [NooFileTableHeader] and
/// [NooFileTableRow] so the grid can't drift between them.
class _TableColumns extends StatelessWidget {
final Widget name;
final Widget col2;
final Widget col3;
final Widget col4;
const _TableColumns({
required this.name,
required this.col2,
required this.col3,
required this.col4,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: _tableInset),
child: Row(
children: [
Expanded(child: name),
SizedBox(width: _col2Width, child: col2),
SizedBox(width: _col3Width, child: col3),
SizedBox(width: _col4Width, child: col4),
],
),
);
}
}
/// Desktop file table header: 36px, 12/600 fg-3 labels, and a sort arrow
/// on the active column. The fourth (status/overflow) column is unlabelled.
class NooFileTableHeader extends StatelessWidget {
/// Label for the flexible Name column.
final String nameLabel;
/// Labels for the 180px and 160px data columns, e.g. "Modified", "Size".
final String col2Label;
final String col3Label;
/// Active sort column - 0 = name, 1 = col2, 2 = col3 - or null for none.
final int? sortColumn;
final bool sortAscending;
/// Called with the tapped column's index; header cells aren't tappable
/// when null.
final ValueChanged<int>? onSort;
const NooFileTableHeader({
super.key,
this.nameLabel = 'Name',
required this.col2Label,
required this.col3Label,
this.sortColumn,
this.sortAscending = true,
this.onSort,
});
Widget _cell(BuildContext context, int index) {
final colors = context.nooColors;
final style = NooText.label.copyWith(
fontSize: 12,
height: 1,
color: colors.fg3,
);
final active = sortColumn == index;
final content = Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
[nameLabel, col2Label, col3Label][index],
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style,
),
),
if (active) ...[
const SizedBox(width: NooSpace.xxs),
Icon(
sortAscending ? LucideIcons.arrowUp : LucideIcons.arrowDown,
size: 12,
color: colors.fg3,
),
],
],
);
return Align(
alignment: Alignment.centerLeft,
child: onSort == null
? content
: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onSort!(index),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: content,
),
),
);
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 36,
child: _TableColumns(
name: _cell(context, 0),
col2: _cell(context, 1),
col3: _cell(context, 2),
col4: const SizedBox.shrink(),
),
);
}
}
/// Desktop file table row: 52px, 30px tile + name (14/500) in the flexible
/// column, two meta-style data columns, then status icons and the overflow
/// button right-aligned in the 120px column. Selected rows get an
/// accent-soft r12 fill; otherwise the row is transparent over the pane.
class NooFileTableRow extends StatelessWidget {
final NooFileKind kind;
final String name;
/// Second and third column contents, e.g. modified time and size.
final String? col2;
final String? col3;
/// Status icons in the last column, drawn before the favorite star.
final List<NooSyncStatus> statuses;
final bool favorite;
final bool selected;
final Widget? thumbnail;
final VoidCallback? onTap;
final VoidCallback? onDoubleTap;
final VoidCallback? onSecondaryTap;
/// Overflow menu tap. The button is hidden when null.
final VoidCallback? onMore;
const NooFileTableRow({
super.key,
required this.kind,
required this.name,
this.col2,
this.col3,
this.statuses = const [],
this.favorite = false,
this.selected = false,
this.thumbnail,
this.onTap,
this.onDoubleTap,
this.onSecondaryTap,
this.onMore,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final metaStyle = NooText.meta.copyWith(color: colors.fg3);
Widget dataCell(String? text) => Text(
text ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: metaStyle,
);
return Material(
color: selected ? colors.accentSoft : Colors.transparent,
borderRadius: BorderRadius.circular(NooRadii.sidebarItem),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
onDoubleTap: onDoubleTap,
onSecondaryTap: onSecondaryTap,
child: SizedBox(
height: NooSizes.rowDesktop,
child: _TableColumns(
name: Row(
children: [
NooFileTile(
kind: kind,
size: NooFileTileSize.desktop,
thumbnail: thumbnail,
),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
),
const SizedBox(width: NooSpace.md),
],
),
col2: dataCell(col2),
col3: dataCell(col3),
col4: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
for (final s in statuses) ...[
NooStatusIcon(s),
const SizedBox(width: 6),
],
if (favorite) ...[
Icon(LucideIcons.star, size: 14, color: colors.accentText),
const SizedBox(width: 6),
],
if (onMore != null) NooOverflowButton(size: 32, onTap: onMore),
],
),
),
),
),
);
}
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
import 'noo_file_kind.dart';
/// The four tile sizes from `DESIGN_SYSTEM.md` 1.2 - pick by context; the
/// icon is always half the tile.
enum NooFileTileSize {
/// 40px / r12 - mobile file rows.
row(40, 12),
/// 30px / r9 - desktop table rows.
desktop(30, 9),
/// 44px / r12 - share sheet/dialog header.
header(44, 12),
/// 32px / r10 - activity items.
activity(32, 10);
final double extent;
final double radius;
const NooFileTileSize(this.extent, this.radius);
double get iconSize => extent / 2;
}
/// Rounded-square file-type tile: the kind's soft tint with its Lucide icon
/// in the strong tint (`DESIGN_SYSTEM.md` 1.2). Pass [thumbnail] (e.g. an
/// `Image`) to show real content instead - it's clipped to the same shape
/// and cover-fit over the kind's tint, which shows while it loads.
class NooFileTile extends StatelessWidget {
final NooFileKind kind;
final NooFileTileSize size;
final Widget? thumbnail;
const NooFileTile({
super.key,
required this.kind,
this.size = NooFileTileSize.row,
this.thumbnail,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final radius = BorderRadius.circular(size.radius);
return SizedBox.square(
dimension: size.extent,
child: thumbnail != null
? ClipRRect(
borderRadius: radius,
child: ColoredBox(
color: kind.background(colors),
child: SizedBox.expand(
child: FittedBox(fit: BoxFit.cover, child: thumbnail),
),
),
)
: DecoratedBox(
decoration: BoxDecoration(
color: kind.background(colors),
borderRadius: radius,
),
child: Center(
child: Icon(
kind.icon,
size: size.iconSize,
color: kind.foreground(colors),
),
),
),
);
}
}
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Per-file status markers for a file row's meta line or a table row's
/// status column (`DESIGN_SYSTEM.md` 2, "Status icons").
enum NooSyncStatus {
synced,
syncing,
error,
shared;
IconData get icon {
switch (this) {
case NooSyncStatus.synced:
return LucideIcons.circleCheck;
case NooSyncStatus.syncing:
return LucideIcons.refreshCw;
case NooSyncStatus.error:
return LucideIcons.circleAlert;
case NooSyncStatus.shared:
return LucideIcons.users;
}
}
Color color(NooColors colors) {
switch (this) {
case NooSyncStatus.synced:
return colors.success;
case NooSyncStatus.syncing:
return colors.accentText;
case NooSyncStatus.error:
return colors.danger;
case NooSyncStatus.shared:
return colors.fg3;
}
}
}
/// A single 14px status icon. For [NooSyncStatus.error] the row's meta
/// text should say what to do ("Couldn't sync · Tap to retry") - this only
/// draws the icon.
class NooStatusIcon extends StatelessWidget {
final NooSyncStatus status;
final double size;
const NooStatusIcon(this.status, {super.key, this.size = 14});
@override
Widget build(BuildContext context) {
return Icon(
status.icon,
size: size,
color: status.color(context.nooColors),
);
}
}
+242
View File
@@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// The two swipe actions the design system defines (`DESIGN_SYSTEM.md` 2,
/// "Swipe action"); which one sits on which side is a user setting
/// (Settings -> Swipe on a file).
enum NooSwipeActionKind { delete, favorite }
/// One side's action for [NooSwipeAction].
class NooSwipeActionSpec {
final NooSwipeActionKind kind;
final VoidCallback onTriggered;
/// Overrides the default label ("Delete" / "Favorite"), e.g.
/// "Unfavorite" for an already-starred file.
final String? label;
/// Overrides the default icon (`trash-2` / `star`).
final IconData? icon;
const NooSwipeActionSpec({
required this.kind,
required this.onTriggered,
this.label,
this.icon,
});
}
/// Wraps a row (normally a `NooFileRow`) so dragging it horizontally slides
/// it aside to uncover a 96px action block: delete is white on
/// `danger-fill`, favorite is white on `accent`, each a 20px icon over a
/// 12/600 label. Releasing past half the block snaps it open, otherwise it
/// springs shut - both on [NooMotion.base] / [NooMotion.ease], no bounce.
/// Tapping the block fires the action and closes it; tapping the row while
/// open just closes it.
class NooSwipeAction extends StatefulWidget {
final Widget child;
/// Revealed on the leading edge (swipe toward the trailing edge).
final NooSwipeActionSpec? startAction;
/// Revealed on the trailing edge (swipe toward the leading edge).
final NooSwipeActionSpec? endAction;
const NooSwipeAction({
super.key,
required this.child,
this.startAction,
this.endAction,
});
/// Width of the uncovered action block.
static const double actionWidth = 96;
@override
State<NooSwipeAction> createState() => _NooSwipeActionState();
}
class _NooSwipeActionState extends State<NooSwipeAction>
with SingleTickerProviderStateMixin {
/// -1 = end action fully revealed, 0 = closed, 1 = start action revealed
/// (in visual left/right terms after [_dir] is applied).
late final AnimationController _ctrl = AnimationController(
vsync: this,
lowerBound: -1,
upperBound: 1,
value: 0,
);
double get _dir => Directionality.of(context) == TextDirection.rtl ? -1 : 1;
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
void _animateTo(double target) {
_ctrl.animateTo(target, duration: NooMotion.base, curve: NooMotion.ease);
}
void _close() => _animateTo(0);
void _onDragUpdate(DragUpdateDetails d) {
// Positive = revealing the start action.
final delta = d.primaryDelta! * _dir / NooSwipeAction.actionWidth;
final min = widget.endAction != null ? -1.0 : 0.0;
final max = widget.startAction != null ? 1.0 : 0.0;
_ctrl.value = (_ctrl.value + delta).clamp(min, max);
}
void _onDragEnd(DragEndDetails d) {
final v = _ctrl.value;
final velocity = d.primaryVelocity! * _dir;
double target;
if (velocity.abs() > 700) {
final opening = velocity.sign == v.sign || v == 0;
target = opening ? velocity.sign : 0;
} else {
target = v.abs() >= 0.5 ? v.sign : 0;
}
if (target > 0 && widget.startAction == null) target = 0;
if (target < 0 && widget.endAction == null) target = 0;
_animateTo(target);
}
void _trigger(NooSwipeActionSpec spec) {
_close();
spec.onTriggered();
}
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
if (widget.startAction == null && widget.endAction == null) {
return widget.child;
}
return GestureDetector(
onHorizontalDragUpdate: _onDragUpdate,
onHorizontalDragEnd: _onDragEnd,
child: ClipRect(
child: AnimatedBuilder(
animation: _ctrl,
builder: (context, child) {
final v = _ctrl.value;
// Only the side being uncovered is painted, so the opposite
// block never peeks out from behind a translucent row.
final showing = v > 0
? widget.startAction
: (v < 0 ? widget.endAction : null);
return Stack(
children: [
if (showing != null)
Positioned.fill(
child: Align(
alignment: v > 0
? AlignmentDirectional.centerStart
: AlignmentDirectional.centerEnd,
child: _ActionBlock(
spec: showing,
colors: colors,
onTap: () => _trigger(showing),
),
),
),
Transform.translate(
offset: Offset(v * NooSwipeAction.actionWidth * _dir, 0),
child: child,
),
],
);
},
child: _OpenAwareChild(
controller: _ctrl,
onCloseTap: _close,
child: widget.child,
),
),
),
);
}
}
/// Swallows taps on the row while it's swiped open (closing it instead),
/// so an open row doesn't also navigate.
class _OpenAwareChild extends AnimatedWidget {
final Widget child;
final VoidCallback onCloseTap;
const _OpenAwareChild({
required AnimationController controller,
required this.onCloseTap,
required this.child,
}) : super(listenable: controller);
@override
Widget build(BuildContext context) {
final open = (listenable as AnimationController).value != 0;
return Stack(
children: [
child,
if (open)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onCloseTap,
),
),
],
);
}
}
class _ActionBlock extends StatelessWidget {
final NooSwipeActionSpec spec;
final NooColors colors;
final VoidCallback onTap;
const _ActionBlock({
required this.spec,
required this.colors,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final isDelete = spec.kind == NooSwipeActionKind.delete;
final bg = isDelete ? colors.dangerFill : colors.accent;
final icon =
spec.icon ?? (isDelete ? LucideIcons.trash2 : LucideIcons.star);
final label = spec.label ?? (isDelete ? 'Delete' : 'Favorite');
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
width: NooSwipeAction.actionWidth,
color: bg,
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 20, color: Colors.white),
const SizedBox(height: 6),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.label.copyWith(
fontSize: 12,
height: 1,
color: Colors.white,
),
),
],
),
),
);
}
}
+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,
),
),
),
],
),
);
}
}
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
import '../core/noo_avatar.dart';
/// Activity feed entry (`DESIGN_SYSTEM.md` section 2, "Activity item"):
/// a 36px [NooAvatar], then the sentence **actor** verb **object** tail
/// with the time below in meta style, and an optional [trailing] slot on
/// the right for the 32px/r10 file tile (built by the caller, so this
/// doesn't depend on the file-kind mapping).
class NooActivityItem extends StatelessWidget {
final String actor;
/// Plain text between actor and object, e.g. `"shared"`.
final String verb;
/// Bold subject of the action, e.g. a file name.
final String? object;
/// Plain text after the object, e.g. `"with you"`.
final String? tail;
final String? time;
/// Avatar initials; defaults to the first letter of [actor].
final String? initials;
/// One of [NooColors.avatarPalette].
final Color? avatarColor;
/// Uses the accent-soft current-user avatar.
final bool currentUser;
/// Right-aligned slot, typically a 32px file tile.
final Widget? trailing;
final VoidCallback? onTap;
const NooActivityItem({
super.key,
required this.actor,
required this.verb,
this.object,
this.tail,
this.time,
this.initials,
this.avatarColor,
this.currentUser = false,
this.trailing,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final base = NooText.body.copyWith(color: colors.fg1);
const bold = TextStyle(fontWeight: FontWeight.w600);
final fallbackInitials = actor.trim().isEmpty
? ''
: actor.trim().characters.first.toUpperCase();
return Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.md,
vertical: NooSpace.sm,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
NooAvatar(
initials: initials ?? fallbackInitials,
size: 36,
color: avatarColor,
current: currentUser,
),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
style: base,
children: [
TextSpan(text: actor, style: bold),
TextSpan(text: ' $verb'),
if (object != null) ...[
const TextSpan(text: ' '),
TextSpan(text: object, style: bold),
],
if (tail != null) TextSpan(text: ' $tail'),
],
),
),
if (time != null) ...[
const SizedBox(height: NooSpace.xxs),
Text(time!, style: NooText.meta.copyWith(color: colors.fg3)),
],
],
),
),
if (trailing != null) ...[
const SizedBox(width: NooSpace.sm),
trailing!,
],
],
),
),
),
);
}
}
+179
View File
@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Grid-view file card: a thumbnail area (file-type soft colour + 32px
/// icon, or a real [thumbnail]) above the name, an overflow button and a
/// meta line (`DESIGN_SYSTEM.md` section 2, "Grid card"). Mobile lays these
/// out 2-up with a 10px gap, desktop 5-up with a 16px gap - the parent grid
/// owns that, this card just fills its cell.
///
/// The placeholder colours/icon are plain parameters rather than a file
/// kind so this stays independent of the file-kind mapping; callers pass
/// e.g. `accentSoft`/`accentText`/`LucideIcons.folder` for a folder.
class NooGridCard extends StatelessWidget {
final String name;
final String? meta;
/// Soft fill behind [icon] when there's no [thumbnail] (e.g. `infoSoft`).
final Color? placeholderColor;
final IconData? icon;
/// Icon colour on the placeholder (e.g. `info`).
final Color? iconColor;
/// Real thumbnail (typically an `Image` with `BoxFit.cover`); replaces
/// the placeholder and is clipped to the card's top corners.
final Widget? thumbnail;
/// 104 (mobile) - 118 (desktop).
final double thumbnailHeight;
final bool selected;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
/// Shows the overflow button when non-null.
final VoidCallback? onMore;
/// `ellipsis` on iOS/desktop, `ellipsis-vertical` on Android.
final bool verticalOverflowIcon;
const NooGridCard({
super.key,
required this.name,
this.meta,
this.placeholderColor,
this.icon,
this.iconColor,
this.thumbnail,
this.thumbnailHeight = 104,
this.selected = false,
this.onTap,
this.onLongPress,
this.onMore,
this.verticalOverflowIcon = false,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final radius = BorderRadius.circular(NooRadii.gridCard);
final thumb = SizedBox(
height: thumbnailHeight,
width: double.infinity,
child:
thumbnail ??
ColoredBox(
color: placeholderColor ?? colors.surface2,
child: Center(
child: Icon(
icon ?? LucideIcons.file,
size: 32,
color: iconColor ?? colors.fg2,
),
),
),
);
return Material(
color: selected ? colors.accentSoft : colors.surface,
borderRadius: radius,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
thumb,
Padding(
padding: EdgeInsets.fromLTRB(
NooSpace.sm,
10,
onMore != null ? NooSpace.xxs : NooSpace.sm,
NooSpace.sm,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(
fontSize: 14,
height: 1.2,
fontWeight: FontWeight.w500,
color: selected ? colors.accentText : colors.fg1,
),
),
if (meta != null) ...[
const SizedBox(height: NooSpace.xxs),
Text(
meta!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(
fontSize: 12,
color: colors.fg3,
),
),
],
],
),
),
if (onMore != null)
_OverflowButton(
icon: verticalOverflowIcon
? LucideIcons.ellipsisVertical
: LucideIcons.ellipsis,
color: colors.fg3,
onTap: onMore!,
),
],
),
),
],
),
),
);
}
}
/// Compact 28px circular hit target so the overflow button doesn't push
/// the name/meta column taller than the text itself.
class _OverflowButton extends StatelessWidget {
final IconData icon;
final Color color;
final VoidCallback onTap;
const _OverflowButton({
required this.icon,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 28,
height: 28,
child: Material(
type: MaterialType.transparency,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Icon(icon, size: 16, color: color),
),
),
);
}
}
+130
View File
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
/// Month group heading for the Photos grid (`DESIGN_SYSTEM.md` section 2,
/// "Photo grid"): group heading (Schibsted 18/500, fg-1) on the left and
/// the item count in meta style (fg-3) on the right.
class NooPhotoGroupHeader extends StatelessWidget {
final String title;
/// e.g. `"128 photos"`; hidden when null.
final String? count;
/// Horizontal inset - 16 on mobile (the grid itself is edge to edge),
/// 24 on desktop.
final EdgeInsetsGeometry padding;
const NooPhotoGroupHeader({
super.key,
required this.title,
this.count,
this.padding = const EdgeInsets.fromLTRB(
NooSpace.md,
NooSpace.lg,
NooSpace.md,
NooSpace.sm,
),
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Padding(
padding: padding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.groupHeading.copyWith(height: 1, color: colors.fg1),
),
),
if (count != null) ...[
const SizedBox(width: NooSpace.sm),
Text(count!, style: NooText.meta.copyWith(color: colors.fg3)),
],
],
),
);
}
}
/// Square-tile photo grid (`DESIGN_SYSTEM.md` section 2, "Photo grid"):
/// mobile is 3 columns with a 2px gap edge to edge ([NooPhotoGrid.mobile]
/// defaults), desktop 8 columns with 4px ([NooPhotoGrid.desktop]).
///
/// Builds lazily from [itemBuilder] (typically returning a `NooPhotoTile`).
/// Use the default constructor inside a `CustomScrollView` as a sliver
/// (one per month group, after a `SliverToBoxAdapter(NooPhotoGroupHeader)`),
/// or [NooPhotoGrid.box] for a non-scrolling box that sizes to its content
/// (e.g. inside a `Column`/`ListView` item).
class NooPhotoGrid extends StatelessWidget {
final int itemCount;
final IndexedWidgetBuilder itemBuilder;
final int columns;
final double gap;
final EdgeInsetsGeometry padding;
final bool _sliver;
/// Sliver form. Defaults are the mobile layout.
const NooPhotoGrid({
super.key,
required this.itemCount,
required this.itemBuilder,
this.columns = 3,
this.gap = 2,
this.padding = EdgeInsets.zero,
}) : _sliver = true;
/// Box form: a shrink-wrapped, non-scrolling grid.
const NooPhotoGrid.box({
super.key,
required this.itemCount,
required this.itemBuilder,
this.columns = 3,
this.gap = 2,
this.padding = EdgeInsets.zero,
}) : _sliver = false;
/// Mobile/desktop column + gap presets from the spec.
static const mobileColumns = 3;
static const mobileGap = 2.0;
static const desktopColumns = 8;
static const desktopGap = 4.0;
SliverGridDelegate get _delegate =>
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: gap,
crossAxisSpacing: gap,
);
@override
Widget build(BuildContext context) {
if (_sliver) {
return SliverPadding(
padding: padding,
sliver: SliverGrid(
gridDelegate: _delegate,
delegate: SliverChildBuilderDelegate(
itemBuilder,
childCount: itemCount,
),
),
);
}
return GridView.builder(
shrinkWrap: true,
primary: false,
physics: const NeverScrollableScrollPhysics(),
padding: padding,
gridDelegate: _delegate,
itemCount: itemCount,
itemBuilder: itemBuilder,
);
}
}
+164
View File
@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Video badge background from `DESIGN_SYSTEM.md` section 2 ("Photo
/// grid"): `rgba(14,13,11,.7)` - not a [NooColors] token, it's a fixed
/// overlay that sits on photo content in both themes.
const _videoBadgeColor = Color(0xB30E0D0B);
/// Square photo tile for [NooPhotoGrid] (`DESIGN_SYSTEM.md` section 2,
/// "Photo grid"): an image [child] (usually `Image` with `BoxFit.cover`)
/// or, while loading/absent, a flat [placeholderColor] from
/// [NooColors.avatarPalette]. Videos get a bottom-right badge with a `play`
/// icon and [videoDuration].
class NooPhotoTile extends StatelessWidget {
final Widget? child;
/// Fill shown behind/instead of [child]; defaults to the first
/// [NooColors.avatarPalette] entry. Use [NooPhotoTile.paletteColor] to
/// pick a stable one per item.
final Color? placeholderColor;
/// Non-null marks the tile as a video and shows the badge, e.g. `0:42`.
/// Pass an empty string for a play-icon-only badge.
final String? videoDuration;
final bool selected;
/// Shows the selection check circle even when not [selected] (i.e. the
/// grid is in multi-select mode).
final bool selectionMode;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
const NooPhotoTile({
super.key,
this.child,
this.placeholderColor,
this.videoDuration,
this.selected = false,
this.selectionMode = false,
this.onTap,
this.onLongPress,
});
/// Stable secondary-palette colour for an item (e.g. from its id/path
/// hash), so placeholders don't reshuffle between rebuilds.
static Color paletteColor(int seed) =>
NooColors.avatarPalette[seed.abs() % NooColors.avatarPalette.length];
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return AspectRatio(
aspectRatio: 1,
child: GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: Stack(
fit: StackFit.expand,
children: [
// Selected tiles inset so an accent-soft frame shows around
// the photo, instead of covering it with a tint.
ColoredBox(color: selected ? colors.accentSoft : Colors.transparent),
AnimatedPadding(
duration: NooMotion.fast,
curve: NooMotion.ease,
padding: EdgeInsets.all(selected ? 8 : 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(selected ? 8 : 0),
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(
color: placeholderColor ?? NooColors.avatarPalette.first,
),
?child,
],
),
),
),
if (videoDuration != null)
Positioned(
right: 6,
bottom: 6,
child: _VideoBadge(duration: videoDuration!),
),
if (selected || selectionMode)
Positioned(
left: 6,
top: 6,
child: _SelectCheck(selected: selected),
),
],
),
),
);
}
}
class _VideoBadge extends StatelessWidget {
final String duration;
const _VideoBadge({required this.duration});
@override
Widget build(BuildContext context) {
return Container(
height: 20,
padding: EdgeInsets.symmetric(horizontal: duration.isEmpty ? 4 : 6),
decoration: BoxDecoration(
color: _videoBadgeColor,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(LucideIcons.play, size: 12, color: Colors.white),
if (duration.isNotEmpty) ...[
const SizedBox(width: 3),
Text(
duration,
style: NooText.meta.copyWith(
fontSize: 11,
height: 1,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
],
),
);
}
}
/// 22px selection circle: accent fill + white check when selected, a
/// white-outlined empty circle on the video-badge scrim otherwise.
class _SelectCheck extends StatelessWidget {
final bool selected;
const _SelectCheck({required this.selected});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Container(
width: 22,
height: 22,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected ? colors.accent : _videoBadgeColor,
border: selected
? null
: Border.all(color: Colors.white, width: 1.5),
),
child: selected
? const Icon(LucideIcons.check, size: 14, color: Colors.white)
: null,
);
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
import '../core/noo_progress_bar.dart';
/// Desktop stat card (`DESIGN_SYSTEM.md` sections 1.3/1.4): radius 18,
/// Stat text (Schibsted 600, 28 on desktop), a label below and an optional
/// 6px [NooProgressBar]. The mobile/Offline equivalent with an action
/// button is `NooSummaryCard`.
class NooStatCard extends StatelessWidget {
final String stat;
final String label;
/// 0-1; renders a [NooProgressBar] under the label.
final double? progress;
/// Optional line under the progress bar, e.g. `"of 50 GB"`.
final String? meta;
final IconData? icon;
/// Defaults to `surface`; pass `surface2` when the card sits on a
/// `surface` pane.
final Color? color;
final VoidCallback? onTap;
const NooStatCard({
super.key,
required this.stat,
required this.label,
this.progress,
this.meta,
this.icon,
this.color,
this.onTap,
});
/// Desktop Stat size (spec 1.3: 32 mobile / 28 desktop); [NooText.stat]
/// only carries the mobile size.
static const _desktopStatSize = 28.0;
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Material(
color: color ?? colors.surface,
borderRadius: BorderRadius.circular(NooRadii.gridCard),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 18, color: colors.fg2),
const SizedBox(height: NooSpace.sm),
],
Text(
stat,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.stat.copyWith(
fontSize: _desktopStatSize,
letterSpacing: -_desktopStatSize * 0.03,
color: colors.fg1,
),
),
const SizedBox(height: NooSpace.xs),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(color: colors.fg2),
),
if (progress != null) ...[
const SizedBox(height: NooSpace.smd),
NooProgressBar(value: progress!),
],
if (meta != null) ...[
const SizedBox(height: NooSpace.xs),
Text(
meta!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(fontSize: 12, color: colors.fg3),
),
],
],
),
),
),
);
}
}
+178
View File
@@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
import 'noo_nav_style.dart';
export 'noo_nav_style.dart';
/// The mobile bottom bar (DESIGN_SYSTEM.md 3, "Mobile"; `iNav`/`aNav` in
/// `Mobile Screen.dc.html`). Shows the 5 pinned tabs - which ones, and in
/// what order, is the caller's business; this only draws [destinations].
///
/// - [NooNavStyle.ios]: surface fill, 1px top `line`, a 50px row (8px top
/// padding) of icon 24 over a 10px label. Active: accent-text, 600.
/// Idle: fg-3, 500. No ripple.
/// - [NooNavStyle.android]: surface fill, 80px. The active icon sits in a
/// 56x32 accent-soft pill (accent-text icon); label is 12px, fg-1/600
/// active and fg-2/500 idle (idle icon fg-2).
///
/// The home indicator / gesture bar area below the row comes from the
/// bottom safe-area inset rather than a fixed 34/20px spacer, so it's
/// right on every device. Meant for `Scaffold.bottomNavigationBar`.
class NooBottomBar extends StatelessWidget {
final NooNavStyle style;
final List<NooNavDestination> destinations;
final int selectedIndex;
final ValueChanged<int> onSelected;
const NooBottomBar({
super.key,
required this.style,
required this.destinations,
required this.selectedIndex,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final ios = style == NooNavStyle.ios;
final row = Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < destinations.length; i++)
Expanded(
child: ios
? _IosItem(
destination: destinations[i],
selected: i == selectedIndex,
onTap: () => onSelected(i),
)
: _AndroidItem(
destination: destinations[i],
selected: i == selectedIndex,
onTap: () => onSelected(i),
),
),
],
);
return Container(
decoration: BoxDecoration(
color: colors.surface,
border: ios ? Border(top: BorderSide(color: colors.line)) : null,
),
child: SafeArea(
top: false,
child: SizedBox(height: ios ? 50 : 80, child: row),
),
);
}
}
class _IosItem extends StatelessWidget {
final NooNavDestination destination;
final bool selected;
final VoidCallback onTap;
const _IosItem({
required this.destination,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final fg = selected ? colors.accentText : colors.fg3;
return Semantics(
button: true,
selected: selected,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Icon(destination.icon, size: 24, color: fg),
const SizedBox(height: 4),
Text(
destination.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.navLabel.copyWith(
fontSize: 10,
height: 1,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: fg,
),
),
],
),
),
),
);
}
}
class _AndroidItem extends StatelessWidget {
final NooNavDestination destination;
final bool selected;
final VoidCallback onTap;
const _AndroidItem({
required this.destination,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Semantics(
button: true,
selected: selected,
// No ripple: the animated pill is the press/selection feedback.
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
duration: NooMotion.fast,
curve: NooMotion.ease,
width: 56,
height: 32,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected
? colors.accentSoft
: colors.accentSoft.withValues(alpha: 0),
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: Icon(
destination.icon,
size: 24,
color: selected ? colors.accentText : colors.fg2,
),
),
const SizedBox(height: 4),
Text(
destination.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: (selected ? NooText.navLabelActive : NooText.navLabel)
.copyWith(
height: 1,
color: selected ? colors.fg1 : colors.fg2,
),
),
],
),
),
);
}
}
+365
View File
@@ -0,0 +1,365 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
import '../core/noo_progress_bar.dart';
/// The mobile drawer shell (DESIGN_SYSTEM.md 2 "Drawer" / 3; `drawer` in
/// `Mobile Screen.dc.html`): 316px wide, `surface` fill, radius 28 on the
/// trailing edge, flat (no elevation). Slots top to bottom:
///
/// 1. [account] - usually a [NooDrawerAccount]
/// 2. [storage] - usually a [NooDrawerStorage]
/// 3. a divider, then - only when [moreItems] isn't empty - the [moreLabel]
/// heading and [moreItems] (the unpinned tabs, as [NooDrawerItem]s)
/// 4. a divider, then [settings] (a [NooDrawerItem])
/// 5. a spacer, then [footer] - the "Edit tabs" [NooDrawerLink]
///
/// Wraps Flutter's [Drawer], so pass it to `Scaffold.drawer` and the open/
/// close gesture and semantics come for free. The scrim is the Scaffold's
/// job, not the drawer's: set `Scaffold.drawerScrimColor` to
/// `context.nooColors.scrim`. The content scrolls if it doesn't fit (e.g.
/// landscape), keeping [footer] pinned to the bottom when it does.
class NooDrawer extends StatelessWidget {
final Widget? account;
final Widget? storage;
final String moreLabel;
final List<Widget> moreItems;
final Widget? settings;
final Widget? footer;
const NooDrawer({
super.key,
this.account,
this.storage,
this.moreLabel = 'More',
this.moreItems = const [],
this.settings,
this.footer,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final insets = MediaQuery.paddingOf(context);
Widget divider(EdgeInsets margin) =>
Container(height: 1, margin: margin, color: colors.line);
final children = <Widget>[
?account,
?storage,
if (account != null || storage != null)
divider(const EdgeInsets.fromLTRB(12, 0, 12, 8)),
if (moreItems.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
moreLabel,
style: NooText.label.copyWith(height: 1, color: colors.fg2),
),
),
...moreItems,
if (settings != null)
divider(const EdgeInsets.symmetric(horizontal: 12, vertical: 8)),
],
?settings,
const Spacer(),
?footer,
];
return Drawer(
width: 316,
backgroundColor: colors.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
shadowColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadiusDirectional.horizontal(
end: Radius.circular(NooRadii.sheetTop),
),
),
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
// Reference: 64px from the screen top (= status bar + 10-24px),
// 12px sides, 28px bottom.
padding: EdgeInsets.fromLTRB(
NooSpace.sm,
insets.top + NooSpace.md,
NooSpace.sm,
math.max(28, insets.bottom + NooSpace.sm),
),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: math.max(
0,
constraints.maxHeight -
insets.top -
NooSpace.md -
math.max(28, insets.bottom + NooSpace.sm),
),
),
child: IntrinsicHeight(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 4,
children: children,
),
),
),
),
),
);
}
}
/// The drawer's account block: 48px [avatar], the account [name] (18
/// Schibsted) over a [subtitle] (usually the server host, 13 fg-3), and a
/// 36px surface-2 circle with `chevron-down` to switch accounts.
class NooDrawerAccount extends StatelessWidget {
/// Usually `NooAvatar(size: 48, current: true, ...)`.
final Widget avatar;
final String name;
final String? subtitle;
/// Tapping the block itself (e.g. open account settings).
final VoidCallback? onTap;
/// The chevron button; hidden when null.
final VoidCallback? onSwitchAccount;
const NooDrawerAccount({
super.key,
required this.avatar,
required this.name,
this.subtitle,
this.onTap,
this.onSwitchAccount,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(NooRadii.input),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
spacing: 12,
children: [
avatar,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 5,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.groupHeading.copyWith(
height: 1,
color: colors.fg1,
),
),
if (subtitle != null)
Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(
height: 1,
color: colors.fg3,
),
),
],
),
),
if (onSwitchAccount != null)
Tooltip(
message: 'Switch account',
child: Material(
color: colors.surface2,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onSwitchAccount,
child: SizedBox.square(
dimension: 36,
child: Icon(
LucideIcons.chevronDown,
size: 18,
color: colors.fg1,
),
),
),
),
),
],
),
),
),
),
);
}
}
/// The drawer's storage meter: a 6px [NooProgressBar] over a meta line
/// ("38.2 GB of 100 GB used").
class NooDrawerStorage extends StatelessWidget {
/// 0-1.
final double value;
final String label;
const NooDrawerStorage({super.key, required this.value, required this.label});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
NooProgressBar(value: value),
Text(
label,
style: NooText.meta.copyWith(height: 1, color: colors.fg3),
),
],
),
);
}
}
/// A 52px pill row in [NooDrawer]: 24px icon, 16/500 label, optional
/// trailing [count] (14/500 fg-3). [selected] gives it an accent-soft fill
/// with accent-text content (label at 600, matching the sidebar's active
/// item).
class NooDrawerItem extends StatelessWidget {
final IconData icon;
final String label;
final String? count;
final bool selected;
final VoidCallback? onTap;
const NooDrawerItem({
super.key,
required this.icon,
required this.label,
this.count,
this.selected = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final fg = selected ? colors.accentText : colors.fg1;
return Semantics(
button: true,
selected: selected,
child: Material(
color: selected ? colors.accentSoft : Colors.transparent,
shape: const StadiumBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: SizedBox(
height: 52,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
spacing: 14,
children: [
Icon(icon, size: 24, color: fg),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.bodyL.copyWith(
height: 1,
fontWeight: selected
? FontWeight.w600
: FontWeight.w500,
color: fg,
),
),
),
if (count != null && count!.isNotEmpty)
Text(
count!,
style: NooText.body.copyWith(
fontSize: 14,
height: 1,
fontWeight: FontWeight.w500,
color: selected ? colors.accentText : colors.fg3,
),
),
],
),
),
),
),
),
);
}
}
/// The accent-text link at the bottom of [NooDrawer] ("Edit tabs"): 16px
/// icon, 14/600 label.
class NooDrawerLink extends StatelessWidget {
final String label;
final IconData icon;
final VoidCallback? onTap;
const NooDrawerLink({
super.key,
this.label = 'Edit tabs',
this.icon = LucideIcons.pencil,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Align(
alignment: AlignmentDirectional.centerStart,
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
customBorder: const StadiumBorder(),
child: Padding(
// Vertical padding only grows the hit target (to 44px); the
// 16px sides match the reference inset.
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Icon(icon, size: 16, color: colors.accentText),
Text(
label,
style: NooText.buttonSm.copyWith(
height: 1,
color: colors.accentText,
),
),
],
),
),
),
),
);
}
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/widgets.dart';
/// Which platform's navigation chrome a mobile nav component draws
/// (DESIGN_SYSTEM.md 3, "Mobile"). Only the chrome differs per platform -
/// content components look the same everywhere, so this enum is only taken
/// by the `nav/` widgets ([NooBottomBar], [NooTopBar]).
enum NooNavStyle {
ios,
android;
/// iOS (and macOS, when a mobile layout is shown there) get the iOS
/// chrome; everything else gets the Android chrome.
static NooNavStyle fromPlatform(TargetPlatform platform) =>
switch (platform) {
TargetPlatform.iOS || TargetPlatform.macOS => NooNavStyle.ios,
_ => NooNavStyle.android,
};
}
/// One destination in a [NooBottomBar]. Data-agnostic on purpose - the app
/// maps its own tab model (e.g. `AppTab`) onto these.
@immutable
class NooNavDestination {
final IconData icon;
final String label;
const NooNavDestination({required this.icon, required this.label});
}
+320
View File
@@ -0,0 +1,320 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
import '../core/noo_progress_bar.dart';
/// The desktop sidebar (DESIGN_SYSTEM.md 3, "Desktop"; the 256px column in
/// `Desktop Screen.dc.html`): `bg` fill, 12/12/16 padding, 2px between
/// rows, and a 1px `line` on its trailing edge separating it from the
/// `surface` main pane. Slots top to bottom:
///
/// 1. [windowControls] - e.g. macOS traffic lights (not built here)
/// 2. [account] - usually a [NooSidebarAccount] (14px gap below it)
/// 3. [items] - the pinned tabs, a [NooSidebarDivider], then the rest, as
/// [NooSidebarItem]s
/// 4. a spacer, then [storage] (usually a [NooSidebarStorage]) and
/// [settings] (a [NooSidebarItem])
///
/// Scrolls when the window is too short, keeping the bottom slots pinned
/// down when it isn't.
class NooSidebar extends StatelessWidget {
final Widget? windowControls;
final Widget? account;
final List<Widget> items;
final Widget? storage;
final Widget? settings;
/// Draw the 1px trailing-edge `line`. Turn off if the main pane draws
/// its own leading border.
final bool showDivider;
const NooSidebar({
super.key,
this.windowControls,
this.account,
this.items = const [],
this.storage,
this.settings,
this.showDivider = true,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
const padding = EdgeInsets.fromLTRB(12, 12, 12, 16);
return Container(
width: NooSizes.sidebarWidth,
decoration: BoxDecoration(
color: colors.bg,
border: showDivider
? BorderDirectional(end: BorderSide(color: colors.line))
: null,
),
child: Material(
type: MaterialType.transparency,
child: LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
padding: padding,
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: math.max(
0,
constraints.maxHeight - padding.vertical,
),
),
child: IntrinsicHeight(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 2,
children: [
?windowControls,
if (account != null)
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: account,
),
...items,
const Spacer(),
?storage,
?settings,
],
),
),
),
),
),
),
);
}
}
/// A 38px sidebar row: 18px icon, 14/500 label, optional trailing [count]
/// (12/500). Idle: fg-1 label, fg-2 icon, fg-3 count, `surface` on hover.
/// Active ([selected]): accent-soft fill, everything accent-text, label at
/// 600. Radius 12.
class NooSidebarItem extends StatelessWidget {
final IconData icon;
final String label;
final String? count;
final bool selected;
final VoidCallback? onTap;
const NooSidebarItem({
super.key,
required this.icon,
required this.label,
this.count,
this.selected = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final radius = BorderRadius.circular(NooRadii.sidebarItem);
return Semantics(
button: true,
selected: selected,
child: Material(
color: selected ? colors.accentSoft : Colors.transparent,
borderRadius: radius,
child: InkWell(
onTap: onTap,
borderRadius: radius,
hoverColor: selected ? Colors.transparent : colors.surface,
splashFactory: NoSplash.splashFactory,
child: SizedBox(
height: NooSizes.sidebarItem,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
spacing: 12,
children: [
Icon(
icon,
size: 18,
color: selected ? colors.accentText : colors.fg2,
),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(
fontSize: 14,
height: 1,
fontWeight: selected
? FontWeight.w600
: FontWeight.w500,
color: selected ? colors.accentText : colors.fg1,
),
),
),
if (count != null && count!.isNotEmpty)
Text(
count!,
style: NooText.body.copyWith(
fontSize: 12,
height: 1,
fontWeight: FontWeight.w500,
color: selected ? colors.accentText : colors.fg3,
),
),
],
),
),
),
),
),
);
}
}
/// The 1px `line` between the pinned and remaining tabs, inset 12px with
/// 8px above and below.
class NooSidebarDivider extends StatelessWidget {
const NooSidebarDivider({super.key});
@override
Widget build(BuildContext context) {
return Container(
height: 1,
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
color: context.nooColors.line,
);
}
}
/// The account switcher card at the top of [NooSidebar]: a `surface` card
/// (radius 14, 8/10 padding) with a 32px [avatar], [name] (14/600) over
/// [subtitle] (12, fg-3), and a `chevron-right` in fg-3.
class NooSidebarAccount extends StatelessWidget {
/// Usually `NooAvatar(size: 32, current: true, ...)`.
final Widget avatar;
final String name;
final String? subtitle;
final VoidCallback? onTap;
const NooSidebarAccount({
super.key,
required this.avatar,
required this.name,
this.subtitle,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final radius = BorderRadius.circular(NooRadii.input);
return Material(
color: colors.surface,
borderRadius: radius,
child: InkWell(
onTap: onTap,
borderRadius: radius,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
spacing: 10,
children: [
avatar,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(
fontSize: 14,
height: 1,
fontWeight: FontWeight.w600,
color: colors.fg1,
),
),
if (subtitle != null)
Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(
fontSize: 12,
height: 1,
color: colors.fg3,
),
),
],
),
),
Icon(LucideIcons.chevronRight, size: 16, color: colors.fg3),
],
),
),
),
);
}
}
/// The sidebar storage meter: "Storage" (12/500 fg-1) and a [detail]
/// ("38.2 of 100 GB", 12 fg-2) on one line, over a 6px [NooProgressBar].
class NooSidebarStorage extends StatelessWidget {
/// 0-1.
final double value;
final String detail;
final String label;
const NooSidebarStorage({
super.key,
required this.value,
required this.detail,
this.label = 'Storage',
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final meta = NooText.meta.copyWith(
fontSize: 12,
height: 1,
color: colors.fg2,
);
return Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
Row(
children: [
Text(
label,
style: meta.copyWith(
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
detail,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
style: meta,
),
),
],
),
NooProgressBar(value: value),
],
),
);
}
}
+142
View File
@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// The desktop main-pane toolbar (DESIGN_SYSTEM.md 3, "Desktop"; the 64px
/// header in `Desktop Screen.dc.html`): 24px leading / 20px trailing
/// padding, 12px gaps, a 1px `line` along the bottom. Left to right:
///
/// - optional back/forward chevrons ([showHistoryNav] - macOS only; Windows
/// doesn't show them), 20px, fg-3
/// - the [title] (22 Schibsted) - or [titleWidget] instead, e.g. a
/// breadcrumb in Files; it inherits the title [DefaultTextStyle]
/// - flexible space
/// - the [search] slot, 260px wide (pass a `NooSearchField(onSurface: true)`)
/// - [actions] - `NooSegmentedControl`, `NooButton(size: toolbar)`, etc.
///
/// A [PreferredSizeWidget], so it can be the main pane's `Scaffold.appBar`.
/// No [SafeArea]: it never sits under a status bar.
class NooToolbar extends StatelessWidget implements PreferredSizeWidget {
final String? title;
final Widget? titleWidget;
final Widget? search;
final List<Widget> actions;
final bool showHistoryNav;
/// Back/forward handlers for [showHistoryNav]; a null one draws that
/// chevron disabled.
final VoidCallback? onBack;
final VoidCallback? onForward;
/// Defaults to `surface` - the toolbar belongs to the main pane.
final Color? backgroundColor;
const NooToolbar({
super.key,
this.title,
this.titleWidget,
this.search,
this.actions = const [],
this.showHistoryNav = false,
this.onBack,
this.onForward,
this.backgroundColor,
}) : assert(
title == null || titleWidget == null,
'Pass either title or titleWidget, not both.',
);
@override
Size get preferredSize => const Size.fromHeight(NooSizes.toolbar);
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final titleStyle = NooText.title.copyWith(height: 1, color: colors.fg1);
return Material(
color: backgroundColor ?? colors.surface,
child: Container(
height: NooSizes.toolbar,
padding: const EdgeInsetsDirectional.only(start: 24, end: 20),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: colors.line)),
),
child: Row(
spacing: 12,
children: [
if (showHistoryNav)
Padding(
padding: const EdgeInsetsDirectional.only(end: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
_HistoryButton(
icon: LucideIcons.chevronLeft,
tooltip: 'Back',
onTap: onBack,
),
_HistoryButton(
icon: LucideIcons.chevronRight,
tooltip: 'Forward',
onTap: onForward,
),
],
),
),
Expanded(
child: Align(
alignment: AlignmentDirectional.centerStart,
child: DefaultTextStyle(
style: titleStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: titleWidget ?? Text(title ?? ''),
),
),
),
if (search != null) SizedBox(width: 260, child: search),
...actions,
],
),
),
);
}
}
/// A 20px fg-3 chevron with a 28px hover/press target. Dimmed when
/// [onTap] is null (nothing to go back/forward to).
class _HistoryButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback? onTap;
const _HistoryButton({
required this.icon,
required this.tooltip,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Tooltip(
message: tooltip,
child: Opacity(
opacity: onTap == null ? 0.45 : 1,
child: InkResponse(
onTap: onTap,
radius: 16,
splashFactory: NoSplash.splashFactory,
hoverColor: colors.surface2,
child: SizedBox.square(
dimension: 28,
child: Icon(icon, size: 20, color: colors.fg3),
),
),
),
);
}
}
+296
View File
@@ -0,0 +1,296 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
import 'noo_nav_style.dart';
export 'noo_nav_style.dart';
// iOS large-title block: 2px above, a 34/0.9 line box (30.6 -> 31), then
// 12px below (14 when there's no search field under it, per the `iSet`
// Settings header in `Mobile Screen.dc.html`).
const double _iosRow = 44;
const double _iosTitleTop = 2;
const double _iosTitleBox = 31;
const double _iosSearch = 38;
const double _iosSearchBottom = 12;
const double _androidRow = 64;
/// Mobile top bar (DESIGN_SYSTEM.md 3, "Mobile"; `iBar`/`aBar`/`iSet`/
/// `aSet` in `Mobile Screen.dc.html`).
///
/// - [NooNavStyle.ios]: a 44px row with [leading] on the left and
/// [actions] on the right, all in accent-text; then the 34px large
/// [title]; then the optional [search] slot (laid out at 38px - pass a
/// `NooSearchField(ios: true)`).
/// - [NooNavStyle.android]: one 64px row: [leading], the 22px [title], then
/// [actions] (fg-1). Android has no [search] slot - search is an action
/// icon there, so [search] is ignored.
///
/// **Why a [PreferredSizeWidget] and not a sliver:** every part of it has a
/// fixed height, so it drops straight into `Scaffold.appBar` (which adds
/// the status-bar inset on top of [preferredSize]; the bar applies that
/// inset itself via [SafeArea]). It's still a plain box widget, so a screen
/// that wants the iOS large title to scroll away with content can put it in
/// a `SliverToBoxAdapter` (or a `SliverPersistentHeader` with a fixed
/// extent) instead - no sliver-specific variant needed. A collapsing
/// large-title-to-inline-title animation isn't in the spec, so it isn't
/// built here.
///
/// Use [NooTopBarButton] for icon actions and [NooTopBarBack] for a pushed
/// screen's back button; both size themselves for the current [style].
class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
final NooNavStyle style;
final String title;
/// Replaces the default leading button - e.g. a [NooTopBarBack] on a
/// pushed screen. When null, a `menu` [NooTopBarButton] calling [onMenu]
/// is shown if [onMenu] is set; otherwise nothing.
final Widget? leading;
final VoidCallback? onMenu;
/// Trailing widgets, left to right: [NooTopBarButton]s, an avatar, etc.
/// Icons inherit the bar's icon color (accent-text on iOS, fg-1 on
/// Android) through [IconTheme].
final List<Widget> actions;
/// iOS only: the search field shown below the large title.
final Widget? search;
/// Defaults to `bg` - the top bar sits on the screen background.
final Color? backgroundColor;
const NooTopBar({
super.key,
required this.style,
required this.title,
this.leading,
this.onMenu,
this.actions = const [],
this.search,
this.backgroundColor,
});
bool get _ios => style == NooNavStyle.ios;
@override
Size get preferredSize {
if (!_ios) return const Size.fromHeight(_androidRow);
return Size.fromHeight(
_iosRow +
_iosTitleTop +
_iosTitleBox +
(search != null ? 12 + _iosSearch + _iosSearchBottom : 14),
);
}
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final lead =
leading ??
(onMenu != null
? NooTopBarButton(
icon: LucideIcons.menu,
tooltip: 'Menu',
onTap: onMenu,
)
: null);
final Widget body = _ios
? _buildIos(colors, lead)
: _buildAndroid(colors, lead);
return _NooTopBarScope(
style: style,
child: IconTheme.merge(
data: IconThemeData(
size: 24,
color: _ios ? colors.accentText : colors.fg1,
),
child: Material(
color: backgroundColor ?? colors.bg,
child: SafeArea(bottom: false, child: body),
),
),
);
}
Widget _buildIos(NooColors colors, Widget? lead) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: _iosRow,
// 44px hit boxes around 24px icons add 10px each side, so 6px of
// row padding puts the icons 16px from the screen edge.
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Row(children: [?lead, const Spacer(), ...actions]),
),
),
Padding(
padding: EdgeInsets.fromLTRB(
NooSpace.md,
_iosTitleTop,
NooSpace.md,
search != null ? 12 : 14,
),
child: SizedBox(
height: _iosTitleBox,
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.largeTitle.copyWith(
leadingDistribution: TextLeadingDistribution.even,
color: colors.fg1,
),
),
),
),
),
if (search != null)
Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.md,
0,
NooSpace.md,
_iosSearchBottom,
),
child: SizedBox(height: _iosSearch, child: search),
),
],
);
}
Widget _buildAndroid(NooColors colors, Widget? lead) {
return SizedBox(
height: _androidRow,
child: Padding(
padding: EdgeInsetsDirectional.only(
start: lead != null ? 4 : 16,
end: 8,
),
child: Row(
spacing: 4,
children: [
?lead,
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.title.copyWith(height: 1, color: colors.fg1),
),
),
...actions,
],
),
),
);
}
}
/// Lets [NooTopBarButton]/[NooTopBarBack] size themselves for the bar
/// they're in without the caller repeating the style.
class _NooTopBarScope extends InheritedWidget {
final NooNavStyle style;
const _NooTopBarScope({required this.style, required super.child});
static NooNavStyle of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_NooTopBarScope>()?.style ??
NooNavStyle.android;
@override
bool updateShouldNotify(_NooTopBarScope oldWidget) =>
style != oldWidget.style;
}
/// A 24px icon action for [NooTopBar] - a 44px (iOS) or 48px (Android)
/// round hit box, colored by the bar's [IconTheme].
class NooTopBarButton extends StatelessWidget {
final IconData icon;
final VoidCallback? onTap;
final String? tooltip;
const NooTopBarButton({
super.key,
required this.icon,
this.onTap,
this.tooltip,
});
@override
Widget build(BuildContext context) {
final box = _NooTopBarScope.of(context) == NooNavStyle.ios ? 44.0 : 48.0;
Widget button = SizedBox.square(
dimension: box,
child: InkResponse(
onTap: onTap,
radius: box / 2,
child: Center(child: Icon(icon)),
),
);
if (tooltip != null) button = Tooltip(message: tooltip, child: button);
return Semantics(button: true, label: tooltip, child: button);
}
}
/// Back button for a pushed screen's [NooTopBar.leading]. iOS: a
/// `chevron-left` plus the previous screen's [label] in 17px accent-text
/// ("‹ Files"). Android: an `arrow-left` icon button ([label] becomes its
/// tooltip).
class NooTopBarBack extends StatelessWidget {
final String label;
final VoidCallback? onTap;
const NooTopBarBack({super.key, this.label = 'Back', this.onTap});
@override
Widget build(BuildContext context) {
final onPressed = onTap ?? () => Navigator.maybePop(context);
if (_NooTopBarScope.of(context) == NooNavStyle.android) {
return NooTopBarButton(
icon: LucideIcons.arrowLeft,
tooltip: label,
onTap: onPressed,
);
}
final colors = context.nooColors;
return Semantics(
button: true,
child: InkResponse(
onTap: onPressed,
highlightShape: BoxShape.rectangle,
borderRadius: BorderRadius.circular(NooRadii.pill),
child: SizedBox(
height: 44,
child: Padding(
// The row already has 6px padding; 4 more lands the chevron at
// the reference's 10px inset.
padding: const EdgeInsetsDirectional.only(start: 4, end: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 2,
children: [
Icon(LucideIcons.chevronLeft, color: colors.accentText),
Text(
label,
style: NooText.bodyL.copyWith(
fontSize: 17,
height: 1,
color: colors.accentText,
),
),
],
),
),
),
),
);
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'nav/noo_nav_style.dart';
/// The one place screens decide mobile vs desktop layout and iOS vs Android
/// chrome (DESIGN_SYSTEM.md 3), so every screen switches at the same point.
class NooLayout {
const NooLayout._();
/// Window width at which the shell swaps the bottom bar + drawer for the
/// desktop sidebar + toolbar, and screens switch to desktop recipes
/// (table rows, 5-column grids, 8-column photos, 24px gutter).
static const double desktopBreakpoint = 900;
static bool isDesktop(BuildContext context) =>
MediaQuery.sizeOf(context).width >= desktopBreakpoint;
static NooNavStyle navStyle(BuildContext context) =>
NooNavStyle.fromPlatform(Theme.of(context).platform);
/// True where the spec calls for the iOS variant of a content detail
/// (`ellipsis` rather than `ellipsis-vertical` overflow, etc.).
static bool iosStyle(BuildContext context) =>
navStyle(context) == NooNavStyle.ios;
/// Horizontal content gutter: 12 on mobile (cards), 24 on desktop.
static double gutter(BuildContext context) => isDesktop(context) ? 24 : 12;
}
+214
View File
@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Desktop counterpart of `showNooSheet`: a 540px, radius-24 card on the
/// scrim, carrying the design system's one allowed shadow
/// ([nooDialogShadow]). `showGeneralDialog` is used instead of
/// `showDialog` so the barrier takes the `scrim` token and the transition
/// follows [NooMotion] (fade + 0.98 scale, no bounce) rather than
/// Material's defaults.
Future<T?> showNooDialog<T>(
BuildContext context, {
Widget? leading,
required String title,
String? subtitle,
required List<Widget> children,
bool barrierDismissible = true,
}) {
final colors = context.nooColors;
return showGeneralDialog<T>(
context: context,
barrierDismissible: barrierDismissible,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: colors.scrim,
transitionDuration: NooMotion.base,
pageBuilder: (dialogContext, _, _) => NooDialog(
leading: leading,
title: title,
subtitle: subtitle,
onClose: () => Navigator.of(dialogContext).pop(),
children: children,
),
transitionBuilder: (_, animation, _, child) {
final curved = CurvedAnimation(parent: animation, curve: NooMotion.ease);
return FadeTransition(
opacity: curved,
child: ScaleTransition(
scale: Tween<double>(
begin: NooMotion.pressScale,
end: 1,
).animate(curved),
child: child,
),
);
},
);
}
/// The dialog card itself, centered in whatever space it's given. Exposed
/// separately from [showNooDialog] so it can be embedded/tested without a
/// route. Sections are spaced 22px apart to match `showNooSheet`, so the
/// same share-sheet parts lay out identically in both.
class NooDialog extends StatelessWidget {
final Widget? leading;
final String title;
final String? subtitle;
final VoidCallback? onClose;
final List<Widget> children;
final double width;
const NooDialog({
super.key,
this.leading,
required this.title,
this.subtitle,
this.onClose,
required this.children,
this.width = 540,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
// Padding (not MediaQuery maths) keeps a 24px margin on every side, so
// the card shrinks to fit narrow windows and short content-heavy
// dialogs scroll rather than overflow.
return Padding(
padding: const EdgeInsets.all(NooSpace.xl),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: width),
child: Container(
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(NooRadii.dialog),
boxShadow: const [nooDialogShadow],
),
// Material (not just a DecoratedBox) so TextFields/InkWells inside
// the dialog have an ancestor to paint on.
child: Material(
type: MaterialType.transparency,
child: Padding(
padding: const EdgeInsets.all(NooSpace.xl),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
NooDialogHeader(
leading: leading,
title: title,
subtitle: subtitle,
onClose: onClose,
),
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final child in children) ...[
const SizedBox(height: 22),
child,
],
],
),
),
),
],
),
),
),
),
),
),
);
}
}
/// Desktop dialog header: leading slot (a 44px file tile for Share),
/// title, optional meta subtitle, and a 32px surface-2 close circle.
/// `NooOverlayHeader` is the mobile sheet variant (36px close).
class NooDialogHeader extends StatelessWidget {
final Widget? leading;
final String title;
final String? subtitle;
final VoidCallback? onClose;
const NooDialogHeader({
super.key,
this.leading,
required this.title,
this.subtitle,
this.onClose,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Row(
children: [
if (leading != null) ...[leading!, const SizedBox(width: NooSpace.sm)],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.sectionTitle.copyWith(
fontSize: 19,
color: colors.fg1,
),
),
if (subtitle != null) ...[
const SizedBox(height: 5),
Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(color: colors.fg3),
),
],
],
),
),
if (onClose != null) ...[
const SizedBox(width: NooSpace.sm),
NooCloseButton(onTap: onClose!),
],
],
);
}
}
/// 32px surface-2 circle with an `x` - the dialog close control.
class NooCloseButton extends StatelessWidget {
final VoidCallback onTap;
final double size;
const NooCloseButton({super.key, required this.onTap, this.size = 32});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Semantics(
button: true,
label: MaterialLocalizations.of(context).closeButtonTooltip,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colors.surface2,
shape: BoxShape.circle,
),
child: Icon(LucideIcons.x, size: 16, color: colors.fg1),
),
),
);
}
}
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Sheet/dialog header: an optional leading tile, title, subtitle, close
/// circle (Noo Design System project,
/// `components/overlays/OverlayHeader.jsx`, mobile variant). [leading] is a
/// plain [Widget] rather than a file kind so this stays decoupled from the
/// file model - pass an `ItemThumbnail`/file-type tile for a file header.
class NooOverlayHeader extends StatelessWidget {
final Widget? leading;
final String title;
final String? subtitle;
final VoidCallback? onClose;
const NooOverlayHeader({
super.key,
this.leading,
required this.title,
this.subtitle,
this.onClose,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (leading != null) ...[leading!, const SizedBox(width: 12)],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.sectionTitle.copyWith(fontSize: 19, color: colors.fg1),
),
if (subtitle != null) ...[
const SizedBox(height: 5),
Text(subtitle!, style: NooText.meta.copyWith(color: colors.fg3)),
],
],
),
),
if (onClose != null)
Semantics(
button: true,
label: 'Close',
child: GestureDetector(
onTap: onClose,
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle),
child: Icon(LucideIcons.x, size: 18, color: colors.fg1),
),
),
),
],
);
}
}
@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
// Building blocks for the share sheet (mobile, via `showNooSheet`) and the
// share dialog (desktop, via `showNooDialog`) - DESIGN_SYSTEM.md section 4,
// "Share sheet / dialog". Both containers already space their children
// 22px apart, so each [NooShareSection] is one of those children.
/// A titled share-sheet section ("Share with people", "Share link", "Send
/// file directly"). [trailing] sits on the title row - the link section's
/// `NooToggle`. [caption] is the meta line under the content (e.g. "Link
/// settings don't apply").
class NooShareSection extends StatelessWidget {
final String title;
final Widget? trailing;
final Widget child;
final String? caption;
const NooShareSection({
super.key,
required this.title,
this.trailing,
required this.child,
this.caption,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(title, style: NooText.sectionTitle.copyWith(color: colors.fg1)),
),
?trailing,
],
),
const SizedBox(height: NooSpace.sm),
child,
if (caption != null) ...[
const SizedBox(height: NooSpace.xs),
Text(caption!, style: NooText.meta.copyWith(color: colors.fg3)),
],
],
);
}
}
/// One person/group with access: avatar, name, subtitle, then either the
/// owner label or a tappable permission pill ("Can edit ▾"). Owner rows
/// aren't interactive, so they get plain fg-3 text instead of a pill - the
/// pill shape is reserved for things that open a menu.
class NooPersonAccessRow extends StatelessWidget {
/// Typically a 36px `NooAvatar`.
final Widget avatar;
final String name;
final String? subtitle;
final bool owner;
/// Pill label for non-owners, e.g. "Can edit" / "Can view".
final String permission;
final VoidCallback? onPermissionTap;
final String ownerLabel;
const NooPersonAccessRow({
super.key,
required this.avatar,
required this.name,
this.subtitle,
this.owner = false,
this.permission = 'Can edit',
this.onPermissionTap,
this.ownerLabel = 'Owner',
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 52),
child: Row(
children: [
avatar,
const SizedBox(width: NooSpace.sm),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(
height: 1.2,
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
if (subtitle != null) ...[
const SizedBox(height: 3),
Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(color: colors.fg3),
),
],
],
),
),
const SizedBox(width: NooSpace.sm),
if (owner)
Text(ownerLabel, style: NooText.label.copyWith(color: colors.fg3))
else
NooPermissionPill(label: permission, onTap: onPermissionTap),
],
),
);
}
}
/// Surface-2 pill with a trailing chevron that opens a permission menu.
/// Also used for the link section's permission option.
class NooPermissionPill extends StatelessWidget {
final String label;
final VoidCallback? onTap;
const NooPermissionPill({super.key, required this.label, this.onTap});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
height: NooSizes.buttonCompact,
padding: const EdgeInsets.only(left: NooSpace.sm, right: 10),
decoration: BoxDecoration(
color: colors.surface2,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: NooText.label.copyWith(height: 1, color: colors.fg1)),
const SizedBox(width: NooSpace.xxs),
Icon(LucideIcons.chevronDown, size: 14, color: colors.fg2),
],
),
),
);
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
/// Mobile bottom sheet on a scrim: radius-28 top, grabber, 22px gap between
/// sections (Noo Design System project, `components/overlays/Sheet.jsx`).
/// `showModalBottomSheet` already supplies the scrim/backdrop-dismiss
/// machinery, so this only standardizes the shape/padding - use it instead
/// of a bare `showModalBottomSheet` for any new sheet. Scroll-controlled so a
/// tall sheet (e.g. share) can grow past half the screen; content scrolls
/// once it hits the top.
Future<T?> showNooSheet<T>(
BuildContext context, {
required List<Widget> children,
}) {
final colors = context.nooColors;
return showModalBottomSheet<T>(
context: context,
backgroundColor: colors.surface,
barrierColor: colors.scrim,
isScrollControlled: true,
useSafeArea: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(NooRadii.sheetTop)),
),
builder: (context) {
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 5,
decoration: BoxDecoration(
color: colors.surface3,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
),
for (final child in children) ...[
const SizedBox(height: 22),
child,
],
],
),
),
);
},
);
}
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
/// Radius-14 inset input (share "Name, email or group", link URL). Unlike
/// the pill `NooSearchField`, this is the inset-panel shape from the radius
/// scale. [trailing] hosts an inline 40px button (`NooButtonSize.field`),
/// e.g. "Copy link" - the field then tightens its right padding to 4 so the
/// button sits inset inside the rounded box. [mono] switches to the mono
/// style for URLs and local paths.
class NooTextField extends StatelessWidget {
final TextEditingController? controller;
final FocusNode? focusNode;
final String? placeholder;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final IconData? leadingIcon;
final Widget? trailing;
final bool mono;
final bool readOnly;
final bool autofocus;
final TextInputType? keyboardType;
/// True (default) on a `surface` background - uses surface-2 fill. Pass
/// false when placed on `bg`, which uses surface instead.
final bool onSurface;
const NooTextField({
super.key,
this.controller,
this.focusNode,
this.placeholder,
this.onChanged,
this.onSubmitted,
this.leadingIcon,
this.trailing,
this.mono = false,
this.readOnly = false,
this.autofocus = false,
this.keyboardType,
this.onSurface = true,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final base = mono
? NooText.mono.copyWith(fontSize: 13)
: NooText.body.copyWith(fontSize: 15, height: 1.2);
return Container(
constraints: const BoxConstraints(minHeight: 48),
padding: EdgeInsets.only(left: NooSpace.smd, right: trailing != null ? 4 : NooSpace.smd),
decoration: BoxDecoration(
color: onSurface ? colors.surface2 : colors.surface,
borderRadius: BorderRadius.circular(NooRadii.input),
),
child: Row(
children: [
if (leadingIcon != null) ...[
Icon(leadingIcon, size: 18, color: colors.fg3),
const SizedBox(width: NooSpace.xs),
],
Expanded(
child: TextField(
controller: controller,
focusNode: focusNode,
onChanged: onChanged,
onSubmitted: onSubmitted,
readOnly: readOnly,
autofocus: autofocus,
keyboardType: keyboardType,
style: base.copyWith(color: colors.fg1),
cursorColor: colors.accentText,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: NooSpace.smd),
hintText: placeholder,
hintStyle: base.copyWith(color: colors.fg3),
),
),
),
if (trailing != null) ...[const SizedBox(width: NooSpace.xs), trailing!],
],
),
);
}
}
+2 -13
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../providers/session_controller.dart';
import '../views/account_view.dart';
import 'noo/core/noo_avatar.dart';
/// Top-bar avatar button that opens the account screen. Replaces the old
/// "Account" bottom-nav destination, matching how Google Drive surfaces
@@ -38,7 +39,6 @@ class ProfileAvatarButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<SessionController>();
final colorScheme = Theme.of(context).colorScheme;
final initial = provider.username.isNotEmpty
? provider.username[0].toUpperCase()
: '?';
@@ -55,18 +55,7 @@ class ProfileAvatarButton extends StatelessWidget {
MaterialPageRoute(builder: (_) => const AccountView()),
);
},
icon: CircleAvatar(
radius: 17,
backgroundColor: colorScheme.primary,
child: Text(
initial,
style: TextStyle(
color: colorScheme.onPrimary,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
icon: NooAvatar(initials: initial, current: true, size: 32),
),
),
);
+9 -8
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../theme/design_tokens.dart';
/// A single icon button that fills with a solid circle when active —
/// the building block for Drive-style toggle controls. Thin wrapper around
@@ -21,7 +22,7 @@ class ToggleIconButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final colors = context.nooColors;
return IconButton(
isSelected: isSelected,
@@ -33,12 +34,12 @@ class ToggleIconButton extends StatelessWidget {
shape: const WidgetStatePropertyAll(CircleBorder()),
foregroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
? colors.accentText
: colors.fg2,
),
backgroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? colorScheme.primaryContainer
? colors.accentSoft
: Colors.transparent,
),
),
@@ -55,12 +56,12 @@ class SegmentedIconGroup extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final colors = context.nooColors;
return Container(
padding: const EdgeInsets.all(2),
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
color: colors.surface,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: Row(mainAxisSize: MainAxisSize.min, children: children),
);
-53
View File
@@ -1,53 +0,0 @@
import 'package:flutter/material.dart';
/// Wraps a thumbnail so it can show the multi-select checkmark state:
/// dimmed with a checkmark overlaid once selected, matching the Google
/// Photos-style long-press-to-select pattern. Shared by the Files and
/// Photos tabs.
class SelectableThumbnail extends StatelessWidget {
final bool isSelected;
final Widget child;
/// Fixed width/height for a small square thumbnail (Files list/grid
/// tiles). Leave null to fill whatever space the parent already gives it
/// instead (e.g. a Photos grid cell, whose size comes from the grid
/// layout rather than a fixed constant).
final double? size;
final double checkmarkSize;
const SelectableThumbnail({
super.key,
required this.isSelected,
required this.child,
this.size,
this.checkmarkSize = 28,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final stack = Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: AnimatedOpacity(
opacity: isSelected ? 0.35 : 1,
duration: const Duration(milliseconds: 150),
child: child,
),
),
if (isSelected)
Center(
child: Icon(
Icons.check_circle_rounded,
color: colorScheme.primary,
size: checkmarkSize,
),
),
],
);
if (size == null) return stack;
return SizedBox(width: size, height: size, child: stack);
}
}
@@ -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'),
),
],
),
],
),
);
}
}
+128
View File
@@ -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),
),
),
],
),
),
);
}
}
+106
View File
@@ -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,
),
),
],
);
}
}
+84
View File
@@ -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,
),
),
],
);
}
}
+184
View File
@@ -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),
);
}
}
+333 -242
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
@@ -10,25 +12,20 @@ import '../models/nextcloud_sharee.dart';
import '../providers/item_operations.dart';
import '../providers/session_controller.dart';
import '../providers/sync_status_controller.dart';
import 'details/details_sheet.dart' show DetailsHeader;
import 'gradual_bottom_sheet.dart';
IconData _shareTypeIcon(ShareType type) {
switch (type) {
case ShareType.user:
return Icons.person_rounded;
case ShareType.group:
return Icons.groups_rounded;
case ShareType.publicLink:
return Icons.link_rounded;
case ShareType.email:
return Icons.email_rounded;
case ShareType.federated:
return Icons.public_rounded;
case ShareType.other:
return Icons.share_rounded;
}
}
import '../theme/design_tokens.dart';
import 'details/details_sheet.dart' show detailsFileTile, detailsMetaLine;
import 'noo/core/noo_avatar.dart';
import 'noo/core/noo_button.dart';
import 'noo/core/noo_chip.dart';
import 'noo/core/noo_toggle.dart';
import 'noo/lists/noo_settings_row.dart';
import 'noo/media/noo_photo_tile.dart';
import 'noo/noo_layout.dart';
import 'noo/overlays/noo_dialog.dart';
import 'noo/overlays/noo_overlay_header.dart';
import 'noo/overlays/noo_share_parts.dart';
import 'noo/overlays/noo_sheet.dart';
import 'noo/overlays/noo_text_field.dart';
/// Builds the "internal link" URL for a file, or null if [fileId] isn't a
/// numeric Nextcloud file id (WebDAV `oc:fileid` was missing for this item).
@@ -42,22 +39,46 @@ String? internalLinkFor(String serverUrl, String fileId) {
return '$url/index.php/f/$fileId';
}
/// Standalone "Share" bottom sheet for a single file/folder — replicates
/// the web client's Sharing panel: search users/teams, "Others with
/// access" (inherited shares), an internal link to copy, an email-share
/// field, and a public link. Every "Share" action in the app (swipe,
/// multi-select, media viewer) opens this same sheet.
String _initial(String s) {
final trimmed = s.trim();
return trimmed.isEmpty ? '?' : trimmed[0].toUpperCase();
}
/// Standalone "Share" bottom sheet/dialog for a single file/folder -
/// `DESIGN_SYSTEM.md` §4 "Share sheet / dialog": share with people, a
/// public link, and (files only) sending the file itself. Every "Share"
/// action in the app (swipe, multi-select, media viewer) opens this same
/// sheet.
///
/// Only used directly on mobile (bundled behind its own [NooOverlayHeader]
/// by [show]); the desktop path hands this straight to [showNooDialog],
/// which renders the header itself via `leading`/`title`/`subtitle`.
class ShareSheet extends StatefulWidget {
final NextcloudItem item;
final ScrollController? scrollController;
const ShareSheet({super.key, required this.item, this.scrollController});
const ShareSheet({super.key, required this.item});
static Future<void> show(BuildContext context, NextcloudItem item) {
return showGradualBottomSheet(
if (NooLayout.isDesktop(context)) {
return showNooDialog(
context,
leading: detailsFileTile(item),
title: item.name,
subtitle: detailsMetaLine(item),
children: [ShareSheet(item: item)],
);
}
return showNooSheet(
context,
builder: (context, scrollController) =>
ShareSheet(item: item, scrollController: scrollController),
children: [
NooOverlayHeader(
leading: detailsFileTile(item),
title: item.name,
subtitle: detailsMetaLine(item),
onClose: () => Navigator.pop(context),
),
ShareSheet(item: item),
],
);
}
@@ -72,15 +93,17 @@ class _ShareSheetState extends State<ShareSheet> {
List<NextcloudShare> _inherited = [];
List<NextcloudSharee> _searchResults = [];
bool _isSearching = false;
bool _isAddingEmail = false;
bool _isAddingPerson = false;
bool _isTogglingLink = false;
bool _isSharingFile = false;
final _searchController = TextEditingController();
final _emailController = TextEditingController();
bool _showInherited = false;
final _peopleController = TextEditingController();
final _linkController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
_emailController.dispose();
_peopleController.dispose();
_linkController.dispose();
super.dispose();
}
@@ -114,6 +137,20 @@ class _ShareSheetState extends State<ShareSheet> {
});
}
/// The single "Name, email or group" field: reuses whichever match is
/// live, or falls back to an email share when the text looks like one -
/// same two underlying actions the pre-rework sheet split across two
/// separate fields.
Future<void> _submitPeopleField() async {
final text = _peopleController.text.trim();
if (text.isEmpty) return;
if (_searchResults.isNotEmpty) {
await _addSharee(_searchResults.first);
} else if (text.contains('@')) {
await _addEmail(text);
}
}
Future<void> _addSharee(NextcloudSharee sharee) async {
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
@@ -135,7 +172,7 @@ class _ShareSheetState extends State<ShareSheet> {
setState(() {
_shares = [..._shares, share];
_searchResults = [];
_searchController.clear();
_peopleController.clear();
});
messenger.showSnackBar(
SnackBar(
@@ -145,10 +182,8 @@ class _ShareSheetState extends State<ShareSheet> {
);
}
Future<void> _addEmailShare() async {
final email = _emailController.text.trim();
if (email.isEmpty) return;
setState(() => _isAddingEmail = true);
Future<void> _addEmail(String email) async {
setState(() => _isAddingPerson = true);
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
final share = await ops.createShare(
@@ -158,7 +193,7 @@ class _ShareSheetState extends State<ShareSheet> {
);
if (!mounted) return;
if (share == null) {
setState(() => _isAddingEmail = false);
setState(() => _isAddingPerson = false);
messenger.showSnackBar(
const SnackBar(
content: Text('Could not create email share'),
@@ -169,8 +204,8 @@ class _ShareSheetState extends State<ShareSheet> {
}
setState(() {
_shares = [..._shares, share];
_emailController.clear();
_isAddingEmail = false;
_peopleController.clear();
_isAddingPerson = false;
});
messenger.showSnackBar(
SnackBar(
@@ -183,10 +218,7 @@ class _ShareSheetState extends State<ShareSheet> {
Future<void> _createPublicLink() async {
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
final share = await ops.createShare(
path: widget.item.path,
shareType: 3,
);
final share = await ops.createShare(path: widget.item.path, shareType: 3);
if (!mounted) return;
if (share == null) {
messenger.showSnackBar(
@@ -217,6 +249,25 @@ class _ShareSheetState extends State<ShareSheet> {
}
}
/// The link toggle: on creates a public link, off removes every public
/// link share (there's normally at most one - the sheet only ever
/// surfaces the first one - but this cleans up any extras from before the
/// rework's single-link toggle).
Future<void> _toggleLink(bool checked) async {
setState(() => _isTogglingLink = true);
if (checked) {
await _createPublicLink();
} else {
final links = _shares
.where((s) => s.shareType == ShareType.publicLink)
.toList();
for (final link in links) {
await _removeShare(link);
}
}
if (mounted) setState(() => _isTogglingLink = false);
}
void _copy(String text, String label) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
@@ -227,11 +278,10 @@ class _ShareSheetState extends State<ShareSheet> {
);
}
/// Downloads the item to a scratch file and hands it to Android's native
/// "Share to..." sheet (`share_plus`) - a completely different action
/// from the rest of this sheet (which shares *within* Nextcloud, via
/// users/links) and the only one here that reads the file's actual
/// bytes.
/// Downloads the item to a scratch file and hands it to the OS's native
/// "Share to..." sheet (`share_plus`) - a completely different action from
/// the rest of this sheet (which shares *within* Nextcloud, via users/
/// links) and the only one here that reads the file's actual bytes.
Future<void> _shareFileDirectly() async {
final session = context.read<SessionController>();
final sync = context.read<SyncStatusController>();
@@ -271,61 +321,203 @@ class _ShareSheetState extends State<ShareSheet> {
}
}
Widget _sectionHeader(String label) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Row(
/// A small popup with the one action the compact [NooPersonAccessRow]
/// permission pill has no room for - removing that person's access. There's
/// no permission-update endpoint on [ItemOperations] yet, so the pill
/// itself is read-only (see the rebuild report).
void _openPersonMenu(NextcloudShare share) {
final label = share.sharedWithDisplayName ?? 'Shared';
void remove() {
Navigator.pop(context);
_removeShare(share);
}
final row = NooSettingsRow(
icon: LucideIcons.userMinus,
label: const Text('Remove access'),
danger: true,
onTap: remove,
);
if (NooLayout.isDesktop(context)) {
showNooDialog(context, title: label, children: [row]);
} else {
showNooSheet(
context,
children: [
NooOverlayHeader(title: label, onClose: () => Navigator.pop(context)),
row,
],
);
}
}
Widget _peopleSection(
NooColors colors,
SessionController session,
List<NextcloudShare> peopleShares,
String? internalLink,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
AbsorbPointer(
absorbing: _isAddingPerson,
child: NooTextField(
controller: _peopleController,
leadingIcon: LucideIcons.search,
placeholder: 'Name, email or group',
onChanged: _search,
onSubmitted: (_) => _submitPeopleField(),
trailing: (_isSearching || _isAddingPerson)
? const Padding(
padding: EdgeInsets.all(4),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: null,
),
),
const SizedBox(width: 6),
Icon(
Icons.info_outline_rounded,
size: 16,
color: colorScheme.onSurfaceVariant,
for (final sharee in _searchResults)
NooSettingsRow(
icon: sharee.type == ShareeType.group
? LucideIcons.users
: LucideIcons.user,
label: Text(sharee.label),
subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null,
onTap: () => _addSharee(sharee),
),
const SizedBox(height: NooSpace.xs),
NooPersonAccessRow(
avatar: NooAvatar(initials: _initial(session.username), current: true),
name: session.username,
owner: true,
),
for (final share in peopleShares)
NooPersonAccessRow(
avatar: NooAvatar(
initials: _initial(share.sharedWithDisplayName ?? share.id),
color: NooPhotoTile.paletteColor(
(share.sharedWithDisplayName ?? share.id).hashCode,
),
icon: share.shareType == ShareType.group ? LucideIcons.users : null,
),
name: share.sharedWithDisplayName ?? 'Shared',
subtitle: share.shareType == ShareType.group
? 'Group'
: (share.shareType == ShareType.email ? 'Invited by email' : null),
permission: (share.permissions & 2) != 0 ? 'Can edit' : 'Can view',
onPermissionTap: () => _openPersonMenu(share),
),
if (_inherited.isNotEmpty) ...[
const SizedBox(height: NooSpace.xs),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _showInherited = !_showInherited),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: NooSpace.xs),
child: Row(
children: [
Icon(LucideIcons.users, size: 16, color: colors.fg3),
const SizedBox(width: NooSpace.xs),
Expanded(
child: Text(
'Others with access (${_inherited.length})',
style: NooText.meta.copyWith(color: colors.fg3),
),
),
Icon(
_showInherited ? LucideIcons.chevronUp : LucideIcons.chevronDown,
size: 16,
color: colors.fg3,
),
],
),
),
),
if (_showInherited)
for (final share in _inherited)
Padding(
padding: const EdgeInsets.only(bottom: NooSpace.xs),
child: NooPersonAccessRow(
avatar: NooAvatar(
initials: _initial(
share.sharedWithDisplayName ?? share.ownerDisplayName,
),
),
name: share.sharedWithDisplayName ?? share.ownerDisplayName,
owner: true,
ownerLabel: 'Inherited',
),
),
],
if (internalLink != null) ...[
const SizedBox(height: NooSpace.xs),
Row(
children: [
Icon(LucideIcons.link, size: 18, color: colors.fg3),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Internal link', style: NooText.bodyL.copyWith(color: colors.fg1)),
Text(
'For people who already have access',
style: NooText.meta.copyWith(color: colors.fg3),
),
],
),
),
NooButton(
size: NooButtonSize.compact,
variant: NooButtonVariant.secondary,
icon: LucideIcons.copy,
onTap: () => _copy(internalLink, 'internal link'),
child: const Text('Copy'),
),
],
),
],
],
);
}
Widget _shareRow(
NextcloudShare share, {
bool removable = true,
bool copyUrl = false,
}) {
final colorScheme = Theme.of(context).colorScheme;
final label = switch (share.shareType) {
ShareType.publicLink => 'Public link',
_ => share.sharedWithDisplayName ?? 'Shared',
};
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
_shareTypeIcon(share.shareType),
color: colorScheme.primary,
),
title: Text(label),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (copyUrl && share.url != null)
IconButton(
icon: const Icon(Icons.copy_rounded),
tooltip: 'Copy link',
onPressed: () => _copy(share.url!, 'link'),
Widget _linkSection(NextcloudShare link) {
final canEdit = (link.permissions & 2) != 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
NooTextField(
controller: _linkController,
mono: true,
readOnly: true,
trailing: NooButton(
size: NooButtonSize.field,
icon: LucideIcons.copy,
onTap: () => _copy(link.url ?? '', 'link'),
child: const Text('Copy link'),
),
),
const SizedBox(height: NooSpace.sm),
Wrap(
spacing: NooSpace.xs,
runSpacing: NooSpace.xs,
children: [
NooChip(child: Text(canEdit ? 'Can edit' : 'View only')),
NooChip(
child: Text(
link.expireDate != null
? 'Expires ${DateFormat.yMMMd().format(link.expireDate!)}'
: 'No expiry',
),
),
if (removable)
IconButton(
icon: Icon(Icons.close_rounded, color: colorScheme.error),
tooltip: 'Remove',
onPressed: () => _removeShare(share),
),
],
),
],
),
],
);
}
@@ -335,170 +527,69 @@ class _ShareSheetState extends State<ShareSheet> {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final session = context.watch<SessionController>();
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
return const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Center(child: CircularProgressIndicator()),
);
}
final userGroupShares = _shares
final peopleShares = _shares
.where(
(s) =>
s.shareType == ShareType.user || s.shareType == ShareType.group,
s.shareType == ShareType.user ||
s.shareType == ShareType.group ||
s.shareType == ShareType.email,
)
.toList();
final publicLinkShares = _shares
.where((s) => s.shareType == ShareType.publicLink)
.toList();
final emailShares = _shares
.where((s) => s.shareType == ShareType.email)
.toList();
final hasLink = publicLinkShares.isNotEmpty;
if (hasLink) {
final url = publicLinkShares.first.url ?? '';
if (_linkController.text != url) _linkController.text = url;
}
final internalLink = internalLinkFor(session.serverUrl, widget.item.id);
return ListView(
controller: widget.scrollController,
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Same icon/name/meta header as DetailsSheet, so this and its
// sibling per-item bottom sheet open on identical chrome - just a
// bare title `Text` here read as under-designed by comparison.
DetailsHeader(
item: widget.item,
padding: const EdgeInsets.only(top: 12, bottom: 8),
NooShareSection(
title: 'Share with people',
child: _peopleSection(colors, session, peopleShares, internalLink),
),
const SizedBox(height: 22),
NooShareSection(
title: 'Share link',
trailing: NooToggle(
checked: hasLink,
onChanged: _isTogglingLink ? null : _toggleLink,
),
caption: hasLink
? null
: 'Anyone with the link can access this ${widget.item.isFolder ? 'folder' : 'file'}.',
child: hasLink ? _linkSection(publicLinkShares.first) : const SizedBox.shrink(),
),
if (!widget.item.isFolder) ...[
const SizedBox(height: 4),
ListTile(
contentPadding: EdgeInsets.zero,
leading: _isSharingFile
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: const Icon(Icons.ios_share_rounded),
title: const Text('Share file directly'),
subtitle: const Text('Send the file itself via another app'),
onTap: _isSharingFile ? null : _shareFileDirectly,
const SizedBox(height: 22),
NooShareSection(
title: 'Send file directly',
caption: "Link settings above don't apply - this sends the file's bytes directly.",
child: NooButton(
variant: NooButtonVariant.outline,
size: NooButtonSize.card,
fullWidth: true,
icon: LucideIcons.send,
disabled: _isSharingFile,
onTap: _isSharingFile ? null : _shareFileDirectly,
child: Text(_isSharingFile ? 'Preparing…' : 'Send file directly'),
),
),
const SizedBox(height: 12),
const Divider(),
],
const SizedBox(height: 12),
_sectionHeader('Internal shares'),
const SizedBox(height: 8),
TextField(
controller: _searchController,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
hintText: 'Type names or teams',
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: _isSearching
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: null,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
filled: true,
fillColor: colorScheme.surfaceContainerLow,
),
onChanged: _search,
),
for (final sharee in _searchResults)
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
sharee.type == ShareeType.group
? Icons.groups_rounded
: Icons.person_rounded,
),
title: Text(sharee.label),
subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null,
onTap: () => _addSharee(sharee),
),
for (final share in userGroupShares) _shareRow(share),
if (_inherited.isNotEmpty)
ExpansionTile(
tilePadding: EdgeInsets.zero,
leading: const Icon(Icons.more_horiz_rounded),
title: Text('Others with access (${_inherited.length})'),
children: [
for (final share in _inherited)
_shareRow(share, removable: false),
],
),
if (internalLink != null)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.link_rounded),
title: const Text('Internal link'),
subtitle: const Text('For people who already have access'),
trailing: IconButton(
icon: const Icon(Icons.copy_rounded),
tooltip: 'Copy link',
onPressed: () => _copy(internalLink, 'internal link'),
),
),
const SizedBox(height: 20),
const Divider(),
const SizedBox(height: 12),
_sectionHeader('External shares'),
const SizedBox(height: 8),
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
enabled: !_isAddingEmail,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
hintText: 'Type an email',
prefixIcon: const Icon(Icons.email_rounded),
suffixIcon: _isAddingEmail
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: null,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
filled: true,
fillColor: colorScheme.surfaceContainerLow,
),
onSubmitted: (_) => _addEmailShare(),
),
for (final share in emailShares) _shareRow(share),
const SizedBox(height: 8),
for (final share in publicLinkShares) _shareRow(share, copyUrl: true),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.link_rounded),
title: const Text('Create public link'),
trailing: IconButton(
icon: const Icon(Icons.add_rounded),
tooltip: 'Create public link',
onPressed: _createPublicLink,
),
),
],
);
}
+313
View File
@@ -0,0 +1,313 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../../models/nextcloud_item.dart';
import '../../providers/session_controller.dart';
import '../../theme/design_tokens.dart';
import '../../views/account_view.dart';
import '../../views/login_view.dart';
import '../../views/search_view.dart';
import '../noo/core/noo_avatar.dart';
import '../noo/core/noo_search_field.dart';
import '../noo/lists/noo_grouped_list.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';
// Small pieces shared by the mobile drawer/top bar and the desktop
// sidebar/toolbar, so both shells show the same account/storage data and
// open the same screens.
String accountInitial(String username) =>
username.isNotEmpty ? username[0].toUpperCase() : '?';
String serverHost(String serverUrl) =>
serverUrl.replaceFirst(RegExp(r'^https?://'), '');
void openSettings(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AccountView()),
);
}
void openSearch(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SearchView()),
);
}
// A local copy rather than importing `synced_header_scaffold.dart`'s, since
// that legacy header is on its way out with the rework.
String _formatBytes(int bytes) {
if (bytes <= 0) return '0 B';
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
/// 0-1 share of the quota used (0 when unknown or unlimited).
double quotaFraction(NextcloudUserQuota? quota) =>
quota != null && quota.totalBytes > 0
? (quota.usedBytes / quota.totalBytes).clamp(0.0, 1.0)
: 0;
/// "38.2 GB of 100 GB used" (drawer).
String quotaUsedLabel(NextcloudUserQuota? quota) {
if (quota == null) return 'Storage usage unavailable';
final total = quota.totalBytes > 0 ? _formatBytes(quota.totalBytes) : '∞';
return '${_formatBytes(quota.usedBytes)} of $total used';
}
/// "38.2 GB of 100 GB" (sidebar, next to the "Storage" label).
String quotaDetail(NextcloudUserQuota? quota) {
if (quota == null) return 'Unavailable';
final total = quota.totalBytes > 0 ? _formatBytes(quota.totalBytes) : '∞';
return '${_formatBytes(quota.usedBytes)} of $total';
}
/// The saved-accounts switcher behind the drawer's chevron and the desktop
/// sidebar's account card: every saved account (tap to switch, same
/// `switchAccount` call - and login-lock gate - as Settings' account list),
/// "Add account", and a shortcut to the full account settings. A sheet on
/// mobile, a dialog on desktop.
void showAccountSwitcher(BuildContext context) {
final session = context.read<SessionController>();
List<Widget> content(BuildContext overlayContext) {
void close() => Navigator.pop(overlayContext);
return [
NooGroupedList(
label: 'Accounts',
children: [
for (final account in session.accounts)
_AccountSwitchRow(
name: account.username,
host: serverHost(account.serverUrl),
active: account.id == session.activeAccountId,
onTap: account.id == session.activeAccountId
? null
: () {
close();
session.switchAccount(account.id);
},
),
NooSettingsRow(
icon: LucideIcons.plus,
accent: true,
label: const Text('Add account'),
onTap: () {
close();
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const LoginView(isAddingAccount: true),
),
);
},
),
NooSettingsRow(
icon: LucideIcons.settings,
label: const Text('Manage accounts'),
onTap: () {
close();
openSettings(context);
},
),
],
),
];
}
if (NooLayout.isDesktop(context)) {
showNooDialog(
context,
title: 'Switch account',
children: [Builder(builder: (c) => Column(children: content(c)))],
);
} else {
showNooSheet(
context,
children: [Builder(builder: (c) => Column(children: content(c)))],
);
}
}
/// A switcher row: avatar, name over host, and a check on the active one.
/// `NooSettingsRow` only takes an icon, not an avatar, hence its own row.
class _AccountSwitchRow extends StatelessWidget {
final String name;
final String host;
final bool active;
final VoidCallback? onTap;
const _AccountSwitchRow({
required this.name,
required this.host,
required this.active,
this.onTap,
});
@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(name),
current: active,
size: 36,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(
height: 1.2,
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
Text(
host,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(
height: 1.2,
color: colors.fg3,
),
),
],
),
),
if (active)
Icon(LucideIcons.check, size: 20, color: colors.accentText),
],
),
),
),
);
}
}
/// The current user's avatar in the mobile top bar. Tap opens Settings;
/// swiping up/down on it cycles to the next/previous saved account
/// immediately, with no confirmation - a shortcut alongside the full
/// switcher (drawer chevron, Settings' account list).
class ShellAvatarButton extends StatelessWidget {
final double hitBox;
const ShellAvatarButton({super.key, this.hitBox = 48});
Future<void> _handleVerticalSwipe(
BuildContext context,
DragEndDetails details,
) async {
final velocity = details.primaryVelocity ?? 0;
if (velocity.abs() < 250) return;
final session = context.read<SessionController>();
final target = velocity < 0
? await session.cycleToNextAccount()
: await session.cycleToPreviousAccount();
if (target == null || !context.mounted) return;
HapticFeedback.selectionClick();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Switched to ${target.username}'),
behavior: SnackBarBehavior.floating,
),
);
}
@override
Widget build(BuildContext context) {
final session = context.watch<SessionController>();
return Tooltip(
message: 'Settings',
child: Semantics(
button: true,
label: 'Settings',
child: GestureDetector(
onVerticalDragEnd: (details) =>
_handleVerticalSwipe(context, details),
child: SizedBox.square(
dimension: hitBox,
child: InkResponse(
onTap: () => openSettings(context),
radius: hitBox / 2,
child: Center(
child: NooAvatar(
initials: accountInitial(session.username),
current: true,
size: 32,
),
),
),
),
),
),
);
}
}
/// A [NooSearchField] that doesn't take input itself but opens
/// `SearchView` on tap - the shell's search entry point (iOS top bar,
/// desktop toolbar). Search has its own screen with results, so the field
/// here is only a launcher.
class ShellSearchLauncher extends StatelessWidget {
final bool ios;
final bool onSurface;
const ShellSearchLauncher({
super.key,
this.ios = false,
this.onSurface = false,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: 'Search',
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => openSearch(context),
child: AbsorbPointer(
child: ExcludeFocus(
child: NooSearchField(
placeholder: 'Search files',
ios: ios,
onSurface: onSurface,
),
),
),
),
),
);
}
}
+32 -23
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../providers/files_controller.dart';
import '../theme/design_tokens.dart';
String sortFieldLabel(FileSortField field) {
switch (field) {
@@ -20,21 +21,28 @@ class SortMenuButton extends StatelessWidget {
final FileSortField field;
final ValueChanged<FileSortField> onChanged;
/// An extra control (e.g. an ascending/descending toggle) shown inside
/// the same pill, between the label and the menu's own dropdown chevron -
/// visually one "Name ↑" chip (DESIGN_SYSTEM.md's sort chip) even though
/// the two behaviors (pick a field / flip direction) stay separate taps.
final Widget? trailing;
const SortMenuButton({
super.key,
required this.field,
required this.onChanged,
this.trailing,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
return PopupMenuButton<FileSortField>(
initialValue: field,
onSelected: onChanged,
offset: const Offset(0, 36),
color: colors.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
itemBuilder: (context) => FileSortField.values.map((f) {
return PopupMenuItem(
@@ -44,44 +52,45 @@ class SortMenuButton extends StatelessWidget {
SizedBox(
width: 20,
child: f == field
? Icon(
Icons.check_rounded,
size: 18,
color: colorScheme.primary,
)
? Icon(Icons.check_rounded, size: 18, color: colors.accentText)
: null,
),
const SizedBox(width: 8),
Text(sortFieldLabel(f)),
Text(sortFieldLabel(f), style: NooText.body.copyWith(color: colors.fg1)),
],
),
);
}).toList(),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Container(
height: 34,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: Row(
// A plain Text child in a Row always takes its full intrinsic
// width no matter what maxLines/overflow say - Flexible is what
// actually lets it shrink to whatever space this row has left
// (it's squeezed by favorite/hidden/storage/list-grid toggles
// alongside it) so the ellipsis can kick in instead of
// overflowing.
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.swap_vert_rounded, size: 16, color: colors.fg1),
const SizedBox(width: 6),
// A plain Text child in a Row always takes its full intrinsic
// width no matter what maxLines/overflow say - Flexible is what
// actually lets it shrink to whatever space this row has left
// (it's squeezed by hidden/storage/list-grid toggles alongside
// it) so the ellipsis can kick in instead of overflowing.
Flexible(
child: Text(
sortFieldLabel(field),
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurfaceVariant,
style: NooText.body.copyWith(
fontWeight: FontWeight.w500,
color: colors.fg1,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Icon(
Icons.arrow_drop_down_rounded,
color: colorScheme.onSurfaceVariant,
),
?trailing,
Icon(Icons.arrow_drop_down_rounded, color: colors.fg1),
],
),
),
-142
View File
@@ -1,142 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../providers/settings_controller.dart';
/// Wraps [child] with the user's configured swipe-left/swipe-right actions
/// (favorite, delete, share, or none) — shared between the Files list view
/// and the Photos grid so both behave identically. Callers own the actual
/// side effects (favoriting, sharing, deleting); this widget only decides
/// which action a given swipe direction maps to and drives the gesture.
class SwipeableItem extends StatelessWidget {
final Key itemKey;
final String itemName;
final SettingsController settings;
final Future<void> Function() onFavorite;
final Future<void> Function() onShare;
final Future<void> Function() onDelete;
final Widget child;
const SwipeableItem({
super.key,
required this.itemKey,
required this.itemName,
required this.settings,
required this.onFavorite,
required this.onShare,
required this.onDelete,
required this.child,
});
static DismissDirection _directionFor(SwipeAction left, SwipeAction right) {
final hasLeft = left != SwipeAction.none;
final hasRight = right != SwipeAction.none;
if (hasLeft && hasRight) return DismissDirection.horizontal;
if (hasRight) return DismissDirection.startToEnd;
if (hasLeft) return DismissDirection.endToStart;
return DismissDirection.none;
}
static Widget _background(
BuildContext context,
SwipeAction action,
Alignment alignment,
) {
if (action == SwipeAction.none) return const SizedBox.shrink();
final colorScheme = Theme.of(context).colorScheme;
final Color color;
final IconData icon;
switch (action) {
case SwipeAction.delete:
color = colorScheme.error;
icon = Icons.delete_rounded;
case SwipeAction.favorite:
color = Colors.red.shade400;
icon = Icons.favorite_rounded;
case SwipeAction.share:
color = colorScheme.primary;
icon = Icons.share_rounded;
case SwipeAction.none:
return const SizedBox.shrink();
}
return Container(
color: color,
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Icon(icon, color: Colors.white),
);
}
@override
Widget build(BuildContext context) {
final rightAction = settings.swipeRightAction;
final leftAction = settings.swipeLeftAction;
final direction = _directionFor(leftAction, rightAction);
if (direction == DismissDirection.none) return child;
return Dismissible(
key: itemKey,
direction: direction,
background: _background(context, rightAction, Alignment.centerLeft),
secondaryBackground: _background(
context,
leftAction,
Alignment.centerRight,
),
confirmDismiss: (dir) async {
final action = dir == DismissDirection.startToEnd
? rightAction
: leftAction;
switch (action) {
case SwipeAction.favorite:
HapticFeedback.selectionClick();
await onFavorite();
return false;
case SwipeAction.share:
HapticFeedback.selectionClick();
await onShare();
return false;
case SwipeAction.delete:
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Delete Item'),
content: Text(
'Delete "$itemName" from the server? This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(
dialogContext,
).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Delete'),
),
],
);
},
);
if (confirmed != true) return false;
HapticFeedback.mediumImpact();
return true; // Actual delete happens in onDismissed.
case SwipeAction.none:
return false;
}
},
onDismissed: (dir) {
final action = dir == DismissDirection.startToEnd
? rightAction
: leftAction;
if (action == SwipeAction.delete) onDelete();
},
child: child,
);
}
}
-34
View File
@@ -1,34 +0,0 @@
import 'package:flutter/material.dart';
import '../models/sync_status.dart';
/// A small corner badge over a thumbnail showing device-sync status - a
/// `cloud_done` badge once a file's mirrored locally, `sync` while it's
/// actively being transferred. Nothing is drawn for `none`/`conflict`
/// (conflicts surface in the sync header, not as per-tile noise).
class SyncStatusBadge extends StatelessWidget {
final SyncItemStatus status;
const SyncStatusBadge({super.key, required this.status});
@override
Widget build(BuildContext context) {
if (status != SyncItemStatus.syncing && status != SyncItemStatus.synced) {
return const SizedBox.shrink();
}
final colorScheme = Theme.of(context).colorScheme;
final icon = status == SyncItemStatus.syncing
? Icons.sync_rounded
: Icons.cloud_done_rounded;
return Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(color: colorScheme.surface, width: 1.5),
),
child: Icon(icon, size: 13, color: colorScheme.primary),
);
}
}
+109
View File
@@ -0,0 +1,109 @@
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import '../../theme/design_tokens.dart';
/// Day-bucketing shared by Recent (Today/Yesterday/This week/Earlier) and
/// Activity (one group per calendar day) - see `DESIGN_SYSTEM.md` §4.
bool _isSameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
/// One labelled bucket of [items], already in their original (newest-first)
/// order.
class TabDayGroup<T> {
final String label;
final List<T> items;
const TabDayGroup(this.label, this.items);
}
/// Buckets newest-first [items] into Today / Yesterday / This week /
/// Earlier. Empty buckets are omitted.
List<TabDayGroup<T>> groupByRecentBucket<T>(
List<T> items,
DateTime Function(T item) dateOf,
) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
// "This week" reaches back 6 days before today (7 days inclusive) -
// anything older falls to "Earlier".
final weekStart = today.subtract(const Duration(days: 6));
final todayItems = <T>[];
final yesterdayItems = <T>[];
final weekItems = <T>[];
final earlierItems = <T>[];
for (final item in items) {
final d = dateOf(item);
final day = DateTime(d.year, d.month, d.day);
if (_isSameDay(day, today)) {
todayItems.add(item);
} else if (_isSameDay(day, yesterday)) {
yesterdayItems.add(item);
} else if (!day.isBefore(weekStart)) {
weekItems.add(item);
} else {
earlierItems.add(item);
}
}
return [
if (todayItems.isNotEmpty) TabDayGroup('Today', todayItems),
if (yesterdayItems.isNotEmpty) TabDayGroup('Yesterday', yesterdayItems),
if (weekItems.isNotEmpty) TabDayGroup('This week', weekItems),
if (earlierItems.isNotEmpty) TabDayGroup('Earlier', earlierItems),
];
}
/// Buckets newest-first [items] one group per calendar day (Activity's
/// "feed grouped by day" - unlike [groupByRecentBucket], days past
/// yesterday each keep their own header rather than folding into "This
/// week"/"Earlier").
List<TabDayGroup<T>> groupByCalendarDay<T>(
List<T> items,
DateTime Function(T item) dateOf,
) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
final order = <DateTime>[];
final groups = <DateTime, List<T>>{};
for (final item in items) {
final d = dateOf(item);
final day = DateTime(d.year, d.month, d.day);
final bucket = groups.putIfAbsent(day, () {
order.add(day);
return [];
});
bucket.add(item);
}
String labelFor(DateTime day) {
if (_isSameDay(day, today)) return 'Today';
if (_isSameDay(day, yesterday)) return 'Yesterday';
return DateFormat.yMMMd().format(day);
}
return [for (final day in order) TabDayGroup(labelFor(day), groups[day]!)];
}
/// The "Today"/"Yesterday"/... label above a day group - `DESIGN_SYSTEM.md`
/// §2's grouped-list label style (13/600 fg-2, inset 8px). Standalone
/// (rather than `NooGroupedList.label`) for Activity, whose items aren't a
/// `NooGroupedList` card.
class TabGroupLabel extends StatelessWidget {
final String label;
const TabGroupLabel(this.label, {super.key});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: Text(label, style: NooText.label.copyWith(color: colors.fg2)),
);
}
}
+10
View File
@@ -0,0 +1,10 @@
/// Turns a full item path plus its own name into a display-friendly parent
/// folder label - shared by Recent ("action + location") and Trash
/// ("original location") per `DESIGN_SYSTEM.md` §4.
String tabLocationLabel(String fullPath, String name) {
final folder = fullPath.length >= name.length
? fullPath.substring(0, fullPath.length - name.length)
: fullPath;
final trimmed = folder.replaceAll(RegExp(r'^/+|/+$'), '');
return trimmed.isEmpty ? 'Home' : trimmed;
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../theme/design_tokens.dart';
import '../noo/core/noo_button.dart';
/// Shared loading/error/empty slivers for Recent/Activity/Trash/Shares (and
/// any future tab shaped like them: a `CustomScrollView` over one flat
/// list), restyled with noo tokens instead of each screen hand-rolling the
/// same centered column - see `FilesView`'s identical error/empty layout,
/// which this mirrors.
/// Centered spinner for a tab's first load (list still empty).
const Widget tabLoadingSliver = SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
);
/// Centered error state: icon, title, message, and a Retry button.
Widget tabErrorSliver(
BuildContext context, {
required String title,
required String message,
required VoidCallback onRetry,
}) {
final colors = context.nooColors;
return SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.circleAlert, size: 56, color: colors.danger),
const SizedBox(height: 16),
Text(
title,
textAlign: TextAlign.center,
style: NooText.cardTitle.copyWith(color: colors.danger),
),
const SizedBox(height: 8),
Text(
message,
textAlign: TextAlign.center,
style: NooText.body.copyWith(color: colors.fg3),
),
const SizedBox(height: 20),
NooButton(
variant: NooButtonVariant.secondary,
size: NooButtonSize.field,
icon: LucideIcons.refreshCw,
onTap: onRetry,
child: const Text('Retry'),
),
],
),
),
),
);
}
/// Centered empty state: an icon over a message.
Widget tabEmptySliver(
BuildContext context, {
required IconData icon,
required String message,
}) {
final colors = context.nooColors;
return SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 56, color: colors.fg3),
const SizedBox(height: 12),
Text(
message,
textAlign: TextAlign.center,
style: NooText.cardTitle.copyWith(color: colors.fg2),
),
],
),
),
),
);
}
/// Bottom padding sliver so the last row/card clears the bottom nav/FAB -
/// same trailing pair every rebuilt tab list ends on.
const List<Widget> tabBottomInsetSlivers = [
SliverToBoxAdapter(child: SizedBox(height: 100)),
SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
];
+98
View File
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../theme/design_tokens.dart';
import '../frosted_glass_container.dart';
import 'viewer_icon_button.dart';
/// The floating action bar (share/favorite/open/download/delete/details)
/// overlaid on the media viewer - kept on `FrostedGlassContainer`'s blurred
/// chrome as a deliberate exception to the design system's flat product UI
/// (see `file_viewer_screen.dart`'s build() comment for why), unlike the
/// now-flat bottom nav bar (`bottom_nav_bar.dart`) this screen sits above.
class MediaActionBar extends StatelessWidget {
final bool isFavorite;
final bool isBusy;
final bool showServerActions;
final VoidCallback onShare;
final VoidCallback onFavorite;
final VoidCallback onDelete;
final VoidCallback onOpenExternally;
final VoidCallback onDownload;
final VoidCallback onDetails;
const MediaActionBar({
super.key,
required this.isFavorite,
required this.isBusy,
required this.showServerActions,
required this.onShare,
required this.onFavorite,
required this.onDelete,
required this.onOpenExternally,
required this.onDownload,
required this.onDetails,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 20, left: 20, right: 20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: FrostedGlassContainer(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ViewerIconButton(
icon: LucideIcons.share2,
tooltip: 'Share',
onTap: isBusy ? null : onShare,
),
if (showServerActions)
ViewerIconButton(
// Matches the star/star-off convention Files' own
// selection toolbar uses for favorite/unfavorite
// (see `files_view.dart`) rather than a filled heart.
icon: isFavorite ? LucideIcons.starOff : LucideIcons.star,
tooltip: isFavorite
? 'Remove from favorites'
: 'Favorite',
onTap: onFavorite,
),
ViewerIconButton(
icon: LucideIcons.externalLink,
tooltip: 'Open externally',
onTap: isBusy ? null : onOpenExternally,
),
if (showServerActions)
ViewerIconButton(
icon: LucideIcons.download,
tooltip: 'Download',
onTap: isBusy ? null : onDownload,
),
if (showServerActions)
ViewerIconButton(
icon: LucideIcons.trash2,
tooltip: 'Delete',
color: colors.danger,
onTap: isBusy ? null : onDelete,
),
ViewerIconButton(
icon: LucideIcons.info,
tooltip: 'Details',
onTap: onDetails,
),
],
),
),
),
),
),
);
}
}
+134
View File
@@ -0,0 +1,134 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../theme/design_tokens.dart';
/// Pinch-zoom/pan/double-tap-to-zoom image page for [FileViewerScreen]'s
/// swipeable `PageView` - one instance per image, from either a server URL
/// or an on-disk path (offline mode).
class MediaImagePreview extends StatefulWidget {
final String? url;
final Map<String, String> headers;
/// When set, read the image from this on-disk path instead of [url] -
/// already-resolved by the caller (see
/// `FileViewerScreen.localPathResolver`'s doc comment).
final String? localPath;
const MediaImagePreview({
super.key,
required this.url,
required this.headers,
this.localPath,
});
@override
State<MediaImagePreview> createState() => _MediaImagePreviewState();
}
class _MediaImagePreviewState extends State<MediaImagePreview>
with SingleTickerProviderStateMixin {
static const _doubleTapScale = 3.0;
final TransformationController _transformController =
TransformationController();
late final AnimationController _animController;
Animation<Matrix4>? _animation;
Offset _doubleTapPosition = Offset.zero;
@override
void initState() {
super.initState();
_animController =
AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
)..addListener(() {
if (_animation != null) {
_transformController.value = _animation!.value;
}
});
}
@override
void dispose() {
_animController.dispose();
_transformController.dispose();
super.dispose();
}
void _onDoubleTapDown(TapDownDetails details) {
_doubleTapPosition = details.localPosition;
}
void _onDoubleTap() {
final isZoomedIn = _transformController.value.getMaxScaleOnAxis() > 1.01;
final Matrix4 endMatrix;
if (isZoomedIn) {
endMatrix = Matrix4.identity();
} else {
final p = _doubleTapPosition;
endMatrix = Matrix4.identity()
..translateByDouble(
-p.dx * (_doubleTapScale - 1),
-p.dy * (_doubleTapScale - 1),
0,
1,
)
..scaleByDouble(_doubleTapScale, _doubleTapScale, _doubleTapScale, 1);
}
_animation = Matrix4Tween(
begin: _transformController.value,
end: endMatrix,
).animate(CurveTween(curve: Curves.easeOut).animate(_animController));
_animController.forward(from: 0);
}
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return SizedBox.expand(
child: GestureDetector(
onDoubleTapDown: _onDoubleTapDown,
onDoubleTap: _onDoubleTap,
child: InteractiveViewer(
transformationController: _transformController,
minScale: 0.8,
maxScale: 5.0,
child: Center(
child: widget.localPath != null
? Image.file(
File(widget.localPath!),
fit: BoxFit.contain,
errorBuilder: (context, error, stack) => Center(
child: Icon(
LucideIcons.imageOff,
color: colors.fg3,
size: 64,
),
),
)
: Image.network(
widget.url!,
headers: widget.headers,
fit: BoxFit.contain,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return Center(
child: CircularProgressIndicator(color: colors.accent),
);
},
errorBuilder: (context, error, stack) => Center(
child: Icon(
LucideIcons.imageOff,
color: colors.fg3,
size: 64,
),
),
),
),
),
),
);
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:pdfx/pdfx.dart';
import '../../models/nextcloud_item.dart';
import '../../providers/session_controller.dart';
import '../../theme/design_tokens.dart';
/// Pinch-zoomable PDF page viewer for [FileViewerScreen]'s static (non-
/// swipeable) preview path.
class MediaPdfPreview extends StatefulWidget {
final NextcloudItem item;
final SessionController session;
/// When set, read bytes from the local path it resolves to instead of
/// fetching from the server - see
/// `FileViewerScreen.localPathResolver`'s doc comment.
final Future<String?> Function(NextcloudItem item)? localPathResolver;
const MediaPdfPreview({
super.key,
required this.item,
required this.session,
this.localPathResolver,
});
@override
State<MediaPdfPreview> createState() => _MediaPdfPreviewState();
}
class _MediaPdfPreviewState extends State<MediaPdfPreview> {
PdfControllerPinch? _controller;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final Uint8List bytes;
if (widget.localPathResolver != null) {
final localPath = await widget.localPathResolver!(widget.item);
if (localPath == null) {
if (mounted) {
setState(() => _error = 'No longer available offline');
}
return;
}
bytes = await File(localPath).readAsBytes();
} else {
bytes = Uint8List.fromList(
await widget.session.service!.fetchBytes(widget.item.path),
);
}
if (!mounted) return;
setState(() {
_controller = PdfControllerPinch(
document: PdfDocument.openData(bytes),
);
});
} catch (e) {
if (mounted) setState(() => _error = e.toString());
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
if (_error != null) {
return Center(
child: Text(
'Could not load PDF: $_error',
style: TextStyle(color: colors.fg2),
),
);
}
if (_controller == null) {
return Center(child: CircularProgressIndicator(color: colors.accent));
}
// pdfx's own default minScale (1.0 = the page's true/100% size) is
// often *larger* than the fit-to-width size a wide page first renders
// at, since that initial render is just normal box layout, not the
// InteractiveViewer transform pinching engages on first touch - so the
// moment you touch the page it snaps up to 1.0 and, with the default
// floor, can never pinch back down past it. A low floor here lets you
// zoom back out past that to the fit-width view you started at.
return PdfViewPinch(controller: _controller!, minScale: 0.3);
}
}
@@ -0,0 +1,88 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import '../../models/nextcloud_item.dart';
import '../../providers/session_controller.dart';
import '../../theme/design_tokens.dart';
/// Plain monospace text-file preview for [FileViewerScreen]'s static (non-
/// swipeable) preview path (`_textPreviewExtensions`).
class MediaTextPreview extends StatefulWidget {
final NextcloudItem item;
final SessionController session;
/// When set, read bytes from the local path it resolves to instead of
/// fetching from the server - see
/// `FileViewerScreen.localPathResolver`'s doc comment.
final Future<String?> Function(NextcloudItem item)? localPathResolver;
const MediaTextPreview({
super.key,
required this.item,
required this.session,
this.localPathResolver,
});
@override
State<MediaTextPreview> createState() => _MediaTextPreviewState();
}
class _MediaTextPreviewState extends State<MediaTextPreview> {
String? _content;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final List<int> bytes;
if (widget.localPathResolver != null) {
final localPath = await widget.localPathResolver!(widget.item);
if (localPath == null) {
if (mounted) {
setState(() => _error = 'No longer available offline');
}
return;
}
bytes = await File(localPath).readAsBytes();
} else {
bytes = await widget.session.service!.fetchBytes(widget.item.path);
}
if (mounted) {
setState(() => _content = utf8.decode(bytes, allowMalformed: true));
}
} catch (e) {
if (mounted) setState(() => _error = e.toString());
}
}
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
if (_error != null) {
return Center(
child: Text(
'Could not load file: $_error',
style: TextStyle(color: colors.fg2),
),
);
}
if (_content == null) {
return Center(child: CircularProgressIndicator(color: colors.accent));
}
return Container(
color: colors.surface,
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(
child: SelectableText(
_content!,
style: NooText.mono.copyWith(color: colors.fg1, height: 1.5),
),
),
);
}
}
@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../models/nextcloud_item.dart';
import '../../theme/design_tokens.dart';
import '../noo/core/noo_button.dart';
/// Fallback for [FileViewerScreen]'s static preview path when the file type
/// has no inline preview at all (not an image/video/pdf/text file) - just
/// name, a generic file icon, and the "open with"/"details" escape hatches.
class MediaUnsupportedPreview extends StatelessWidget {
final NextcloudItem item;
final bool isBusy;
final VoidCallback onOpenExternally;
final VoidCallback onOpenDetails;
const MediaUnsupportedPreview({
super.key,
required this.item,
required this.isBusy,
required this.onOpenExternally,
required this.onOpenDetails,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.file, color: colors.fg3, size: 72),
const SizedBox(height: 16),
Text(
item.name,
textAlign: TextAlign.center,
style: NooText.cardTitle.copyWith(
fontSize: 16,
color: colors.fg1,
),
),
const SizedBox(height: 8),
Text(
'No inline preview for this file type.',
style: NooText.meta.copyWith(color: colors.fg3),
),
const SizedBox(height: 28),
NooButton(
size: NooButtonSize.card,
icon: LucideIcons.externalLink,
disabled: isBusy,
onTap: onOpenExternally,
child: const Text('Open with...'),
),
const SizedBox(height: 12),
NooButton(
variant: NooButtonVariant.outline,
size: NooButtonSize.card,
icon: LucideIcons.info,
onTap: onOpenDetails,
child: const Text('Details'),
),
],
),
),
);
}
}
+310
View File
@@ -0,0 +1,310 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
import '../../providers/settings_controller.dart';
import '../../theme/design_tokens.dart';
import '../frosted_glass_container.dart';
import '../seek_bar_painter.dart';
import 'viewer_icon_button.dart';
/// Playing video page for [FileViewerScreen]'s swipeable `PageView`, plus
/// its floating transport controls (play/pause, position, mute, seek bar).
class MediaVideoPreview extends StatefulWidget {
final String? url;
final Map<String, String> headers;
final bool isActive;
/// When set, read the video from this on-disk path instead of [url] -
/// already-resolved by the caller (see
/// `FileViewerScreen.localPathResolver`'s doc comment).
final String? localPath;
/// Distance from the bottom of the screen the transport controls should
/// sit at. Passed in by the parent so the controls can track the floating
/// action bar: resting just above it when visible, sliding down to hug
/// the screen edge when the bar (and rest of the chrome) is hidden.
final double controlsBottomOffset;
const MediaVideoPreview({
super.key,
required this.url,
required this.headers,
this.localPath,
this.isActive = true,
this.controlsBottomOffset = 24,
});
@override
State<MediaVideoPreview> createState() => _MediaVideoPreviewState();
}
class _MediaVideoPreviewState extends State<MediaVideoPreview> {
VideoPlayerController? _controller;
String? _error;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
try {
final controller = widget.localPath != null
? VideoPlayerController.file(File(widget.localPath!))
: VideoPlayerController.networkUrl(
Uri.parse(widget.url!),
httpHeaders: widget.headers,
);
await controller.initialize();
if (!mounted) {
controller.dispose();
return;
}
setState(() => _controller = controller);
if (widget.isActive) controller.play();
} catch (e) {
if (mounted) setState(() => _error = e.toString());
}
}
@override
void didUpdateWidget(covariant MediaVideoPreview oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isActive != widget.isActive) {
if (widget.isActive) {
_controller?.play();
} else {
_controller?.pause();
}
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
String _formatDuration(Duration d) {
final minutes = d.inMinutes.remainder(60);
final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final progressBarStyle = context
.watch<SettingsController>()
.mediaProgressBarStyle;
if (_error != null) {
return Center(
child: Text(
'Could not play video: $_error',
style: TextStyle(color: colors.fg2),
),
);
}
final controller = _controller;
if (controller == null || !controller.value.isInitialized) {
return Center(child: CircularProgressIndicator(color: colors.accent));
}
return SizedBox.expand(
child: Stack(
alignment: Alignment.center,
children: [
Center(
child: AspectRatio(
aspectRatio: controller.value.aspectRatio,
child: VideoPlayer(controller),
),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOutCubic,
left: 16,
right: 16,
bottom: widget.controlsBottomOffset,
child: AnimatedBuilder(
animation: controller,
builder: (context, _) {
final fg = colors.fg1;
// Same frosted-glass treatment (and user opacity/blur
// settings) as the back button and media action bar, so the
// transport controls match the rest of the app's chrome.
return FrostedGlassContainer(
borderRadius: 24,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 4,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
ViewerIconButton(
icon: controller.value.isPlaying
? LucideIcons.pause
: LucideIcons.play,
tooltip: controller.value.isPlaying
? 'Pause'
: 'Play',
onTap: () => controller.value.isPlaying
? controller.pause()
: controller.play(),
),
Expanded(
child: Text(
'${_formatDuration(controller.value.position)} / ${_formatDuration(controller.value.duration)}',
textAlign: TextAlign.center,
style: NooText.meta.copyWith(color: fg),
),
),
ViewerIconButton(
icon: controller.value.volume == 0
? LucideIcons.volumeX
: LucideIcons.volume2,
tooltip: controller.value.volume == 0
? 'Unmute'
: 'Mute',
onTap: () => controller.setVolume(
controller.value.volume == 0 ? 1 : 0,
),
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: WavySeekBar(
controller: controller,
playedColor: fg,
trackColor: fg.withValues(alpha: 0.3),
style: progressBarStyle,
),
),
],
),
),
);
},
),
),
],
),
);
}
}
/// A Material You "expressive" wavy seek bar: the played portion of the
/// track animates as a travelling sine wave while playing and settles flat
/// when paused, matching the Android 16+ media player style. Drag or tap
/// anywhere on it to seek. Still driven by [SeekBarPainter] (shared with the
/// Settings style picker's preview) - only [playedColor]/[trackColor] come
/// from [NooColors] now instead of `ColorScheme`.
class WavySeekBar extends StatefulWidget {
final VideoPlayerController controller;
final Color playedColor;
final Color trackColor;
final MediaProgressBarStyle style;
const WavySeekBar({
super.key,
required this.controller,
required this.playedColor,
required this.trackColor,
required this.style,
});
@override
State<WavySeekBar> createState() => _WavySeekBarState();
}
class _WavySeekBarState extends State<WavySeekBar>
with SingleTickerProviderStateMixin {
late final AnimationController _waveController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
double? _dragRatio;
@override
void dispose() {
_waveController.dispose();
super.dispose();
}
void _seekToRatio(double ratio) {
final duration = widget.controller.value.duration;
widget.controller.seekTo(duration * ratio.clamp(0.0, 1.0));
}
@override
Widget build(BuildContext context) {
final value = widget.controller.value;
final animatesWave =
widget.style == MediaProgressBarStyle.wavy ||
widget.style == MediaProgressBarStyle.squiggly;
final isPlaying = animatesWave && value.isPlaying && _dragRatio == null;
if (isPlaying && !_waveController.isAnimating) {
_waveController.repeat();
} else if (!isPlaying && _waveController.isAnimating) {
_waveController.stop();
}
final durationMs = value.duration.inMilliseconds;
final baseRatio = durationMs > 0
? value.position.inMilliseconds / durationMs
: 0.0;
final ratio = (_dragRatio ?? baseRatio).clamp(0.0, 1.0);
return SizedBox(
height: 28,
child: LayoutBuilder(
builder: (context, constraints) {
void updateFromDx(double dx) {
setState(
() => _dragRatio = (dx / constraints.maxWidth).clamp(0.0, 1.0),
);
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (details) =>
updateFromDx(details.localPosition.dx),
onHorizontalDragUpdate: (details) =>
updateFromDx(details.localPosition.dx),
onHorizontalDragEnd: (_) {
if (_dragRatio != null) _seekToRatio(_dragRatio!);
setState(() => _dragRatio = null);
},
onTapUp: (details) =>
_seekToRatio(details.localPosition.dx / constraints.maxWidth),
child: AnimatedBuilder(
animation: _waveController,
builder: (context, _) {
return CustomPaint(
size: Size(constraints.maxWidth, 28),
painter: SeekBarPainter(
style: widget.style,
progress: ratio,
phase: _waveController.value * 2 * math.pi,
animate: isPlaying,
playedColor: widget.playedColor,
trackColor: widget.trackColor,
),
);
},
),
);
},
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More