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
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import '../models/app_tab.dart';
import '../views/activity_view.dart';
import '../views/files_view.dart';
import '../views/photos_view.dart';
import '../views/recent_view.dart';
import '../views/shares_view.dart';
import '../views/trash_view.dart';
/// Builds the view widget for a given [AppTab]. Shared by the main shell
/// (for visible tabs) and the "more tabs" dropdown (for launching a tab
/// that's currently hidden from the bottom nav bar).
Widget buildAppTabView(AppTab tab, ScrollController controller) {
switch (tab) {
case AppTab.files:
return FilesView(scrollController: controller);
case AppTab.photos:
return PhotosView(scrollController: controller);
case AppTab.activity:
return ActivityView(scrollController: controller);
case AppTab.trash:
return TrashView(scrollController: controller);
case AppTab.shares:
return SharesView(scrollController: controller);
case AppTab.recent:
return RecentView(scrollController: controller);
}
}
+72
View File
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
/// Tappable "Home / Folder / Sub-folder" trail shown whenever the user has
/// navigated below the root, in both the Files and Photos tabs.
class Breadcrumbs extends StatelessWidget {
final List<String> pathStack;
final ValueChanged<int> onTap;
const Breadcrumbs({super.key, required this.pathStack, required this.onTap});
String _labelFor(int index) {
if (index == 0) return 'Home';
final segments = pathStack[index]
.split('/')
.where((s) => s.isNotEmpty)
.toList();
return segments.isEmpty ? 'Home' : segments.last;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final lastIndex = pathStack.length - 1;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var i = 0; i < pathStack.length; i++) ...[
if (i > 0)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(
Icons.chevron_right_rounded,
size: 16,
color: colorScheme.outlineVariant,
),
),
if (i == lastIndex)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
_labelFor(i),
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
),
),
)
else
// A chip's whole pill is the tap target (not just the text),
// so the previous plain-text crumbs were fiddly to hit.
ActionChip(
label: Text(_labelFor(i)),
labelStyle: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.primary,
),
onPressed: () => onTap(i),
backgroundColor: colorScheme.surfaceContainerHigh,
side: BorderSide.none,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: 4),
),
],
],
),
);
}
}
@@ -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),
),
),
],
),
),
);
},
);
}
}
+60
View File
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
typedef GradualSheetBuilder =
Widget Function(BuildContext context, ScrollController scrollController);
/// Shows a bottom sheet that can be dragged open/closed gradually (and
/// snaps to a few comfortable stops on release) instead of the default
/// modal bottom sheet's binary fully-open/fully-dismissed behavior.
/// [builder] gets a [ScrollController] it can hand to its own scrollable
/// content so dragging that content also resizes the sheet; content that
/// manages its own scrolling (e.g. a TabBarView of independent lists) can
/// just ignore it.
Future<T?> showGradualBottomSheet<T>(
BuildContext context, {
required GradualSheetBuilder builder,
double initialSize = 0.6,
double minSize = 0.3,
double maxSize = 0.95,
}) {
final colorScheme = Theme.of(context).colorScheme;
return showModalBottomSheet<T>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
return DraggableScrollableSheet(
initialChildSize: initialSize,
minChildSize: minSize,
maxChildSize: maxSize,
snap: true,
snapSizes: [minSize, initialSize, maxSize],
expand: false,
builder: (context, scrollController) {
return 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: builder(context, scrollController)),
],
),
);
},
);
},
);
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/app_tab.dart';
import '../providers/server_provider.dart';
import 'app_tab_view_builder.dart';
/// Opens a tab that's currently hidden from the bottom nav bar as its own
/// standalone screen. Shown in every tab's toolbar (not just Files) so
/// hidden destinations stay reachable no matter which tab is active.
/// Always visible; dims/disables itself when nothing is hidden.
class MoreTabsButton extends StatelessWidget {
const MoreTabsButton({super.key});
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
return PopupMenuButton<AppTab>(
icon: const Icon(Icons.apps_rounded),
tooltip: 'More tabs',
enabled: provider.hiddenTabs.isNotEmpty,
onSelected: (tab) => _openHiddenTab(context, tab),
itemBuilder: (context) => [
for (final tab in provider.tabOrder.where(
(t) => provider.hiddenTabs.contains(t),
))
PopupMenuItem(
value: tab,
child: Row(
children: [
Icon(tab.icon, size: 20),
const SizedBox(width: 12),
Text(tab.label),
],
),
),
],
);
}
void _openHiddenTab(BuildContext context, AppTab tab) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
Scaffold(body: buildAppTabView(tab, ScrollController())),
),
);
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ class ProfileAvatarButton extends StatelessWidget {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
tooltip: 'Account',
tooltip: 'Settings',
onPressed: () {
Navigator.push(
context,
+21 -18
View File
@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
/// A single icon button that fills with a solid circle when active —
/// the building block for Drive-style toggle controls.
/// the building block for Drive-style toggle controls. Thin wrapper around
/// the native Material 3 toggle `IconButton` (its `isSelected` constructor
/// param, not a hand-rolled Material+InkWell) so every call site here gets
/// the built-in selected/unselected treatment for free.
class ToggleIconButton extends StatelessWidget {
final IconData icon;
final bool isSelected;
@@ -20,26 +23,26 @@ class ToggleIconButton extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final button = Material(
color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
shape: const CircleBorder(),
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(
icon,
size: 20,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
return IconButton(
isSelected: isSelected,
icon: Icon(icon, size: 20),
tooltip: tooltip,
onPressed: onTap,
visualDensity: VisualDensity.compact,
style: ButtonStyle(
shape: const WidgetStatePropertyAll(CircleBorder()),
foregroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
backgroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? colorScheme.primaryContainer
: Colors.transparent,
),
),
);
return tooltip != null ? Tooltip(message: tooltip!, child: button) : button;
}
}
+53
View File
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
/// Wraps a thumbnail so it can show the multi-select checkmark state:
/// dimmed with a checkmark overlaid once selected, matching the Google
/// Photos-style long-press-to-select pattern. Shared by the Files and
/// Photos tabs.
class SelectableThumbnail extends StatelessWidget {
final bool isSelected;
final Widget child;
/// Fixed width/height for a small square thumbnail (Files list/grid
/// tiles). Leave null to fill whatever space the parent already gives it
/// instead (e.g. a Photos grid cell, whose size comes from the grid
/// layout rather than a fixed constant).
final double? size;
final double checkmarkSize;
const SelectableThumbnail({
super.key,
required this.isSelected,
required this.child,
this.size,
this.checkmarkSize = 28,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final stack = Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: AnimatedOpacity(
opacity: isSelected ? 0.35 : 1,
duration: const Duration(milliseconds: 150),
child: child,
),
),
if (isSelected)
Center(
child: Icon(
Icons.check_circle_rounded,
color: colorScheme.primary,
size: checkmarkSize,
),
),
],
);
if (size == null) return stack;
return SizedBox(width: size, height: size, child: stack);
}
}
+406
View File
@@ -0,0 +1,406 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../models/nextcloud_item.dart';
import '../models/nextcloud_share.dart';
import '../models/nextcloud_sharee.dart';
import '../providers/server_provider.dart';
import 'gradual_bottom_sheet.dart';
IconData _shareTypeIcon(ShareType type) {
switch (type) {
case ShareType.user:
return Icons.person_rounded;
case ShareType.group:
return Icons.groups_rounded;
case ShareType.publicLink:
return Icons.link_rounded;
case ShareType.email:
return Icons.email_rounded;
case ShareType.federated:
return Icons.public_rounded;
case ShareType.other:
return Icons.share_rounded;
}
}
/// Builds the "internal link" URL for a file, or null if [fileId] isn't a
/// numeric Nextcloud file id (WebDAV `oc:fileid` was missing for this item).
String? internalLinkFor(String serverUrl, String fileId) {
if (int.tryParse(fileId) == null) return null;
var url = serverUrl.trim();
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://$url';
}
if (url.endsWith('/')) url = url.substring(0, url.length - 1);
return '$url/index.php/f/$fileId';
}
/// Standalone "Share" bottom sheet for a single file/folder — replicates
/// the web client's Sharing panel: search users/teams, "Others with
/// access" (inherited shares), an internal link to copy, an email-share
/// field, and a public link. Every "Share" action in the app (swipe,
/// multi-select, media viewer) opens this same sheet.
class ShareSheet extends StatefulWidget {
final NextcloudItem item;
final ScrollController? scrollController;
const ShareSheet({super.key, required this.item, this.scrollController});
static Future<void> show(BuildContext context, NextcloudItem item) {
return showGradualBottomSheet(
context,
builder: (context, scrollController) =>
ShareSheet(item: item, scrollController: scrollController),
);
}
@override
State<ShareSheet> createState() => _ShareSheetState();
}
class _ShareSheetState extends State<ShareSheet> {
bool _requested = false;
bool _isLoading = true;
List<NextcloudShare> _shares = [];
List<NextcloudShare> _inherited = [];
List<NextcloudSharee> _searchResults = [];
bool _isSearching = false;
final _searchController = TextEditingController();
final _emailController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
_emailController.dispose();
super.dispose();
}
Future<void> _load() async {
final provider = context.read<ServerProvider>();
final results = await Future.wait([
provider.fetchItemShares(widget.item),
provider.fetchInheritedShares(widget.item),
]);
if (!mounted) return;
setState(() {
_shares = results[0];
_inherited = results[1];
_isLoading = false;
});
}
Future<void> _search(String query) async {
final trimmed = query.trim();
if (trimmed.isEmpty) {
setState(() => _searchResults = []);
return;
}
setState(() => _isSearching = true);
final provider = context.read<ServerProvider>();
final results = await provider.searchSharees(trimmed);
if (!mounted) return;
setState(() {
_searchResults = results;
_isSearching = false;
});
}
Future<void> _addSharee(NextcloudSharee sharee) async {
final provider = context.read<ServerProvider>();
final messenger = ScaffoldMessenger.of(context);
final share = await provider.createShare(
path: widget.item.path,
shareType: sharee.shareTypeValue,
shareWith: sharee.shareWith,
);
if (!mounted) return;
if (share == null) {
messenger.showSnackBar(
const SnackBar(
content: Text('Could not share this item'),
behavior: SnackBarBehavior.floating,
),
);
return;
}
setState(() {
_shares = [..._shares, share];
_searchResults = [];
_searchController.clear();
});
messenger.showSnackBar(
SnackBar(
content: Text('Shared with ${sharee.label}'),
behavior: SnackBarBehavior.floating,
),
);
}
Future<void> _addEmailShare() async {
final email = _emailController.text.trim();
if (email.isEmpty) return;
final provider = context.read<ServerProvider>();
final messenger = ScaffoldMessenger.of(context);
final share = await provider.createShare(
path: widget.item.path,
shareType: 4,
shareWith: email,
);
if (!mounted) return;
if (share == null) {
messenger.showSnackBar(
const SnackBar(
content: Text('Could not create email share'),
behavior: SnackBarBehavior.floating,
),
);
return;
}
setState(() {
_shares = [..._shares, share];
_emailController.clear();
});
messenger.showSnackBar(
SnackBar(
content: Text('Shared with $email'),
behavior: SnackBarBehavior.floating,
),
);
}
Future<void> _createPublicLink() async {
final provider = context.read<ServerProvider>();
final messenger = ScaffoldMessenger.of(context);
final share = await provider.createShare(
path: widget.item.path,
shareType: 3,
);
if (!mounted) return;
if (share == null) {
messenger.showSnackBar(
const SnackBar(
content: Text('Could not create public link'),
behavior: SnackBarBehavior.floating,
),
);
return;
}
setState(() => _shares = [..._shares, share]);
}
Future<void> _removeShare(NextcloudShare share) async {
final provider = context.read<ServerProvider>();
final messenger = ScaffoldMessenger.of(context);
final success = await provider.deleteShare(share);
if (!mounted) return;
if (success) {
setState(() => _shares = _shares.where((s) => s.id != share.id).toList());
} else {
messenger.showSnackBar(
const SnackBar(
content: Text('Could not remove share'),
behavior: SnackBarBehavior.floating,
),
);
}
}
void _copy(String text, String label) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Copied $label'),
behavior: SnackBarBehavior.floating,
),
);
}
Widget _sectionHeader(String label) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
Text(
label,
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15),
),
const SizedBox(width: 6),
Icon(
Icons.info_outline_rounded,
size: 16,
color: colorScheme.onSurfaceVariant,
),
],
);
}
Widget _shareRow(
NextcloudShare share, {
bool removable = true,
bool copyUrl = false,
}) {
final colorScheme = Theme.of(context).colorScheme;
final label = switch (share.shareType) {
ShareType.publicLink => 'Public link',
_ => share.sharedWithDisplayName ?? 'Shared',
};
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
_shareTypeIcon(share.shareType),
color: colorScheme.primary,
),
title: Text(label),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (copyUrl && share.url != null)
IconButton(
icon: const Icon(Icons.copy_rounded),
tooltip: 'Copy link',
onPressed: () => _copy(share.url!, 'link'),
),
if (removable)
IconButton(
icon: Icon(Icons.close_rounded, color: colorScheme.error),
tooltip: 'Remove',
onPressed: () => _removeShare(share),
),
],
),
);
}
@override
Widget build(BuildContext context) {
if (!_requested) {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
final userGroupShares = _shares
.where(
(s) =>
s.shareType == ShareType.user || s.shareType == ShareType.group,
)
.toList();
final publicLinkShares = _shares
.where((s) => s.shareType == ShareType.publicLink)
.toList();
final emailShares = _shares
.where((s) => s.shareType == ShareType.email)
.toList();
final internalLink = internalLinkFor(provider.serverUrl, widget.item.id);
return ListView(
controller: widget.scrollController,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
children: [
Text(
widget.item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 16),
_sectionHeader('Internal shares'),
const SizedBox(height: 8),
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Type names or teams',
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: _isSearching
? const Padding(
padding: EdgeInsets.all(14),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: null,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
filled: true,
fillColor: colorScheme.surfaceContainerLow,
),
onChanged: _search,
),
for (final sharee in _searchResults)
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
sharee.type == ShareeType.group
? Icons.groups_rounded
: Icons.person_rounded,
),
title: Text(sharee.label),
subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null,
onTap: () => _addSharee(sharee),
),
for (final share in userGroupShares) _shareRow(share),
if (_inherited.isNotEmpty)
ExpansionTile(
tilePadding: EdgeInsets.zero,
leading: const Icon(Icons.more_horiz_rounded),
title: Text('Others with access (${_inherited.length})'),
children: [
for (final share in _inherited)
_shareRow(share, removable: false),
],
),
if (internalLink != null)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.link_rounded),
title: const Text('Internal link'),
subtitle: const Text('For people who already have access'),
trailing: IconButton(
icon: const Icon(Icons.copy_rounded),
tooltip: 'Copy link',
onPressed: () => _copy(internalLink, 'internal link'),
),
),
const SizedBox(height: 20),
const Divider(),
const SizedBox(height: 12),
_sectionHeader('External shares'),
const SizedBox(height: 8),
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
hintText: 'Type an email',
prefixIcon: const Icon(Icons.email_outlined),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
filled: true,
fillColor: colorScheme.surfaceContainerLow,
),
onSubmitted: (_) => _addEmailShare(),
),
for (final share in emailShares) _shareRow(share),
const SizedBox(height: 8),
for (final share in publicLinkShares) _shareRow(share, copyUrl: true),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.link_rounded),
title: const Text('Create public link'),
trailing: IconButton(
icon: const Icon(Icons.add_rounded),
tooltip: 'Create public link',
onPressed: _createPublicLink,
),
),
],
);
}
}
+90
View File
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import '../providers/server_provider.dart';
String sortFieldLabel(FileSortField field) {
switch (field) {
case FileSortField.name:
return 'Name';
case FileSortField.dateCreated:
return 'Date created';
case FileSortField.dateModified:
return 'Date modified';
case FileSortField.size:
return 'Size';
}
}
/// Dropdown trigger for choosing which field a list/grid is sorted by,
/// shared by the Files and Photos tabs.
class SortMenuButton extends StatelessWidget {
final FileSortField field;
final ValueChanged<FileSortField> onChanged;
const SortMenuButton({
super.key,
required this.field,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return PopupMenuButton<FileSortField>(
initialValue: field,
onSelected: onChanged,
offset: const Offset(0, 36),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
itemBuilder: (context) => FileSortField.values.map((f) {
return PopupMenuItem(
value: f,
child: Row(
children: [
SizedBox(
width: 20,
child: f == field
? Icon(
Icons.check_rounded,
size: 18,
color: colorScheme.primary,
)
: null,
),
const SizedBox(width: 8),
Text(sortFieldLabel(f)),
],
),
);
}).toList(),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
// A plain Text child in a Row always takes its full intrinsic
// width no matter what maxLines/overflow say - Flexible is what
// actually lets it shrink to whatever space this row has left
// (it's squeezed by favorite/hidden/storage/list-grid toggles
// alongside it) so the ellipsis can kick in instead of
// overflowing.
children: [
Flexible(
child: Text(
sortFieldLabel(field),
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Icon(
Icons.arrow_drop_down_rounded,
color: colorScheme.onSurfaceVariant,
),
],
),
),
);
}
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
/// Wraps [child] as a sliver that pins to the top of the scroll view when
/// used with `SliverPersistentHeader(pinned: true, ...)`, or scrolls away
/// normally with `pinned: false`. [height] must match the child's actual
/// rendered height - Flutter's sliver protocol needs an extent up front,
/// not one measured after layout, so callers own getting this right for
/// whatever fixed-height content they pass in.
class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
final double height;
final Widget child;
const StickyHeaderDelegate({required this.height, required this.child});
@override
double get minExtent => height;
@override
double get maxExtent => height;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return Material(color: Theme.of(context).colorScheme.surface, child: child);
}
@override
bool shouldRebuild(covariant StickyHeaderDelegate oldDelegate) {
return oldDelegate.height != height || oldDelegate.child != child;
}
}
+113
View File
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../providers/server_provider.dart';
/// Wraps [child] with the user's configured swipe-left/swipe-right actions
/// (favorite, delete, share, or none) — shared between the Files list view
/// and the Photos grid so both behave identically. Callers own the actual
/// side effects (favoriting, sharing, deleting); this widget only decides
/// which action a given swipe direction maps to and drives the gesture.
class SwipeableItem extends StatelessWidget {
final Key itemKey;
final ServerProvider provider;
final Future<void> Function() onFavorite;
final Future<void> Function() onShare;
final Future<void> Function() onDelete;
final Widget child;
const SwipeableItem({
super.key,
required this.itemKey,
required this.provider,
required this.onFavorite,
required this.onShare,
required this.onDelete,
required this.child,
});
static DismissDirection _directionFor(SwipeAction left, SwipeAction right) {
final hasLeft = left != SwipeAction.none;
final hasRight = right != SwipeAction.none;
if (hasLeft && hasRight) return DismissDirection.horizontal;
if (hasRight) return DismissDirection.startToEnd;
if (hasLeft) return DismissDirection.endToStart;
return DismissDirection.none;
}
static Widget _background(
BuildContext context,
SwipeAction action,
Alignment alignment,
) {
if (action == SwipeAction.none) return const SizedBox.shrink();
final colorScheme = Theme.of(context).colorScheme;
final Color color;
final IconData icon;
switch (action) {
case SwipeAction.delete:
color = colorScheme.error;
icon = Icons.delete_rounded;
case SwipeAction.favorite:
color = Colors.red.shade400;
icon = Icons.favorite_rounded;
case SwipeAction.share:
color = colorScheme.primary;
icon = Icons.share_rounded;
case SwipeAction.none:
return const SizedBox.shrink();
}
return Container(
color: color,
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Icon(icon, color: Colors.white),
);
}
@override
Widget build(BuildContext context) {
final rightAction = provider.swipeRightAction;
final leftAction = provider.swipeLeftAction;
final direction = _directionFor(leftAction, rightAction);
if (direction == DismissDirection.none) return child;
return Dismissible(
key: itemKey,
direction: direction,
background: _background(context, rightAction, Alignment.centerLeft),
secondaryBackground: _background(
context,
leftAction,
Alignment.centerRight,
),
confirmDismiss: (dir) async {
final action = dir == DismissDirection.startToEnd
? rightAction
: leftAction;
switch (action) {
case SwipeAction.favorite:
HapticFeedback.selectionClick();
await onFavorite();
return false;
case SwipeAction.share:
HapticFeedback.selectionClick();
await onShare();
return false;
case SwipeAction.delete:
HapticFeedback.mediumImpact();
return true; // Actual delete happens in onDismissed.
case SwipeAction.none:
return false;
}
},
onDismissed: (dir) {
final action = dir == DismissDirection.startToEnd
? rightAction
: leftAction;
if (action == SwipeAction.delete) onDelete();
},
child: child,
);
}
}
+336
View File
@@ -0,0 +1,336 @@
import 'package:flutter/material.dart';
import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart';
String formatBytes(int bytes) {
if (bytes <= 0) return '0 B';
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
/// "5.16 GB / 100 GB" — or "5.16 GB / ∞" when the server reports no quota
/// limit (or doesn't report one at all).
String formatQuota(NextcloudUserQuota quota) {
final total = quota.totalBytes > 0 ? formatBytes(quota.totalBytes) : '∞';
return '${formatBytes(quota.usedBytes)} / $total';
}
/// Shared "pull down to see sync status" scroll header used by both the
/// Files and Photos tabs: a compact sync-status chip that expands into a
/// storage-quota panel (Google Photos' "pull to see backup status"
/// pattern), sitting on a gray backdrop above a white rounded-top card that
/// holds the tab's own content slivers.
class SyncedHeaderScaffold extends StatefulWidget {
final ScrollController scrollController;
final ServerProvider provider;
/// Trailing icons in the app bar (e.g. an upload button, the avatar).
final List<Widget> actions;
/// The tab's own content, already built as slivers (a filter/sort row,
/// breadcrumbs, the grid/list, empty/loading/error states, etc.). Wrapped
/// internally in the white rounded-top card.
final List<Widget> contentSlivers;
/// What a deliberate pull-to-sync should trigger. Defaults to
/// [ServerProvider.refreshData] (the current folder's listing); pass e.g.
/// `provider.fetchTrash` for a tab backed by different data.
final Future<void> Function()? onRefresh;
const SyncedHeaderScaffold({
super.key,
required this.scrollController,
required this.provider,
required this.actions,
required this.contentSlivers,
this.onRefresh,
});
@override
State<SyncedHeaderScaffold> createState() => _SyncedHeaderScaffoldState();
}
class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
static const double _lockThreshold = 100;
static const double _syncThreshold = 14;
static const double _pullingEpsilon = 8;
bool _headerLocked = false;
bool _isPulling = false;
double _pullDistance = 0;
void _revealPanel() {
if (widget.scrollController.hasClients) {
widget.scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
}
void _lockOpen() {
if (!_headerLocked) setState(() => _headerLocked = true);
_revealPanel();
}
bool _handleScrollNotification(ScrollNotification notification) {
final metrics = notification.metrics;
if (metrics.pixels < metrics.minScrollExtent) {
_pullDistance = metrics.minScrollExtent - metrics.pixels;
final pulling = _pullDistance > _pullingEpsilon;
if (pulling != _isPulling && !_headerLocked) {
setState(() => _isPulling = pulling);
}
} else if (_isPulling) {
setState(() => _isPulling = false);
}
if (notification is ScrollEndNotification) {
final pulled = _pullDistance;
_pullDistance = 0;
if (_isPulling) setState(() => _isPulling = false);
final refresh = widget.onRefresh ?? widget.provider.refreshData;
if (pulled >= _lockThreshold) {
_lockOpen();
refresh();
} else if (pulled >= _syncThreshold) {
refresh();
}
}
return false;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final provider = widget.provider;
final Widget leadingWidget;
if (_headerLocked) {
leadingWidget = IconButton(
icon: const Icon(Icons.keyboard_arrow_up_rounded),
tooltip: 'Collapse',
onPressed: () => setState(() => _headerLocked = false),
);
} else if (_isPulling) {
leadingWidget = const SizedBox.shrink();
} else {
leadingWidget = _SyncStatusChip(provider: provider, onTap: _lockOpen);
}
return ColoredBox(
color: colorScheme.surfaceContainer,
child: NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: CustomScrollView(
controller: widget.scrollController,
// Plain BouncingScrollPhysics only bounces/overscrolls reliably
// once content already fills the viewport — with too little
// content (e.g. a single item) the pull gesture can fail to
// register at all. AlwaysScrollableScrollPhysics keeps the pull
// (and therefore the sync header) working regardless of content
// length.
physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(),
),
slivers: [
SliverAppBar(
pinned: true,
stretch: true,
expandedHeight: _headerLocked ? 190 : kToolbarHeight,
collapsedHeight: kToolbarHeight,
backgroundColor: colorScheme.surfaceContainer,
surfaceTintColor: colorScheme.surfaceContainer,
scrolledUnderElevation: 0,
automaticallyImplyLeading: false,
leadingWidth: _headerLocked ? 56 : 160,
leading: leadingWidget,
actions: widget.actions,
flexibleSpace: FlexibleSpaceBar(
background: _SyncedStretchPanel(
provider: provider,
forceVisible: _headerLocked,
),
),
),
DecoratedSliver(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(28),
),
),
sliver: SliverMainAxisGroup(slivers: widget.contentSlivers),
),
],
),
),
);
}
}
/// A compact, always-visible summary of the same status shown by
/// [_SyncedStretchPanel] — tapping it is a discoverable shortcut for pulling
/// the header down to expand that same "Synced" view.
class _SyncStatusChip extends StatelessWidget {
final ServerProvider provider;
final VoidCallback onTap;
const _SyncStatusChip({required this.provider, required this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final quota = provider.quota;
return Padding(
padding: const EdgeInsets.only(left: 12),
child: Material(
color: Colors.transparent,
shape: const StadiumBorder(),
child: InkWell(
onTap: onTap,
customBorder: const StadiumBorder(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
provider.isLoading
? Icons.cloud_sync_rounded
: Icons.cloud_done_outlined,
color: colorScheme.primary,
size: 18,
),
const SizedBox(width: 6),
Text(
quota != null ? formatQuota(quota) : 'Sync',
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
),
),
),
);
}
}
/// Revealed by pulling the header down past its normal height (Google
/// Photos' "pull to see backup status" pattern). Shows storage quota;
/// [_SyncedHeaderScaffoldState] decides via [forceVisible] whether it stays
/// locked open.
class _SyncedStretchPanel extends StatelessWidget {
final ServerProvider provider;
/// Keeps the panel fully shown even without live overscroll — used once
/// the header has "locked" open after a deliberate pull-down.
final bool forceVisible;
const _SyncedStretchPanel({
required this.provider,
this.forceVisible = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final topInset = MediaQuery.of(context).padding.top;
return LayoutBuilder(
builder: (context, constraints) {
final settings = context
.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final maxExtent = settings?.maxExtent ?? constraints.maxHeight;
final stretch = (constraints.maxHeight - maxExtent).clamp(0.0, 80.0);
// Reaches 1.0 (title fully hidden) at 50px of pull — comfortably
// before the 100px lock threshold.
final progress = forceVisible ? 1.0 : (stretch / 50).clamp(0.0, 1.0);
final quota = provider.quota;
return Stack(
children: [
if (progress > 0)
// Anchored below the toolbar row (icons), never the bottom,
// so it can never share space with the title above.
Positioned(
left: 20,
right: 20,
top: topInset + kToolbarHeight + 4,
child: Opacity(
opacity: progress,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
provider.isLoading ? 'Syncing…' : 'Synced',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (provider.serverUrl.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
provider.serverUrl.replaceFirst(
RegExp(r'^https?://'),
'',
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
Icon(
Icons.cloud_done_outlined,
color: colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
quota != null
? formatQuota(quota)
: 'Pull to refresh',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
),
),
],
),
),
),
],
);
},
);
}
}