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:
2026-09-15 22:44:42 -04:00
co-authored by Claude Sonnet 5
parent f62d912ef7
commit d68cc43368
75 changed files with 6491 additions and 1213 deletions
@@ -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,
),
),
],
),
),
],
),
),
);
},
);
}
}
+64
View File
@@ -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,
),
),
),
],
),
);
},
);
}
}
+135
View File
@@ -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),
),
),
],
),
),
);
},
);
}
}