Rebuild UI on the Noo design system
Replaces the Material 3 screens with the Noo design system: warm neutrals, one violet accent, pill controls, Schibsted Grotesk/ Instrument Sans, Lucide icons, no gradients/shadows. - New lib/widgets/noo/ component kit (core, lists, files, media, nav, overlays) plus lib/theme/design_tokens.dart for the color/type/space/ radius/motion tokens. - Rebuilt the app shell (top/bottom bars, drawer, desktop sidebar and toolbar), every tab (Files/Offline, Photos, Favorites, Recent, Activity, Trash, Shares), Settings, the lock and login screens, the media viewer, search, the details/share sheets, and the share-upload and move/copy destination pickers. - Added a design canvas (linked from DESIGN_SYSTEM.md) covering the screens the spec didn't already describe, with matching Android and iOS chrome; wrote up the approved recipes into DESIGN_SYSTEM.md §4. - Removed now-dead legacy widgets (media_grid_tile, swipeable_item, sync_status_badge, selectable_thumbnail) and updated architecture.md/styling.md/standards.md to describe the new structure and component/testing conventions. - Added widget tests for the noo/ component kit. This is a UI-only rework: no provider/model/service behavior changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/saved_account.dart';
|
||||
import '../../providers/session_controller.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../../views/login_view.dart';
|
||||
import '../noo/core/noo_avatar.dart';
|
||||
import '../noo/lists/noo_settings_row.dart';
|
||||
import '../shell/shell_common.dart';
|
||||
import 'settings_dialogs.dart';
|
||||
import 'settings_section.dart';
|
||||
|
||||
/// Settings section 2: every saved account (switch/remove), then "Add
|
||||
/// account" (DESIGN_SYSTEM.md 4).
|
||||
class SettingsAccountsSection extends StatelessWidget {
|
||||
const SettingsAccountsSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionController>();
|
||||
|
||||
return SettingsSection(
|
||||
title: 'Accounts',
|
||||
children: [
|
||||
for (final account in session.accounts)
|
||||
_SavedAccountRow(
|
||||
account: account,
|
||||
isActive: account.id == session.activeAccountId,
|
||||
onTap: account.id == session.activeAccountId
|
||||
? null
|
||||
: () => session.switchAccount(account.id),
|
||||
onRemove: () => confirmRemoveAccount(
|
||||
context,
|
||||
session,
|
||||
accountId: account.id,
|
||||
username: account.username,
|
||||
host: serverHost(account.serverUrl),
|
||||
isActive: account.id == session.activeAccountId,
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.userPlus,
|
||||
accent: true,
|
||||
label: const Text('Add account'),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const LoginView(isAddingAccount: true)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved-account row: avatar, name over host, a check on the active one,
|
||||
/// and a remove button - [NooSettingsRow] can't show an avatar leading
|
||||
/// widget (only an [IconData]), so this is its own row built to the same
|
||||
/// proportions (mirrors `shell_common.dart`'s private `_AccountSwitchRow`,
|
||||
/// which the account switcher sheet/dialog uses for the same data).
|
||||
class _SavedAccountRow extends StatelessWidget {
|
||||
final SavedAccount account;
|
||||
final bool isActive;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const _SavedAccountRow({
|
||||
required this.account,
|
||||
required this.isActive,
|
||||
required this.onTap,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Material(
|
||||
color: colors.surface,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md, vertical: 10),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
NooAvatar(initials: accountInitial(account.username), current: isActive, size: 36),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 2,
|
||||
children: [
|
||||
Text(
|
||||
account.username,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.bodyL.copyWith(
|
||||
height: 1.1,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.fg1,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
serverHost(account.serverUrl),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: NooText.meta.copyWith(color: colors.fg3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isActive) Icon(LucideIcons.checkCircle, size: 20, color: colors.accentText),
|
||||
GestureDetector(
|
||||
onTap: onRemove,
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle),
|
||||
child: Icon(LucideIcons.trash2, size: 16, color: colors.danger),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/settings_controller.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/core/noo_segmented_control.dart';
|
||||
import '../noo/core/noo_toggle.dart';
|
||||
import '../noo/lists/noo_settings_row.dart';
|
||||
import '../noo/noo_layout.dart';
|
||||
import '../noo/overlays/noo_dialog.dart';
|
||||
import '../noo/overlays/noo_sheet.dart';
|
||||
import '../seek_bar_painter.dart';
|
||||
import 'settings_section.dart';
|
||||
|
||||
String _seekBarStyleLabel(MediaProgressBarStyle style) {
|
||||
switch (style) {
|
||||
case MediaProgressBarStyle.classic:
|
||||
return 'Default';
|
||||
case MediaProgressBarStyle.wavy:
|
||||
return 'Wavy';
|
||||
case MediaProgressBarStyle.slim:
|
||||
return 'Slim';
|
||||
case MediaProgressBarStyle.squiggly:
|
||||
return 'Squiggly';
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings section 6: appearance. Only the theme mode (System/Light/Dark)
|
||||
/// is in the design system's own recipe - the accent-color picker, dynamic
|
||||
/// color, AMOLED and the video seek-bar style are all settings that predate
|
||||
/// the design system and have no place in it (a single fixed accent, no
|
||||
/// per-user color choice). They still work, just demoted into a separate
|
||||
/// "Advanced appearance" group beneath the main card per the rebuild brief,
|
||||
/// rather than dropped - see the handoff report for whether to remove them
|
||||
/// outright.
|
||||
class SettingsAppearanceSection extends StatelessWidget {
|
||||
const SettingsAppearanceSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsController>();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SettingsSection(
|
||||
title: 'Appearance',
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.sunMoon,
|
||||
label: const Text('Theme'),
|
||||
trailing: NooSegmentedControl<ThemeMode>(
|
||||
size: NooSegmentedSize.sm,
|
||||
value: settings.themeMode,
|
||||
onChanged: settings.setThemeMode,
|
||||
options: const [
|
||||
NooSegmentOption(value: ThemeMode.system, icon: LucideIcons.monitor, label: 'System'),
|
||||
NooSegmentOption(value: ThemeMode.light, icon: LucideIcons.sun, label: 'Light'),
|
||||
NooSegmentOption(value: ThemeMode.dark, icon: LucideIcons.moon, label: 'Dark'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: NooSpace.xl),
|
||||
SettingsSection(
|
||||
title: 'Advanced appearance',
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.palette,
|
||||
label: const Text('Accent color'),
|
||||
subtitle: Text(settings.useDynamicColor ? 'Matching your wallpaper' : 'Custom color'),
|
||||
trailing: _AccentSwatchDot(color: settings.useDynamicColor ? null : settings.seedColor),
|
||||
onTap: () => _openAccentPicker(context, settings),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.sparkles,
|
||||
label: const Text('AMOLED black'),
|
||||
subtitle: const Text('Use pure black backgrounds in dark mode'),
|
||||
trailing: NooToggle(checked: settings.amoledDark, onChanged: settings.setAmoledDark),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.waves,
|
||||
label: const Text('Seek bar style'),
|
||||
subtitle: const Text('The progress bar style used when playing videos'),
|
||||
value: _seekBarStyleLabel(settings.mediaProgressBarStyle),
|
||||
onTap: () => _openSeekBarPicker(context, settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The current accent-color choice, shown as a small filled circle in the
|
||||
/// "Accent color" row - a wallpaper icon when following the OS's dynamic
|
||||
/// color instead of a fixed swatch.
|
||||
class _AccentSwatchDot extends StatelessWidget {
|
||||
final Color? color;
|
||||
const _AccentSwatchDot({this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: color ?? colors.surface3,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: color == null ? Icon(LucideIcons.wallpaper, size: 12, color: colors.fg2) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _openAccentPicker(BuildContext context, SettingsController settings) {
|
||||
Widget grid(NooColors colors) => Wrap(
|
||||
spacing: 14,
|
||||
runSpacing: 14,
|
||||
children: [
|
||||
_AccentSwatch(
|
||||
isSelected: settings.useDynamicColor,
|
||||
onTap: () {
|
||||
settings.setUseDynamicColor(true);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
background: colors.surface3,
|
||||
child: Icon(LucideIcons.wallpaper, color: colors.fg2, size: 20),
|
||||
),
|
||||
for (final color in AppTheme.seedColors)
|
||||
_AccentSwatch(
|
||||
isSelected: !settings.useDynamicColor && settings.seedColor == color,
|
||||
onTap: () {
|
||||
settings.setSeedColor(color);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
background: color,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final colors = context.nooColors;
|
||||
if (NooLayout.isDesktop(context)) {
|
||||
showNooDialog(context, title: 'Accent color', children: [grid(colors)]);
|
||||
} else {
|
||||
showNooSheet(context, children: [grid(colors)]);
|
||||
}
|
||||
}
|
||||
|
||||
class _AccentSwatch extends StatelessWidget {
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final Color background;
|
||||
final Widget? child;
|
||||
|
||||
const _AccentSwatch({
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
required this.background,
|
||||
this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
shape: BoxShape.circle,
|
||||
border: isSelected ? Border.all(color: colors.fg1, width: 3) : null,
|
||||
),
|
||||
child: isSelected && child == null
|
||||
? const Icon(LucideIcons.check, color: Colors.white, size: 20)
|
||||
: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _openSeekBarPicker(BuildContext context, SettingsController settings) {
|
||||
Widget grid() => GridView.count(
|
||||
crossAxisCount: 2,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 1.35,
|
||||
children: [
|
||||
for (final style in MediaProgressBarStyle.values)
|
||||
_SeekBarStyleOption(
|
||||
style: style,
|
||||
isSelected: style == settings.mediaProgressBarStyle,
|
||||
onTap: () {
|
||||
settings.setMediaProgressBarStyle(style);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (NooLayout.isDesktop(context)) {
|
||||
showNooDialog(context, title: 'Seek bar style', children: [grid()]);
|
||||
} else {
|
||||
showNooSheet(context, children: [grid()]);
|
||||
}
|
||||
}
|
||||
|
||||
class _SeekBarStyleOption extends StatelessWidget {
|
||||
final MediaProgressBarStyle style;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SeekBarStyleOption({required this.style, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Material(
|
||||
color: isSelected ? colors.accentSoft : colors.surface2,
|
||||
borderRadius: BorderRadius.circular(NooRadii.input),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(NooRadii.input),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 100,
|
||||
height: 28,
|
||||
child: SeekBarPreview(
|
||||
style: style,
|
||||
playedColor: isSelected ? colors.accentText : colors.fg2,
|
||||
trackColor: colors.surface3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_seekBarStyleLabel(style),
|
||||
style: NooText.body.copyWith(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected ? colors.accentText : colors.fg1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../providers/session_controller.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/core/noo_button.dart';
|
||||
import '../noo/noo_layout.dart';
|
||||
import '../noo/overlays/noo_dialog.dart';
|
||||
import '../noo/overlays/noo_overlay_header.dart';
|
||||
import '../noo/overlays/noo_sheet.dart';
|
||||
|
||||
/// Shared by the account card's own quick "Remove account" action and each
|
||||
/// row in the saved-accounts list - same confirm/remove/pop-to-root gate
|
||||
/// either way (mirrors the pre-rework `_confirmRemoveActive`/
|
||||
/// `_AccountsCard._confirmRemove`), just restyled as [showNooDialog]
|
||||
/// (desktop) / [showNooSheet] (mobile) instead of an [AlertDialog].
|
||||
Future<void> confirmRemoveAccount(
|
||||
BuildContext context,
|
||||
SessionController session, {
|
||||
required String accountId,
|
||||
required String username,
|
||||
required String host,
|
||||
required bool isActive,
|
||||
}) async {
|
||||
final hasOtherAccounts = session.accounts.length > 1;
|
||||
final message = isActive && hasOtherAccounts
|
||||
? 'Remove $username ($host)? Another saved account will become active.'
|
||||
: 'Remove $username ($host)? You can add it again later.';
|
||||
|
||||
var confirmed = false;
|
||||
void cancel() => Navigator.pop(context);
|
||||
void confirm() {
|
||||
confirmed = true;
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
Widget buttons(NooColors colors) => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: NooButton(
|
||||
variant: NooButtonVariant.secondary,
|
||||
size: NooButtonSize.card,
|
||||
fullWidth: true,
|
||||
onTap: cancel,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: NooSpace.sm),
|
||||
Expanded(
|
||||
child: NooButton(
|
||||
variant: NooButtonVariant.danger,
|
||||
size: NooButtonSize.card,
|
||||
fullWidth: true,
|
||||
onTap: confirm,
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final colors = context.nooColors;
|
||||
if (NooLayout.isDesktop(context)) {
|
||||
await showNooDialog(
|
||||
context,
|
||||
title: 'Remove account',
|
||||
children: [
|
||||
Text(message, style: NooText.body.copyWith(color: colors.fg2)),
|
||||
buttons(colors),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
await showNooSheet(
|
||||
context,
|
||||
children: [
|
||||
NooOverlayHeader(title: 'Remove account', onClose: cancel),
|
||||
Text(message, style: NooText.body.copyWith(color: colors.fg2)),
|
||||
buttons(colors),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (!confirmed) return;
|
||||
if (!context.mounted) return;
|
||||
final navigator = Navigator.of(context);
|
||||
await session.removeAccount(accountId);
|
||||
// Only when this was the last saved account does isLoggedIn drop to false
|
||||
// and main.dart swap the root route to LoginView underneath - pop back to
|
||||
// it then, rather than leaving Settings stranded on top. Removing a
|
||||
// non-active account, or falling back to another one, both keep the user
|
||||
// logged in, so Settings should just stay put.
|
||||
if (!session.isLoggedIn) {
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/app_tab.dart';
|
||||
import '../../providers/session_controller.dart';
|
||||
import '../../providers/sync_status_controller.dart';
|
||||
import '../../services/sync_service.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../app_tab_view_builder.dart';
|
||||
import '../noo/core/noo_button.dart';
|
||||
import '../noo/core/noo_toggle.dart';
|
||||
import '../noo/lists/noo_settings_row.dart';
|
||||
import '../noo/nav/noo_top_bar.dart';
|
||||
import '../noo/nav/noo_toolbar.dart';
|
||||
import '../noo/noo_layout.dart';
|
||||
import 'settings_section.dart';
|
||||
|
||||
/// Settings section 4: device sync (which folders/files mirror locally,
|
||||
/// notifications, cellular use, and a manual "Sync now").
|
||||
class SettingsFileSyncSection extends StatefulWidget {
|
||||
const SettingsFileSyncSection({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsFileSyncSection> createState() => _SettingsFileSyncSectionState();
|
||||
}
|
||||
|
||||
class _SettingsFileSyncSectionState extends State<SettingsFileSyncSection> {
|
||||
bool _syncingNow = false;
|
||||
|
||||
Future<void> _syncNow(SessionController session, SyncStatusController sync) async {
|
||||
setState(() => _syncingNow = true);
|
||||
try {
|
||||
await SyncService.syncNow(session, sync);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not start sync: $e'), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _syncingNow = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Offline tab has its own chrome-free content (see the shared
|
||||
/// rebuild contract's "division of chrome" - only the shell draws top
|
||||
/// bars for a tab), so opening it as a pushed screen from here needs a
|
||||
/// minimal top bar of its own to stay navigable.
|
||||
void _openOfflineFiles(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => Scaffold(
|
||||
appBar: NooLayout.isDesktop(context)
|
||||
? const NooToolbar(title: 'Offline')
|
||||
: NooTopBar(
|
||||
style: NooLayout.navStyle(context),
|
||||
title: 'Offline',
|
||||
leading: const NooTopBarBack(),
|
||||
),
|
||||
body: buildAppTabView(AppTab.offline, ScrollController()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionController>();
|
||||
final sync = context.watch<SyncStatusController>();
|
||||
final colors = context.nooColors;
|
||||
final folders = sync.syncedPaths;
|
||||
final everything = sync.syncEverything;
|
||||
final hasSyncScope = everything || folders.isNotEmpty;
|
||||
|
||||
return SettingsSection(
|
||||
title: 'File sync',
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.cloud,
|
||||
label: const Text('Sync everything'),
|
||||
subtitle: const Text('Mirror the whole account instead of picking folders'),
|
||||
trailing: NooToggle(checked: everything, onChanged: sync.setSyncEverything),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.hardDriveDownload,
|
||||
label: const Text('View offline files'),
|
||||
subtitle: Text(
|
||||
everything
|
||||
? 'Every folder in this account is synced'
|
||||
: folders.isEmpty
|
||||
? 'Nothing synced yet'
|
||||
: '${folders.length} synced folder${folders.length == 1 ? '' : 's'}/file${folders.length == 1 ? '' : 's'}',
|
||||
),
|
||||
onTap: () => _openOfflineFiles(context),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.bellRing,
|
||||
label: const Text('Background sync notifications'),
|
||||
subtitle: const Text(
|
||||
'Show a notification when a background sync updates files. '
|
||||
'Conflicts and "Sync now" always notify.',
|
||||
),
|
||||
trailing: NooToggle(checked: sync.syncNotifications, onChanged: sync.setSyncNotifications),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.signal,
|
||||
label: const Text('Sync on cellular'),
|
||||
subtitle: const Text('Off = background sync only runs on Wi-Fi'),
|
||||
trailing: NooToggle(checked: sync.syncOnCellular, onChanged: sync.setSyncOnCellular),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.refreshCw,
|
||||
label: const Text('Sync now'),
|
||||
trailing: _syncingNow
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: colors.accentText),
|
||||
)
|
||||
: NooButton(
|
||||
variant: NooButtonVariant.tonal,
|
||||
size: NooButtonSize.compact,
|
||||
disabled: !hasSyncScope,
|
||||
onTap: () => _syncNow(session, sync),
|
||||
child: const Text('Sync now'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/files_controller.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/lists/noo_settings_row.dart';
|
||||
import 'settings_section.dart';
|
||||
|
||||
String _cachePolicyLabel(CachePolicy policy) {
|
||||
switch (policy) {
|
||||
case CachePolicy.never:
|
||||
return 'Never cache';
|
||||
case CachePolicy.interval:
|
||||
return 'Refresh periodically';
|
||||
case CachePolicy.manual:
|
||||
return 'Refresh manually only';
|
||||
}
|
||||
}
|
||||
|
||||
String _cachePolicyDescription(CachePolicy policy) {
|
||||
switch (policy) {
|
||||
case CachePolicy.never:
|
||||
return 'Every visit to a folder fetches it fresh; synced files are '
|
||||
'checked each time you open the app';
|
||||
case CachePolicy.interval:
|
||||
return "Reuse a folder's listing until it's a few minutes old; synced "
|
||||
'files update in the background on the same schedule';
|
||||
case CachePolicy.manual:
|
||||
return "Reuse a folder's listing until you pull to refresh; synced "
|
||||
'files only update when you pull down or tap Sync now';
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings section 5: how often folder listings and synced files refresh
|
||||
/// from the server.
|
||||
class SettingsFilesCacheSection extends StatelessWidget {
|
||||
const SettingsFilesCacheSection({super.key});
|
||||
|
||||
void _openPolicyPicker(BuildContext context, FilesController files) {
|
||||
showSettingsPicker(
|
||||
context,
|
||||
title: 'Cache policy',
|
||||
options: [
|
||||
for (final policy in CachePolicy.values)
|
||||
NooSettingsRow(
|
||||
label: Text(_cachePolicyLabel(policy)),
|
||||
subtitle: Text(_cachePolicyDescription(policy)),
|
||||
trailing: policy == files.cachePolicy
|
||||
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText)
|
||||
: null,
|
||||
onTap: () {
|
||||
files.setCachePolicy(policy);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final files = context.watch<FilesController>();
|
||||
|
||||
return SettingsSection(
|
||||
title: 'Files cache',
|
||||
subtitle: 'How often folder listings and synced files refresh from the server',
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.database,
|
||||
label: const Text('Cache policy'),
|
||||
value: _cachePolicyLabel(files.cachePolicy),
|
||||
onTap: () => _openPolicyPicker(context, files),
|
||||
),
|
||||
if (files.cachePolicy == CachePolicy.interval)
|
||||
_CacheIntervalRow(
|
||||
minutes: files.cacheIntervalMinutes,
|
||||
onChanged: files.setCacheIntervalMinutes,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The refresh-interval slider shown under "Cache policy" while
|
||||
/// [CachePolicy.interval] is selected - not a plain [NooSettingsRow] since
|
||||
/// it needs a full-width [Slider], but paints its own `surface` background
|
||||
/// the same way so it sits correctly inside the group's line dividers (see
|
||||
/// `NooGroupedList`'s doc comment).
|
||||
class _CacheIntervalRow extends StatelessWidget {
|
||||
final int minutes;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
const _CacheIntervalRow({required this.minutes, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Material(
|
||||
color: colors.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Refresh interval', style: NooText.bodyL.copyWith(color: colors.fg1)),
|
||||
Text('$minutes min', style: NooText.meta.copyWith(color: colors.fg3)),
|
||||
],
|
||||
),
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
activeTrackColor: colors.accent,
|
||||
inactiveTrackColor: colors.surface3,
|
||||
thumbColor: colors.accent,
|
||||
overlayColor: colors.accent.withValues(alpha: 0.12),
|
||||
),
|
||||
child: Slider(
|
||||
value: minutes.toDouble(),
|
||||
min: 1,
|
||||
max: 60,
|
||||
divisions: 59,
|
||||
label: '$minutes min',
|
||||
onChanged: (value) => onChanged(value.round()),
|
||||
),
|
||||
),
|
||||
if (minutes < 15)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'While the app is closed, synced files refresh at most every '
|
||||
'15 min (an Android limit).',
|
||||
style: NooText.meta.copyWith(color: colors.fg3),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/lists/noo_grouped_list.dart';
|
||||
import '../noo/noo_layout.dart';
|
||||
import '../noo/overlays/noo_dialog.dart';
|
||||
import '../noo/overlays/noo_sheet.dart';
|
||||
|
||||
/// One block of Settings (DESIGN_SYSTEM.md 4's 8-part order): a
|
||||
/// [NooGroupedList] on mobile (label above a radius-20 card), or a titled,
|
||||
/// bordered radius-20 card holding a flat row group on desktop
|
||||
/// ("Mobile uses one column of grouped lists. Desktop uses a 2-column grid
|
||||
/// of cards with a 1px line and radius 20."). Shared by every
|
||||
/// `lib/widgets/settings/*.dart` section so both layouts stay in sync from
|
||||
/// one place.
|
||||
class SettingsSection extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final List<Widget> children;
|
||||
|
||||
const SettingsSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
if (!NooLayout.isDesktop(context)) {
|
||||
return NooGroupedList(
|
||||
label: title,
|
||||
footer: subtitle != null ? Text(subtitle!) : null,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
border: Border.all(color: colors.line),
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
),
|
||||
padding: const EdgeInsets.all(NooSpace.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: NooText.cardTitle.copyWith(color: colors.fg1)),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(subtitle!, style: NooText.meta.copyWith(color: colors.fg3)),
|
||||
],
|
||||
const SizedBox(height: NooSpace.md),
|
||||
FlatRowGroup(children: children),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The line-gapped row stack [NooGroupedList] draws internally, without its
|
||||
/// own outer card/label - used for [SettingsSection]'s desktop card body,
|
||||
/// which already supplies the surrounding card chrome, so nesting a second
|
||||
/// radius-20 card there would double up the framing. Also reused directly
|
||||
/// by `settings_tabs.dart`, whose desktop card mixes plain rows with a
|
||||
/// reorderable list under one shared title/border instead of going through
|
||||
/// [SettingsSection] itself.
|
||||
class FlatRowGroup extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
const FlatRowGroup({super.key, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(NooRadii.input),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: colors.line),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < children.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 1),
|
||||
children[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The picker pattern for every "value + chevron" settings row (cache
|
||||
/// policy, swipe actions, default tab...): a titled option list, a
|
||||
/// [NooGroupedList] sheet on mobile or a [NooDialog] on desktop (mirrors
|
||||
/// `files_controls_row.dart`'s sort/filter sheets). Each option row should
|
||||
/// call `Navigator.pop(context)` itself after applying its choice.
|
||||
void showSettingsPicker(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required List<Widget> options,
|
||||
}) {
|
||||
if (NooLayout.isDesktop(context)) {
|
||||
showNooDialog(context, title: title, children: [Column(children: options)]);
|
||||
} else {
|
||||
showNooSheet(context, children: [NooGroupedList(label: title, children: options)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/session_controller.dart';
|
||||
import '../noo/core/noo_toggle.dart';
|
||||
import '../noo/lists/noo_settings_row.dart';
|
||||
import 'settings_section.dart';
|
||||
|
||||
/// Per-platform copy for the login-lock row's label (DESIGN_SYSTEM.md 3's
|
||||
/// "Biometric label" row, both the mobile table and the macOS/Windows
|
||||
/// desktop table). Falls back to generic copy on platforms the spec
|
||||
/// doesn't name (Linux, tests).
|
||||
String biometricLabel(TargetPlatform platform) {
|
||||
switch (platform) {
|
||||
case TargetPlatform.iOS:
|
||||
return 'Lock with Face ID';
|
||||
case TargetPlatform.android:
|
||||
return 'Lock with fingerprint';
|
||||
case TargetPlatform.macOS:
|
||||
return 'Touch ID';
|
||||
case TargetPlatform.windows:
|
||||
return 'Windows Hello';
|
||||
case TargetPlatform.linux:
|
||||
case TargetPlatform.fuchsia:
|
||||
return 'Lock with device credential';
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings section 3: login lock, which gates opening the app, switching
|
||||
/// accounts, and revealing hidden files behind the device's own PIN/
|
||||
/// biometric credential (see `AppLockService` - this app never stores or
|
||||
/// handles a PIN itself).
|
||||
class SettingsSecuritySection extends StatelessWidget {
|
||||
const SettingsSecuritySection({super.key});
|
||||
|
||||
Future<void> _handleLoginLockChanged(
|
||||
BuildContext context,
|
||||
SessionController session,
|
||||
bool value,
|
||||
) async {
|
||||
final success = value ? await session.setupLoginLock() : await session.disableLoginLock();
|
||||
if (!success && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
value
|
||||
? "Could not set up login lock - make sure this device has a PIN, pattern, password, or biometric configured"
|
||||
: 'Could not turn off login lock',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionController>();
|
||||
final platform = Theme.of(context).platform;
|
||||
|
||||
return SettingsSection(
|
||||
title: 'Security',
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.lock,
|
||||
label: Text(biometricLabel(platform)),
|
||||
subtitle: const Text("Require this device's PIN or biometric to open Noo"),
|
||||
trailing: NooToggle(
|
||||
checked: session.loginLockEnabled,
|
||||
onChanged: (value) => _handleLoginLockChanged(context, session, value),
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.arrowLeftRight,
|
||||
label: const Text('Lock account switching'),
|
||||
subtitle: const Text('Unlock to switch between saved accounts'),
|
||||
trailing: NooToggle(
|
||||
checked: session.lockAccountSwitching,
|
||||
onChanged: session.loginLockEnabled ? session.setLockAccountSwitching : null,
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.eyeOff,
|
||||
label: const Text('Lock hidden files'),
|
||||
subtitle: const Text('Unlock to reveal hidden files and folders'),
|
||||
trailing: NooToggle(
|
||||
checked: session.lockHiddenFiles,
|
||||
onChanged: session.loginLockEnabled ? session.setLockHiddenFiles : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/settings_controller.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/lists/noo_settings_row.dart';
|
||||
import 'settings_section.dart';
|
||||
|
||||
String _swipeActionLabel(SwipeAction action) {
|
||||
switch (action) {
|
||||
case SwipeAction.none:
|
||||
return 'None';
|
||||
case SwipeAction.favorite:
|
||||
return 'Favorite';
|
||||
case SwipeAction.delete:
|
||||
return 'Delete';
|
||||
case SwipeAction.share:
|
||||
return 'Share';
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings section 8: what swiping a file left/right does in list view.
|
||||
class SettingsSwipeSection extends StatelessWidget {
|
||||
const SettingsSwipeSection({super.key});
|
||||
|
||||
void _openPicker(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required SwipeAction current,
|
||||
required ValueChanged<SwipeAction> onChanged,
|
||||
}) {
|
||||
showSettingsPicker(
|
||||
context,
|
||||
title: title,
|
||||
options: [
|
||||
for (final action in SwipeAction.values)
|
||||
NooSettingsRow(
|
||||
label: Text(_swipeActionLabel(action)),
|
||||
trailing: action == current
|
||||
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText)
|
||||
: null,
|
||||
onTap: () {
|
||||
onChanged(action);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsController>();
|
||||
|
||||
return SettingsSection(
|
||||
title: 'Swipe on a file',
|
||||
subtitle: 'Choose what swiping a file left or right does in list view',
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.chevronsRight,
|
||||
label: const Text('Swipe right'),
|
||||
value: _swipeActionLabel(settings.swipeRightAction),
|
||||
onTap: () => _openPicker(
|
||||
context,
|
||||
title: 'Swipe right',
|
||||
current: settings.swipeRightAction,
|
||||
onChanged: settings.setSwipeRightAction,
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.chevronsLeft,
|
||||
label: const Text('Swipe left'),
|
||||
value: _swipeActionLabel(settings.swipeLeftAction),
|
||||
onTap: () => _openPicker(
|
||||
context,
|
||||
title: 'Swipe left',
|
||||
current: settings.swipeLeftAction,
|
||||
onChanged: settings.setSwipeLeftAction,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/app_tab.dart';
|
||||
import '../../providers/settings_controller.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/core/noo_toggle.dart';
|
||||
import '../noo/lists/noo_settings_row.dart';
|
||||
import '../noo/lists/noo_tab_order_row.dart';
|
||||
import '../noo/noo_layout.dart';
|
||||
import 'settings_section.dart';
|
||||
|
||||
/// Settings section 7: bottom-nav tab order, visibility (pinned = shown, up
|
||||
/// to [maxVisibleTabs]) and default tab.
|
||||
class SettingsTabsSection extends StatelessWidget {
|
||||
const SettingsTabsSection({super.key});
|
||||
|
||||
void _openDefaultTabPicker(BuildContext context, SettingsController settings) {
|
||||
final visible = settings.tabOrder.where((t) => !settings.hiddenTabs.contains(t)).toList();
|
||||
showSettingsPicker(
|
||||
context,
|
||||
title: 'Default tab',
|
||||
options: [
|
||||
for (final tab in visible)
|
||||
NooSettingsRow(
|
||||
icon: tab.icon,
|
||||
label: Text(tab.label),
|
||||
trailing: tab == settings.defaultTab
|
||||
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText)
|
||||
: null,
|
||||
onTap: () {
|
||||
settings.setDefaultTab(tab);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTogglePin(BuildContext context, SettingsController settings, AppTab tab, bool pinned) {
|
||||
// setTabHidden's `hidden` argument is the *new* hidden state, which is
|
||||
// just the tab's current pinned (= visible) state flipped.
|
||||
final error = settings.setTabHidden(tab, pinned);
|
||||
if (error != null && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleReorder(SettingsController settings, int oldIndex, int newIndex) {
|
||||
final order = List<AppTab>.from(settings.tabOrder);
|
||||
final tab = order.removeAt(oldIndex);
|
||||
order.insert(newIndex, tab);
|
||||
settings.setTabOrder(order);
|
||||
}
|
||||
|
||||
static const _subtitle = 'Drag to reorder, pin up to 5 to the bottom bar';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsController>();
|
||||
final desktop = NooLayout.isDesktop(context);
|
||||
|
||||
final optionRows = [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.arrowUpDown,
|
||||
label: const Text('Tap tab to scroll to top'),
|
||||
subtitle: const Text('Tapping the current bottom bar tab scrolls its list back to the top'),
|
||||
trailing: NooToggle(
|
||||
checked: settings.tapTabToScrollTop,
|
||||
onChanged: settings.setTapTabToScrollTop,
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.star,
|
||||
label: const Text('Default tab'),
|
||||
value: settings.defaultTab.label,
|
||||
onTap: () => _openDefaultTabPicker(context, settings),
|
||||
),
|
||||
];
|
||||
final orderList = _TabOrderList(
|
||||
tabs: settings.tabOrder,
|
||||
hiddenTabs: settings.hiddenTabs,
|
||||
flat: desktop,
|
||||
onReorder: (oldIndex, newIndex) => _handleReorder(settings, oldIndex, newIndex),
|
||||
onTogglePin: (tab, pinned) => _handleTogglePin(context, settings, tab, pinned),
|
||||
);
|
||||
|
||||
if (!desktop) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SettingsSection(title: 'Tabs', subtitle: _subtitle, children: optionRows),
|
||||
const SizedBox(height: NooSpace.md),
|
||||
orderList,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: one bordered card holding both the plain option rows and
|
||||
// the reorderable list, so Tabs still reads as a single grid cell
|
||||
// ("a 2-column grid of cards with a 1px line and radius 20") instead of
|
||||
// splitting into two stacked cards the way the mobile layout does.
|
||||
final colors = context.nooColors;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
border: Border.all(color: colors.line),
|
||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||
),
|
||||
padding: const EdgeInsets.all(NooSpace.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Tabs', style: NooText.cardTitle.copyWith(color: colors.fg1)),
|
||||
const SizedBox(height: 4),
|
||||
Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)),
|
||||
const SizedBox(height: NooSpace.md),
|
||||
FlatRowGroup(children: optionRows),
|
||||
const SizedBox(height: NooSpace.sm),
|
||||
orderList,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The reorderable [NooTabOrderRow] stack - a radius-20, line-divided card
|
||||
/// like [NooGroupedList] draws, but [NooGroupedList] itself has no
|
||||
/// reordering support, so this rebuilds that same look around a
|
||||
/// [ReorderableListView.builder] instead of wrapping it.
|
||||
class _TabOrderList extends StatelessWidget {
|
||||
final List<AppTab> tabs;
|
||||
final Set<AppTab> hiddenTabs;
|
||||
final void Function(int oldIndex, int newIndex) onReorder;
|
||||
final void Function(AppTab tab, bool pinned) onTogglePin;
|
||||
|
||||
/// True when an ancestor (the desktop card) already supplies the
|
||||
/// surrounding radius-20 border, so this should render just the divided
|
||||
/// rows without its own outer clip/line background.
|
||||
final bool flat;
|
||||
|
||||
const _TabOrderList({
|
||||
required this.tabs,
|
||||
required this.hiddenTabs,
|
||||
required this.onReorder,
|
||||
required this.onTogglePin,
|
||||
this.flat = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final list = ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: tabs.length,
|
||||
onReorderItem: onReorder,
|
||||
itemBuilder: (context, index) {
|
||||
final tab = tabs[index];
|
||||
final pinned = !hiddenTabs.contains(tab);
|
||||
return Padding(
|
||||
key: ValueKey(tab),
|
||||
padding: EdgeInsets.only(top: index > 0 ? 1 : 0),
|
||||
child: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: NooTabOrderRow(
|
||||
icon: tab.icon,
|
||||
label: tab.label,
|
||||
pinned: pinned,
|
||||
onTogglePin: () => onTogglePin(tab, pinned),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(flat ? NooRadii.input : NooRadii.card),
|
||||
child: DecoratedBox(decoration: BoxDecoration(color: colors.line), child: list),
|
||||
);
|
||||
}
|
||||
}
|
||||
+333
-242
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()),
|
||||
];
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
|
||||
/// Icon-only action button for the media viewer's floating chrome (top bar,
|
||||
/// bottom action bar, video transport controls) - a fixed 22px icon in a
|
||||
/// round hit box, colored from [NooColors] rather than Material's
|
||||
/// [ColorScheme]. Kept as one small widget (matching the pre-rework
|
||||
/// `_ActionIconButton` it replaces - see `styling.md`) so every floating
|
||||
/// control in this screen looks the same instead of drifting apart across
|
||||
/// the three call sites that need one.
|
||||
class ViewerIconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Overrides the default `fg-1` (e.g. `danger` for delete). Ignored while
|
||||
/// [onTap] is null - a disabled button always dims to the same faded fg-1
|
||||
/// regardless of its normal color.
|
||||
final Color? color;
|
||||
|
||||
const ViewerIconButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onTap,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final fg = onTap == null
|
||||
? colors.fg1.withValues(alpha: 0.4)
|
||||
: (color ?? colors.fg1);
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: const CircleBorder(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
child: Icon(icon, color: fg, size: 22),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user