- Every tab now wraps its CustomScrollView in SafeArea(top: true) - the pinned sort/filter row was riding up underneath the status bar once the floating top bar above it fully collapsed, since that bar's own internal SafeArea only reserved the inset while it had some height left to put it in. Reserving it outside the scrolling region entirely fixes this regardless of the floating header's state. Caught live on device and backed by a new regression test. - The avatar menu now covers the top bar (title included) instead of sitting below it - positioned off just the status-bar inset rather than the top bar's full height on top of that. - The avatar menu's card now carries a second, tighter contact shadow alongside nooDialogShadow - that shadow alone was essentially invisible on a small card over a dark theme's near-black background. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
248 lines
7.9 KiB
Dart
248 lines
7.9 KiB
Dart
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 '../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';
|
|
|
|
/// Recently modified files across the whole account (not folders), newest
|
|
/// 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;
|
|
|
|
/// This tab's own shell top bar, planted as its first sliver - see
|
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
|
final PreferredSizeWidget? topBar;
|
|
|
|
const RecentView({super.key, required this.scrollController, this.topBar});
|
|
|
|
@override
|
|
State<RecentView> createState() => _RecentViewState();
|
|
}
|
|
|
|
class _RecentViewState extends State<RecentView> {
|
|
bool _requested = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.nooColors;
|
|
final recent = context.watch<RecentController>();
|
|
|
|
if (!_requested) {
|
|
_requested = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => recent.fetchAll());
|
|
}
|
|
|
|
final items = recent.items;
|
|
final isDesktop = NooLayout.isDesktop(context);
|
|
final groups = groupByRecentBucket<NextcloudItem>(
|
|
items,
|
|
(item) => item.lastModified,
|
|
);
|
|
|
|
final List<Widget> contentSlivers = [
|
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
|
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
|
|
if (recent.isLoading && items.isEmpty)
|
|
tabLoadingSliver
|
|
else if (recent.errorMessage != null)
|
|
tabErrorSliver(
|
|
context,
|
|
title: 'Could not load recent files',
|
|
message: recent.errorMessage!,
|
|
onRetry: recent.fetchAll,
|
|
)
|
|
else if (items.isEmpty)
|
|
tabEmptySliver(
|
|
context,
|
|
icon: LucideIcons.clock,
|
|
message: 'No recent files',
|
|
)
|
|
else if (isDesktop)
|
|
_buildDesktopTable(context, groups)
|
|
else
|
|
_buildMobileGroups(context, groups),
|
|
...tabBottomInsetSlivers(context),
|
|
];
|
|
|
|
return ColoredBox(
|
|
color: colors.bg,
|
|
child: RefreshIndicator(
|
|
color: colors.accent,
|
|
backgroundColor: colors.surface,
|
|
onRefresh: recent.fetchAll,
|
|
// See files_view.dart's identical fix - without this, the sticky
|
|
// controls row rides up under the status bar once the floating top
|
|
// bar above it fully collapses.
|
|
child: SafeArea(
|
|
top: true,
|
|
bottom: false,
|
|
child: CustomScrollView(
|
|
controller: widget.scrollController,
|
|
// See files_view.dart's identical fix - without this, pull-to-
|
|
// refresh can't be triggered on an empty or single-item list.
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
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();
|
|
Navigator.push(
|
|
context,
|
|
FileViewerScreen.route(item: item, siblings: siblings),
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|