Add in-app copy/move, background downloads, and a Favorites tab
Build APK / build (push) Successful in 5m18s
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:
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/app_tab.dart';
|
||||
import '../views/activity_view.dart';
|
||||
import '../views/favorites_view.dart';
|
||||
import '../views/files_view.dart';
|
||||
import '../views/photos_view.dart';
|
||||
import '../views/recent_view.dart';
|
||||
@@ -16,6 +17,8 @@ Widget buildAppTabView(AppTab tab, ScrollController controller) {
|
||||
return FilesView(scrollController: controller);
|
||||
case AppTab.photos:
|
||||
return PhotosView(scrollController: controller);
|
||||
case AppTab.favorites:
|
||||
return FavoritesView(scrollController: controller);
|
||||
case AppTab.activity:
|
||||
return ActivityView(scrollController: controller);
|
||||
case AppTab.trash:
|
||||
|
||||
@@ -51,11 +51,24 @@ class DetailsSheet extends StatelessWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
DetailsHeader(item: item),
|
||||
// Icon size matches the bottom nav bar's own tab icons
|
||||
// (FloatingBottomNavBar) rather than TabBar's oversized default,
|
||||
// so a "tabs" affordance reads the same size everywhere in the
|
||||
// app.
|
||||
const TabBar(
|
||||
tabs: [
|
||||
Tab(icon: Icon(Icons.info_outline_rounded), text: 'Info'),
|
||||
Tab(icon: Icon(Icons.history_rounded), text: 'Versions'),
|
||||
Tab(icon: Icon(Icons.electric_bolt_rounded), text: 'Activity'),
|
||||
Tab(
|
||||
icon: Icon(Icons.info_outline_rounded, size: 22),
|
||||
text: 'Info',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.history_rounded, size: 22),
|
||||
text: 'Versions',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.electric_bolt_rounded, size: 22),
|
||||
text: 'Activity',
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/move_copy_result.dart';
|
||||
import 'gradual_bottom_sheet.dart';
|
||||
|
||||
/// Shown after a Move/Copy batch comes back with `MoveCopyResult.conflicts`
|
||||
/// non-empty (an item with the same name already exists at the
|
||||
/// destination) - summarizes every conflict at once rather than prompting
|
||||
/// per item as they're hit, and lets the user resolve them all the same
|
||||
/// way (overwrite/keep both) or open a per-item breakdown. Returns the
|
||||
/// `ConflictChoice` map `ServerProvider.resolveConflicts` expects, or null
|
||||
/// if dismissed without choosing.
|
||||
class MoveCopyConflictSheet extends StatefulWidget {
|
||||
final List<MoveCopyConflict> conflicts;
|
||||
final ScrollController? scrollController;
|
||||
|
||||
const MoveCopyConflictSheet({
|
||||
super.key,
|
||||
required this.conflicts,
|
||||
this.scrollController,
|
||||
});
|
||||
|
||||
static Future<Map<String, ConflictChoice>?> show(
|
||||
BuildContext context,
|
||||
List<MoveCopyConflict> conflicts,
|
||||
) {
|
||||
return showGradualBottomSheet<Map<String, ConflictChoice>>(
|
||||
context,
|
||||
builder: (context, scrollController) => MoveCopyConflictSheet(
|
||||
conflicts: conflicts,
|
||||
scrollController: scrollController,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<MoveCopyConflictSheet> createState() => _MoveCopyConflictSheetState();
|
||||
}
|
||||
|
||||
class _MoveCopyConflictSheetState extends State<MoveCopyConflictSheet> {
|
||||
bool _decidingPerItem = false;
|
||||
late final Map<String, ConflictChoice> _perItemChoices = {
|
||||
for (final conflict in widget.conflicts)
|
||||
conflict.item.id: ConflictChoice.keepBoth,
|
||||
};
|
||||
|
||||
void _resolveAll(ConflictChoice choice) {
|
||||
Navigator.pop(context, {
|
||||
for (final conflict in widget.conflicts) conflict.item.id: choice,
|
||||
});
|
||||
}
|
||||
|
||||
void _confirmPerItem() {
|
||||
Navigator.pop(context, _perItemChoices);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final count = widget.conflicts.length;
|
||||
|
||||
return ListView(
|
||||
controller: widget.scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||
children: [
|
||||
Text(
|
||||
count == 1
|
||||
? '1 item already exists at the destination'
|
||||
: '$count items already exist at the destination',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Choose how to handle the conflicts.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!_decidingPerItem) ...[
|
||||
FilledButton(
|
||||
onPressed: () => _resolveAll(ConflictChoice.overwrite),
|
||||
child: const Text('Overwrite all'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => _resolveAll(ConflictChoice.keepBoth),
|
||||
child: const Text('Keep both (rename)'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () => setState(() => _decidingPerItem = true),
|
||||
child: const Text('Decide per item'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
for (final conflict in widget.conflicts)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
conflict.item.isFolder
|
||||
? Icons.folder_rounded
|
||||
: Icons.insert_drive_file_rounded,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
conflict.item.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_decidingPerItem) ...[
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: SegmentedButton<ConflictChoice>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: ConflictChoice.overwrite,
|
||||
label: Text('Overwrite'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ConflictChoice.keepBoth,
|
||||
label: Text('Keep both'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ConflictChoice.skip,
|
||||
label: Text('Skip'),
|
||||
),
|
||||
],
|
||||
selected: {_perItemChoices[conflict.item.id]!},
|
||||
showSelectedIcon: false,
|
||||
onSelectionChanged: (selection) => setState(() {
|
||||
_perItemChoices[conflict.item.id] = selection.first;
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_decidingPerItem) ...[
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _confirmPerItem,
|
||||
child: const Text('Confirm'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user