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:
@@ -1,11 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/nextcloud_item.dart';
|
||||
import '../../providers/item_operations.dart';
|
||||
import '../../providers/session_controller.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/lists/noo_grouped_list.dart';
|
||||
import '../noo/media/noo_activity_item.dart';
|
||||
import '../noo/media/noo_photo_tile.dart';
|
||||
|
||||
/// Per-file activity feed — same list-item look as the account-wide
|
||||
/// Activity tab (activity_view.dart), scoped to a single item.
|
||||
/// Per-file activity feed - the same actor/verb/tail mapping and
|
||||
/// avatar-palette convention as the account-wide Activity tab
|
||||
/// (`activity_view.dart`), scoped to a single item.
|
||||
class DetailsActivityTab extends StatefulWidget {
|
||||
final NextcloudItem item;
|
||||
|
||||
@@ -36,107 +43,52 @@ class _DetailsActivityTabState extends State<DetailsActivityTab> {
|
||||
_requested = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final colors = context.nooColors;
|
||||
final session = context.watch<SessionController>();
|
||||
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (_activities.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No recent activity',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.activity, size: 40, color: colors.fg3),
|
||||
const SizedBox(height: NooSpace.sm),
|
||||
Text(
|
||||
'No recent activity',
|
||||
style: NooText.cardTitle.copyWith(fontSize: 16, color: colors.fg2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||
itemCount: _activities.length,
|
||||
itemBuilder: (context, index) {
|
||||
final act = _activities[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
act.icon,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
act.title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
DateFormat.jm().format(act.timestamp),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
act.subject,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
return NooGroupedList(
|
||||
children: [
|
||||
for (final act in _activities)
|
||||
ColoredBox(
|
||||
color: colors.surface,
|
||||
child: NooActivityItem(
|
||||
actor: act.author,
|
||||
verb: act.title,
|
||||
tail: act.subject.isNotEmpty && act.subject != act.title
|
||||
? act.subject
|
||||
: null,
|
||||
time: DateFormat.yMMMd().add_jm().format(act.timestamp),
|
||||
avatarColor: NooPhotoTile.paletteColor(act.author.hashCode),
|
||||
currentUser:
|
||||
act.author.toLowerCase() == session.username.toLowerCase(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../models/nextcloud_item.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/lists/noo_grouped_list.dart';
|
||||
import '../synced_header_scaffold.dart' show formatBytes;
|
||||
|
||||
/// Plain metadata tab of the Details sheet — name, type, size, location,
|
||||
/// and dates. No network calls, so no loading state needed.
|
||||
/// Plain metadata list of the Details sheet/dialog - name, type, size,
|
||||
/// location, and dates. No network calls, so no loading state needed.
|
||||
class DetailsInfoTab extends StatelessWidget {
|
||||
final NextcloudItem item;
|
||||
final ScrollController? scrollController;
|
||||
|
||||
const DetailsInfoTab({super.key, required this.item, this.scrollController});
|
||||
const DetailsInfoTab({super.key, required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
final rows = <(String, String)>[
|
||||
('Name', item.name),
|
||||
('Type', item.isFolder ? 'Folder' : (item.mimeType ?? 'File')),
|
||||
@@ -26,39 +24,51 @@ class DetailsInfoTab extends StatelessWidget {
|
||||
('Favorite', item.isFavorite ? 'Yes' : 'No'),
|
||||
];
|
||||
|
||||
return ListView.separated(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||
itemCount: rows.length,
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final (label, value) = rows[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
return NooGroupedList(
|
||||
children: [for (final row in rows) _InfoRow(label: row.$1, value: row.$2)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return ColoredBox(
|
||||
color: colors.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: NooSpace.md,
|
||||
vertical: NooSpace.sm,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: Text(
|
||||
label,
|
||||
style: NooText.body.copyWith(fontSize: 14, color: colors.fg3),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: NooText.body.copyWith(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.fg1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,160 +1,149 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import '../../models/nextcloud_item.dart';
|
||||
import '../gradual_bottom_sheet.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/core/noo_segmented_control.dart';
|
||||
import '../noo/files/noo_file_kind.dart';
|
||||
import '../noo/files/noo_file_tile.dart';
|
||||
import '../noo/noo_layout.dart';
|
||||
import '../noo/overlays/noo_dialog.dart';
|
||||
import '../noo/overlays/noo_overlay_header.dart';
|
||||
import '../noo/overlays/noo_sheet.dart';
|
||||
import '../synced_header_scaffold.dart' show formatBytes;
|
||||
import 'details_activity_tab.dart';
|
||||
import 'details_info_tab.dart';
|
||||
import 'details_versions_tab.dart';
|
||||
|
||||
IconData _detailsItemIcon(NextcloudItemType type) {
|
||||
switch (type) {
|
||||
case NextcloudItemType.folder:
|
||||
return Icons.folder_rounded;
|
||||
case NextcloudItemType.image:
|
||||
return Icons.image_rounded;
|
||||
case NextcloudItemType.video:
|
||||
return Icons.movie_rounded;
|
||||
case NextcloudItemType.audio:
|
||||
return Icons.audiotrack_rounded;
|
||||
case NextcloudItemType.document:
|
||||
return Icons.description_rounded;
|
||||
case NextcloudItemType.archive:
|
||||
return Icons.folder_zip_rounded;
|
||||
case NextcloudItemType.file:
|
||||
return Icons.insert_drive_file_rounded;
|
||||
}
|
||||
/// The file-type tile every per-item sheet/dialog opens on
|
||||
/// (`DESIGN_SYSTEM.md` §1.2/§4) - shared by [DetailsSheet] and [ShareSheet]
|
||||
/// so both overlays for the same item use the same header tile.
|
||||
Widget detailsFileTile(
|
||||
NextcloudItem item, {
|
||||
NooFileTileSize size = NooFileTileSize.header,
|
||||
}) {
|
||||
return NooFileTile(
|
||||
kind: NooFileKind.from(
|
||||
name: item.name,
|
||||
mimeType: item.mimeType,
|
||||
isDirectory: item.isFolder,
|
||||
),
|
||||
size: size,
|
||||
);
|
||||
}
|
||||
|
||||
/// Reusable "Details" bottom sheet for a single file/folder — Info,
|
||||
/// Versions, and Activity tabs. Sharing lives in its own sheet
|
||||
/// ([ShareSheet]), triggered separately from wherever a "Share" action
|
||||
/// appears. Triggered from long-press selection (exactly one item), the
|
||||
/// media viewer's action bar, and the "open externally" flow for
|
||||
/// unsupported file types.
|
||||
/// The header's meta subtitle - "12.3 KB · Documents" for a file, "Folder"
|
||||
/// for a folder (`DESIGN_SYSTEM.md` §4: "size · folder").
|
||||
String detailsMetaLine(NextcloudItem item) {
|
||||
if (item.isFolder) return 'Folder';
|
||||
final parent = p.basename(p.dirname(item.path));
|
||||
final location = parent.isEmpty || parent == '.' || parent == '/'
|
||||
? 'Home'
|
||||
: parent;
|
||||
return '${formatBytes(item.size)} · $location';
|
||||
}
|
||||
|
||||
enum _DetailsTab { info, versions, activity }
|
||||
|
||||
/// Reusable "Details" bottom sheet/dialog for a single file/folder - Info,
|
||||
/// Versions and Activity switched by a segmented control (there's no
|
||||
/// tab-strip component in the noo kit, so this is the closest fit - see the
|
||||
/// rebuild report). Sharing lives in its own sheet ([ShareSheet]), triggered
|
||||
/// separately from wherever a "Share" action appears. Triggered from
|
||||
/// long-press selection (exactly one item), the media viewer's action bar,
|
||||
/// and the "open externally" flow for unsupported file types.
|
||||
///
|
||||
/// Only used directly on mobile (bundles its own [NooOverlayHeader]); the
|
||||
/// desktop path in [show] passes [_DetailsBody] straight to [showNooDialog],
|
||||
/// which renders the header itself via `leading`/`title`/`subtitle`.
|
||||
class DetailsSheet extends StatelessWidget {
|
||||
final NextcloudItem item;
|
||||
|
||||
const DetailsSheet({super.key, required this.item});
|
||||
|
||||
static Future<void> show(BuildContext context, NextcloudItem item) {
|
||||
return showGradualBottomSheet(
|
||||
context,
|
||||
builder: (context, scrollController) => DetailsSheet(item: item),
|
||||
);
|
||||
if (NooLayout.isDesktop(context)) {
|
||||
return showNooDialog(
|
||||
context,
|
||||
leading: detailsFileTile(item),
|
||||
title: item.name,
|
||||
subtitle: detailsMetaLine(item),
|
||||
children: [_DetailsBody(item: item)],
|
||||
);
|
||||
}
|
||||
return showNooSheet(context, children: [DetailsSheet(item: item)]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 3,
|
||||
child: Column(
|
||||
children: [
|
||||
DetailsHeader(item: item),
|
||||
// Icon size matches the bottom nav bar's own tab icons
|
||||
// (FloatingBottomNavBar) rather than TabBar's oversized default,
|
||||
// so a "tabs" affordance reads the same size everywhere in the
|
||||
// app.
|
||||
const TabBar(
|
||||
tabs: [
|
||||
Tab(
|
||||
icon: Icon(Icons.info_outline_rounded, size: 22),
|
||||
text: 'Info',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.history_rounded, size: 22),
|
||||
text: 'Versions',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.electric_bolt_rounded, size: 22),
|
||||
text: 'Activity',
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
DetailsInfoTab(item: item),
|
||||
DetailsVersionsTab(item: item),
|
||||
DetailsActivityTab(item: item),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
NooOverlayHeader(
|
||||
leading: detailsFileTile(item),
|
||||
title: item.name,
|
||||
subtitle: detailsMetaLine(item),
|
||||
onClose: () => Navigator.pop(context),
|
||||
),
|
||||
const SizedBox(height: NooSpace.lg),
|
||||
_DetailsBody(item: item),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Icon/name/size/date row shared by [DetailsSheet], the media viewer's
|
||||
/// collapsed "peek" state (which shows just this, before the user drags the
|
||||
/// sheet open far enough to reveal the tabs), and [ShareSheet]'s own title
|
||||
/// (so every per-item bottom sheet opens on the same header instead of
|
||||
/// [ShareSheet] alone using a bare title `Text`).
|
||||
class DetailsHeader extends StatelessWidget {
|
||||
/// The segmented control plus whichever tab's content it selects. A private
|
||||
/// [StatefulWidget] rather than three fields on [DetailsSheet] so the
|
||||
/// selection survives independently of how the header/container around it
|
||||
/// is built (mobile embeds this once, desktop hands it to [showNooDialog]
|
||||
/// directly).
|
||||
class _DetailsBody extends StatefulWidget {
|
||||
final NextcloudItem item;
|
||||
|
||||
/// Defaults to this widget's own standalone inset ([DetailsSheet]); a
|
||||
/// caller whose surrounding scroll view already applies horizontal
|
||||
/// padding (e.g. [ShareSheet]'s `ListView`) should pass a padding with
|
||||
/// zero left/right to avoid doubling it up.
|
||||
final EdgeInsetsGeometry padding;
|
||||
const _DetailsBody({required this.item});
|
||||
|
||||
const DetailsHeader({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.padding = const EdgeInsets.fromLTRB(20, 12, 20, 8),
|
||||
});
|
||||
@override
|
||||
State<_DetailsBody> createState() => _DetailsBodyState();
|
||||
}
|
||||
|
||||
class _DetailsBodyState extends State<_DetailsBody> {
|
||||
_DetailsTab _tab = _DetailsTab.info;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
NooSegmentedControl<_DetailsTab>(
|
||||
onSurface: true,
|
||||
fill: true,
|
||||
value: _tab,
|
||||
onChanged: (tab) => setState(() => _tab = tab),
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: _DetailsTab.info,
|
||||
icon: LucideIcons.info,
|
||||
label: 'Info',
|
||||
),
|
||||
child: Icon(
|
||||
_detailsItemIcon(item.type),
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
size: 22,
|
||||
NooSegmentOption(
|
||||
value: _DetailsTab.versions,
|
||||
icon: LucideIcons.history,
|
||||
label: 'Versions',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.isFolder
|
||||
? DateFormat.yMMMd().format(item.lastModified)
|
||||
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
NooSegmentOption(
|
||||
value: _DetailsTab.activity,
|
||||
icon: LucideIcons.activity,
|
||||
label: 'Activity',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: NooSpace.lg),
|
||||
switch (_tab) {
|
||||
_DetailsTab.info => DetailsInfoTab(item: widget.item),
|
||||
_DetailsTab.versions => DetailsVersionsTab(item: widget.item),
|
||||
_DetailsTab.activity => DetailsActivityTab(item: widget.item),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import 'package:file_saver/file_saver.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/nextcloud_file_version.dart';
|
||||
import '../../models/nextcloud_item.dart';
|
||||
import '../../providers/item_operations.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../noo/files/noo_file_kind.dart';
|
||||
import '../noo/files/noo_file_tile.dart';
|
||||
import '../noo/lists/noo_grouped_list.dart';
|
||||
import '../synced_header_scaffold.dart' show formatBytes;
|
||||
|
||||
/// File version history — a synthetic "Current version" row (from the
|
||||
/// File version history - a synthetic "Current version" row (from the
|
||||
/// item's own metadata) followed by whatever the DAV versions endpoint
|
||||
/// returns, each with Restore/Download actions.
|
||||
class DetailsVersionsTab extends StatefulWidget {
|
||||
@@ -75,7 +80,7 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
|
||||
await FileSaver.instance.saveFile(
|
||||
name: baseName,
|
||||
filePath: tempPath,
|
||||
ext: ext,
|
||||
fileExtension: ext,
|
||||
);
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
@@ -101,11 +106,12 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
|
||||
_requested = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final items = [
|
||||
@@ -117,44 +123,126 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
|
||||
),
|
||||
..._versions,
|
||||
];
|
||||
final kind = NooFileKind.from(
|
||||
name: widget.item.name,
|
||||
mimeType: widget.item.mimeType,
|
||||
isDirectory: false,
|
||||
);
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 4),
|
||||
itemBuilder: (context, index) {
|
||||
final version = items[index];
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.history_rounded,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
size: 20,
|
||||
),
|
||||
return NooGroupedList(
|
||||
children: [
|
||||
for (final version in items)
|
||||
_VersionRow(
|
||||
kind: kind,
|
||||
version: version,
|
||||
onRestore: () => _restore(version),
|
||||
onDownload: () => _download(version),
|
||||
),
|
||||
title: Text(
|
||||
version.isCurrent
|
||||
? 'Current version'
|
||||
: DateFormat.yMMMd().add_jm().format(version.timestamp),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${DateFormat.yMMMd().format(version.timestamp)} • ${formatBytes(version.size)}',
|
||||
),
|
||||
trailing: version.isCurrent
|
||||
? null
|
||||
: PopupMenuButton<String>(
|
||||
onSelected: (value) => value == 'restore'
|
||||
? _restore(version)
|
||||
: _download(version),
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(value: 'restore', child: Text('Restore')),
|
||||
PopupMenuItem(value: 'download', child: Text('Download')),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VersionRow extends StatelessWidget {
|
||||
final NooFileKind kind;
|
||||
final NextcloudFileVersion version;
|
||||
final VoidCallback onRestore;
|
||||
final VoidCallback onDownload;
|
||||
|
||||
const _VersionRow({
|
||||
required this.kind,
|
||||
required this.version,
|
||||
required this.onRestore,
|
||||
required this.onDownload,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return ColoredBox(
|
||||
color: colors.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: NooSpace.md,
|
||||
vertical: NooSpace.sm,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
NooFileTile(kind: kind, size: NooFileTileSize.activity),
|
||||
const SizedBox(width: NooSpace.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
version.isCurrent
|
||||
? 'Current version'
|
||||
: DateFormat.yMMMd().add_jm().format(version.timestamp),
|
||||
style: NooText.bodyL.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.fg1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${formatBytes(version.size)} · ${DateFormat.yMMMd().format(version.timestamp)}',
|
||||
style: NooText.meta.copyWith(color: colors.fg3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!version.isCurrent) ...[
|
||||
const SizedBox(width: NooSpace.xs),
|
||||
_RowIconButton(
|
||||
icon: LucideIcons.rotateCcw,
|
||||
tooltip: 'Restore',
|
||||
onTap: onRestore,
|
||||
),
|
||||
const SizedBox(width: NooSpace.xxs),
|
||||
_RowIconButton(
|
||||
icon: LucideIcons.download,
|
||||
tooltip: 'Download',
|
||||
onTap: onDownload,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A small round icon button matching [NooCloseButton]'s visual language
|
||||
/// (surface-2 circle) but for an arbitrary action - promote this to `noo/`
|
||||
/// if another row-level icon action needs the same treatment.
|
||||
class _RowIconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _RowIconButton({
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle),
|
||||
child: Icon(icon, size: 16, color: colors.fg2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user