Add Trash/Shares/Recent tabs, file sharing, Details sheet, and rebrand to Noo
Major additions since the initial rebuild: - New tabs: Trash, Shares, Recent, configurable tab visibility/order/default - Nextcloud sharing: search users/teams, internal link, email shares, public links, "others with access" - in its own Share bottom sheet - Details bottom sheet (Info/Versions/Activity) with a persistent peek panel in the media viewer, reachable from selection, swipe, and the media viewer - Files/Photos: tap-and-hold multi-select with a sticky selection toolbar, swipe actions (favorite/delete/share), sticky sort/filter controls, list/grid views, breadcrumb chips, whole-account Photos view - Media viewer: pinch/double-tap zoom on images, predictive-back support, fixed PDF zoom-out floor - Settings: Material You theming incl. AMOLED black, configurable bottom bar tabs, swipe actions, media seek bar style, and folder-listing cache policy (never/periodic/manual) to cut down on refetching - Login flow moved to an in-app Chrome Custom Tab with retry-tolerant polling - Replaced ad hoc widgets with native Material 3 equivalents (toggle IconButtons, SliverAppBar selection toolbar, SearchBar, drag handles) - Rebrand: app renamed to Noo throughout, package id -> dev.ayushya.noo, new adaptive launcher icon Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/nextcloud_item.dart';
|
||||
import '../../providers/server_provider.dart';
|
||||
|
||||
/// Per-file activity feed — same list-item look as the account-wide
|
||||
/// Activity tab (activity_view.dart), scoped to a single item.
|
||||
class DetailsActivityTab extends StatefulWidget {
|
||||
final NextcloudItem item;
|
||||
|
||||
const DetailsActivityTab({super.key, required this.item});
|
||||
|
||||
@override
|
||||
State<DetailsActivityTab> createState() => _DetailsActivityTabState();
|
||||
}
|
||||
|
||||
class _DetailsActivityTabState extends State<DetailsActivityTab> {
|
||||
bool _requested = false;
|
||||
bool _isLoading = true;
|
||||
List<NextcloudActivity> _activities = [];
|
||||
|
||||
Future<void> _load() async {
|
||||
final provider = context.read<ServerProvider>();
|
||||
final result = await provider.fetchFileActivity(widget.item);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_activities = result;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_requested) {
|
||||
_requested = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_activities.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No recent activity',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../models/nextcloud_item.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.
|
||||
class DetailsInfoTab extends StatelessWidget {
|
||||
final NextcloudItem item;
|
||||
final ScrollController? scrollController;
|
||||
|
||||
const DetailsInfoTab({super.key, required this.item, this.scrollController});
|
||||
|
||||
@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')),
|
||||
if (!item.isFolder) ('Size', formatBytes(item.size)),
|
||||
('Location', item.path),
|
||||
('Modified', DateFormat.yMMMd().add_jm().format(item.lastModified)),
|
||||
('Created', DateFormat.yMMMd().add_jm().format(item.dateCreated)),
|
||||
('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,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../models/nextcloud_item.dart';
|
||||
import '../gradual_bottom_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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 3,
|
||||
child: Column(
|
||||
children: [
|
||||
DetailsHeader(item: item),
|
||||
const TabBar(
|
||||
tabs: [
|
||||
Tab(icon: Icon(Icons.info_outline_rounded), text: 'Info'),
|
||||
Tab(icon: Icon(Icons.history_rounded), text: 'Versions'),
|
||||
Tab(icon: Icon(Icons.electric_bolt_rounded), text: 'Activity'),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
DetailsInfoTab(item: item),
|
||||
DetailsVersionsTab(item: item),
|
||||
DetailsActivityTab(item: item),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Icon/name/size/date row shared by [DetailsSheet] and the media viewer's
|
||||
/// collapsed "peek" state (which shows just this, before the user drags the
|
||||
/// sheet open far enough to reveal the tabs).
|
||||
class DetailsHeader extends StatelessWidget {
|
||||
final NextcloudItem item;
|
||||
|
||||
const DetailsHeader({super.key, required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
_detailsItemIcon(item.type),
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
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.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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'package:file_saver/file_saver.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.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/server_provider.dart';
|
||||
import '../synced_header_scaffold.dart' show formatBytes;
|
||||
|
||||
/// 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 {
|
||||
final NextcloudItem item;
|
||||
|
||||
const DetailsVersionsTab({super.key, required this.item});
|
||||
|
||||
@override
|
||||
State<DetailsVersionsTab> createState() => _DetailsVersionsTabState();
|
||||
}
|
||||
|
||||
class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
|
||||
bool _requested = false;
|
||||
bool _isLoading = true;
|
||||
List<NextcloudFileVersion> _versions = [];
|
||||
|
||||
Future<void> _load() async {
|
||||
final provider = context.read<ServerProvider>();
|
||||
final result = await provider.fetchFileVersions(widget.item);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_versions = result;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _restore(NextcloudFileVersion version) async {
|
||||
final provider = context.read<ServerProvider>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final success = await provider.restoreFileVersion(
|
||||
widget.item,
|
||||
version.versionLabel,
|
||||
);
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
success ? 'Restored version' : 'Could not restore version',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
if (success) _load();
|
||||
}
|
||||
|
||||
Future<void> _download(NextcloudFileVersion version) async {
|
||||
final provider = context.read<ServerProvider>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempPath = p.join(
|
||||
tempDir.path,
|
||||
'${version.versionLabel}_${widget.item.name}',
|
||||
);
|
||||
final success = await provider.downloadVersion(
|
||||
widget.item,
|
||||
version.versionLabel,
|
||||
tempPath,
|
||||
);
|
||||
if (!success) throw Exception('download failed');
|
||||
final ext = p.extension(widget.item.name).replaceFirst('.', '');
|
||||
final baseName = p.basenameWithoutExtension(widget.item.name);
|
||||
await FileSaver.instance.saveFile(
|
||||
name: baseName,
|
||||
filePath: tempPath,
|
||||
ext: ext,
|
||||
);
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Version downloaded'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Could not download version'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_requested) {
|
||||
_requested = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final items = [
|
||||
NextcloudFileVersion(
|
||||
versionLabel: 'current',
|
||||
timestamp: widget.item.lastModified,
|
||||
size: widget.item.size,
|
||||
isCurrent: true,
|
||||
),
|
||||
..._versions,
|
||||
];
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
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')),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/nextcloud_item.dart';
|
||||
import 'details_sheet.dart';
|
||||
|
||||
/// A persistent (non-modal) draggable panel that rests as a small "peek"
|
||||
/// of a file's details - just the header - so it's always partly visible
|
||||
/// without blocking the content above it, and expands into the full
|
||||
/// [DetailsSheet] (with its Info/Versions/Activity tabs) once dragged past
|
||||
/// [_expandThreshold]. Meant to be placed directly in a `Stack`, not shown
|
||||
/// via `showModalBottomSheet`/`showBottomSheet` - there's no scrim and
|
||||
/// nothing behind it is blocked from receiving gestures.
|
||||
class PeekDetailsSheet extends StatefulWidget {
|
||||
final NextcloudItem item;
|
||||
|
||||
const PeekDetailsSheet({super.key, required this.item});
|
||||
|
||||
@override
|
||||
State<PeekDetailsSheet> createState() => _PeekDetailsSheetState();
|
||||
}
|
||||
|
||||
class _PeekDetailsSheetState extends State<PeekDetailsSheet> {
|
||||
static const _peekSize = 0.16;
|
||||
static const _maxSize = 0.9;
|
||||
static const _expandThreshold = 0.3;
|
||||
|
||||
double _size = _peekSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final expanded = _size > _expandThreshold;
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: _peekSize,
|
||||
minChildSize: _peekSize,
|
||||
maxChildSize: _maxSize,
|
||||
snap: true,
|
||||
snapSizes: const [_peekSize, _maxSize],
|
||||
builder: (context, scrollController) {
|
||||
return NotificationListener<DraggableScrollableNotification>(
|
||||
onNotification: (notification) {
|
||||
if ((notification.extent - _size).abs() > 0.002) {
|
||||
setState(() => _size = notification.extent);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: expanded
|
||||
? DetailsSheet(item: widget.item)
|
||||
// Peek state: just the header, but still wrapped in
|
||||
// a scrollable using the sheet's own controller - a
|
||||
// DraggableScrollableSheet only picks up drag-to-
|
||||
// resize gestures from a Scrollable using that
|
||||
// controller, even over otherwise-static content.
|
||||
: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: DetailsHeader(item: widget.item),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user