Add in-app copy/move, background downloads, and a Favorites tab
Build APK / build (push) Successful in 5m18s

- WebDAV MOVE/COPY-backed copy/move for files and folders, with a
  destination picker (independent nav state so it doesn't disturb the
  Files tab's browsing position) and a conflict-resolution sheet
  (overwrite all / keep both / decide per item).
- Downloads now use the same foreground-service + notification approach
  as uploads, surviving app closure.
- Favorites is now its own tab (account-wide, via a WebDAV SEARCH query)
  instead of a Files-tab filter toggle, which couldn't represent
  favorited items outside the currently browsed folder correctly.
- Share sheet restyled to match the app's design language, plus a
  "share file directly" action via the OS share sheet; details sheet
  tab icons resized to match the bottom nav.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 16:20:56 -04:00
co-authored by Claude Sonnet 5
parent 44a23c3632
commit b1400e399f
21 changed files with 2851 additions and 155 deletions
+87 -2
View File
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/nextcloud_item.dart';
import '../models/nextcloud_share.dart';
import '../models/nextcloud_sharee.dart';
@@ -67,6 +70,8 @@ class _ShareSheetState extends State<ShareSheet> {
List<NextcloudShare> _inherited = [];
List<NextcloudSharee> _searchResults = [];
bool _isSearching = false;
bool _isAddingEmail = false;
bool _isSharingFile = false;
final _searchController = TextEditingController();
final _emailController = TextEditingController();
@@ -141,6 +146,7 @@ class _ShareSheetState extends State<ShareSheet> {
Future<void> _addEmailShare() async {
final email = _emailController.text.trim();
if (email.isEmpty) return;
setState(() => _isAddingEmail = true);
final provider = context.read<ServerProvider>();
final messenger = ScaffoldMessenger.of(context);
final share = await provider.createShare(
@@ -150,6 +156,7 @@ class _ShareSheetState extends State<ShareSheet> {
);
if (!mounted) return;
if (share == null) {
setState(() => _isAddingEmail = false);
messenger.showSnackBar(
const SnackBar(
content: Text('Could not create email share'),
@@ -161,6 +168,7 @@ class _ShareSheetState extends State<ShareSheet> {
setState(() {
_shares = [..._shares, share];
_emailController.clear();
_isAddingEmail = false;
});
messenger.showSnackBar(
SnackBar(
@@ -217,6 +225,41 @@ class _ShareSheetState extends State<ShareSheet> {
);
}
/// Downloads the item to a scratch file and hands it to Android's native
/// "Share to..." sheet (`share_plus`) - a completely different action
/// from the rest of this sheet (which shares *within* Nextcloud, via
/// users/links) and the only one here that reads the file's actual
/// bytes.
Future<void> _shareFileDirectly() async {
final provider = context.read<ServerProvider>();
if (provider.service == null) return;
setState(() => _isSharingFile = true);
final messenger = ScaffoldMessenger.of(context);
try {
final tempDir = await getTemporaryDirectory();
final tempPath = p.join(tempDir.path, widget.item.name);
await provider.service!.downloadToFile(widget.item.path, tempPath);
if (!mounted) return;
await SharePlus.instance.share(
ShareParams(
files: [XFile(tempPath)],
fileNameOverrides: [widget.item.name],
),
);
} catch (_) {
if (mounted) {
messenger.showSnackBar(
const SnackBar(
content: Text('Could not share the file'),
behavior: SnackBarBehavior.floating,
),
);
}
} finally {
if (mounted) setState(() => _isSharingFile = false);
}
}
Widget _sectionHeader(String label) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
@@ -314,17 +357,43 @@ class _ShareSheetState extends State<ShareSheet> {
item: widget.item,
padding: const EdgeInsets.only(top: 12, bottom: 8),
),
const SizedBox(height: 8),
if (!widget.item.isFolder) ...[
const SizedBox(height: 4),
ListTile(
contentPadding: EdgeInsets.zero,
leading: _isSharingFile
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: const Icon(Icons.ios_share_rounded),
title: const Text('Share file directly'),
subtitle: const Text('Send the file itself via another app'),
onTap: _isSharingFile ? null : _shareFileDirectly,
),
const SizedBox(height: 12),
const Divider(),
],
const SizedBox(height: 12),
_sectionHeader('Internal shares'),
const SizedBox(height: 8),
TextField(
controller: _searchController,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
hintText: 'Type names or teams',
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: _isSearching
? const Padding(
padding: EdgeInsets.all(14),
padding: EdgeInsets.all(12),
child: SizedBox(
width: 16,
height: 16,
@@ -381,9 +450,25 @@ class _ShareSheetState extends State<ShareSheet> {
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
enabled: !_isAddingEmail,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
hintText: 'Type an email',
prefixIcon: const Icon(Icons.email_rounded),
suffixIcon: _isAddingEmail
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: null,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
filled: true,
fillColor: colorScheme.surfaceContainerLow,