Rebuild UI on the Noo design system

Replaces the Material 3 screens with the Noo design system: warm
neutrals, one violet accent, pill controls, Schibsted Grotesk/
Instrument Sans, Lucide icons, no gradients/shadows.

- New lib/widgets/noo/ component kit (core, lists, files, media, nav,
  overlays) plus lib/theme/design_tokens.dart for the color/type/space/
  radius/motion tokens.
- Rebuilt the app shell (top/bottom bars, drawer, desktop sidebar and
  toolbar), every tab (Files/Offline, Photos, Favorites, Recent,
  Activity, Trash, Shares), Settings, the lock and login screens, the
  media viewer, search, the details/share sheets, and the share-upload
  and move/copy destination pickers.
- Added a design canvas (linked from DESIGN_SYSTEM.md) covering the
  screens the spec didn't already describe, with matching Android and
  iOS chrome; wrote up the approved recipes into DESIGN_SYSTEM.md §4.
- Removed now-dead legacy widgets (media_grid_tile, swipeable_item,
  sync_status_badge, selectable_thumbnail) and updated
  architecture.md/styling.md/standards.md to describe the new
  structure and component/testing conventions.
- Added widget tests for the noo/ component kit.

This is a UI-only rework: no provider/model/service behavior changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 01:51:58 -04:00
co-authored by Claude Opus 5.5
parent 51682a901d
commit 56784ef212
125 changed files with 15263 additions and 6804 deletions
File diff suppressed because it is too large Load Diff
+99 -120
View File
@@ -1,11 +1,31 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/nextcloud_item.dart';
import '../providers/files_controller.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart';
import '../providers/session_controller.dart';
import '../theme/design_tokens.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/media/noo_activity_item.dart';
import '../widgets/noo/media/noo_photo_tile.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/tabs/tab_day_groups.dart';
import '../widgets/tabs/tab_state_slivers.dart';
/// The whole-account activity feed - lives on [FilesController] (fetched
/// alongside the current Files folder) rather than its own controller, see
/// `FilesController.refreshData`. Grouped by calendar day per
/// `DESIGN_SYSTEM.md` §4; desktop centers the feed at 760px.
///
/// [NextcloudActivity] only carries a rendered `title`/`subject` string
/// pair plus an `author` username - the server response has no structured
/// actor/verb/object breakdown or file reference, so the bold "actor"
/// segment is the author and the rest of the sentence (already fully
/// formed server-side) rides as one plain run rather than being split
/// further, and there's no reliable file to show in the trailing file
/// tile slot - see this screen's rebuild report for what a richer feed
/// would need from the service layer.
class ActivityView extends StatelessWidget {
final ScrollController scrollController;
@@ -13,129 +33,88 @@ class ActivityView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final files = context.watch<FilesController>();
final session = context.watch<SessionController>();
final activities = files.activities;
final isDesktop = NooLayout.isDesktop(context);
final groups = groupByCalendarDay<NextcloudActivity>(
activities,
(a) => a.timestamp,
);
Widget feed;
if (files.isLoading && activities.isEmpty) {
feed = tabLoadingSliver;
} else if (activities.isEmpty) {
feed = tabEmptySliver(
context,
icon: LucideIcons.activity,
message: 'No recent activity',
);
} else {
feed = SliverPadding(
padding: EdgeInsets.symmetric(horizontal: NooLayout.gutter(context)),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final group = groups[index];
return Padding(
padding: const EdgeInsets.only(bottom: 18),
child: NooGroupedList(
label: group.label,
children: [
for (final act in group.items)
Material(
color: colors.surface,
child: NooActivityItem(
actor: act.author,
verb: act.title,
tail: act.subject.isNotEmpty && act.subject != act.title
? act.subject
: null,
time: DateFormat.jm().format(act.timestamp),
avatarColor: NooPhotoTile.paletteColor(act.author.hashCode),
currentUser:
act.author.toLowerCase() == session.username.toLowerCase(),
),
),
],
),
);
}, childCount: groups.length),
),
);
}
final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: 16)),
if (files.isLoading && activities.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (activities.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'No recent activity',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final act = activities[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.2),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
act.icon,
color: colorScheme.onPrimaryContainer,
size: 20,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
act.title,
style: theme.textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
const SizedBox(width: 8),
Text(
DateFormat.jm().format(act.timestamp),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
const SizedBox(height: 4),
Text(
act.subject,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
const SizedBox(height: 6),
Text(
'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w500,
fontSize: 11,
),
),
],
),
),
],
),
),
);
}, childCount: activities.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
feed,
...tabBottomInsetSlivers,
];
return SyncedHeaderScaffold(
scrollController: scrollController,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
contentSlivers: contentSlivers,
final scrollView = CustomScrollView(
controller: scrollController,
slivers: contentSlivers,
);
return ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: () => context.read<FilesController>().refreshData(),
// "Desktop limits it to 760px wide" (DESIGN_SYSTEM.md §4) - centered
// rather than left-aligned, so a wide window doesn't stretch the
// feed's short sentences edge to edge.
child: isDesktop
? Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: scrollView,
),
)
: scrollView,
),
);
}
}
+264 -277
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/app_tab.dart';
@@ -13,16 +13,22 @@ import '../providers/item_operations.dart';
import '../providers/session_controller.dart';
import '../providers/settings_controller.dart';
import '../services/download_service.dart';
import '../theme/design_tokens.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/item_icon.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/segmented_icon_toggle.dart';
import '../widgets/selectable_thumbnail.dart';
import '../widgets/files_controls_row.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/files/noo_file_tile.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/lists/noo_settings_row.dart';
import '../widgets/noo/media/noo_grid_card.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/noo/core/noo_button.dart';
import '../widgets/share_sheet.dart';
import '../widgets/sort_menu_button.dart';
import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.dart';
import '../widgets/synced_header_scaffold.dart' show formatBytes;
import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.dart';
@@ -35,9 +41,11 @@ import 'move_copy_destination_picker.dart';
/// wide list also means the breadcrumb-vs-content mismatch a filter
/// toggle had - the trail showing wherever Files was last browsing while
/// the content showed something else entirely - simply can't happen).
/// Shares Files' own sort/hidden/storage-scope/grid-list display prefs
/// (via `FilesController.applyFilesDisplayPrefs`) rather than a separate
/// parallel settings dimension.
/// Shares Files' own sort/hidden/type-filter/storage-scope/grid-list
/// display prefs (via `FilesController.applyFilesDisplayPrefs`, and
/// `FilesControlsRow` for the controls themselves) rather than a separate
/// parallel settings dimension. Renders content only - the shell owns the
/// top bar, bottom bar, drawer and FAB.
class FavoritesView extends StatefulWidget {
final ScrollController scrollController;
@@ -79,7 +87,14 @@ class _FavoritesViewState extends State<FavoritesView>
/// scroll (row already fits).
Future<void> _playScrollHint(ScrollController scrollController) async {
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted || !scrollController.hasClients) return;
// `hasClients` only means a position is attached, not that it's
// finished its first layout - reading maxScrollExtent before that
// throws (min/maxScrollExtent are still null internally).
if (!mounted ||
!scrollController.hasClients ||
!scrollController.position.hasContentDimensions) {
return;
}
final maxExtent = scrollController.position.maxScrollExtent;
if (maxExtent <= 0) return;
final double peak = maxExtent < 36 ? maxExtent : 36;
@@ -348,174 +363,87 @@ class _FavoritesViewState extends State<FavoritesView>
);
}
Widget _buildControlsRow(FilesController files) {
return SizedBox(
height: 44,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
/// A single item's actions (the same list the multi-select bar offers,
/// just for one item) - mirrors `FilesView._showItemActionsSheet`.
void _showItemActionsSheet(BuildContext context, NextcloudItem item) {
final actions = _buildSelectionActions([item]);
showNooSheet(
context,
children: [
NooGroupedList(
children: [
IconButton(
icon: Icon(
files.filesSortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
for (final action in actions)
NooSettingsRow(
icon: action.icon,
label: Text(action.label),
onTap: () {
Navigator.pop(context);
action.onTap();
},
),
visualDensity: VisualDensity.compact,
tooltip: files.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: files.toggleFilesSortOrder,
),
SizedBox(
width: 130,
child: SortMenuButton(
field: files.filesSortField,
onChanged: files.setFilesSortField,
),
),
ToggleIconButton(
icon: files.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: files.showHiddenFiles,
onTap: () => files.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: files.storageScope == StorageScope.cloud,
onTap: () => files.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: files.storageScope == StorageScope.external,
onTap: () => files.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
),
const SizedBox(width: 8),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !files.isGridView,
onTap: () => files.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: files.isGridView,
onTap: () => files.setGridView(true),
tooltip: 'Grid view',
),
],
),
],
),
),
],
);
}
Widget _buildListTile(
/// A real image/video thumbnail for [item]'s file tile/card, sized so
/// `NooFileTile`'s `FittedBox` (which needs a concretely-sized child to
/// scale) always has one to work with; null when there's nothing to show
/// (falls back to the kind's soft-color tile).
Widget? _thumbnailFor(
BuildContext context,
NextcloudItem item,
List<NextcloudItem> siblings,
) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final session = context.watch<SessionController>();
final isSelected = _selectedIds.contains(item.id);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.5)
: colorScheme.surfaceContainerLow,
child: InkWell(
onTap: () {
if (_isSelecting) {
_toggleSelection(item);
} else {
_openFavorite(context, item, siblings);
}
},
onLongPress: () => _toggleSelection(item),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
SelectableThumbnail(
isSelected: isSelected,
size: 44,
checkmarkSize: 24,
child: ItemThumbnail(
item: item,
service: session.service,
size: 44,
borderRadius: 12,
iconSize: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
item.isFolder
? 'Folder'
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
),
),
),
);
}
Widget _buildGridCard(
BuildContext context,
NextcloudItem item,
List<NextcloudItem> siblings,
) {
final theme = Theme.of(context);
final session = context.watch<SessionController>();
final isSelected = _selectedIds.contains(item.id);
NextcloudItem item, {
required double extent,
}) {
final isMedia =
(item.type == NextcloudItemType.image ||
item.type == NextcloudItemType.video) &&
item.previewUrl != null;
final iconColor = getIconColor(context, item.type);
if (!isMedia) return null;
final session = context.watch<SessionController>();
return Image.network(
item.previewUrl!,
width: extent,
height: extent,
headers: session.service?.authHeaders,
fit: BoxFit.cover,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) => const SizedBox.shrink(),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Material(
color: theme.colorScheme.surfaceContainerLow,
child: InkWell(
String _metaFor(NextcloudItem item) {
return item.isFolder
? 'Folder'
: '${formatBytes(item.size)} · ${DateFormat.yMMMd().format(item.lastModified)}';
}
Widget _buildRow(
BuildContext context,
NextcloudItem item,
List<NextcloudItem> siblings,
) {
final isSelected = _selectedIds.contains(item.id);
final kind = NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card),
child: NooFileRow(
kind: kind,
name: item.name,
meta: _metaFor(item),
favorite: item.isFavorite,
iosStyle: NooLayout.iosStyle(context),
selected: isSelected,
thumbnail: _thumbnailFor(context, item, extent: NooSizes.rowMobile),
onTap: () {
if (_isSelecting) {
_toggleSelection(item);
@@ -524,66 +452,87 @@ class _FavoritesViewState extends State<FavoritesView>
}
},
onLongPress: () => _toggleSelection(item),
child: SelectableThumbnail(
isSelected: isSelected,
checkmarkSize: 32,
child: isMedia
? Image.network(
item.previewUrl!,
headers: session.service?.authHeaders,
fit: BoxFit.cover,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) => Container(
color: theme.colorScheme.secondaryContainer,
child: Icon(
getItemIcon(item.type),
color: theme.colorScheme.onSecondaryContainer,
size: 32,
),
),
)
: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
getItemIcon(item.type),
color: iconColor,
size: 24,
),
),
Text(
item.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
],
),
),
),
onMore: () => _showItemActionsSheet(context, item),
),
),
);
}
Widget _buildTableRow(
BuildContext context,
NextcloudItem item,
List<NextcloudItem> siblings,
) {
final isSelected = _selectedIds.contains(item.id);
final kind = NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
);
return NooFileTableRow(
kind: kind,
name: item.name,
col2: item.isFolder ? null : DateFormat.yMMMd().format(item.lastModified),
col3: item.isFolder ? null : formatBytes(item.size),
favorite: item.isFavorite,
selected: isSelected,
thumbnail: _thumbnailFor(context, item, extent: NooFileTileSize.desktop.extent),
onTap: () {
if (_isSelecting) {
_toggleSelection(item);
} else {
_openFavorite(context, item, siblings);
}
},
// Desktop has no long-press gesture; right-click is this row's
// equivalent entry point into multi-select.
onSecondaryTap: () => _toggleSelection(item),
onMore: () => _showItemActionsSheet(context, item),
);
}
Widget _buildGridCard(
BuildContext context,
NextcloudItem item,
List<NextcloudItem> siblings,
bool isDesktop,
) {
final colors = context.nooColors;
final isSelected = _selectedIds.contains(item.id);
final kind = NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
);
final thumbnailHeight = isDesktop ? 118.0 : 104.0;
return NooGridCard(
name: item.name,
meta: item.isFolder ? 'Folder' : formatBytes(item.size),
placeholderColor: kind.background(colors),
icon: kind.icon,
iconColor: kind.foreground(colors),
thumbnailHeight: thumbnailHeight,
thumbnail: _thumbnailFor(context, item, extent: thumbnailHeight),
selected: isSelected,
verticalOverflowIcon: !NooLayout.iosStyle(context),
onTap: () {
if (_isSelecting) {
_toggleSelection(item);
} else {
_openFavorite(context, item, siblings);
}
},
onLongPress: () => _toggleSelection(item),
onMore: () => _showItemActionsSheet(context, item),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final isDesktop = NooLayout.isDesktop(context);
final favoritesController = context.watch<FavoritesController>();
final filesController = context.watch<FilesController>();
@@ -600,18 +549,26 @@ class _FavoritesViewState extends State<FavoritesView>
.where((i) => _selectedIds.contains(i.id))
.toList();
final controlsRow = Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.sm,
NooSpace.sm,
NooSpace.sm,
NooSpace.xs,
),
child: FilesControlsRow(folderPath: filesController.currentFolderPath),
);
final contentSlivers = <Widget>[
SliverPersistentHeader(
pinned: !_isSelecting,
delegate: StickyHeaderDelegate(
height: 60,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: _buildControlsRow(filesController),
),
height: _isSelecting ? 56 : 64,
child: _isSelecting
? _buildSelectionBar(context, selectedItems)
: controlsRow,
),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (favoritesController.isLoading && favorites.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
@@ -626,32 +583,25 @@ class _FavoritesViewState extends State<FavoritesView>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
Icon(LucideIcons.circleAlert, size: 56, color: colors.danger),
const SizedBox(height: 16),
Text(
'Could not load favorites',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
style: NooText.cardTitle.copyWith(color: colors.danger),
),
const SizedBox(height: 8),
Text(
favoritesController.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: NooText.body.copyWith(color: colors.fg3),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: favoritesController.fetchAll,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
NooButton(
variant: NooButtonVariant.secondary,
size: NooButtonSize.field,
icon: LucideIcons.refreshCw,
onTap: favoritesController.fetchAll,
child: const Text('Retry'),
),
],
),
@@ -665,17 +615,11 @@ class _FavoritesViewState extends State<FavoritesView>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.favorite_border_rounded,
size: 64,
color: colorScheme.outlineVariant,
),
Icon(LucideIcons.star, size: 56, color: colors.fg3),
const SizedBox(height: 12),
Text(
'No favorites yet',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: NooText.cardTitle.copyWith(color: colors.fg2),
),
],
),
@@ -683,30 +627,70 @@ class _FavoritesViewState extends State<FavoritesView>
)
else if (filesController.isGridView)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
padding: EdgeInsets.symmetric(
horizontal: NooLayout.gutter(context),
vertical: NooSpace.xs,
),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.1,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isDesktop ? 5 : 2,
crossAxisSpacing: isDesktop ? 16 : 10,
mainAxisSpacing: isDesktop ? 16 : 10,
childAspectRatio: isDesktop ? 0.92 : 0.85,
),
delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(context, favorites[index], siblings);
return _buildGridCard(context, favorites[index], siblings, isDesktop);
}, childCount: favorites.length),
),
)
else if (isDesktop)
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: NooLayout.gutter(context)),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == 0) {
return NooFileTableHeader(
col2Label: 'Modified',
col3Label: 'Size',
sortColumn: switch (filesController.filesSortField) {
FileSortField.name => 0,
FileSortField.dateModified => 1,
FileSortField.size => 2,
FileSortField.dateCreated => null,
},
sortAscending: filesController.filesSortAscending,
onSort: (column) {
final field = [
FileSortField.name,
FileSortField.dateModified,
FileSortField.size,
][column];
if (field == filesController.filesSortField) {
filesController.toggleFilesSortOrder();
} else {
filesController.setFilesSortField(field);
}
},
);
}
return _buildTableRow(
context,
favorites[index - 1],
siblings,
);
}, childCount: favorites.length + 1),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
padding: EdgeInsets.symmetric(horizontal: NooLayout.gutter(context)),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return _buildListTile(context, favorites[index], siblings);
return _buildRow(context, favorites[index], siblings);
}, childCount: favorites.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
];
return PopScope(
@@ -714,28 +698,30 @@ class _FavoritesViewState extends State<FavoritesView>
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelecting) _clearSelection();
},
child: SyncedHeaderScaffold(
scrollController: widget.scrollController,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: favoritesController.fetchAll,
selectionBar: _isSelecting
? _buildSelectionBar(context, theme, selectedItems)
: null,
contentSlivers: contentSlivers,
child: ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: favoritesController.fetchAll,
child: CustomScrollView(
controller: widget.scrollController,
slivers: contentSlivers,
),
),
),
);
}
/// Replaces the top bar entirely while selecting (see
/// `SyncedHeaderScaffold.selectionBar`) - mirrors Files/Photos' identical
/// selection bar.
/// Replaces the controls row's own sticky slot while selecting - mirrors
/// Files/Photos' identical selection bar.
Widget _buildSelectionBar(
BuildContext context,
ThemeData theme,
List<NextcloudItem> selectedItems,
) {
final colors = context.nooColors;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
child: Row(
children: [
SizedBox(
@@ -746,7 +732,7 @@ class _FavoritesViewState extends State<FavoritesView>
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
icon: Icon(LucideIcons.x, size: 20, color: colors.fg1),
tooltip: 'Cancel selection',
onPressed: _clearSelection,
visualDensity: VisualDensity.compact,
@@ -754,8 +740,9 @@ class _FavoritesViewState extends State<FavoritesView>
const SizedBox(width: 8),
Text(
'${selectedItems.length} selected',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
style: NooText.cardTitle.copyWith(
fontSize: 17,
color: colors.fg1,
),
),
],
@@ -771,7 +758,7 @@ class _FavoritesViewState extends State<FavoritesView>
children: [
for (final action in _buildSelectionActions(selectedItems))
IconButton(
icon: Icon(action.icon, size: 20),
icon: Icon(action.icon, size: 20, color: colors.fg1),
tooltip: action.label,
onPressed: action.onTap,
visualDensity: VisualDensity.compact,
File diff suppressed because it is too large Load Diff
+660 -468
View File
File diff suppressed because it is too large Load Diff
+15 -28
View File
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../providers/session_controller.dart';
import '../theme/design_tokens.dart';
import '../widgets/noo/core/noo_button.dart';
/// Shown in place of the main shell whenever [SessionController.needsUnlock] is
/// true - a fresh app launch with login lock configured, or returning to the
@@ -48,10 +51,10 @@ class _LockScreenViewState extends State<LockScreenView>
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
return Scaffold(
backgroundColor: colors.bg,
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
@@ -63,10 +66,7 @@ class _LockScreenViewState extends State<LockScreenView>
// silhouette on transparent, meant to be recolored rather
// than shown as-is.
ColorFiltered(
colorFilter: ColorFilter.mode(
colorScheme.onSurface,
BlendMode.srcIn,
),
colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn),
child: Image.asset(
'assets/icon/app_icon_monochrome.png',
width: 80,
@@ -77,35 +77,22 @@ class _LockScreenViewState extends State<LockScreenView>
Text(
'Noo is locked',
textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
style: NooText.title.copyWith(color: colors.fg1),
),
const SizedBox(height: 8),
Text(
'Unlock with your PIN or biometrics to continue',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: NooText.body.copyWith(color: colors.fg3),
),
const SizedBox(height: 32),
SizedBox(
height: 54,
child: FilledButton.icon(
onPressed: _authenticating ? null : _attemptUnlock,
icon: _authenticating
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: colorScheme.onPrimary,
),
)
: const Icon(Icons.lock_open_rounded),
label: Text(_authenticating ? 'Unlocking…' : 'Unlock'),
),
NooButton(
variant: NooButtonVariant.primary,
size: NooButtonSize.cta,
icon: LucideIcons.lockOpen,
disabled: _authenticating,
onTap: _attemptUnlock,
child: Text(_authenticating ? 'Unlocking…' : 'Unlock'),
),
],
),
+177 -247
View File
@@ -1,13 +1,22 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../models/saved_account.dart';
import '../providers/session_controller.dart';
import '../theme/design_tokens.dart';
import '../widgets/noo/core/noo_avatar.dart';
import '../widgets/noo/core/noo_button.dart';
import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/noo/overlays/noo_text_field.dart';
import '../widgets/settings/settings_dialogs.dart';
import '../widgets/shell/shell_common.dart';
import 'login_webview_view.dart';
class LoginView extends StatefulWidget {
/// True when this is pushed from Settings ("Add account") on top of an
/// already-logged-in session, rather than shown as the app's root screen
/// with no account yet. Adds an AppBar/back affordance and auto-pops once
/// with no account yet. Adds a top bar/back affordance and auto-pops once
/// the new account becomes active.
final bool isAddingAccount;
@@ -18,9 +27,9 @@ class LoginView extends StatefulWidget {
}
class _LoginViewState extends State<LoginView> {
final _formKey = GlobalKey<FormState>();
final _urlController = TextEditingController();
String? _originalActiveAccountId;
String? _localError;
bool _popped = false;
bool _webViewPushed = false;
@@ -39,12 +48,14 @@ class _LoginViewState extends State<LoginView> {
}
void _handleContinue() {
if (!_formKey.currentState!.validate()) return;
final url = _urlController.text.trim();
if (url.isEmpty) {
setState(() => _localError = 'Please enter your Nextcloud server address');
return;
}
setState(() => _localError = null);
FocusScope.of(context).unfocus();
context.read<SessionController>().startLoginFlow(
_urlController.text.trim(),
addAccount: widget.isAddingAccount,
);
context.read<SessionController>().startLoginFlow(url, addAccount: widget.isAddingAccount);
}
/// Resumes a saved account with one tap (see [SessionController.switchAccount]).
@@ -68,47 +79,25 @@ class _LoginViewState extends State<LoginView> {
);
}
/// Mirrors AccountView's `_AccountsCard._confirmRemove` - same
/// destructive-action gate, just reached from the login screen instead of
/// Settings.
/// Mirrors Settings' own account removal - same confirm/remove gate, just
/// reached from the login screen instead of the saved-accounts list.
Future<void> _confirmRemoveAccount(SavedAccount account) async {
final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Remove Account'),
content: Text(
'Remove ${account.username} ($host)? You can add it again later.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Remove'),
),
],
);
},
await confirmRemoveAccount(
context,
context.read<SessionController>(),
accountId: account.id,
username: account.username,
host: serverHost(account.serverUrl),
isActive: false,
);
if (confirmed != true || !mounted) return;
await context.read<SessionController>().removeAccount(account.id);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final session = context.watch<SessionController>();
final isAwaitingBrowser =
session.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
final isAwaitingBrowser = session.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
final isInitiating = session.loginFlowStatus == LoginFlowStatus.initiating;
// A new/refreshed account has just become active - pop back to
@@ -128,9 +117,7 @@ class _LoginViewState extends State<LoginView> {
// SessionController.startLoginFlow's doc comment for why. Push it the
// moment there's a URL to show; _webViewPushed resets once that route
// pops (cancelled or done) so a retry after cancelling pushes it again.
if (!_webViewPushed &&
isAwaitingBrowser &&
session.pendingLoginUrl != null) {
if (!_webViewPushed && isAwaitingBrowser && session.pendingLoginUrl != null) {
_webViewPushed = true;
final url = session.pendingLoginUrl!;
WidgetsBinding.instance.addPostFrameCallback((_) async {
@@ -154,9 +141,17 @@ class _LoginViewState extends State<LoginView> {
});
}
final errorMessage =
_localError ?? (session.loginFlowStatus == LoginFlowStatus.error ? session.errorMessage : null);
final body = Scaffold(
backgroundColor: colors.bg,
appBar: widget.isAddingAccount
? AppBar(title: const Text('Add Account'))
? NooTopBar(
style: NooLayout.navStyle(context),
title: 'Add account',
leading: const NooTopBarBack(),
)
: null,
body: SafeArea(
child: Center(
@@ -172,8 +167,7 @@ class _LoginViewState extends State<LoginView> {
// deleting it, specifically so it can be resumed
// from here with one tap - no need to repeat
// Login Flow v2.
if (!widget.isAddingAccount &&
session.accounts.isNotEmpty) ...[
if (!widget.isAddingAccount && session.accounts.isNotEmpty) ...[
_SavedAccountsSection(
accounts: session.accounts,
onSelect: (account) => _continueAsAccount(account),
@@ -182,33 +176,21 @@ class _LoginViewState extends State<LoginView> {
const SizedBox(height: 28),
Row(
children: [
const Expanded(child: Divider()),
Expanded(child: Divider(color: colors.line)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
'or',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
child: Text('or', style: NooText.meta.copyWith(color: colors.fg3)),
),
const Expanded(child: Divider()),
Expanded(child: Divider(color: colors.line)),
],
),
const SizedBox(height: 28),
],
_ServerForm(
formKey: _formKey,
urlController: _urlController,
isLoading:
isInitiating || session.isLoading || isAwaitingBrowser,
errorMessage:
session.loginFlowStatus == LoginFlowStatus.error
? session.errorMessage
: null,
isLoading: isInitiating || session.isLoading || isAwaitingBrowser,
errorMessage: errorMessage,
onContinue: _handleContinue,
theme: theme,
colorScheme: colorScheme,
),
],
),
@@ -236,157 +218,94 @@ class _LoginViewState extends State<LoginView> {
}
class _ServerForm extends StatelessWidget {
final GlobalKey<FormState> formKey;
final TextEditingController urlController;
final bool isLoading;
final String? errorMessage;
final VoidCallback onContinue;
final ThemeData theme;
final ColorScheme colorScheme;
const _ServerForm({
required this.formKey,
required this.urlController,
required this.isLoading,
required this.errorMessage,
required this.onContinue,
required this.theme,
required this.colorScheme,
});
@override
Widget build(BuildContext context) {
return Form(
key: formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
// Same monochrome-tinted treatment as the splash/lock screens
// (see main.dart's _SplashView) - the asset is a plain white
// silhouette on transparent, meant to be recolored rather than
// shown as-is. Used everywhere the app shows its own icon
// in-app, rather than the full-color launcher icon.
child: ColorFiltered(
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.onSurface,
BlendMode.srcIn,
),
child: Image.asset(
'assets/icon/app_icon_monochrome.png',
width: 80,
height: 80,
),
),
final colors = context.nooColors;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
// Same monochrome-tinted treatment as the splash/lock screens
// (see main.dart's _SplashView) - the asset is a plain white
// silhouette on transparent, meant to be recolored rather than
// shown as-is. Used everywhere the app shows its own icon
// in-app, rather than the full-color launcher icon.
child: ColorFiltered(
colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn),
child: Image.asset('assets/icon/app_icon_monochrome.png', width: 80, height: 80),
),
const SizedBox(height: 24),
Text(
'Noo',
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
),
const SizedBox(height: 24),
Text('Noo', textAlign: TextAlign.center, style: NooText.largeTitle.copyWith(color: colors.fg1)),
const SizedBox(height: 6),
Text(
'Connect to your self-hosted server',
textAlign: TextAlign.center,
style: NooText.body.copyWith(color: colors.fg3),
),
const SizedBox(height: 36),
if (errorMessage != null)
Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colors.dangerSoft,
borderRadius: BorderRadius.circular(NooRadii.input),
),
),
const SizedBox(height: 6),
Text(
'Connect to your self-hosted server',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 36),
if (errorMessage != null)
Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Icon(
Icons.error_outline_rounded,
color: colorScheme.onErrorContainer,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
errorMessage!,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.w500,
),
child: Row(
children: [
Icon(LucideIcons.circleAlert, color: colors.danger, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
errorMessage!,
style: NooText.body.copyWith(
fontSize: 13,
fontWeight: FontWeight.w500,
color: colors.danger,
),
),
],
),
),
TextFormField(
controller: urlController,
keyboardType: TextInputType.url,
autofillHints: const [AutofillHints.url],
decoration: InputDecoration(
labelText: 'Server Address',
hintText: 'cloud.example.com',
prefixIcon: const Icon(Icons.dns_outlined),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
),
filled: true,
fillColor: colorScheme.surfaceContainerLow,
),
validator: (val) {
if (val == null || val.trim().isEmpty) {
return 'Please enter your Nextcloud server address';
}
return null;
},
onFieldSubmitted: (_) => onContinue(),
),
const SizedBox(height: 28),
SizedBox(
height: 54,
child: FilledButton(
onPressed: isLoading ? null : onContinue,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(27),
),
),
child: isLoading
? SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: colorScheme.onPrimary,
),
)
: const Text(
'Continue',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(height: 24),
Text(
"You'll finish signing in on the page that opens. This app never sees your password.",
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
NooTextField(
controller: urlController,
placeholder: 'cloud.example.com',
leadingIcon: LucideIcons.server,
keyboardType: TextInputType.url,
onSubmitted: (_) => onContinue(),
),
const SizedBox(height: 28),
NooButton(
variant: NooButtonVariant.primary,
size: NooButtonSize.cta,
fullWidth: true,
disabled: isLoading,
onTap: onContinue,
child: Text(isLoading ? 'Connecting…' : 'Continue'),
),
const SizedBox(height: 24),
Text(
"You'll finish signing in on the page that opens. This app never sees your password.",
textAlign: TextAlign.center,
style: NooText.meta.copyWith(color: colors.fg3, fontSize: 11),
),
],
);
}
}
@@ -398,44 +317,35 @@ class _SavedAccountsSection extends StatelessWidget {
final ValueChanged<SavedAccount> onSelect;
final ValueChanged<SavedAccount> onRemove;
const _SavedAccountsSection({
required this.accounts,
required this.onSelect,
required this.onRemove,
});
const _SavedAccountsSection({required this.accounts, required this.onSelect, required this.onRemove});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text(
'Continue as',
style: theme.textTheme.labelLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
child: Text('Continue as', style: NooText.label.copyWith(color: colors.fg2)),
),
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
for (final account in accounts) ...[
_SavedAccountRow(
account: account,
onTap: () => onSelect(account),
onRemove: () => onRemove(account),
),
if (account != accounts.last)
const Divider(height: 1, indent: 16, endIndent: 16),
ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card),
child: DecoratedBox(
decoration: BoxDecoration(color: colors.line),
child: Column(
children: [
for (var i = 0; i < accounts.length; i++) ...[
if (i > 0) const SizedBox(height: 1),
_SavedAccountRow(
account: accounts[i],
onTap: () => onSelect(accounts[i]),
onRemove: () => onRemove(accounts[i]),
),
],
],
],
),
),
),
],
@@ -448,40 +358,60 @@ class _SavedAccountRow extends StatelessWidget {
final VoidCallback onTap;
final VoidCallback onRemove;
const _SavedAccountRow({
required this.account,
required this.onTap,
required this.onRemove,
});
const _SavedAccountRow({required this.account, required this.onTap, required this.onRemove});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl;
final initial = account.username.isNotEmpty
? account.username[0].toUpperCase()
: '?';
final colors = context.nooColors;
return ListTile(
leading: CircleAvatar(
backgroundColor: colorScheme.primary,
child: Text(
initial,
style: TextStyle(
color: colorScheme.onPrimary,
fontWeight: FontWeight.bold,
return Material(
color: colors.surface,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md, vertical: 10),
child: Row(
spacing: 12,
children: [
NooAvatar(initials: accountInitial(account.username), size: 36),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 2,
children: [
Text(
account.username,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.bodyL.copyWith(
height: 1.1,
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
Text(
serverHost(account.serverUrl),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.meta.copyWith(color: colors.fg3),
),
],
),
),
GestureDetector(
onTap: onRemove,
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle),
child: Icon(LucideIcons.x, size: 16, color: colors.fg2),
),
),
],
),
),
),
title: Text(account.username),
subtitle: Text(host),
trailing: IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: 'Remove account',
visualDensity: VisualDensity.compact,
onPressed: onRemove,
),
onTap: onTap,
);
}
}
+17 -6
View File
@@ -1,7 +1,11 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../providers/session_controller.dart';
import '../theme/design_tokens.dart';
import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/noo_layout.dart';
/// Every Login Flow v2 page - first login or an additional account -
/// rendered in Flutter's own WebView rather than a Chrome Custom Tab or
@@ -63,6 +67,7 @@ class _LoginWebViewViewState extends State<LoginWebViewView> {
@override
Widget build(BuildContext context) {
final session = context.watch<SessionController>();
final colors = context.nooColors;
// The login flow finished (success or error) or was cancelled
// elsewhere - close this screen automatically rather than leaving it
@@ -81,18 +86,24 @@ class _LoginWebViewViewState extends State<LoginWebViewView> {
if (!didPop) _cancel();
},
child: Scaffold(
appBar: AppBar(
title: const Text('Sign in'),
leading: IconButton(
icon: const Icon(Icons.close_rounded),
backgroundColor: colors.bg,
appBar: NooTopBar(
style: NooLayout.navStyle(context),
title: 'Log in',
leading: NooTopBarButton(
icon: LucideIcons.x,
tooltip: 'Cancel',
onPressed: _cancel,
onTap: _cancel,
),
),
body: Stack(
children: [
WebViewWidget(controller: _controller),
if (_pageLoading) const LinearProgressIndicator(),
if (_pageLoading)
LinearProgressIndicator(
color: colors.accent,
backgroundColor: colors.surface3,
),
],
),
),
+152 -309
View File
@@ -1,21 +1,22 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../models/move_copy_result.dart';
import '../models/nextcloud_item.dart';
import '../providers/files_controller.dart';
import '../providers/item_operations.dart';
import '../providers/session_controller.dart';
import '../theme/design_tokens.dart';
import '../widgets/breadcrumbs.dart';
import '../widgets/item_icon.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/move_copy_conflict_sheet.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/segmented_icon_toggle.dart';
import '../widgets/sort_menu_button.dart';
import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.dart';
import '../widgets/noo/core/noo_button.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/nav/noo_toolbar.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/tabs/tab_state_slivers.dart';
/// Destination-folder browser for moving/copying [items] (a multi-select
/// batch from Files or Photos). Visually mirrors `ShareUploadView`'s
@@ -175,352 +176,194 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
).pop(MoveCopyResult(succeeded: succeeded, failed: failed));
}
Widget _buildControlsRow(FilesController files) {
return SizedBox(
height: 44,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
IconButton(
icon: Icon(
files.filesSortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: files.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: files.toggleFilesSortOrder,
),
SizedBox(
width: 130,
child: SortMenuButton(
field: files.filesSortField,
onChanged: files.setFilesSortField,
),
),
ToggleIconButton(
icon: files.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: files.showHiddenFiles,
onTap: () => files.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: files.storageScope == StorageScope.cloud,
onTap: () => files.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: files.storageScope == StorageScope.external,
onTap: () => files.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
),
const SizedBox(width: 8),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !files.isGridView,
onTap: () => files.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: files.isGridView,
onTap: () => files.setGridView(true),
tooltip: 'Grid view',
),
],
),
],
// Only folders are valid Move/Copy destinations, so the listing - unlike
// Files' own - never shows plain files at all.
Widget _buildRow(NextcloudItem item, int index, int count) {
final colors = context.nooColors;
final row = NooFileRow(
kind: NooFileKind.folder,
name: item.name,
meta: 'Folder',
iosStyle: NooLayout.iosStyle(context),
onTap: () => _navigateToFolder(item.path),
);
final isFirst = index == 0;
final isLast = index == count - 1;
return Column(
children: [
ClipRRect(
borderRadius: BorderRadius.vertical(
top: isFirst ? const Radius.circular(NooRadii.card) : Radius.zero,
bottom: isLast ? const Radius.circular(NooRadii.card) : Radius.zero,
),
child: row,
),
),
if (!isLast) Container(height: 1, color: colors.line),
],
);
}
// Only folders are valid Move/Copy destinations - files still show (so
// the listing matches what the Files tab itself would show for this
// folder) but are visually dimmed and inert, same treatment as
// ShareUploadView's own destination browser.
Widget _buildListTile(NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final session = context.watch<SessionController>();
final isFolder = item.isFolder;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Opacity(
opacity: isFolder ? 1 : 0.5,
child: Material(
color: colorScheme.surfaceContainerLow,
child: InkWell(
onTap: isFolder ? () => _navigateToFolder(item.path) : null,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
ItemThumbnail(
item: item,
service: session.service,
size: 44,
borderRadius: 12,
iconSize: 22,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
isFolder
? 'Folder'
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (isFolder) const Icon(Icons.chevron_right_rounded),
],
),
),
),
),
),
),
);
}
Widget _buildGridCard(NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isFolder = item.isFolder;
final iconColor = getIconColor(context, item.type);
return Opacity(
opacity: isFolder ? 1 : 0.5,
child: Material(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: isFolder ? () => _navigateToFolder(item.path) : null,
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
getItemIcon(item.type),
color: iconColor,
size: 24,
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
const SizedBox(height: 2),
Text(
isFolder ? 'Folder' : formatBytes(item.size),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
],
),
),
),
),
Widget _buildDesktopRow(NextcloudItem item) {
return NooFileTableRow(
kind: NooFileKind.folder,
name: item.name,
onTap: () => _navigateToFolder(item.path),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final desktop = NooLayout.isDesktop(context);
final files = context.watch<FilesController>();
final ops = context.read<ItemOperations>();
final hasBreadcrumbs = _pathStack.length > 1;
final currentLabel = _currentPath == '/'
? 'Home'
: _currentPath.split('/').where((s) => s.isNotEmpty).last;
final items = files.applyFilesDisplayPrefs(_rawItems);
final folders = files
.applyFilesDisplayPrefs(_rawItems)
.where((item) => item.isFolder)
.toList();
final invalidDestination = _destinationIsInvalid();
final gutter = NooLayout.gutter(context);
final verb = widget.copy ? 'Copying' : 'Moving';
final itemCount = widget.items.length;
final controlsColumn = Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildControlsRow(files),
if (hasBreadcrumbs) ...[
const SizedBox(height: 10),
SizedBox(
height: 32,
child: Breadcrumbs(
pathStack: _pathStack,
onTap: _navigateToPathIndex,
),
),
],
],
),
);
final contentSlivers = <Widget>[
SliverPersistentHeader(
pinned: true,
delegate: StickyHeaderDelegate(
height: hasBreadcrumbs ? 114 : 72,
child: controlsColumn,
final slivers = <Widget>[
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(gutter, NooSpace.md, gutter, NooSpace.sm),
child: hasBreadcrumbs
? SizedBox(
height: 32,
child: Breadcrumbs(
pathStack: _pathStack,
onTap: _navigateToPathIndex,
),
)
: const SizedBox.shrink(),
),
),
if (_isLoading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
tabLoadingSliver
else if (folders.isEmpty)
tabEmptySliver(
context,
icon: LucideIcons.folder,
message: 'No folders here',
)
else if (items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'Folder is empty',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
)
else if (files.isGridView)
else if (desktop)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.1,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildDesktopRow(folders[index]),
childCount: folders.length,
),
delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(items[index]);
}, childCount: items.length),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return _buildListTile(items[index]);
}, childCount: items.length),
delegate: SliverChildBuilderDelegate(
(context, index) => _buildRow(folders[index], index, folders.length),
childCount: folders.length,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.xl)),
];
final title = widget.copy ? 'Copy to...' : 'Move to...';
return PopScope(
canPop: _pathStack.length <= 1,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) _navigateUp();
},
child: Scaffold(
body: SyncedHeaderScaffold(
scrollController: _scrollController,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
contentSlivers: contentSlivers,
backgroundColor: colors.bg,
appBar: desktop
? NooToolbar(
title: title,
actions: [
const MoreTabsButton(),
const SizedBox(width: NooSpace.xs),
NooButton(
variant: NooButtonVariant.secondary,
size: NooButtonSize.toolbar,
onTap: () => Navigator.maybePop(context),
child: const Text('Cancel'),
),
],
)
: NooTopBar(
style: NooLayout.navStyle(context),
title: title,
leading: const NooTopBarBack(label: 'Cancel'),
// Stacks a hidden tab's own screen on top rather than
// jumping the shell there, so it doesn't abandon this
// picker - safe to keep, unlike ShareUploadView's chrome.
actions: const [MoreTabsButton()],
),
body: SafeArea(
top: false,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: () => _fetch(_currentPath),
child: CustomScrollView(controller: _scrollController, slivers: slivers),
),
),
bottomNavigationBar: Material(
color: colorScheme.surfaceContainerHigh,
elevation: 8,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
bottomNavigationBar: DecoratedBox(
decoration: BoxDecoration(
color: colors.surface,
border: Border(top: BorderSide(color: colors.line)),
),
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
padding: const EdgeInsets.fromLTRB(
NooSpace.lg,
NooSpace.md,
NooSpace.lg,
NooSpace.md,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (invalidDestination) ...[
Text(
"Can't ${widget.copy ? 'copy' : 'move'} a folder into "
"itself or one of its own subfolders",
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
Text(
invalidDestination
? "Can't ${widget.copy ? 'copy' : 'move'} a folder into "
"itself or one of its own subfolders"
: '$verb $itemCount item${itemCount == 1 ? '' : 's'}',
style: NooText.meta.copyWith(
color: invalidDestination ? colors.danger : colors.fg3,
),
const SizedBox(height: 8),
],
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: invalidDestination || _isSubmitting
? null
: () => _confirm(ops),
icon: _isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
widget.copy
? Icons.copy_rounded
: Icons.drive_file_move_rounded,
),
const SizedBox(height: NooSpace.sm),
NooButton(
variant: NooButtonVariant.primary,
size: NooButtonSize.cta,
fullWidth: true,
disabled: invalidDestination || _isSubmitting,
icon: _isSubmitting
? null
: (widget.copy ? LucideIcons.copy : LucideIcons.folderInput),
onTap: invalidDestination || _isSubmitting
? null
: () => _confirm(ops),
child: _isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
label: Text(
'${widget.copy ? 'Copy' : 'Move'} ${widget.items.length} '
'item(s) to $currentLabel',
),
),
)
: Text(widget.copy ? 'Copy here' : 'Move here'),
),
],
),
+365 -228
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/nextcloud_item.dart';
@@ -11,18 +12,42 @@ import '../providers/photos_controller.dart';
import '../providers/pick_controller.dart';
import '../providers/session_controller.dart';
import '../services/download_service.dart';
import '../theme/design_tokens.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/segmented_icon_toggle.dart';
import '../widgets/selectable_thumbnail.dart';
import '../widgets/noo/core/noo_button.dart';
import '../widgets/noo/core/noo_chip.dart';
import '../widgets/noo/core/noo_segmented_control.dart';
import '../widgets/noo/core/noo_toggle.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/lists/noo_settings_row.dart';
import '../widgets/noo/media/noo_photo_group.dart';
import '../widgets/noo/media/noo_photo_tile.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/share_sheet.dart';
import '../widgets/sort_menu_button.dart';
import '../widgets/sort_menu_button.dart' show sortFieldLabel;
import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.dart';
import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.dart';
/// The type chips PhotosController actually has data for (DESIGN_SYSTEM.md
/// section 4 also lists "Camera", but there's no camera-upload/EXIF source
/// to distinguish that from any other photo - see the view's doc comment).
enum _PhotoTypeFilter { all, image, video }
/// A contiguous run of [PhotosController.items] that shares a calendar
/// month, in whatever order the controller already sorted them - see
/// [_PhotosViewState._groupByMonth].
class _MonthGroup {
final DateTime month;
final List<NextcloudItem> items;
_MonthGroup(this.month, this.items);
}
/// The Photos tab: every image/video across the account, grouped by month
/// (DESIGN_SYSTEM.md section 4). Renders content only - the shell
/// (`MainShellView`) owns the top bar, bottom bar, drawer and FAB.
class PhotosView extends StatefulWidget {
final ScrollController scrollController;
@@ -39,6 +64,12 @@ class _PhotosViewState extends State<PhotosView>
final ScrollController _selectionActionsScrollController = ScrollController();
final List<AnimationController> _scrollHintControllers = [];
// Session-local only (not one of PhotosController's persisted display
// prefs) - a pure narrowing of the already-fetched/filtered/sorted list,
// same way the type chips narrow Files' listing without touching the
// controller.
_PhotoTypeFilter _typeFilter = _PhotoTypeFilter.all;
bool get _isSelecting => _selectedIds.isNotEmpty;
void _toggleSelection(NextcloudItem item) {
@@ -64,7 +95,14 @@ class _PhotosViewState extends State<PhotosView>
/// starts. No-ops if there's nothing to scroll (row already fits).
Future<void> _playScrollHint(ScrollController scrollController) async {
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted || !scrollController.hasClients) return;
// `hasClients` only means a position is attached, not that it's
// finished its first layout - reading maxScrollExtent before that
// throws (min/maxScrollExtent are still null internally).
if (!mounted ||
!scrollController.hasClients ||
!scrollController.position.hasContentDimensions) {
return;
}
final maxExtent = scrollController.position.maxScrollExtent;
if (maxExtent <= 0) return;
final double peak = maxExtent < 36 ? maxExtent : 36;
@@ -195,10 +233,39 @@ class _PhotosViewState extends State<PhotosView>
];
}
/// Splits an already-filtered/sorted list into contiguous month runs
/// (`dateCreated`) in list order - deliberately not a global re-sort by
/// date, so a "Name"/"Size" sort chip still governs the order shown
/// (see the group header row it feeds).
List<_MonthGroup> _groupByMonth(List<NextcloudItem> items) {
final groups = <_MonthGroup>[];
for (final item in items) {
final d = item.dateCreated;
final month = DateTime(d.year, d.month);
if (groups.isNotEmpty && groups.last.month == month) {
groups.last.items.add(item);
} else {
groups.add(_MonthGroup(month, [item]));
}
}
return groups;
}
bool _matchesTypeFilter(NextcloudItem item) {
switch (_typeFilter) {
case _PhotoTypeFilter.all:
return true;
case _PhotoTypeFilter.image:
return item.type == NextcloudItemType.image;
case _PhotoTypeFilter.video:
return item.type == NextcloudItemType.video;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final isDesktop = NooLayout.isDesktop(context);
final photosController = context.watch<PhotosController>();
final filesController = context.watch<FilesController>();
final pick = context.watch<PickController>();
@@ -210,89 +277,44 @@ class _PhotosViewState extends State<PhotosView>
);
}
final photos = photosController.items;
final selectedItems = photos
final allPhotos = photosController.items;
final visiblePhotos = allPhotos.where(_matchesTypeFilter).toList();
// Selection tracks the full (favorites/hidden-filtered) list, not just
// the type-filtered view, so switching the type chip mid-selection
// never silently drops a selected item from the bulk actions.
final selectedItems = allPhotos
.where((i) => _selectedIds.contains(i.id))
.toList();
final groups = _groupByMonth(visiblePhotos);
final controlsRow = Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: SizedBox(
height: 44,
child: Row(
children: [
IconButton(
icon: Icon(
photosController.sortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: photosController.sortAscending
? 'Ascending'
: 'Descending',
onPressed: photosController.toggleSortOrder,
),
Expanded(
child: SortMenuButton(
field: photosController.sortField,
onChanged: photosController.setSortField,
),
),
ToggleIconButton(
icon: photosController.showFavoritesOnly
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
isSelected: photosController.showFavoritesOnly,
onTap: photosController.toggleFavoritesFilter,
tooltip: 'Favorites only',
),
const SizedBox(width: 4),
ToggleIconButton(
icon: photosController.showHidden
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: photosController.showHidden,
onTap: () => photosController.toggleShowHidden(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: filesController.storageScope == StorageScope.cloud,
onTap: () =>
filesController.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected:
filesController.storageScope == StorageScope.external,
onTap: () =>
filesController.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
),
],
),
),
final controlsRow = _buildControlsRow(
context,
photosController,
filesController,
);
final List<Widget> contentSlivers = [
// Sticky while browsing; once selecting, the selection bar takes over
// the very top of the screen instead (see `selectionBar` below), so
// this is free to scroll away rather than staying pinned under it.
// the same slot instead.
SliverPersistentHeader(
pinned: !_isSelecting,
delegate: StickyHeaderDelegate(height: 60, child: controlsRow),
delegate: StickyHeaderDelegate(
height: _isSelecting ? 56 : 64,
child: _isSelecting
? _buildSelectionBar(context, pick, selectedItems)
: Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.sm,
NooSpace.sm,
NooSpace.sm,
NooSpace.xs,
),
child: controlsRow,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (photosController.isLoading && photos.isEmpty)
if (photosController.isLoading && allPhotos.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
@@ -306,85 +328,82 @@ class _PhotosViewState extends State<PhotosView>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
Icon(LucideIcons.circleAlert, size: 56, color: colors.danger),
const SizedBox(height: 16),
Text(
'Could not load photos',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
style: NooText.cardTitle.copyWith(color: colors.danger),
),
const SizedBox(height: 8),
Text(
photosController.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: NooText.body.copyWith(color: colors.fg3),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: photosController.fetchAllMedia,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
NooButton(
variant: NooButtonVariant.secondary,
size: NooButtonSize.field,
icon: LucideIcons.refreshCw,
onTap: photosController.fetchAllMedia,
child: const Text('Retry'),
),
],
),
),
),
)
else if (photos.isEmpty)
else if (visiblePhotos.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.photo_library_outlined,
size: 64,
color: colorScheme.outlineVariant,
),
Icon(LucideIcons.image, size: 56, color: colors.fg3),
const SizedBox(height: 12),
Text(
'No photos found in Nextcloud library',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
allPhotos.isEmpty
? 'No photos found in Nextcloud library'
: 'No photos match this filter',
style: NooText.cardTitle.copyWith(color: colors.fg2),
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.0,
for (final group in groups) ...[
SliverToBoxAdapter(
child: NooPhotoGroupHeader(
title: DateFormat('MMMM yyyy').format(group.month),
count:
'${group.items.length} ${group.items.length == 1 ? 'item' : 'items'}',
padding: EdgeInsets.fromLTRB(
NooLayout.gutter(context),
NooSpace.lg,
NooLayout.gutter(context),
NooSpace.sm,
),
),
delegate: SliverChildBuilderDelegate((context, index) {
final photo = photos[index];
return _buildPhotoTile(context, photo);
}, childCount: photos.length),
),
),
NooPhotoGrid(
itemCount: group.items.length,
columns: isDesktop
? NooPhotoGrid.desktopColumns
: NooPhotoGrid.mobileColumns,
gap: isDesktop ? NooPhotoGrid.desktopGap : NooPhotoGrid.mobileGap,
padding: EdgeInsets.symmetric(
horizontal: isDesktop ? NooLayout.gutter(context) : 0,
),
itemBuilder: (context, index) =>
_buildPhotoTile(context, group.items[index], visiblePhotos),
),
],
// Fixed clearance so the last row isn't hidden behind the floating
// nav bar, regardless of grid length.
const SliverToBoxAdapter(child: SizedBox(height: 100)),
// For a short grid this stretches the white card's background down to
// the screen edge (matching the empty state above); for a long grid
// that already fills the viewport it contributes nothing extra.
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
];
return PopScope(
@@ -392,29 +411,181 @@ class _PhotosViewState extends State<PhotosView>
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelecting) _clearSelection();
},
child: SyncedHeaderScaffold(
scrollController: widget.scrollController,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: photosController.fetchAllMedia,
selectionBar: _isSelecting
? _buildSelectionBar(context, theme, pick, selectedItems)
: null,
contentSlivers: contentSlivers,
child: ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: photosController.fetchAllMedia,
child: CustomScrollView(
controller: widget.scrollController,
slivers: contentSlivers,
),
),
),
);
}
/// Replaces the top bar entirely while selecting (see
/// `SyncedHeaderScaffold.selectionBar`) - a close button, the "N
/// selected" count, and the horizontally-scrollable bulk actions.
/// Type chips (All/Photos/Videos - "Camera" from the spec has no data
/// source, see the view's doc comment), a sort chip and a filter chip for
/// favorites-only/hidden/external-storage, mirroring `FilesControlsRow`'s
/// layout/pattern.
Widget _buildControlsRow(
BuildContext context,
PhotosController photos,
FilesController files,
) {
final filtersActive =
photos.showFavoritesOnly ||
photos.showHidden ||
files.storageScope != StorageScope.cloud;
return SizedBox(
height: 44,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
NooChip(
icon: photos.sortAscending
? LucideIcons.arrowUp
: LucideIcons.arrowDown,
onTap: () => _showSortSheet(context, photos),
child: Text(sortFieldLabel(photos.sortField)),
),
const SizedBox(width: 8),
NooChip(
selected: _typeFilter == _PhotoTypeFilter.all,
onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.all),
child: const Text('All'),
),
const SizedBox(width: 8),
NooChip(
selected: _typeFilter == _PhotoTypeFilter.image,
onTap: () =>
setState(() => _typeFilter = _PhotoTypeFilter.image),
child: const Text('Photos'),
),
const SizedBox(width: 8),
NooChip(
selected: _typeFilter == _PhotoTypeFilter.video,
onTap: () =>
setState(() => _typeFilter = _PhotoTypeFilter.video),
child: const Text('Videos'),
),
const SizedBox(width: 8),
NooChip(
icon: LucideIcons.filter,
trailing: NooChipTrailing.menu,
selected: filtersActive,
onTap: () => _showFilterSheet(context, photos, files),
child: const Text('Filter'),
),
],
),
),
);
}
void _showSortSheet(BuildContext context, PhotosController photos) {
showNooSheet(
context,
children: [
NooSegmentedControl<bool>(
fill: true,
value: photos.sortAscending,
onChanged: (asc) {
if (asc != photos.sortAscending) photos.toggleSortOrder();
},
options: const [
NooSegmentOption(
value: true,
icon: LucideIcons.arrowUp,
label: 'Ascending',
),
NooSegmentOption(
value: false,
icon: LucideIcons.arrowDown,
label: 'Descending',
),
],
),
NooGroupedList(
children: [
for (final field in FileSortField.values)
NooSettingsRow(
label: Text(sortFieldLabel(field)),
trailing: field == photos.sortField
? Icon(
LucideIcons.check,
size: 18,
color: context.nooColors.accentText,
)
: null,
onTap: () {
photos.setSortField(field);
Navigator.pop(context);
},
),
],
),
],
);
}
void _showFilterSheet(
BuildContext context,
PhotosController photos,
FilesController files,
) {
showNooSheet(
context,
children: [
NooGroupedList(
children: [
NooSettingsRow(
icon: LucideIcons.heart,
label: const Text('Favorites only'),
trailing: NooToggle(
checked: photos.showFavoritesOnly,
onChanged: (_) => photos.toggleFavoritesFilter(),
),
),
NooSettingsRow(
icon: LucideIcons.eye,
label: const Text('Show hidden files'),
trailing: NooToggle(
checked: photos.showHidden,
onChanged: (_) => photos.toggleShowHidden(),
),
),
NooSettingsRow(
icon: LucideIcons.hardDrive,
label: const Text('External storage'),
trailing: NooToggle(
checked: files.storageScope == StorageScope.external,
onChanged: (external) => files.setStorageScope(
external ? StorageScope.external : StorageScope.cloud,
),
),
),
],
),
],
);
}
/// Replaces the controls row's own sticky slot while selecting - a close
/// button, the "N selected" count, and the horizontally-scrollable bulk
/// actions. Mirrors `FilesView`'s identical selection bar.
Widget _buildSelectionBar(
BuildContext context,
ThemeData theme,
PickController pick,
List<NextcloudItem> selectedItems,
) {
final colors = context.nooColors;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
child: Row(
children: [
SizedBox(
@@ -425,7 +596,7 @@ class _PhotosViewState extends State<PhotosView>
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
icon: Icon(LucideIcons.x, size: 20, color: colors.fg1),
tooltip: 'Cancel selection',
onPressed: _clearSelection,
visualDensity: VisualDensity.compact,
@@ -433,8 +604,9 @@ class _PhotosViewState extends State<PhotosView>
const SizedBox(width: 8),
Text(
'${selectedItems.length} selected',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
style: NooText.cardTitle.copyWith(
fontSize: 17,
color: colors.fg1,
),
),
],
@@ -447,7 +619,7 @@ class _PhotosViewState extends State<PhotosView>
scrollDirection: Axis.horizontal,
// Left-aligned (not anchored to the trailing edge) so the
// first action's left edge sits at a fixed spot - lining up
// with the controls row's own first icon directly below it -
// with the controls row's own first chip directly below it -
// regardless of how many actions there are.
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -457,7 +629,7 @@ class _PhotosViewState extends State<PhotosView>
selectedItems,
))
IconButton(
icon: Icon(action.icon, size: 20),
icon: Icon(action.icon, size: 20, color: colors.fg1),
tooltip: action.label,
onPressed: action.onTap,
visualDensity: VisualDensity.compact,
@@ -471,117 +643,82 @@ class _PhotosViewState extends State<PhotosView>
);
}
Widget _buildPhotoTile(BuildContext context, NextcloudItem photo) {
final colorScheme = Theme.of(context).colorScheme;
Widget _buildPhotoTile(
BuildContext context,
NextcloudItem photo,
List<NextcloudItem> siblings,
) {
final pick = context.watch<PickController>();
final session = context.watch<SessionController>();
final isSelected = _selectedIds.contains(photo.id);
final isVideo = photo.type == NextcloudItemType.video;
// No border radius on the tile itself — the outer ClipRRect below is
// the only place that rounds it, so a swipe doesn't reveal a stray
// floating rounded corner mid-drag (see the same fix in files_view).
final tile = GestureDetector(
return NooPhotoTile(
selected: isSelected,
selectionMode: _isSelecting,
// No real duration source (no video-metadata extraction anywhere in
// the app) - an empty string still marks it as a video and shows the
// icon-only badge (DESIGN_SYSTEM.md: "else icon-only").
videoDuration: isVideo ? '' : null,
placeholderColor: NooPhotoTile.paletteColor(photo.id.hashCode),
onTap: () {
if (pick.isPicking) {
_handlePickTap(context, pick, photo);
} else if (_isSelecting) {
_toggleSelection(photo);
} else {
_openLightbox(context, photo);
_openLightbox(context, photo, siblings);
}
},
onLongPress: pick.isPicking && !pick.pickRequest!.allowMultiple
? null
: () => _toggleSelection(photo),
child: ColoredBox(
color: colorScheme.surfaceContainerHigh,
child: SelectableThumbnail(
isSelected: isSelected,
checkmarkSize: 32,
child: Stack(
fit: StackFit.expand,
children: [
photo.previewUrl != null
? LayoutBuilder(
builder: (context, constraints) {
// Decode at the tile's actual rendered size rather
// than the full 500x500 preview the server returns —
// cheaper to decode/cache while scrolling a grid.
final cachePixels =
(constraints.maxWidth *
MediaQuery.of(context).devicePixelRatio)
.round();
return Image.network(
photo.previewUrl!,
headers: session.service?.authHeaders,
fit: BoxFit.cover,
cacheWidth: cachePixels,
cacheHeight: cachePixels,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) =>
_buildFallbackTile(context, photo),
);
},
)
: _buildFallbackTile(context, photo),
if (photo.type == NextcloudItemType.video)
Positioned(
bottom: 8,
right: 8,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 16,
),
),
),
if (photo.isFavorite)
const Positioned(
top: 8,
right: 8,
child: Icon(
Icons.favorite_rounded,
color: Colors.red,
size: 18,
),
),
],
),
),
),
child: photo.previewUrl != null
? LayoutBuilder(
builder: (context, constraints) {
// Decode at the tile's actual rendered size rather than the
// full 500x500 preview the server returns - cheaper to
// decode/cache while scrolling a grid.
final cachePixels =
(constraints.maxWidth *
MediaQuery.of(context).devicePixelRatio)
.round();
return Image.network(
photo.previewUrl!,
headers: session.service?.authHeaders,
fit: BoxFit.cover,
cacheWidth: cachePixels,
cacheHeight: cachePixels,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) =>
_buildFallbackTile(context, photo),
);
},
)
: _buildFallbackTile(context, photo),
);
return ClipRRect(borderRadius: BorderRadius.circular(16), child: tile);
}
Widget _buildFallbackTile(BuildContext context, NextcloudItem photo) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
color: colorScheme.secondaryContainer,
final colors = context.nooColors;
final kind = NooFileKind.from(name: photo.name, mimeType: photo.mimeType);
return ColoredBox(
color: kind.background(colors),
child: Center(
child: Icon(
photo.type == NextcloudItemType.video
? Icons.movie_rounded
: Icons.image_rounded,
color: colorScheme.onSecondaryContainer,
size: 32,
),
child: Icon(kind.icon, color: kind.foreground(colors), size: 32),
),
);
}
void _openLightbox(BuildContext context, NextcloudItem photo) {
final photos = context.read<PhotosController>().items;
void _openLightbox(
BuildContext context,
NextcloudItem photo,
List<NextcloudItem> siblings,
) {
Navigator.push(
context,
FileViewerScreen.route(item: photo, siblings: photos),
FileViewerScreen.route(item: photo, siblings: siblings),
);
}
+173 -198
View File
@@ -1,54 +1,29 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/nextcloud_item.dart';
import '../providers/recent_controller.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart';
import '../theme/design_tokens.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/lists/noo_settings_row.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/share_sheet.dart';
import '../widgets/tabs/tab_day_groups.dart';
import '../widgets/tabs/tab_location.dart';
import '../widgets/tabs/tab_state_slivers.dart';
import 'file_viewer_screen.dart';
IconData _recentItemIcon(NextcloudItemType type) {
switch (type) {
case NextcloudItemType.folder:
return Icons.folder_rounded;
case NextcloudItemType.image:
return Icons.image_rounded;
case NextcloudItemType.video:
return Icons.movie_rounded;
case NextcloudItemType.audio:
return Icons.audiotrack_rounded;
case NextcloudItemType.document:
return Icons.description_rounded;
case NextcloudItemType.archive:
return Icons.folder_zip_rounded;
case NextcloudItemType.file:
return Icons.insert_drive_file_rounded;
}
}
Color _recentItemIconColor(BuildContext context, NextcloudItemType type) {
final colorScheme = Theme.of(context).colorScheme;
switch (type) {
case NextcloudItemType.folder:
return colorScheme.primary;
case NextcloudItemType.image:
return Colors.amber.shade700;
case NextcloudItemType.video:
return Colors.deepOrange.shade600;
case NextcloudItemType.audio:
return Colors.purple.shade600;
case NextcloudItemType.document:
return Colors.blue.shade700;
case NextcloudItemType.archive:
return Colors.teal.shade700;
case NextcloudItemType.file:
return colorScheme.outline;
}
}
/// Recently modified files across the whole account (not folders), newest
/// first — see [RecentController.fetchAll].
/// first - see [RecentController.fetchAll]. Grouped into Today/Yesterday/
/// This week/Earlier per `DESIGN_SYSTEM.md` §4; meta is "Modified {time} ·
/// {location}" since the server only ever reports a modification, not a
/// distinct action per entry.
class RecentView extends StatefulWidget {
final ScrollController scrollController;
@@ -63,8 +38,7 @@ class _RecentViewState extends State<RecentView> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final recent = context.watch<RecentController>();
if (!_requested) {
@@ -73,174 +47,164 @@ class _RecentViewState extends State<RecentView> {
}
final items = recent.items;
final isDesktop = NooLayout.isDesktop(context);
final groups = groupByRecentBucket<NextcloudItem>(
items,
(item) => item.lastModified,
);
final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: 16)),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
if (recent.isLoading && items.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
tabLoadingSliver
else if (recent.errorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Could not load recent files',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
recent.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: recent.fetchAll,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
],
),
),
),
tabErrorSliver(
context,
title: 'Could not load recent files',
message: recent.errorMessage!,
onRetry: recent.fetchAll,
)
else if (items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.history_rounded,
size: 64,
color: colorScheme.outlineVariant,
),
const SizedBox(height: 12),
Text(
'No recent files',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
tabEmptySliver(
context,
icon: LucideIcons.clock,
message: 'No recent files',
)
else if (isDesktop)
_buildDesktopTable(context, groups)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = items[index];
return _buildTile(context, item);
}, childCount: items.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
_buildMobileGroups(context, groups),
...tabBottomInsetSlivers,
];
return SyncedHeaderScaffold(
scrollController: widget.scrollController,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: recent.fetchAll,
contentSlivers: contentSlivers,
);
}
Widget _buildTile(BuildContext context, NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final iconColor = _recentItemIconColor(context, item.type);
final folderPath = item.path.substring(
0,
item.path.length - item.name.length,
);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => _openFile(context, item),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_recentItemIcon(item.type),
color: iconColor,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
folderPath.isEmpty ? '/' : folderPath,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
DateFormat.yMMMd().add_jm().format(item.lastModified),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
),
return ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: recent.fetchAll,
child: CustomScrollView(
controller: widget.scrollController,
slivers: contentSlivers,
),
),
);
}
Widget _buildMobileGroups(
BuildContext context,
List<TabDayGroup<NextcloudItem>> groups,
) {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final group = groups[index];
return Padding(
padding: EdgeInsets.only(bottom: index == groups.length - 1 ? 0 : 18),
child: NooGroupedList(
label: group.label,
children: [
for (final item in group.items) _buildRow(context, item),
],
),
);
}, childCount: groups.length),
),
);
}
Widget _buildDesktopTable(
BuildContext context,
List<TabDayGroup<NextcloudItem>> groups,
) {
return SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.xl,
vertical: NooSpace.xs,
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final group = groups[index];
return Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TabGroupLabel(group.label),
const NooFileTableHeader(col2Label: 'Modified', col3Label: 'Location'),
for (final item in group.items) _buildDesktopRow(context, item),
],
),
);
}, childCount: groups.length),
),
);
}
Widget _buildRow(BuildContext context, NextcloudItem item) {
return NooFileRow(
kind: NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
),
name: item.name,
meta: 'Modified ${_metaTime(item.lastModified)} · ${_location(item)}',
favorite: item.isFavorite,
iosStyle: NooLayout.iosStyle(context),
onTap: () => _openFile(context, item),
onMore: () => _showItemSheet(context, item),
);
}
Widget _buildDesktopRow(BuildContext context, NextcloudItem item) {
return NooFileTableRow(
kind: NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
),
name: item.name,
col2: _metaTime(item.lastModified),
col3: _location(item),
favorite: item.isFavorite,
onTap: () => _openFile(context, item),
onMore: () => _showItemSheet(context, item),
);
}
void _showItemSheet(BuildContext context, NextcloudItem item) {
showNooSheet(
context,
children: [
NooGroupedList(
children: [
NooSettingsRow(
icon: LucideIcons.info,
label: const Text('Details'),
onTap: () {
Navigator.pop(context);
DetailsSheet.show(context, item);
},
),
NooSettingsRow(
icon: LucideIcons.share2,
label: const Text('Share'),
onTap: () {
Navigator.pop(context);
ShareSheet.show(context, item);
},
),
],
),
],
);
}
/// Preserves the original screen's exact tap behavior: always opens the
/// media viewer (the server's recent-files search only ever returns
/// files, never folders, so there's no folder-navigation case to handle).
void _openFile(BuildContext context, NextcloudItem item) {
final recent = context.read<RecentController>();
final siblings = recent.items.where((i) => i.isMedia).toList();
@@ -250,3 +214,14 @@ class _RecentViewState extends State<RecentView> {
);
}
}
String _metaTime(DateTime dt) {
final now = DateTime.now();
if (dt.year == now.year && dt.month == now.month && dt.day == now.day) {
return DateFormat.jm().format(dt);
}
if (dt.year == now.year) return DateFormat.MMMd().format(dt);
return DateFormat.yMMMd().format(dt);
}
String _location(NextcloudItem item) => tabLocationLabel(item.path, item.name);
+236 -89
View File
@@ -1,14 +1,29 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:path/path.dart' as p;
import 'package:provider/provider.dart';
import '../models/app_tab.dart';
import '../models/nextcloud_item.dart';
import '../providers/files_controller.dart';
import '../providers/settings_controller.dart';
import '../theme/design_tokens.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/core/noo_search_field.dart';
import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/nav/noo_toolbar.dart';
import '../widgets/noo/noo_layout.dart';
import 'file_viewer_screen.dart';
/// Full-text file search, pushed from the shell's search entry point
/// (`openSearch` in `widgets/shell/shell_common.dart`). Debounces input,
/// then lists matches as file rows - tapping one jumps to the result on the
/// Files tab (see [_openResult]). Per DESIGN_SYSTEM.md §4 "Search".
class SearchView extends StatefulWidget {
const SearchView({super.key});
@@ -74,25 +89,6 @@ class _SearchViewState extends State<SearchView> {
}
}
IconData _iconFor(NextcloudItemType type) {
switch (type) {
case NextcloudItemType.folder:
return Icons.folder_rounded;
case NextcloudItemType.image:
return Icons.image_rounded;
case NextcloudItemType.video:
return Icons.movie_rounded;
case NextcloudItemType.audio:
return Icons.audiotrack_rounded;
case NextcloudItemType.document:
return Icons.description_rounded;
case NextcloudItemType.archive:
return Icons.folder_zip_rounded;
case NextcloudItemType.file:
return Icons.insert_drive_file_rounded;
}
}
/// Navigates the Files tab to [item] (its own folder if it's a folder,
/// its parent folder otherwise - rebuilding the whole breadcrumb trail
/// from root rather than assuming it's under wherever the user was
@@ -113,98 +109,249 @@ class _SearchViewState extends State<SearchView> {
}
}
void _clear() {
_controller.clear();
_focusNode.requestFocus();
_onChanged('');
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final desktop = NooLayout.isDesktop(context);
return Scaffold(
body: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: SearchBar(
controller: _controller,
focusNode: _focusNode,
autoFocus: true,
hintText: 'Search your files...',
elevation: const WidgetStatePropertyAll(0),
onChanged: _onChanged,
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.pop(context),
backgroundColor: colors.bg,
appBar: desktop
? NooToolbar(
titleWidget: _buildField(onSurface: true),
actions: [
IconTheme.merge(
data: IconThemeData(color: colors.fg1, size: 24),
child: NooTopBarButton(
icon: LucideIcons.x,
tooltip: 'Close',
onTap: () => Navigator.pop(context),
),
),
trailing: [
if (_controller.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () {
_controller.clear();
_onChanged('');
},
],
)
: null,
body: SafeArea(
top: !desktop,
child: desktop
? _buildBody(context, desktop)
: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.xs,
NooSpace.xs,
NooSpace.md,
NooSpace.xs,
),
child: Row(
children: [
IconTheme.merge(
data: IconThemeData(color: colors.fg1, size: 24),
child: NooTopBarButton(
icon: LucideIcons.arrowLeft,
tooltip: 'Back',
onTap: () => Navigator.pop(context),
),
),
Expanded(child: _buildField(onSurface: false)),
],
),
),
Expanded(child: _buildBody(context, desktop)),
],
),
),
);
}
Widget _buildField({required bool onSurface}) {
// The pill `NooSearchField`, not the inset-panel `NooTextField` - this
// is a search field (DESIGN_SYSTEM.md's "Search field" component,
// radius 999 outside the one iOS-inline exception), not a form input.
// `ios: false` matches the Android shape we're building against now;
// revisit once the iOS large-title/inline-field entry point is built.
return NooSearchField(
controller: _controller,
focusNode: _focusNode,
autofocus: true,
placeholder: 'Search files',
onChanged: _onChanged,
onSurface: onSurface,
ios: false,
trailing: _controller.text.isEmpty
? null
: _ClearFieldButton(onTap: _clear),
);
}
Widget _buildBody(BuildContext context, bool desktop) {
final colors = context.nooColors;
if (_isSearching) {
return Center(child: CircularProgressIndicator(color: colors.accent));
}
if (_error != null) {
return _buildMessage(
context,
icon: LucideIcons.circleAlert,
iconColor: colors.danger,
title: 'Couldn\'t search',
message: _error,
);
}
if (!_hasSearched) {
return _buildMessage(
context,
icon: LucideIcons.search,
iconColor: colors.fg3,
title: 'Search your files',
message: 'Find files and folders by name.',
);
}
if (_results.isEmpty) {
return _buildMessage(
context,
icon: LucideIcons.searchX,
iconColor: colors.fg3,
title: 'No results found',
);
}
return desktop ? _buildDesktopResults(context) : _buildMobileResults(context);
}
Widget _buildMessage(
BuildContext context, {
required IconData icon,
required Color iconColor,
required String title,
String? message,
}) {
final colors = context.nooColors;
return Center(
child: Padding(
padding: const EdgeInsets.all(NooSpace.xl),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 56, color: iconColor),
const SizedBox(height: NooSpace.sm),
Text(
title,
textAlign: TextAlign.center,
style: NooText.cardTitle.copyWith(color: colors.fg2),
),
Expanded(child: _buildBody(context, colorScheme, theme)),
if (message != null) ...[
const SizedBox(height: NooSpace.xxs),
Text(
message,
textAlign: TextAlign.center,
style: NooText.body.copyWith(color: colors.fg3),
),
],
],
),
),
);
}
Widget _buildBody(
BuildContext context,
ColorScheme colorScheme,
ThemeData theme,
) {
if (_isSearching) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
_error!,
textAlign: TextAlign.center,
style: TextStyle(color: colorScheme.error),
),
),
);
}
if (!_hasSearched) {
return const SizedBox.shrink();
}
if (_results.isEmpty) {
return Center(
child: Text(
'No results found',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
);
}
Widget _buildMobileResults(BuildContext context) {
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.fromLTRB(
NooSpace.sm,
NooSpace.xs,
NooSpace.sm,
NooSpace.xl,
),
physics: const BouncingScrollPhysics(),
itemCount: _results.length,
itemBuilder: (context, index) {
final item = _results[index];
return ListTile(
leading: Icon(_iconFor(item.type), color: colorScheme.primary),
title: Text(item.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
item.isFolder
? item.path
: DateFormat.yMMMd().format(item.lastModified),
maxLines: 1,
overflow: TextOverflow.ellipsis,
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card),
child: NooFileRow(
kind: NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
),
name: item.name,
meta: _mobileMeta(item),
favorite: item.isFavorite,
iosStyle: NooLayout.iosStyle(context),
onTap: () => _openResult(item),
),
),
onTap: () => _openResult(item),
);
},
);
}
Widget _buildDesktopResults(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.xl,
vertical: NooSpace.sm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const NooFileTableHeader(col2Label: 'Modified', col3Label: 'Location'),
for (final item in _results)
NooFileTableRow(
kind: NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
),
name: item.name,
col2: DateFormat.yMMMd().format(item.lastModified),
col3: item.isFolder ? item.path : p.dirname(item.path),
favorite: item.isFavorite,
onTap: () => _openResult(item),
),
],
),
);
}
/// Mirrors the pre-rework screen's exact copy: a folder result shows its
/// own full path (there's no separate "location" field to fall back to),
/// a file result shows when it was last modified.
String _mobileMeta(NextcloudItem item) =>
item.isFolder ? item.path : DateFormat.yMMMd().format(item.lastModified);
}
/// Small round "clear" button for [NooTextField.trailing] - clears the
/// query and puts focus back in the field, same as the old `SearchBar`'s
/// trailing close icon.
class _ClearFieldButton extends StatelessWidget {
final VoidCallback onTap;
const _ClearFieldButton({required this.onTap});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return SizedBox.square(
dimension: 32,
child: Material(
type: MaterialType.transparency,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Icon(LucideIcons.x, size: 16, color: colors.fg3),
),
),
);
}
}
+150 -349
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../models/app_tab.dart';
import '../models/nextcloud_item.dart';
@@ -9,24 +8,31 @@ import '../providers/session_controller.dart';
import '../providers/settings_controller.dart';
import '../services/share_intent_service.dart';
import '../services/upload_service.dart';
import '../theme/design_tokens.dart';
import '../widgets/breadcrumbs.dart';
import '../widgets/item_icon.dart';
import '../widgets/marquee_title.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/segmented_icon_toggle.dart';
import '../widgets/sort_menu_button.dart';
import '../widgets/sticky_header_delegate.dart';
import '../widgets/synced_header_scaffold.dart';
import '../widgets/noo/core/noo_button.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/nav/noo_toolbar.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/shell/shell_common.dart';
import '../widgets/tabs/tab_state_slivers.dart';
/// Shown when another app shares one or more files to Noo (Android's
/// "Share to..." sheet). Lets the user browse to a destination folder,
/// mirroring the Files tab's own controls/filters/listing so this feels
/// like the same browser rather than a stripped-down picker, then hands the
/// actual prepare+upload off to [UploadService] - a real Android foreground
/// reusing `FilesController`'s shared folder-browsing state so this feels
/// like the same browser Files itself uses, then hands the actual
/// prepare+upload off to [UploadService] - a real Android foreground
/// service (see `ShareUploadService.kt`'s doc comment), not something this
/// screen or even the app needs to stay open for. [files] only ever carry
/// cheap Uri metadata (see [ShareIntentService]'s doc comment); that
/// service is the only thing that ever reads their actual bytes.
/// service is the only thing that ever reads their actual bytes. Pushed
/// via `Navigator` from outside `MainShellView` (a cold share-intent
/// launch, or Files' own "+" -> "Upload file"), so - unlike a tab - it
/// builds its own complete top chrome rather than relying on the shell's.
class ShareUploadView extends StatefulWidget {
final List<SharedFileRef> files;
@@ -86,243 +92,48 @@ class _ShareUploadViewState extends State<ShareUploadView> {
);
}
Widget _buildControlsRow(FilesController files) {
return SizedBox(
height: 44,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
IconButton(
icon: Icon(
files.filesSortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: files.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: files.toggleFilesSortOrder,
),
SizedBox(
width: 130,
child: SortMenuButton(
field: files.filesSortField,
onChanged: files.setFilesSortField,
),
),
ToggleIconButton(
icon: files.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: files.showHiddenFiles,
onTap: () => files.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: files.storageScope == StorageScope.cloud,
onTap: () => files.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: files.storageScope == StorageScope.external,
onTap: () => files.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
),
const SizedBox(width: 8),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.select_all_rounded,
isSelected: files.filesTypeFilter == FilesTypeFilter.all,
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all),
tooltip: 'Files & folders',
),
ToggleIconButton(
icon: Icons.insert_drive_file_outlined,
isSelected:
files.filesTypeFilter == FilesTypeFilter.filesOnly,
onTap: () =>
files.setFilesTypeFilter(FilesTypeFilter.filesOnly),
tooltip: 'Files only',
),
ToggleIconButton(
icon: Icons.folder_outlined,
isSelected:
files.filesTypeFilter == FilesTypeFilter.foldersOnly,
onTap: () =>
files.setFilesTypeFilter(FilesTypeFilter.foldersOnly),
tooltip: 'Folders only',
),
],
),
const SizedBox(width: 8),
SegmentedIconGroup(
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !files.isGridView,
onTap: () => files.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: files.isGridView,
onTap: () => files.setGridView(true),
tooltip: 'Grid view',
),
],
),
],
// Only folders are valid upload destinations, so the listing - unlike
// Files' own - never shows plain files at all.
Widget _buildRow(BuildContext context, NextcloudItem item, int index, int count) {
final colors = context.nooColors;
final files = context.read<FilesController>();
final row = NooFileRow(
kind: NooFileKind.folder,
name: item.name,
meta: 'Folder',
iosStyle: NooLayout.iosStyle(context),
onTap: () => files.navigateToFolder(item.path),
);
final isFirst = index == 0;
final isLast = index == count - 1;
return Column(
children: [
ClipRRect(
borderRadius: BorderRadius.vertical(
top: isFirst ? const Radius.circular(NooRadii.card) : Radius.zero,
bottom: isLast ? const Radius.circular(NooRadii.card) : Radius.zero,
),
child: row,
),
),
if (!isLast) Container(height: 1, color: colors.line),
],
);
}
// Only folders are valid upload destinations - files still show (so the
// listing matches what the Files tab itself would show for this folder)
// but are visually dimmed and inert rather than hidden outright.
Widget _buildListTile(BuildContext context, NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
Widget _buildDesktopRow(BuildContext context, NextcloudItem item) {
final files = context.read<FilesController>();
final session = context.watch<SessionController>();
final isFolder = item.isFolder;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Opacity(
opacity: isFolder ? 1 : 0.5,
child: Material(
color: colorScheme.surfaceContainerLow,
child: InkWell(
onTap: isFolder ? () => files.navigateToFolder(item.path) : null,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
ItemThumbnail(
item: item,
service: session.service,
size: 44,
borderRadius: 12,
iconSize: 22,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
isFolder
? 'Folder'
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (isFolder) const Icon(Icons.chevron_right_rounded),
],
),
),
),
),
),
),
);
}
Widget _buildGridCard(BuildContext context, NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final files = context.read<FilesController>();
final isFolder = item.isFolder;
final iconColor = getIconColor(context, item.type);
return Opacity(
opacity: isFolder ? 1 : 0.5,
child: Material(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: isFolder ? () => files.navigateToFolder(item.path) : null,
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
getItemIcon(item.type),
color: iconColor,
size: 24,
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
const SizedBox(height: 2),
Text(
isFolder ? 'Folder' : formatBytes(item.size),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
],
),
),
),
),
return NooFileTableRow(
kind: NooFileKind.folder,
name: item.name,
onTap: () => files.navigateToFolder(item.path),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final desktop = NooLayout.isDesktop(context);
final files = context.watch<FilesController>();
final session = context.watch<SessionController>();
final hasBreadcrumbs = files.pathStack.length > 1;
@@ -330,131 +141,124 @@ class _ShareUploadViewState extends State<ShareUploadView> {
final currentLabel = currentPath == '/'
? 'Home'
: currentPath.split('/').where((s) => s.isNotEmpty).last;
final items = files.items;
final folders = files.items.where((item) => item.isFolder).toList();
final gutter = NooLayout.gutter(context);
// Mirrors FilesView's own controls-row + breadcrumbs sticky header
// exactly (padding, heights) so this reads as the same browser, just
// reached from a share intent instead of the Files tab.
final controlsColumn = Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildControlsRow(files),
if (hasBreadcrumbs) ...[
const SizedBox(height: 10),
SizedBox(
height: 32,
child: Breadcrumbs(
pathStack: files.pathStack,
onTap: (index) => files.navigateToPathIndex(index),
),
),
],
],
),
);
final contentSlivers = <Widget>[
SliverPersistentHeader(
pinned: true,
delegate: StickyHeaderDelegate(
height: hasBreadcrumbs ? 114 : 72,
child: controlsColumn,
final slivers = <Widget>[
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(gutter, NooSpace.md, gutter, NooSpace.sm),
child: hasBreadcrumbs
? SizedBox(
height: 32,
child: Breadcrumbs(
pathStack: files.pathStack,
onTap: (index) => files.navigateToPathIndex(index),
),
)
: const SizedBox.shrink(),
),
),
if (files.isLoading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
tabLoadingSliver
else if (folders.isEmpty)
tabEmptySliver(
context,
icon: LucideIcons.folder,
message: 'No folders here',
)
else if (items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
'Folder is empty',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
)
else if (files.isGridView)
else if (desktop)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.1,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildDesktopRow(context, folders[index]),
childCount: folders.length,
),
delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(context, items[index]);
}, childCount: items.length),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return _buildListTile(context, items[index]);
}, childCount: items.length),
delegate: SliverChildBuilderDelegate(
(context, index) =>
_buildRow(context, folders[index], index, folders.length),
childCount: folders.length,
),
),
),
// So the last row isn't hidden behind the bottom "Upload to..." bar.
const SliverToBoxAdapter(child: SizedBox(height: 24)),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.xl)),
];
final title = 'Upload to...';
return Scaffold(
body: SyncedHeaderScaffold(
scrollController: _scrollController,
// Same trailing actions as every other tab except "More tabs" -
// there's nowhere useful for it to go while mid-upload (jumping to
// Trash/Shares/etc. would abandon this destination picker), so the
// top chrome is identical to every other tab minus that one entry.
// Backing out is still the system back gesture/button, same as any
// other pushed screen.
actions: const [ProfileAvatarButton()],
contentSlivers: contentSlivers,
backgroundColor: colors.bg,
appBar: desktop
? NooToolbar(
title: title,
actions: [
NooButton(
variant: NooButtonVariant.secondary,
size: NooButtonSize.toolbar,
onTap: () => Navigator.of(context).maybePop(),
child: const Text('Cancel'),
),
],
)
: NooTopBar(
style: NooLayout.navStyle(context),
title: title,
leading: const NooTopBarBack(label: 'Cancel'),
// Same top-bar access as every tab (avatar -> Settings, swipe
// to switch accounts) - nothing else belongs here: jumping to
// another tab mid-upload would abandon this destination
// picker, so there's no "more tabs" entry.
actions: const [ShellAvatarButton()],
),
body: SafeArea(
top: false,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: files.refreshData,
child: CustomScrollView(controller: _scrollController, slivers: slivers),
),
),
// A rounded-top, elevated bar "peeking" up from the bottom edge - the
// uploading-file summary (marqueed if it doesn't fit on one line)
// sits directly above the destination button, both inside the one
// sheet, rather than the summary living up in the scrolling content
// far away from the action it describes.
bottomNavigationBar: Material(
color: colorScheme.surfaceContainerHigh,
elevation: 8,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
// A flat surface bar with a top line, peeking up from the bottom edge -
// the uploading-file summary (marqueed if it doesn't fit on one line)
// sits directly above the destination button.
bottomNavigationBar: DecoratedBox(
decoration: BoxDecoration(
color: colors.surface,
border: Border(top: BorderSide(color: colors.line)),
),
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
padding: const EdgeInsets.fromLTRB(
NooSpace.lg,
NooSpace.md,
NooSpace.lg,
NooSpace.md,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Same pill/chip look and exact same duller background
// color as the filter toggles' shared background
// (SegmentedIconGroup's `surfaceContainerHigh`), so it reads
// as part of the same visual language rather than a new
// accent color. Sized to the text itself (like a real chip)
// up to the row's available width - MarqueeTitle needs a
// concrete (not just loose) width to know whether/how far
// to scroll, so this measures the text once up front rather
// than leaving the chip unconstrained.
// Sized to the text itself (like a real chip) up to the
// row's available width - MarqueeTitle needs a concrete
// (not just loose) width to know whether/how far to scroll,
// so this measures the text once up front rather than
// leaving the chip unconstrained.
LayoutBuilder(
builder: (context, constraints) {
final uploadingText = widget.files.length == 1
? 'Uploading ${widget.files.first.name}'
: 'Uploading ${widget.files.length} files';
final chipTextStyle = theme.textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
final chipTextStyle = NooText.label.copyWith(
color: colors.fg2,
);
const horizontalPadding = 28.0;
final painter = TextPainter(
@@ -470,31 +274,28 @@ class _ShareUploadViewState extends State<ShareUploadView> {
return Container(
width: chipWidth,
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
horizontal: NooSpace.smd,
vertical: NooSpace.xs,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
color: colors.surface2,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
child: SizedBox(
height: 20,
child: MarqueeTitle(
text: uploadingText,
style: chipTextStyle,
),
child: MarqueeTitle(text: uploadingText, style: chipTextStyle),
),
);
},
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => _uploadHere(session),
icon: const Icon(Icons.upload_rounded),
label: Text('Upload to $currentLabel'),
),
const SizedBox(height: NooSpace.sm),
NooButton(
variant: NooButtonVariant.primary,
size: NooButtonSize.cta,
fullWidth: true,
icon: LucideIcons.upload,
onTap: () => _uploadHere(session),
child: Text('Upload to $currentLabel'),
),
],
),
+158 -207
View File
@@ -1,49 +1,55 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/nextcloud_item.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/app_tab.dart';
import '../models/nextcloud_share.dart';
import '../providers/files_controller.dart';
import '../providers/settings_controller.dart';
import '../providers/shares_controller.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart';
import '../theme/design_tokens.dart';
import '../widgets/noo/core/noo_segmented_control.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/tabs/tab_state_slivers.dart';
IconData _shareItemIcon(NextcloudItemType type) {
switch (type) {
case NextcloudItemType.folder:
return Icons.folder_rounded;
case NextcloudItemType.image:
return Icons.image_rounded;
case NextcloudItemType.video:
return Icons.movie_rounded;
case NextcloudItemType.audio:
return Icons.audiotrack_rounded;
case NextcloudItemType.document:
return Icons.description_rounded;
case NextcloudItemType.archive:
return Icons.folder_zip_rounded;
case NextcloudItemType.file:
return Icons.insert_drive_file_rounded;
}
}
/// Which slice of [SharesController.shares] is on screen - `With you` maps
/// straight onto the controller's own `sharedWithMe: true` fetch, but the
/// controller only ever exposes one boolean scope, so `By you` and `Links`
/// both use its `sharedWithMe: false` fetch and are told apart client-side
/// by [NextcloudShare.shareType] (see `_visibleShares`) - `Links` and
/// `By you` never trigger a re-fetch when switching between each other,
/// only when crossing to/from `With you`.
enum _ShareScope { withYou, byYou, links }
IconData _shareTypeIcon(ShareType type) {
switch (type) {
case ShareType.user:
return Icons.person_rounded;
return LucideIcons.user;
case ShareType.group:
return Icons.groups_rounded;
return LucideIcons.users;
case ShareType.publicLink:
return Icons.link_rounded;
return LucideIcons.link;
case ShareType.email:
return Icons.email_rounded;
return LucideIcons.mail;
case ShareType.federated:
return Icons.public_rounded;
return LucideIcons.globe;
case ShareType.other:
return Icons.share_rounded;
return LucideIcons.share2;
}
}
/// "Can edit" when any write bit (update/create/delete) is set on the OCS
/// share permissions bitmask, "Can view" otherwise - kept to the two
/// broad buckets the row's one-line meta has room for.
String _permissionLabel(int permissions) {
const update = 2, create = 4, delete = 8;
final canWrite = permissions & (update | create | delete) != 0;
return canWrite ? 'Can edit' : 'Can view';
}
class SharesView extends StatefulWidget {
final ScrollController scrollController;
@@ -55,11 +61,13 @@ class SharesView extends StatefulWidget {
class _SharesViewState extends State<SharesView> {
bool _requested = false;
// Mirrors `SharesController.sharedWithMe`'s own default (false, "shared
// by me") so the first fetch this triggers matches what's shown.
_ShareScope _scope = _ShareScope.byYou;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final sharesController = context.watch<SharesController>();
if (!_requested) {
@@ -69,202 +77,147 @@ class _SharesViewState extends State<SharesView> {
);
}
final shares = sharesController.shares;
final shares = _visibleShares(sharesController);
final isDesktop = NooLayout.isDesktop(context);
final List<Widget> contentSlivers = [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: SegmentedButton<bool>(
segments: const [
ButtonSegment(value: false, label: Text('Shared by me')),
ButtonSegment(value: true, label: Text('Shared with me')),
SliverPadding(
padding: EdgeInsets.fromLTRB(
NooLayout.gutter(context),
NooSpace.md,
NooLayout.gutter(context),
0,
),
sliver: SliverToBoxAdapter(
child: NooSegmentedControl<_ShareScope>(
value: _scope,
options: const [
NooSegmentOption(value: _ShareScope.withYou, label: 'With you'),
NooSegmentOption(value: _ShareScope.byYou, label: 'By you'),
NooSegmentOption(value: _ShareScope.links, label: 'Links'),
],
selected: {sharesController.sharedWithMe},
onSelectionChanged: (set) =>
sharesController.setSharedWithMe(set.first),
onChanged: (scope) => _selectScope(sharesController, scope),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
if (sharesController.isLoading && shares.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
tabLoadingSliver
else if (sharesController.errorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Could not load shares',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
sharesController.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: sharesController.fetchAll,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
],
),
),
),
tabErrorSliver(
context,
title: 'Could not load shares',
message: sharesController.errorMessage!,
onRetry: sharesController.fetchAll,
)
else if (shares.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.groups_rounded,
size: 64,
color: colorScheme.outlineVariant,
),
const SizedBox(height: 12),
Text(
sharesController.sharedWithMe
? 'Nothing has been shared with you'
: 'You haven\'t shared anything yet',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
)
tabEmptySliver(context, icon: LucideIcons.share2, message: _emptyMessage())
else if (isDesktop)
_buildDesktopTable(context, shares)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final share = shares[index];
return _buildShareTile(context, share);
}, childCount: shares.length),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
_buildMobileList(context, shares),
...tabBottomInsetSlivers,
];
return SyncedHeaderScaffold(
scrollController: widget.scrollController,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: sharesController.fetchAll,
contentSlivers: contentSlivers,
return ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: sharesController.fetchAll,
child: CustomScrollView(
controller: widget.scrollController,
slivers: contentSlivers,
),
),
);
}
Widget _buildShareTile(BuildContext context, NextcloudShare share) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final iconColor = colorScheme.primary;
List<NextcloudShare> _visibleShares(SharesController c) {
switch (_scope) {
case _ShareScope.withYou:
return c.shares;
case _ShareScope.byYou:
return c.shares.where((s) => s.shareType != ShareType.publicLink).toList();
case _ShareScope.links:
return c.shares.where((s) => s.shareType == ShareType.publicLink).toList();
}
}
final withWhom = share.sharedWithMe
? 'From ${share.ownerDisplayName}'
: (share.sharedWithDisplayName != null
? 'With ${share.sharedWithDisplayName}'
: 'Shared link');
String _emptyMessage() {
switch (_scope) {
case _ShareScope.withYou:
return 'Nothing has been shared with you';
case _ShareScope.byYou:
return "You haven't shared anything yet";
case _ShareScope.links:
return 'No share links yet';
}
}
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_shareItemIcon(share.itemType),
color: iconColor,
size: 22,
),
void _selectScope(SharesController c, _ShareScope scope) {
setState(() => _scope = scope);
// No-ops (see `SharesController.setSharedWithMe`) when moving between
// `byYou`/`links`, which share the same `sharedWithMe: false` fetch.
c.setSharedWithMe(scope == _ShareScope.withYou);
}
Widget _buildMobileList(BuildContext context, List<NextcloudShare> shares) {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
sliver: SliverToBoxAdapter(
child: NooGroupedList(
children: [
for (final share in shares)
NooFileRow(
kind: NooFileKind.from(name: share.name, isDirectory: share.isFolder),
name: share.name,
meta: '${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}',
iosStyle: NooLayout.iosStyle(context),
onTap: share.isFolder ? () => _openFolder(context, share) : null,
trailing: Icon(_shareTypeIcon(share.shareType), size: 16),
onMore: () => _confirmUnshare(context, share),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
share.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
_shareTypeIcon(share.shareType),
size: 13,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
'$withWhom • ${DateFormat.yMMMd().format(share.sharedAt)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
],
),
),
IconButton(
icon: Icon(Icons.link_off_rounded, color: colorScheme.error),
tooltip: share.sharedWithMe ? 'Remove' : 'Unshare',
onPressed: () => _confirmUnshare(context, share),
),
],
),
],
),
),
);
}
Widget _buildDesktopTable(BuildContext context, List<NextcloudShare> shares) {
return SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.xl,
vertical: NooSpace.xs,
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == 0) {
return const NooFileTableHeader(col2Label: 'Owner', col3Label: 'Permission');
}
final share = shares[index - 1];
return NooFileTableRow(
kind: NooFileKind.from(name: share.name, isDirectory: share.isFolder),
name: share.name,
col2: share.ownerDisplayName,
col3: _permissionLabel(share.permissions),
onTap: share.isFolder ? () => _openFolder(context, share) : null,
onMore: () => _confirmUnshare(context, share),
);
}, childCount: shares.length + 1),
),
);
}
/// Only folders are safely tappable - unlike Recent/Favorites, a share
/// doesn't carry the size/mime-type `FileViewerScreen` needs, so opening
/// a file share here would have to synthesize that data instead of
/// reading it, same as the original screen (no tap action at all).
void _openFolder(BuildContext context, NextcloudShare share) {
context.read<FilesController>().navigateToAbsoluteFolder(share.path);
context.read<SettingsController>().requestTab(AppTab.files);
}
Future<void> _confirmUnshare(
BuildContext context,
NextcloudShare share,
@@ -273,7 +226,7 @@ class _SharesViewState extends State<SharesView> {
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(share.sharedWithMe ? 'Remove Share' : 'Unshare'),
title: Text(share.sharedWithMe ? 'Remove share' : 'Unshare'),
content: Text(
share.sharedWithMe
? 'Remove "${share.name}" shared with you by ${share.ownerDisplayName}?'
@@ -298,9 +251,7 @@ class _SharesViewState extends State<SharesView> {
if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
final success = await context.read<SharesController>().deleteShare(
share,
);
final success = await context.read<SharesController>().deleteShare(share);
messenger.showSnackBar(
SnackBar(
content: Text(
+276 -185
View File
@@ -1,31 +1,28 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/nextcloud_item.dart';
import '../providers/trash_controller.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart';
IconData _trashItemIcon(NextcloudItemType type) {
switch (type) {
case NextcloudItemType.folder:
return Icons.folder_rounded;
case NextcloudItemType.image:
return Icons.image_rounded;
case NextcloudItemType.video:
return Icons.movie_rounded;
case NextcloudItemType.audio:
return Icons.audiotrack_rounded;
case NextcloudItemType.document:
return Icons.description_rounded;
case NextcloudItemType.archive:
return Icons.folder_zip_rounded;
case NextcloudItemType.file:
return Icons.insert_drive_file_rounded;
}
}
import '../theme/design_tokens.dart';
import '../widgets/noo/core/noo_button.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/files/noo_file_tile.dart';
import '../widgets/noo/lists/noo_banner.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/tabs/tab_location.dart';
import '../widgets/tabs/tab_state_slivers.dart';
/// The Trash tab: a retention banner (`NooBanner`, with "Empty trash" as
/// its danger action) then the trashed-items list, per `DESIGN_SYSTEM.md`
/// §4. There's no dedicated "empty trash" endpoint anywhere in
/// [TrashController]/`NextcloudService` - [_emptyTrash] composes it from
/// the existing per-item [TrashController.deleteForever], the same way
/// `FavoritesView._confirmDeleteSelected` builds its bulk delete from a
/// per-item call.
class TrashView extends StatefulWidget {
final ScrollController scrollController;
@@ -40,8 +37,7 @@ class _TrashViewState extends State<TrashView> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final colors = context.nooColors;
final trashController = context.watch<TrashController>();
if (!_requested) {
@@ -52,176 +48,135 @@ class _TrashViewState extends State<TrashView> {
}
final trash = trashController.items;
final isDesktop = NooLayout.isDesktop(context);
final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: 16)),
if (trashController.isLoading && trash.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (trashController.errorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Could not load trash',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
trashController.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: trashController.fetchAll,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
],
),
),
),
)
else if (trash.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.delete_outline_rounded,
size: 64,
color: colorScheme.outlineVariant,
),
const SizedBox(height: 12),
Text(
'Trash is empty',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = trash[index];
return _buildTrashTile(context, item);
}, childCount: trash.length),
SliverPadding(
padding: EdgeInsets.fromLTRB(
NooLayout.gutter(context),
NooSpace.md,
NooLayout.gutter(context),
0,
),
sliver: SliverToBoxAdapter(
child: NooBanner(
actionLabel: trash.isEmpty ? null : 'Empty trash',
onAction: trash.isEmpty ? null : () => _confirmEmptyTrash(context, trash),
child: const Text('Deleted items are kept for 30 days, then removed automatically.'),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 100)),
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
if (trashController.isLoading && trash.isEmpty)
tabLoadingSliver
else if (trashController.errorMessage != null)
tabErrorSliver(
context,
title: 'Could not load trash',
message: trashController.errorMessage!,
onRetry: trashController.fetchAll,
)
else if (trash.isEmpty)
tabEmptySliver(
context,
icon: LucideIcons.trash2,
message: 'Trash is empty',
)
else if (isDesktop)
_buildDesktopTable(context, trash)
else
_buildMobileList(context, trash),
...tabBottomInsetSlivers,
];
return SyncedHeaderScaffold(
scrollController: widget.scrollController,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: trashController.fetchAll,
contentSlivers: contentSlivers,
);
}
Widget _buildTrashTile(BuildContext context, NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final iconColor = colorScheme.outline;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_trashItemIcon(item.type),
color: iconColor,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
item.deletedAt != null
? 'Deleted ${DateFormat.yMMMd().format(item.deletedAt!)}'
: 'Deleted',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.restore_rounded),
tooltip: 'Restore',
color: colorScheme.primary,
onPressed: () => _restore(context, item),
),
IconButton(
icon: Icon(
Icons.delete_forever_rounded,
color: colorScheme.error,
),
tooltip: 'Delete forever',
onPressed: () => _confirmDeleteForever(context, item),
),
],
),
return ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: trashController.fetchAll,
child: CustomScrollView(
controller: widget.scrollController,
slivers: contentSlivers,
),
),
);
}
Widget _buildMobileList(BuildContext context, List<NextcloudItem> trash) {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
sliver: SliverToBoxAdapter(
child: NooGroupedList(
children: [
for (final item in trash)
NooFileRow(
kind: NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
),
name: item.name,
meta: _meta(item),
iosStyle: NooLayout.iosStyle(context),
trailing: SizedBox.square(
dimension: 40,
child: Semantics(
button: true,
label: 'Restore',
child: InkResponse(
onTap: () => _restore(context, item),
radius: 20,
child: const Icon(LucideIcons.rotateCcw),
),
),
),
onMore: () => _confirmDeleteForever(context, item),
),
],
),
),
);
}
Widget _buildDesktopTable(BuildContext context, List<NextcloudItem> trash) {
return SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.xl,
vertical: NooSpace.xs,
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index == 0) {
return const NooFileTableHeader(
col2Label: 'Deleted',
col3Label: 'Original location',
);
}
final item = trash[index - 1];
return _TrashDesktopRow(
kind: NooFileKind.from(
name: item.name,
mimeType: item.mimeType,
isDirectory: item.isFolder,
),
name: item.name,
col2: _deletedLabel(item),
col3: item.originalLocation ?? 'Unknown location',
onRestore: () => _restore(context, item),
onMore: () => _confirmDeleteForever(context, item),
);
}, childCount: trash.length + 1),
),
);
}
String _meta(NextcloudItem item) =>
'Deleted ${_deletedLabel(item)} · ${tabLocationLabel(item.originalLocation ?? item.path, item.name)}';
String _deletedLabel(NextcloudItem item) =>
item.deletedAt != null ? DateFormat.yMMMd().format(item.deletedAt!) : 'recently';
Future<void> _restore(BuildContext context, NextcloudItem item) async {
final messenger = ScaffoldMessenger.of(context);
final success = await context.read<TrashController>().restore(item);
@@ -243,7 +198,7 @@ class _TrashViewState extends State<TrashView> {
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Delete Forever'),
title: const Text('Delete forever'),
content: Text(
'Permanently delete "${item.name}"? This cannot be undone.',
),
@@ -257,7 +212,7 @@ class _TrashViewState extends State<TrashView> {
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Delete Forever'),
child: const Text('Delete forever'),
),
],
);
@@ -276,4 +231,140 @@ class _TrashViewState extends State<TrashView> {
),
);
}
Future<void> _confirmEmptyTrash(
BuildContext context,
List<NextcloudItem> trash,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Empty trash'),
content: Text(
'Permanently delete all ${trash.length} item(s) in trash? This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Empty trash'),
),
],
);
},
);
if (confirmed != true || !context.mounted) return;
final controller = context.read<TrashController>();
final messenger = ScaffoldMessenger.of(context);
var succeeded = 0;
for (final item in trash) {
if (await controller.deleteForever(item)) succeeded++;
}
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text('Deleted $succeeded of ${trash.length} item(s)'),
behavior: SnackBarBehavior.floating,
),
);
}
}
/// Desktop trash row: like `NooFileTableRow`, but its 120px last column
/// holds a tonal "Restore" button plus the overflow button (permanent
/// delete) instead of status icons - `NooFileTableRow` has no slot for
/// screen-specific actions there. Column widths (180/160/120) are matched
/// by hand to `NooFileTableHeader`'s (private in `noo_file_table.dart`) so
/// this still lines up under it; promoting an optional `actions` slot onto
/// `NooFileTableRow` would let this fold back into the shared component.
class _TrashDesktopRow extends StatelessWidget {
final NooFileKind kind;
final String name;
final String col2;
final String col3;
final VoidCallback onRestore;
final VoidCallback onMore;
const _TrashDesktopRow({
required this.kind,
required this.name,
required this.col2,
required this.col3,
required this.onRestore,
required this.onMore,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final metaStyle = NooText.meta.copyWith(color: colors.fg3);
return SizedBox(
height: NooSizes.rowDesktop,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
child: Row(
children: [
Expanded(
child: Row(
children: [
NooFileTile(kind: kind, size: NooFileTileSize.desktop),
const SizedBox(width: NooSpace.sm),
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.body.copyWith(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colors.fg1,
),
),
),
const SizedBox(width: NooSpace.md),
],
),
),
SizedBox(
width: 180,
child: Text(col2, maxLines: 1, overflow: TextOverflow.ellipsis, style: metaStyle),
),
SizedBox(
width: 160,
child: Text(col3, maxLines: 1, overflow: TextOverflow.ellipsis, style: metaStyle),
),
SizedBox(
width: 120,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
NooButton(
variant: NooButtonVariant.tonal,
size: NooButtonSize.xs,
onTap: onRestore,
child: const Text('Restore'),
),
const SizedBox(width: 4),
NooOverflowButton(size: 24, onTap: onMore),
],
),
),
),
],
),
),
);
}
}