Dedupe native transfer/notification code and split ServerProvider into focused controllers
Build APK / build (push) Successful in 5m20s

Consolidates duplicated GET/PUT/notification-channel logic across
DownloadService/ShareUploadService/SyncEngine into shared Kotlin helpers,
gives upload/download real batch queueing instead of dropping a second
concurrent batch, and dedupes repeated Dart channel-argument boilerplate.

Replaces the 2300+ line ServerProvider god object with ten focused
ChangeNotifiers (SessionController, SettingsController, FilesController,
PhotosController, FavoritesController, TrashController, SharesController,
RecentController, SyncStatusController, PickController) plus ItemOperations,
a plain coordinator for cross-domain item mutations - fixing the coupling
where device-sync status, per-tab data, and global UI prefs all lived in
one object. Updates every view/widget call site accordingly and refreshes
the architecture/server/standards/styling docs to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 10:04:12 -04:00
co-authored by Claude Sonnet 5
parent faaec5365f
commit ff5f04e9a1
58 changed files with 4158 additions and 3594 deletions
+112 -103
View File
@@ -2,7 +2,10 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/app_tab.dart';
import '../models/saved_account.dart';
import '../providers/server_provider.dart';
import '../providers/files_controller.dart';
import '../providers/session_controller.dart';
import '../providers/settings_controller.dart';
import '../providers/sync_status_controller.dart';
import '../services/sync_service.dart';
import '../theme/app_theme.dart';
import '../widgets/frosted_glass_container.dart';
@@ -17,8 +20,10 @@ class AccountView extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final quota = provider.quota;
final session = context.watch<SessionController>();
final settings = context.watch<SettingsController>();
final files = context.watch<FilesController>();
final quota = files.quota;
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
@@ -46,7 +51,7 @@ class AccountView extends StatelessWidget {
radius: 28,
backgroundColor: colorScheme.primary,
child: Text(
(quota?.userName ?? provider.username)
(quota?.userName ?? session.username)
.substring(0, 1)
.toUpperCase(),
style: theme.textTheme.headlineSmall?.copyWith(
@@ -61,14 +66,14 @@ class AccountView extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
quota?.userName ?? provider.username,
quota?.userName ?? session.username,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
quota?.email ?? provider.username,
quota?.email ?? session.username,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -119,8 +124,8 @@ class AccountView extends StatelessWidget {
children: [
Expanded(
child: Text(
Uri.tryParse(provider.serverUrl)?.host ??
provider.serverUrl,
Uri.tryParse(session.serverUrl)?.host ??
session.serverUrl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
@@ -185,7 +190,7 @@ class AccountView extends StatelessWidget {
icon: const Icon(Icons.refresh_rounded),
tooltip: 'Refresh cached data',
onPressed: () async {
await provider.refreshData();
await files.refreshData();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@@ -200,14 +205,14 @@ class AccountView extends StatelessWidget {
icon: const Icon(Icons.logout_rounded),
tooltip: 'Logout',
color: Colors.orange.shade700,
onPressed: () => _handleLogout(context, provider),
onPressed: () => _handleLogout(context, session),
),
IconButton(
icon: const Icon(Icons.delete_outline_rounded),
tooltip: 'Remove account',
color: colorScheme.error,
onPressed: () =>
_confirmRemoveActive(context, provider),
_confirmRemoveActive(context, session),
),
],
),
@@ -271,7 +276,7 @@ class AccountView extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
provider.useDynamicColor
settings.useDynamicColor
? 'Matching your wallpaper'
: 'Custom color',
style: theme.textTheme.bodySmall?.copyWith(
@@ -283,8 +288,8 @@ class AccountView extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_AccentSwatch(
isSelected: provider.useDynamicColor,
onTap: () => provider.setUseDynamicColor(true),
isSelected: settings.useDynamicColor,
onTap: () => settings.setUseDynamicColor(true),
borderColor: colorScheme.onSurface,
background: colorScheme.surfaceContainerHighest,
child: Icon(
@@ -295,11 +300,11 @@ class AccountView extends StatelessWidget {
),
...AppTheme.seedColors.map((color) {
final isSelected =
!provider.useDynamicColor &&
provider.seedColor == color;
!settings.useDynamicColor &&
settings.seedColor == color;
return _AccentSwatch(
isSelected: isSelected,
onTap: () => provider.setSeedColor(color),
onTap: () => settings.setSeedColor(color),
borderColor: colorScheme.onSurface,
background: color,
child: isSelected
@@ -343,9 +348,9 @@ class AccountView extends StatelessWidget {
label: Text('Dark'),
),
],
selected: {provider.themeMode},
selected: {settings.themeMode},
onSelectionChanged: (set) =>
provider.setThemeMode(set.first),
settings.setThemeMode(set.first),
),
),
const SizedBox(height: 8),
@@ -355,8 +360,8 @@ class AccountView extends StatelessWidget {
subtitle: const Text(
'Use pure black backgrounds in dark mode',
),
value: provider.amoledDark,
onChanged: provider.setAmoledDark,
value: settings.amoledDark,
onChanged: settings.setAmoledDark,
),
],
),
@@ -380,8 +385,8 @@ class AccountView extends StatelessWidget {
subtitle: const Text(
'Tapping the current bottom bar tab scrolls its list back to the top',
),
value: provider.tapTabToScrollTop,
onChanged: provider.setTapTabToScrollTop,
value: settings.tapTabToScrollTop,
onChanged: settings.setTapTabToScrollTop,
),
),
const SizedBox(height: 24),
@@ -462,24 +467,24 @@ class AccountView extends StatelessWidget {
);
}
/// Logout keeps the account saved (see [ServerProvider.logout]) so it's
/// harmless/reversible from a one-tap resume on the login screen -
/// Logout keeps the account saved (see [SessionController.logout]) so
/// it's harmless/reversible from a one-tap resume on the login screen -
/// doesn't need a confirmation dialog the way [_confirmRemoveActive] does.
Future<void> _handleLogout(
BuildContext context,
ServerProvider provider,
SessionController session,
) async {
final navigator = Navigator.of(context);
await provider.logout();
await session.logout();
navigator.popUntil((route) => route.isFirst);
}
Future<void> _confirmRemoveActive(
BuildContext context,
ServerProvider provider,
SessionController session,
) async {
final host = Uri.tryParse(provider.serverUrl)?.host ?? provider.serverUrl;
final hasOtherAccounts = provider.accounts.length > 1;
final host = Uri.tryParse(session.serverUrl)?.host ?? session.serverUrl;
final hasOtherAccounts = session.accounts.length > 1;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
@@ -487,8 +492,8 @@ class AccountView extends StatelessWidget {
title: const Text('Remove Account'),
content: Text(
hasOtherAccounts
? 'Remove ${provider.username} ($host)? Another saved account will become active.'
: 'Remove ${provider.username} ($host)? You can add it again later.',
? 'Remove ${session.username} ($host)? Another saved account will become active.'
: 'Remove ${session.username} ($host)? You can add it again later.',
),
actions: [
TextButton(
@@ -507,15 +512,15 @@ class AccountView extends StatelessWidget {
},
);
if (confirmed != true) return;
final id = provider.activeAccountId;
final id = session.activeAccountId;
if (id == null) return;
if (!context.mounted) return;
final navigator = Navigator.of(context);
await provider.removeAccount(id);
await session.removeAccount(id);
// Only pop back to the root route if that was the last account and the
// session actually ended - if it fell back to another saved account,
// Settings just keeps showing (now for that account) instead.
if (!provider.isLoggedIn) {
if (!session.isLoggedIn) {
navigator.popUntil((route) => route.isFirst);
}
}
@@ -529,10 +534,10 @@ class _AccountsCard extends StatelessWidget {
Future<void> _confirmRemove(
BuildContext context,
ServerProvider provider,
SessionController session,
SavedAccount account,
) async {
final isActive = account.id == provider.activeAccountId;
final isActive = account.id == session.activeAccountId;
final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl;
final confirmed = await showDialog<bool>(
context: context,
@@ -540,7 +545,7 @@ class _AccountsCard extends StatelessWidget {
return AlertDialog(
title: const Text('Remove Account'),
content: Text(
isActive && provider.accounts.length > 1
isActive && session.accounts.length > 1
? 'Remove ${account.username} ($host)? Another saved account will become active.'
: 'Remove ${account.username} ($host)? You can add it again later.',
),
@@ -563,13 +568,13 @@ class _AccountsCard extends StatelessWidget {
if (confirmed == true) {
if (!context.mounted) return;
final navigator = Navigator.of(context);
await provider.removeAccount(account.id);
await session.removeAccount(account.id);
// Only when this was the last saved account does isLoggedIn drop to
// false and main.dart swap the root route to LoginView underneath -
// pop back to it then, rather than leaving Settings stranded on top.
// Removing a non-active account, or falling back to another one,
// both keep the user logged in, so Settings should just stay put.
if (!provider.isLoggedIn) {
if (!session.isLoggedIn) {
navigator.popUntil((route) => route.isFirst);
}
}
@@ -577,8 +582,8 @@ class _AccountsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
final accounts = provider.accounts;
final session = context.watch<SessionController>();
final accounts = session.accounts;
return Card(
child: Column(
@@ -586,11 +591,11 @@ class _AccountsCard extends StatelessWidget {
for (final account in accounts) ...[
_AccountRow(
account: account,
isActive: account.id == provider.activeAccountId,
onTap: account.id == provider.activeAccountId
isActive: account.id == session.activeAccountId,
onTap: account.id == session.activeAccountId
? null
: () => provider.switchAccount(account.id),
onRemove: () => _confirmRemove(context, provider, account),
: () => session.switchAccount(account.id),
onRemove: () => _confirmRemove(context, session, account),
),
const Divider(height: 1, indent: 16, endIndent: 16),
],
@@ -671,12 +676,12 @@ class _SecurityCard extends StatelessWidget {
Future<void> _handleLoginLockChanged(
BuildContext context,
ServerProvider provider,
SessionController session,
bool value,
) async {
final success = value
? await provider.setupLoginLock()
: await provider.disableLoginLock();
? await session.setupLoginLock()
: await session.disableLoginLock();
if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -693,7 +698,7 @@ class _SecurityCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
final session = context.watch<SessionController>();
return Card(
child: Column(
@@ -704,18 +709,18 @@ class _SecurityCard extends StatelessWidget {
subtitle: const Text(
"Require this device's PIN or biometric to open Noo",
),
value: provider.loginLockEnabled,
value: session.loginLockEnabled,
onChanged: (value) =>
_handleLoginLockChanged(context, provider, value),
_handleLoginLockChanged(context, session, value),
),
const Divider(height: 1, indent: 16, endIndent: 16),
SwitchListTile(
secondary: const Icon(Icons.swap_horiz_rounded),
title: const Text('Lock account switching'),
subtitle: const Text('Unlock to switch between saved accounts'),
value: provider.lockAccountSwitching,
onChanged: provider.loginLockEnabled
? provider.setLockAccountSwitching
value: session.lockAccountSwitching,
onChanged: session.loginLockEnabled
? session.setLockAccountSwitching
: null,
),
const Divider(height: 1, indent: 16, endIndent: 16),
@@ -723,9 +728,9 @@ class _SecurityCard extends StatelessWidget {
secondary: const Icon(Icons.visibility_off_rounded),
title: const Text('Lock hidden files'),
subtitle: const Text('Unlock to reveal hidden files and folders'),
value: provider.lockHiddenFiles,
onChanged: provider.loginLockEnabled
? provider.setLockHiddenFiles
value: session.lockHiddenFiles,
onChanged: session.loginLockEnabled
? session.setLockHiddenFiles
: null,
),
],
@@ -744,10 +749,13 @@ class _DeviceSyncCard extends StatefulWidget {
class _DeviceSyncCardState extends State<_DeviceSyncCard> {
bool _syncingNow = false;
Future<void> _syncNow(ServerProvider provider) async {
Future<void> _syncNow(
SessionController session,
SyncStatusController sync,
) async {
setState(() => _syncingNow = true);
try {
await SyncService.syncNow(provider);
await SyncService.syncNow(session, sync);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -766,9 +774,10 @@ class _DeviceSyncCardState extends State<_DeviceSyncCard> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final folders = provider.syncedPaths;
final everything = provider.syncEverything;
final session = context.watch<SessionController>();
final sync = context.watch<SyncStatusController>();
final folders = sync.syncedPaths;
final everything = sync.syncEverything;
return Card(
child: Column(
@@ -781,7 +790,7 @@ class _DeviceSyncCardState extends State<_DeviceSyncCard> {
'Mirror the whole account instead of picking folders',
),
value: everything,
onChanged: provider.setSyncEverything,
onChanged: sync.setSyncEverything,
),
const Divider(height: 1, indent: 16, endIndent: 16),
if (everything)
@@ -814,7 +823,7 @@ class _DeviceSyncCardState extends State<_DeviceSyncCard> {
trailing: IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: 'Stop syncing',
onPressed: () => provider.removeSyncedPath(folder),
onPressed: () => sync.removeSyncedPath(folder),
),
),
const Divider(height: 1, indent: 16, endIndent: 16),
@@ -823,8 +832,8 @@ class _DeviceSyncCardState extends State<_DeviceSyncCard> {
secondary: const Icon(Icons.signal_cellular_alt_rounded),
title: const Text('Sync on cellular'),
subtitle: const Text('Off = background sync only runs on Wi-Fi'),
value: provider.syncOnCellular,
onChanged: provider.setSyncOnCellular,
value: sync.syncOnCellular,
onChanged: sync.setSyncOnCellular,
),
const Divider(height: 1, indent: 16, endIndent: 16),
ListTile(
@@ -840,7 +849,7 @@ class _DeviceSyncCardState extends State<_DeviceSyncCard> {
: const Icon(Icons.sync_rounded),
title: const Text('Sync now'),
enabled: (everything || folders.isNotEmpty) && !_syncingNow,
onTap: () => _syncNow(provider),
onTap: () => _syncNow(session, sync),
),
],
),
@@ -857,9 +866,9 @@ class _BottomBarAppearanceCard extends StatefulWidget {
}
class _BottomBarAppearanceCardState extends State<_BottomBarAppearanceCard> {
// Mirrors the provider values locally so the slider thumb and the live
// Mirrors the controller values locally so the slider thumb and the live
// preview track the drag gesture on every frame. Driving them straight off
// `provider.bottomBar*` instead would tie the slider's own responsiveness
// `settings.bottomBar*` instead would tie the slider's own responsiveness
// to a full Provider-wide rebuild — including the expensive BackdropFilter
// blur in the preview — which can't keep up with fast drag ticks and makes
// the thumb appear stuck until the next unrelated rebuild.
@@ -870,9 +879,9 @@ class _BottomBarAppearanceCardState extends State<_BottomBarAppearanceCard> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final opacity = _opacity ?? provider.bottomBarOpacity;
final blur = _blur ?? provider.bottomBarBlur;
final settings = context.watch<SettingsController>();
final opacity = _opacity ?? settings.bottomBarOpacity;
final blur = _blur ?? settings.bottomBarBlur;
return Card(
child: Padding(
@@ -974,7 +983,7 @@ class _BottomBarAppearanceCardState extends State<_BottomBarAppearanceCard> {
min: 0.1,
max: 1.0,
onChanged: (value) => setState(() => _opacity = value),
onChangeEnd: provider.setBottomBarOpacity,
onChangeEnd: settings.setBottomBarOpacity,
),
Row(
@@ -994,7 +1003,7 @@ class _BottomBarAppearanceCardState extends State<_BottomBarAppearanceCard> {
min: 0,
max: 40,
onChanged: (value) => setState(() => _blur = value),
onChangeEnd: provider.setBottomBarBlur,
onChangeEnd: settings.setBottomBarBlur,
),
],
),
@@ -1075,7 +1084,7 @@ class _SwipeActionsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final provider = context.watch<ServerProvider>();
final settings = context.watch<SettingsController>();
return Card(
child: Padding(
@@ -1086,16 +1095,16 @@ class _SwipeActionsCard extends StatelessWidget {
context,
theme,
label: 'Swipe right',
value: provider.swipeRightAction,
onChanged: provider.setSwipeRightAction,
value: settings.swipeRightAction,
onChanged: settings.setSwipeRightAction,
),
const Divider(height: 1),
_buildRow(
context,
theme,
label: 'Swipe left',
value: provider.swipeLeftAction,
onChanged: provider.setSwipeLeftAction,
value: settings.swipeLeftAction,
onChanged: settings.setSwipeLeftAction,
),
],
),
@@ -1157,21 +1166,21 @@ class _MediaPlayerCard extends StatelessWidget {
Future<void> _openPicker(
BuildContext context,
ServerProvider provider,
SettingsController settings,
) async {
final selected = await showDialog<MediaProgressBarStyle>(
context: context,
builder: (_) =>
_SeekBarStyleDialog(current: provider.mediaProgressBarStyle),
_SeekBarStyleDialog(current: settings.mediaProgressBarStyle),
);
if (selected != null) provider.setMediaProgressBarStyle(selected);
if (selected != null) settings.setMediaProgressBarStyle(selected);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final settings = context.watch<SettingsController>();
return Card(
child: ListTile(
@@ -1182,17 +1191,17 @@ class _MediaPlayerCard extends StatelessWidget {
fontWeight: FontWeight.w600,
),
),
subtitle: Text(_seekBarStyleLabel(provider.mediaProgressBarStyle)),
subtitle: Text(_seekBarStyleLabel(settings.mediaProgressBarStyle)),
trailing: SizedBox(
width: 64,
height: 24,
child: SeekBarPreview(
style: provider.mediaProgressBarStyle,
style: settings.mediaProgressBarStyle,
playedColor: colorScheme.primary,
trackColor: colorScheme.outlineVariant,
),
),
onTap: () => _openPicker(context, provider),
onTap: () => _openPicker(context, settings),
),
);
}
@@ -1354,7 +1363,7 @@ class _CacheSettingsCard extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final files = context.watch<FilesController>();
return Card(
child: Padding(
@@ -1362,9 +1371,9 @@ class _CacheSettingsCard extends StatelessWidget {
child: Column(
children: [
RadioGroup<CachePolicy>(
groupValue: provider.cachePolicy,
groupValue: files.cachePolicy,
onChanged: (value) {
if (value != null) provider.setCachePolicy(value);
if (value != null) files.setCachePolicy(value);
},
child: Column(
children: [
@@ -1377,7 +1386,7 @@ class _CacheSettingsCard extends StatelessWidget {
],
),
),
if (provider.cachePolicy == CachePolicy.interval)
if (files.cachePolicy == CachePolicy.interval)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column(
@@ -1392,7 +1401,7 @@ class _CacheSettingsCard extends StatelessWidget {
style: theme.textTheme.bodyMedium,
),
Text(
'${provider.cacheIntervalMinutes} min',
'${files.cacheIntervalMinutes} min',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -1400,13 +1409,13 @@ class _CacheSettingsCard extends StatelessWidget {
],
),
Slider(
value: provider.cacheIntervalMinutes.toDouble(),
value: files.cacheIntervalMinutes.toDouble(),
min: 1,
max: 60,
divisions: 59,
label: '${provider.cacheIntervalMinutes} min',
label: '${files.cacheIntervalMinutes} min',
onChanged: (value) =>
provider.setCacheIntervalMinutes(value.round()),
files.setCacheIntervalMinutes(value.round()),
),
],
),
@@ -1423,7 +1432,7 @@ class _TabSettingsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
final settings = context.watch<SettingsController>();
return Card(
clipBehavior: Clip.antiAlias,
@@ -1431,20 +1440,20 @@ class _TabSettingsCard extends StatelessWidget {
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
onReorderItem: (oldIndex, newIndex) {
final order = List<AppTab>.from(provider.tabOrder);
final order = List<AppTab>.from(settings.tabOrder);
final tab = order.removeAt(oldIndex);
order.insert(newIndex, tab);
provider.setTabOrder(order);
settings.setTabOrder(order);
},
children: [
for (final tab in provider.tabOrder)
for (final tab in settings.tabOrder)
_TabConfigRow(
key: ValueKey(tab),
tab: tab,
isVisible: !provider.hiddenTabs.contains(tab),
isDefault: provider.defaultTab == tab,
isVisible: !settings.hiddenTabs.contains(tab),
isDefault: settings.defaultTab == tab,
onVisibilityChanged: (value) {
final error = provider.setTabHidden(tab, !value);
final error = settings.setTabHidden(tab, !value);
if (error != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -1454,7 +1463,7 @@ class _TabSettingsCard extends StatelessWidget {
);
}
},
onSetDefault: () => provider.setDefaultTab(tab),
onSetDefault: () => settings.setDefaultTab(tab),
),
],
),
+4 -5
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../providers/server_provider.dart';
import '../providers/files_controller.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart';
@@ -15,13 +15,13 @@ class ActivityView extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final activities = provider.activities;
final files = context.watch<FilesController>();
final activities = files.activities;
final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: 16)),
if (provider.isLoading && activities.isEmpty)
if (files.isLoading && activities.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
@@ -134,7 +134,6 @@ class ActivityView extends StatelessWidget {
return SyncedHeaderScaffold(
scrollController: scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
contentSlivers: contentSlivers,
);
+63 -80
View File
@@ -7,7 +7,11 @@ import 'package:share_plus/share_plus.dart';
import '../models/app_tab.dart';
import '../models/nextcloud_item.dart';
import '../models/selection_action.dart';
import '../providers/server_provider.dart';
import '../providers/favorites_controller.dart';
import '../providers/files_controller.dart';
import '../providers/item_operations.dart';
import '../providers/session_controller.dart';
import '../providers/settings_controller.dart';
import '../services/download_service.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/item_icon.dart';
@@ -24,7 +28,7 @@ import 'move_copy_destination_picker.dart';
/// Every favorited file/folder across the whole account, account-wide - a
/// real tab rather than a filter toggle scoped to whatever folder the
/// Files tab happens to be browsing (see `ServerProvider.fetchAllFavorites`
/// Files tab happens to be browsing (see `FavoritesController.fetchAll`
/// for why: a favorited item several folders deep needs to show up
/// regardless of whether its parent folders are themselves favorited,
/// which a current-folder-only filter can never do, and a flat account-
@@ -32,7 +36,7 @@ import 'move_copy_destination_picker.dart';
/// toggle had - the trail showing wherever Files was last browsing while
/// the content showed something else entirely - simply can't happen).
/// Shares Files' own sort/hidden/storage-scope/grid-list display prefs
/// (via `ServerProvider.applyFilesDisplayPrefs`) rather than a separate
/// (via `FilesController.applyFilesDisplayPrefs`) rather than a separate
/// parallel settings dimension.
class FavoritesView extends StatefulWidget {
final ScrollController scrollController;
@@ -128,13 +132,12 @@ class _FavoritesViewState extends State<FavoritesView>
/// back on this tab either way.
void _openFavorite(
BuildContext context,
ServerProvider provider,
NextcloudItem item,
List<NextcloudItem> siblings,
) {
if (item.isFolder) {
provider.navigateToAbsoluteFolder(item.path);
provider.requestTab(AppTab.files);
context.read<FilesController>().navigateToAbsoluteFolder(item.path);
context.read<SettingsController>().requestTab(AppTab.files);
} else {
Navigator.push(
context,
@@ -145,42 +148,39 @@ class _FavoritesViewState extends State<FavoritesView>
/// The bulk actions shown in the sticky selection toolbar for the
/// currently-selected items - same set Files/Photos offer.
List<SelectionAction> _buildSelectionActions(
ServerProvider provider,
List<NextcloudItem> selected,
) {
List<SelectionAction> _buildSelectionActions(List<NextcloudItem> selected) {
return [
SelectionAction(
icon: Icons.favorite_border_rounded,
label: 'Remove from favorites',
onTap: () => _unfavoriteSelected(provider, selected),
onTap: () => _unfavoriteSelected(selected),
),
SelectionAction(
icon: Icons.share_rounded,
label: 'Share',
onTap: () => selected.length == 1
? ShareSheet.show(context, selected.single)
: _shareSelected(context, provider, selected),
: _shareSelected(context, selected),
),
SelectionAction(
icon: Icons.download_rounded,
label: 'Download',
onTap: () => _downloadSelected(context, provider, selected),
onTap: () => _downloadSelected(context, selected),
),
SelectionAction(
icon: Icons.delete_outline_rounded,
label: 'Delete',
onTap: () => _confirmDeleteSelected(context, provider, selected),
onTap: () => _confirmDeleteSelected(context, selected),
),
SelectionAction(
icon: Icons.copy_rounded,
label: 'Copy',
onTap: () => _moveOrCopySelected(provider, selected, copy: true),
onTap: () => _moveOrCopySelected(selected, copy: true),
),
SelectionAction(
icon: Icons.drive_file_move_rounded,
label: 'Move',
onTap: () => _moveOrCopySelected(provider, selected, copy: false),
onTap: () => _moveOrCopySelected(selected, copy: false),
),
if (selected.length == 1)
SelectionAction(
@@ -191,21 +191,19 @@ class _FavoritesViewState extends State<FavoritesView>
];
}
Future<void> _unfavoriteSelected(
ServerProvider provider,
List<NextcloudItem> items,
) async {
Future<void> _unfavoriteSelected(List<NextcloudItem> items) async {
final ops = context.read<ItemOperations>();
for (final item in items) {
await provider.toggleItemFavorite(item);
await ops.toggleItemFavorite(item);
}
_clearSelection();
}
Future<void> _shareSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
const SnackBar(
@@ -216,7 +214,7 @@ class _FavoritesViewState extends State<FavoritesView>
final lines = <String>[];
for (final item in items) {
final link = await provider.createShareLink(item);
final link = await ops.createShareLink(item);
if (link != null) {
lines.add(items.length > 1 ? '${item.name}: $link' : link);
}
@@ -252,16 +250,16 @@ class _FavoritesViewState extends State<FavoritesView>
/// `FilesView._downloadSelected`'s identical doc comment for why.
Future<void> _downloadSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final session = context.read<SessionController>();
final files = items.where((i) => !i.isFolder).toList();
_clearSelection();
if (files.isEmpty) return;
final messenger = ScaffoldMessenger.of(context);
try {
await DownloadService.startDownload(provider, files);
await DownloadService.startDownload(session, files);
messenger.showSnackBar(
SnackBar(
content: Text(
@@ -284,7 +282,6 @@ class _FavoritesViewState extends State<FavoritesView>
Future<void> _confirmDeleteSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final confirmed = await showDialog<bool>(
@@ -313,10 +310,11 @@ class _FavoritesViewState extends State<FavoritesView>
);
if (confirmed != true || !context.mounted) return;
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
var succeeded = 0;
for (final item in items) {
final success = await provider.deleteItem(item.path);
final success = await ops.deleteItem(item.path);
if (success) succeeded++;
}
@@ -330,7 +328,6 @@ class _FavoritesViewState extends State<FavoritesView>
}
Future<void> _moveOrCopySelected(
ServerProvider provider,
List<NextcloudItem> items, {
required bool copy,
}) async {
@@ -351,7 +348,7 @@ class _FavoritesViewState extends State<FavoritesView>
);
}
Widget _buildControlsRow(ServerProvider provider) {
Widget _buildControlsRow(FilesController files) {
return SizedBox(
height: 44,
child: SingleChildScrollView(
@@ -360,28 +357,28 @@ class _FavoritesViewState extends State<FavoritesView>
children: [
IconButton(
icon: Icon(
provider.filesSortAscending
files.filesSortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: provider.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: provider.toggleFilesSortOrder,
tooltip: files.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: files.toggleFilesSortOrder,
),
SizedBox(
width: 130,
child: SortMenuButton(
field: provider.filesSortField,
onChanged: provider.setFilesSortField,
field: files.filesSortField,
onChanged: files.setFilesSortField,
),
),
ToggleIconButton(
icon: provider.showHiddenFiles
icon: files.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: provider.showHiddenFiles,
onTap: () => provider.toggleShowHiddenFiles(),
isSelected: files.showHiddenFiles,
onTap: () => files.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
@@ -389,14 +386,14 @@ class _FavoritesViewState extends State<FavoritesView>
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: provider.storageScope == StorageScope.cloud,
onTap: () => provider.setStorageScope(StorageScope.cloud),
isSelected: files.storageScope == StorageScope.cloud,
onTap: () => files.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: provider.storageScope == StorageScope.external,
onTap: () => provider.setStorageScope(StorageScope.external),
isSelected: files.storageScope == StorageScope.external,
onTap: () => files.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
@@ -406,14 +403,14 @@ class _FavoritesViewState extends State<FavoritesView>
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !provider.isGridView,
onTap: () => provider.setGridView(false),
isSelected: !files.isGridView,
onTap: () => files.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: provider.isGridView,
onTap: () => provider.setGridView(true),
isSelected: files.isGridView,
onTap: () => files.setGridView(true),
tooltip: 'Grid view',
),
],
@@ -427,11 +424,11 @@ class _FavoritesViewState extends State<FavoritesView>
Widget _buildListTile(
BuildContext context,
NextcloudItem item,
ServerProvider provider,
List<NextcloudItem> siblings,
) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final session = context.watch<SessionController>();
final isSelected = _selectedIds.contains(item.id);
return Padding(
@@ -447,7 +444,7 @@ class _FavoritesViewState extends State<FavoritesView>
if (_isSelecting) {
_toggleSelection(item);
} else {
_openFavorite(context, provider, item, siblings);
_openFavorite(context, item, siblings);
}
},
onLongPress: () => _toggleSelection(item),
@@ -461,7 +458,7 @@ class _FavoritesViewState extends State<FavoritesView>
checkmarkSize: 24,
child: ItemThumbnail(
item: item,
provider: provider,
service: session.service,
size: 44,
borderRadius: 12,
iconSize: 22,
@@ -503,10 +500,10 @@ class _FavoritesViewState extends State<FavoritesView>
Widget _buildGridCard(
BuildContext context,
NextcloudItem item,
ServerProvider provider,
List<NextcloudItem> siblings,
) {
final theme = Theme.of(context);
final session = context.watch<SessionController>();
final isSelected = _selectedIds.contains(item.id);
final isMedia =
(item.type == NextcloudItemType.image ||
@@ -523,7 +520,7 @@ class _FavoritesViewState extends State<FavoritesView>
if (_isSelecting) {
_toggleSelection(item);
} else {
_openFavorite(context, provider, item, siblings);
_openFavorite(context, item, siblings);
}
},
onLongPress: () => _toggleSelection(item),
@@ -533,7 +530,7 @@ class _FavoritesViewState extends State<FavoritesView>
child: isMedia
? Image.network(
item.previewUrl!,
headers: provider.service?.authHeaders,
headers: session.service?.authHeaders,
fit: BoxFit.cover,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
@@ -587,16 +584,17 @@ class _FavoritesViewState extends State<FavoritesView>
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final favoritesController = context.watch<FavoritesController>();
final filesController = context.watch<FilesController>();
if (!_requested) {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => provider.fetchAllFavorites(),
(_) => favoritesController.fetchAll(),
);
}
final favorites = provider.favoriteItems;
final favorites = favoritesController.items;
final siblings = favorites.where((i) => !i.isFolder).toList();
final selectedItems = favorites
.where((i) => _selectedIds.contains(i.id))
@@ -609,17 +607,17 @@ class _FavoritesViewState extends State<FavoritesView>
height: 60,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: _buildControlsRow(provider),
child: _buildControlsRow(filesController),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (provider.isFavoritesLoading && favorites.isEmpty)
if (favoritesController.isLoading && favorites.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (provider.favoritesErrorMessage != null)
else if (favoritesController.errorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
@@ -643,7 +641,7 @@ class _FavoritesViewState extends State<FavoritesView>
),
const SizedBox(height: 8),
Text(
provider.favoritesErrorMessage!,
favoritesController.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -651,7 +649,7 @@ class _FavoritesViewState extends State<FavoritesView>
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: provider.fetchAllFavorites,
onPressed: favoritesController.fetchAll,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
@@ -683,7 +681,7 @@ class _FavoritesViewState extends State<FavoritesView>
),
),
)
else if (provider.isGridView)
else if (filesController.isGridView)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverGrid(
@@ -694,12 +692,7 @@ class _FavoritesViewState extends State<FavoritesView>
mainAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(
context,
favorites[index],
provider,
siblings,
);
return _buildGridCard(context, favorites[index], siblings);
}, childCount: favorites.length),
),
)
@@ -708,12 +701,7 @@ class _FavoritesViewState extends State<FavoritesView>
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return _buildListTile(
context,
favorites[index],
provider,
siblings,
);
return _buildListTile(context, favorites[index], siblings);
}, childCount: favorites.length),
),
),
@@ -728,11 +716,10 @@ class _FavoritesViewState extends State<FavoritesView>
},
child: SyncedHeaderScaffold(
scrollController: widget.scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: provider.fetchAllFavorites,
onRefresh: favoritesController.fetchAll,
selectionBar: _isSelecting
? _buildSelectionBar(context, theme, provider, selectedItems)
? _buildSelectionBar(context, theme, selectedItems)
: null,
contentSlivers: contentSlivers,
),
@@ -745,7 +732,6 @@ class _FavoritesViewState extends State<FavoritesView>
Widget _buildSelectionBar(
BuildContext context,
ThemeData theme,
ServerProvider provider,
List<NextcloudItem> selectedItems,
) {
return Padding(
@@ -783,10 +769,7 @@ class _FavoritesViewState extends State<FavoritesView>
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final action in _buildSelectionActions(
provider,
selectedItems,
))
for (final action in _buildSelectionActions(selectedItems))
IconButton(
icon: Icon(action.icon, size: 20),
tooltip: action.label,
+45 -40
View File
@@ -9,7 +9,9 @@ import 'package:pdfx/pdfx.dart';
import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart';
import '../providers/item_operations.dart';
import '../providers/session_controller.dart';
import '../providers/settings_controller.dart';
import '../services/download_service.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/frosted_glass_container.dart';
@@ -148,13 +150,13 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
}
Future<String> _downloadToTemp(
ServerProvider provider,
SessionController session,
NextcloudItem item,
) async {
final tempDir = await getTemporaryDirectory();
final savePath = p.join(tempDir.path, item.name);
setState(() => _downloadProgress = 0);
await provider.service!.downloadToFile(
await session.service!.downloadToFile(
item.path,
savePath,
onProgress: (received, total) {
@@ -167,11 +169,11 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
return savePath;
}
Future<void> _openExternally(ServerProvider provider) async {
Future<void> _openExternally(SessionController session) async {
final item = _currentItem;
setState(() => _isBusy = true);
try {
final path = await _downloadToTemp(provider, item);
final path = await _downloadToTemp(session, item);
final result = await OpenFile.open(path);
if (mounted && result.type != ResultType.done) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -194,10 +196,10 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
/// `UploadService`/`ShareUploadService.kt` on the upload side: a real
/// Android Service survives the app being closed mid-download, and one
/// notification covers progress/cancel instead of blocking this screen.
Future<void> _downloadToDevice(ServerProvider provider) async {
Future<void> _downloadToDevice(SessionController session) async {
final item = _currentItem;
try {
await DownloadService.startDownload(provider, [item]);
await DownloadService.startDownload(session, [item]);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -216,16 +218,16 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
}
}
Future<void> _toggleFavorite(ServerProvider provider) async {
Future<void> _toggleFavorite(ItemOperations ops) async {
final item = _currentItem;
await provider.toggleItemFavorite(item);
await ops.toggleItemFavorite(item);
if (!mounted) return;
setState(() {
_mediaItems[_currentIndex] = item.copyWith(isFavorite: !item.isFavorite);
});
}
Future<void> _deleteCurrentItem(ServerProvider provider) async {
Future<void> _deleteCurrentItem(ItemOperations ops) async {
final item = _currentItem;
final confirmed = await showDialog<bool>(
context: context,
@@ -254,7 +256,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
if (confirmed != true || !mounted) return;
setState(() => _isBusy = true);
final success = await provider.deleteItem(item.path);
final success = await ops.deleteItem(item.path);
if (!mounted) return;
setState(() => _isBusy = false);
@@ -283,7 +285,9 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
@override
Widget build(BuildContext context) {
final provider = context.read<ServerProvider>();
final session = context.read<SessionController>();
final settings = context.read<SettingsController>();
final ops = context.read<ItemOperations>();
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
@@ -296,7 +300,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
Positioned.fill(
child: Container(
color: colorScheme.surface,
child: _buildBody(context, provider),
child: _buildBody(context, session),
),
),
if (_downloadProgress != null)
@@ -322,8 +326,8 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: FrostedGlassContainer(
opacity: provider.bottomBarOpacity,
blurSigma: provider.bottomBarBlur,
opacity: settings.bottomBarOpacity,
blurSigma: settings.bottomBarBlur,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
@@ -367,13 +371,13 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
child: _MediaActionBar(
isFavorite: _currentItem.isFavorite,
isBusy: _isBusy,
opacity: provider.bottomBarOpacity,
blurSigma: provider.bottomBarBlur,
opacity: settings.bottomBarOpacity,
blurSigma: settings.bottomBarBlur,
onShare: () => ShareSheet.show(context, _currentItem),
onFavorite: () => _toggleFavorite(provider),
onDelete: () => _deleteCurrentItem(provider),
onOpenExternally: () => _openExternally(provider),
onDownload: () => _downloadToDevice(provider),
onFavorite: () => _toggleFavorite(ops),
onDelete: () => _deleteCurrentItem(ops),
onOpenExternally: () => _openExternally(session),
onDownload: () => _downloadToDevice(session),
onDetails: () => DetailsSheet.show(context, _currentItem),
),
),
@@ -385,10 +389,11 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
);
}
Widget _buildBody(BuildContext context, ServerProvider provider) {
Widget _buildBody(BuildContext context, SessionController session) {
if (!_isSwipeable) {
return _buildStaticPreview(context, provider);
return _buildStaticPreview(context, session);
}
final settings = context.read<SettingsController>();
return PageView.builder(
controller: _pageController,
@@ -399,24 +404,24 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
if (mediaItem.type == NextcloudItemType.image) {
return _ImagePreview(
key: ValueKey(mediaItem.id),
url: provider.service!.fileUrl(mediaItem.path),
headers: provider.service!.authHeaders,
url: session.service!.fileUrl(mediaItem.path),
headers: session.service!.authHeaders,
);
}
return _VideoPreview(
key: ValueKey(mediaItem.id),
url: provider.service!.fileUrl(mediaItem.path),
headers: provider.service!.authHeaders,
url: session.service!.fileUrl(mediaItem.path),
headers: session.service!.authHeaders,
isActive: index == _currentIndex,
controlsBottomOffset: _controlsVisible ? 108 : 24,
opacity: provider.bottomBarOpacity,
blurSigma: provider.bottomBarBlur,
opacity: settings.bottomBarOpacity,
blurSigma: settings.bottomBarBlur,
);
},
);
}
Widget _buildStaticPreview(BuildContext context, ServerProvider provider) {
Widget _buildStaticPreview(BuildContext context, SessionController session) {
switch (widget.item.type) {
case NextcloudItemType.image:
case NextcloudItemType.video:
@@ -424,15 +429,15 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
return const SizedBox.shrink();
default:
if (_isPdf) {
return _PdfPreview(item: widget.item, provider: provider);
return _PdfPreview(item: widget.item, session: session);
}
if (_isText) {
return _TextPreview(item: widget.item, provider: provider);
return _TextPreview(item: widget.item, session: session);
}
return _UnsupportedPreview(
item: widget.item,
isBusy: _isBusy,
onOpenExternally: () => _openExternally(provider),
onOpenExternally: () => _openExternally(session),
onOpenDetails: () => DetailsSheet.show(context, widget.item),
);
}
@@ -752,7 +757,7 @@ class _VideoPreviewState extends State<_VideoPreview> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final progressBarStyle = context
.watch<ServerProvider>()
.watch<SettingsController>()
.mediaProgressBarStyle;
if (_error != null) {
return Center(
@@ -966,9 +971,9 @@ class _WavySeekBarState extends State<_WavySeekBar>
class _PdfPreview extends StatefulWidget {
final NextcloudItem item;
final ServerProvider provider;
final SessionController session;
const _PdfPreview({required this.item, required this.provider});
const _PdfPreview({required this.item, required this.session});
@override
State<_PdfPreview> createState() => _PdfPreviewState();
@@ -986,7 +991,7 @@ class _PdfPreviewState extends State<_PdfPreview> {
Future<void> _load() async {
try {
final bytes = await widget.provider.service!.fetchBytes(widget.item.path);
final bytes = await widget.session.service!.fetchBytes(widget.item.path);
if (!mounted) return;
setState(() {
_controller = PdfControllerPinch(
@@ -1033,9 +1038,9 @@ class _PdfPreviewState extends State<_PdfPreview> {
class _TextPreview extends StatefulWidget {
final NextcloudItem item;
final ServerProvider provider;
final SessionController session;
const _TextPreview({required this.item, required this.provider});
const _TextPreview({required this.item, required this.session});
@override
State<_TextPreview> createState() => _TextPreviewState();
@@ -1053,7 +1058,7 @@ class _TextPreviewState extends State<_TextPreview> {
Future<void> _load() async {
try {
final bytes = await widget.provider.service!.fetchBytes(widget.item.path);
final bytes = await widget.session.service!.fetchBytes(widget.item.path);
if (mounted) {
setState(() => _content = utf8.decode(bytes, allowMalformed: true));
}
+137 -137
View File
@@ -9,7 +9,12 @@ import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/nextcloud_item.dart';
import '../models/selection_action.dart';
import '../providers/server_provider.dart';
import '../providers/files_controller.dart';
import '../providers/item_operations.dart';
import '../providers/pick_controller.dart';
import '../providers/session_controller.dart';
import '../providers/settings_controller.dart';
import '../providers/sync_status_controller.dart';
import '../services/download_service.dart';
import '../services/share_intent_service.dart';
import '../widgets/breadcrumbs.dart';
@@ -204,16 +209,14 @@ class _FilesViewState extends State<FilesView>
/// folders are still browsable, a matching file either toggles selection
/// (multi-select requests) or immediately finishes the pick, and a
/// non-matching file (wrong mime type for the caller) is rejected.
void _handlePickTap(
BuildContext context,
ServerProvider provider,
NextcloudItem item,
) {
void _handlePickTap(BuildContext context, NextcloudItem item) {
final files = context.read<FilesController>();
final pick = context.read<PickController>();
if (item.isFolder) {
provider.navigateToFolder(item.path);
files.navigateToFolder(item.path);
return;
}
if (!provider.itemMatchesPickFilter(item)) {
if (!pick.itemMatchesPickFilter(item)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("This app can't accept this file type"),
@@ -222,25 +225,27 @@ class _FilesViewState extends State<FilesView>
);
return;
}
if (provider.pickRequest!.allowMultiple) {
if (pick.pickRequest!.allowMultiple) {
_toggleSelection(item);
} else {
provider.confirmPick([item]);
pick.confirmPick([item]);
}
}
/// The bulk actions shown in the sticky selection toolbar for the
/// currently-selected items.
List<SelectionAction> _buildSelectionActions(
ServerProvider provider,
BuildContext context,
List<NextcloudItem> selected,
) {
if (provider.isPicking) {
final pick = context.watch<PickController>();
final sync = context.watch<SyncStatusController>();
if (pick.isPicking) {
return [
SelectionAction(
icon: Icons.check_rounded,
label: 'Use ${selected.length} item(s)',
onTap: () => provider.confirmPick(selected),
onTap: () => pick.confirmPick(selected),
),
];
}
@@ -252,52 +257,52 @@ class _FilesViewState extends State<FilesView>
label: selected.every((i) => i.isFavorite)
? 'Remove from favorites'
: 'Favorite',
onTap: () => _favoriteSelected(provider, selected),
onTap: () => _favoriteSelected(context, selected),
),
SelectionAction(
icon: Icons.share_rounded,
label: 'Share',
onTap: () => selected.length == 1
? ShareSheet.show(context, selected.single)
: _shareSelected(context, provider, selected),
: _shareSelected(context, selected),
),
SelectionAction(
icon: Icons.download_rounded,
label: 'Download',
onTap: () => _downloadSelected(context, provider, selected),
onTap: () => _downloadSelected(context, selected),
),
SelectionAction(
icon: Icons.delete_outline_rounded,
label: 'Delete',
onTap: () => _confirmDeleteSelected(context, provider, selected),
onTap: () => _confirmDeleteSelected(context, selected),
),
SelectionAction(
icon: Icons.copy_rounded,
label: 'Copy',
onTap: () => _moveOrCopySelected(provider, selected, copy: true),
onTap: () => _moveOrCopySelected(context, selected, copy: true),
),
SelectionAction(
icon: Icons.drive_file_move_rounded,
label: 'Move',
onTap: () => _moveOrCopySelected(provider, selected, copy: false),
onTap: () => _moveOrCopySelected(context, selected, copy: false),
),
if (selected.length == 1)
SelectionAction(
icon: Icons.drive_file_rename_outline_rounded,
label: 'Rename',
onTap: () => _renameItem(provider, selected.single),
onTap: () => _renameItem(selected.single),
),
if (selected.length == 1)
SelectionAction(
icon: provider.isPathSynced(selected.single.path)
icon: sync.isPathSynced(selected.single.path)
? Icons.sync_rounded
: Icons.sync_outlined,
label: provider.isPathSynced(selected.single.path)
label: sync.isPathSynced(selected.single.path)
? 'Stop syncing to device'
: 'Sync to device',
onTap: () => provider.isPathSynced(selected.single.path)
? provider.removeSyncedPath(selected.single.path)
: provider.addSyncedPath(selected.single.path),
onTap: () => sync.isPathSynced(selected.single.path)
? sync.removeSyncedPath(selected.single.path)
: sync.addSyncedPath(selected.single.path),
),
if (selected.length == 1)
SelectionAction(
@@ -308,7 +313,7 @@ class _FilesViewState extends State<FilesView>
];
}
Future<void> _renameItem(ServerProvider provider, NextcloudItem item) async {
Future<void> _renameItem(NextcloudItem item) async {
final controller = TextEditingController(text: item.name);
final newName = await showDialog<String>(
context: context,
@@ -345,7 +350,8 @@ class _FilesViewState extends State<FilesView>
if (!mounted) return;
final messenger = ScaffoldMessenger.of(context);
final success = await provider.renameItem(item, newName);
final ops = context.read<ItemOperations>();
final success = await ops.renameItem(item, newName);
_clearSelection();
messenger.showSnackBar(
SnackBar(
@@ -359,9 +365,9 @@ class _FilesViewState extends State<FilesView>
Future<void> _shareSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
const SnackBar(
@@ -372,7 +378,7 @@ class _FilesViewState extends State<FilesView>
final lines = <String>[];
for (final item in items) {
final link = await provider.createShareLink(item);
final link = await ops.createShareLink(item);
if (link != null) {
lines.add(items.length > 1 ? '${item.name}: $link' : link);
}
@@ -408,14 +414,14 @@ class _FilesViewState extends State<FilesView>
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final files = context.watch<FilesController>();
final pathDepth = provider.pathStack.length;
final pathDepth = files.pathStack.length;
final navigatingDeeper = pathDepth > _lastPathDepth;
_lastPathDepth = pathDepth;
final hasBreadcrumbs = provider.pathStack.length > 1;
final selectedItems = provider.items
final hasBreadcrumbs = files.pathStack.length > 1;
final selectedItems = files.items
.where((i) => _selectedIds.contains(i.id))
.toList();
@@ -445,16 +451,16 @@ class _FilesViewState extends State<FilesView>
children: [
IconButton(
icon: Icon(
provider.filesSortAscending
files.filesSortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: provider.filesSortAscending
tooltip: files.filesSortAscending
? 'Ascending'
: 'Descending',
onPressed: provider.toggleFilesSortOrder,
onPressed: files.toggleFilesSortOrder,
),
// A plain width, not Expanded - this row scrolls
// horizontally now, which gives every control unbounded
@@ -462,16 +468,16 @@ class _FilesViewState extends State<FilesView>
SizedBox(
width: 130,
child: SortMenuButton(
field: provider.filesSortField,
onChanged: provider.setFilesSortField,
field: files.filesSortField,
onChanged: files.setFilesSortField,
),
),
ToggleIconButton(
icon: provider.showHiddenFiles
icon: files.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: provider.showHiddenFiles,
onTap: () => provider.toggleShowHiddenFiles(),
isSelected: files.showHiddenFiles,
onTap: () => files.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
@@ -479,17 +485,17 @@ class _FilesViewState extends State<FilesView>
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: provider.storageScope == StorageScope.cloud,
isSelected: files.storageScope == StorageScope.cloud,
onTap: () =>
provider.setStorageScope(StorageScope.cloud),
files.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected:
provider.storageScope == StorageScope.external,
files.storageScope == StorageScope.external,
onTap: () =>
provider.setStorageScope(StorageScope.external),
files.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
@@ -500,17 +506,17 @@ class _FilesViewState extends State<FilesView>
ToggleIconButton(
icon: Icons.select_all_rounded,
isSelected:
provider.filesTypeFilter == FilesTypeFilter.all,
files.filesTypeFilter == FilesTypeFilter.all,
onTap: () =>
provider.setFilesTypeFilter(FilesTypeFilter.all),
files.setFilesTypeFilter(FilesTypeFilter.all),
tooltip: 'Files & folders',
),
ToggleIconButton(
icon: Icons.insert_drive_file_outlined,
isSelected:
provider.filesTypeFilter ==
files.filesTypeFilter ==
FilesTypeFilter.filesOnly,
onTap: () => provider.setFilesTypeFilter(
onTap: () => files.setFilesTypeFilter(
FilesTypeFilter.filesOnly,
),
tooltip: 'Files only',
@@ -518,9 +524,9 @@ class _FilesViewState extends State<FilesView>
ToggleIconButton(
icon: Icons.folder_outlined,
isSelected:
provider.filesTypeFilter ==
files.filesTypeFilter ==
FilesTypeFilter.foldersOnly,
onTap: () => provider.setFilesTypeFilter(
onTap: () => files.setFilesTypeFilter(
FilesTypeFilter.foldersOnly,
),
tooltip: 'Folders only',
@@ -532,14 +538,14 @@ class _FilesViewState extends State<FilesView>
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !provider.isGridView,
onTap: () => provider.setGridView(false),
isSelected: !files.isGridView,
onTap: () => files.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: provider.isGridView,
onTap: () => provider.setGridView(true),
isSelected: files.isGridView,
onTap: () => files.setGridView(true),
tooltip: 'Grid view',
),
],
@@ -553,8 +559,8 @@ class _FilesViewState extends State<FilesView>
SizedBox(
height: 32,
child: Breadcrumbs(
pathStack: provider.pathStack,
onTap: (index) => provider.navigateToPathIndex(index),
pathStack: files.pathStack,
onTap: (index) => files.navigateToPathIndex(index),
),
),
],
@@ -574,12 +580,12 @@ class _FilesViewState extends State<FilesView>
),
),
// Files List / Grid
if (provider.isLoading)
if (files.isLoading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (provider.errorMessage != null)
else if (files.errorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
@@ -603,7 +609,7 @@ class _FilesViewState extends State<FilesView>
),
const SizedBox(height: 8),
Text(
provider.errorMessage!,
files.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -611,7 +617,7 @@ class _FilesViewState extends State<FilesView>
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: provider.refreshData,
onPressed: files.refreshData,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry Connection'),
),
@@ -620,7 +626,7 @@ class _FilesViewState extends State<FilesView>
),
),
)
else if (provider.items.isEmpty)
else if (files.items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
@@ -643,7 +649,7 @@ class _FilesViewState extends State<FilesView>
),
),
)
else if (provider.isGridView)
else if (files.isGridView)
SliverPadding(
key: const ValueKey('files-grid'),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
@@ -655,14 +661,14 @@ class _FilesViewState extends State<FilesView>
mainAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate((context, index) {
final item = provider.items[index];
final item = files.items[index];
return _FolderEnterAnimation(
key: ValueKey('${provider.currentFolderPath}::${item.id}'),
key: ValueKey('${files.currentFolderPath}::${item.id}'),
index: index,
fromRight: navigatingDeeper,
child: _buildGridCard(context, item, provider),
child: _buildGridCard(context, item),
);
}, childCount: provider.items.length),
}, childCount: files.items.length),
),
)
else
@@ -671,14 +677,14 @@ class _FilesViewState extends State<FilesView>
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = provider.items[index];
final item = files.items[index];
return _FolderEnterAnimation(
key: ValueKey('${provider.currentFolderPath}::${item.id}'),
key: ValueKey('${files.currentFolderPath}::${item.id}'),
index: index,
fromRight: navigatingDeeper,
child: _buildListTile(context, item, provider),
child: _buildListTile(context, item),
);
}, childCount: provider.items.length),
}, childCount: files.items.length),
),
),
@@ -693,29 +699,28 @@ class _FilesViewState extends State<FilesView>
];
return PopScope(
canPop: !_isSelecting && provider.pathStack.length <= 1,
canPop: !_isSelecting && files.pathStack.length <= 1,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
if (_isSelecting) {
_clearSelection();
} else {
provider.navigateUp();
files.navigateUp();
}
},
child: SyncedHeaderScaffold(
scrollController: widget.scrollController,
provider: provider,
actions: [
IconButton(
icon: const Icon(Icons.add_rounded),
tooltip: 'New',
onPressed: () => _showCreateMenu(context, provider),
onPressed: () => _showCreateMenu(context),
),
const MoreTabsButton(),
const ProfileAvatarButton(),
],
selectionBar: _isSelecting
? _buildSelectionBar(context, theme, provider, selectedItems)
? _buildSelectionBar(context, theme, selectedItems)
: null,
contentSlivers: contentSlivers,
),
@@ -728,7 +733,6 @@ class _FilesViewState extends State<FilesView>
Widget _buildSelectionBar(
BuildContext context,
ThemeData theme,
ServerProvider provider,
List<NextcloudItem> selectedItems,
) {
return Padding(
@@ -771,7 +775,7 @@ class _FilesViewState extends State<FilesView>
mainAxisSize: MainAxisSize.min,
children: [
for (final action in _buildSelectionActions(
provider,
context,
selectedItems,
))
IconButton(
@@ -790,13 +794,14 @@ class _FilesViewState extends State<FilesView>
}
Future<void> _favoriteSelected(
ServerProvider provider,
BuildContext context,
List<NextcloudItem> items,
) async {
final ops = context.read<ItemOperations>();
final allFavorited = items.every((i) => i.isFavorite);
for (final item in items) {
if (item.isFavorite == allFavorited) {
await provider.toggleItemFavorite(item);
await ops.toggleItemFavorite(item);
}
}
_clearSelection();
@@ -810,9 +815,10 @@ class _FilesViewState extends State<FilesView>
/// notification for the whole selection instead of blocking here.
Future<void> _downloadSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final session = context.read<SessionController>();
final sync = context.read<SyncStatusController>();
final files = items.where((i) => !i.isFolder).toList();
_clearSelection();
if (files.isEmpty) return;
@@ -822,9 +828,9 @@ class _FilesViewState extends State<FilesView>
// Already mirrored locally by device sync (every selected file, not
// just some)? Save straight from the local copy instead of a fresh
// network fetch through DownloadService - see
// ServerProvider.localSyncedFilePath.
// SyncStatusController.localSyncedFilePath.
final localPaths = await Future.wait(
files.map(provider.localSyncedFilePath),
files.map(sync.localSyncedFilePath),
);
if (localPaths.every((path) => path != null)) {
try {
@@ -859,7 +865,7 @@ class _FilesViewState extends State<FilesView>
}
try {
await DownloadService.startDownload(provider, files);
await DownloadService.startDownload(session, files);
messenger.showSnackBar(
SnackBar(
content: Text(
@@ -882,7 +888,6 @@ class _FilesViewState extends State<FilesView>
Future<void> _confirmDeleteSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final confirmed = await showDialog<bool>(
@@ -912,9 +917,10 @@ class _FilesViewState extends State<FilesView>
if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
final ops = context.read<ItemOperations>();
var succeeded = 0;
for (final item in items) {
final success = await provider.deleteItem(item.path);
final success = await ops.deleteItem(item.path);
if (success) succeeded++;
}
@@ -928,7 +934,7 @@ class _FilesViewState extends State<FilesView>
}
Future<void> _moveOrCopySelected(
ServerProvider provider,
BuildContext context,
List<NextcloudItem> items, {
required bool copy,
}) async {
@@ -937,7 +943,7 @@ class _FilesViewState extends State<FilesView>
items,
copy: copy,
);
if (!mounted || result == null) return;
if (!context.mounted || result == null) return;
_clearSelection();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -949,13 +955,15 @@ class _FilesViewState extends State<FilesView>
);
}
Widget _buildListTile(
BuildContext context,
NextcloudItem item,
ServerProvider provider,
) {
Widget _buildListTile(BuildContext context, NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final files = context.watch<FilesController>();
final pick = context.watch<PickController>();
final sync = context.watch<SyncStatusController>();
final session = context.watch<SessionController>();
final settings = context.watch<SettingsController>();
final ops = context.read<ItemOperations>();
final isSelected = _selectedIds.contains(item.id);
// No border radius here — the outer ClipRRect below is the only place
@@ -968,17 +976,17 @@ class _FilesViewState extends State<FilesView>
: colorScheme.surfaceContainerLow,
child: InkWell(
onTap: () {
if (provider.isPicking) {
_handlePickTap(context, provider, item);
if (pick.isPicking) {
_handlePickTap(context, item);
} else if (_isSelecting) {
_toggleSelection(item);
} else if (item.isFolder) {
provider.navigateToFolder(item.path);
files.navigateToFolder(item.path);
} else {
_openFile(context, item, provider);
_openFile(context, item);
}
},
onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple
onLongPress: pick.isPicking && !pick.pickRequest!.allowMultiple
? null
: () => _toggleSelection(item),
child: Padding(
@@ -994,7 +1002,7 @@ class _FilesViewState extends State<FilesView>
checkmarkSize: 24,
child: ItemThumbnail(
item: item,
provider: provider,
service: session.service,
size: 44,
borderRadius: 12,
iconSize: 22,
@@ -1004,7 +1012,7 @@ class _FilesViewState extends State<FilesView>
right: -2,
bottom: -2,
child: SyncStatusBadge(
status: provider.syncStatusFor(item),
status: sync.syncStatusFor(item),
),
),
],
@@ -1039,15 +1047,15 @@ class _FilesViewState extends State<FilesView>
),
);
final content = _isSelecting || provider.isPicking
final content = _isSelecting || pick.isPicking
? card
: SwipeableItem(
itemKey: ValueKey('file-${item.id}'),
itemName: item.name,
provider: provider,
onFavorite: () => provider.toggleItemFavorite(item),
settings: settings,
onFavorite: () => ops.toggleItemFavorite(item),
onShare: () => ShareSheet.show(context, item),
onDelete: () => _deleteViaSwipe(provider, item),
onDelete: () => _deleteViaSwipe(item),
child: card,
);
@@ -1057,11 +1065,9 @@ class _FilesViewState extends State<FilesView>
);
}
Future<void> _deleteViaSwipe(
ServerProvider provider,
NextcloudItem item,
) async {
final success = await provider.deleteItem(item.path);
Future<void> _deleteViaSwipe(NextcloudItem item) async {
final ops = context.read<ItemOperations>();
final success = await ops.deleteItem(item.path);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -1073,11 +1079,10 @@ class _FilesViewState extends State<FilesView>
);
}
Widget _buildGridCard(
BuildContext context,
NextcloudItem item,
ServerProvider provider,
) {
Widget _buildGridCard(BuildContext context, NextcloudItem item) {
final files = context.watch<FilesController>();
final pick = context.watch<PickController>();
final sync = context.watch<SyncStatusController>();
final isSelected = _selectedIds.contains(item.id);
final isMedia =
(item.type == NextcloudItemType.image ||
@@ -1090,17 +1095,17 @@ class _FilesViewState extends State<FilesView>
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
if (provider.isPicking) {
_handlePickTap(context, provider, item);
if (pick.isPicking) {
_handlePickTap(context, item);
} else if (_isSelecting) {
_toggleSelection(item);
} else if (item.isFolder) {
provider.navigateToFolder(item.path);
files.navigateToFolder(item.path);
} else {
_openFile(context, item, provider);
_openFile(context, item);
}
},
onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple
onLongPress: pick.isPicking && !pick.pickRequest!.allowMultiple
? null
: () => _toggleSelection(item),
child: Stack(
@@ -1109,13 +1114,13 @@ class _FilesViewState extends State<FilesView>
isSelected: isSelected,
checkmarkSize: 32,
child: isMedia
? _buildMediaGridContent(context, item, provider)
? _buildMediaGridContent(context, item)
: _buildPlainGridContent(context, item),
),
Positioned(
right: 6,
bottom: 6,
child: SyncStatusBadge(status: provider.syncStatusFor(item)),
child: SyncStatusBadge(status: sync.syncStatusFor(item)),
),
],
),
@@ -1126,11 +1131,8 @@ class _FilesViewState extends State<FilesView>
/// Grid content for images/videos: the actual preview fills the whole
/// card as a background, with the name/size legible over a bottom scrim
/// — matching a Google Photos-style grid instead of a small icon badge.
Widget _buildMediaGridContent(
BuildContext context,
NextcloudItem item,
ServerProvider provider,
) {
Widget _buildMediaGridContent(BuildContext context, NextcloudItem item) {
final session = context.watch<SessionController>();
final theme = Theme.of(context);
return LayoutBuilder(
@@ -1143,7 +1145,7 @@ class _FilesViewState extends State<FilesView>
children: [
Image.network(
item.previewUrl!,
headers: provider.service?.authHeaders,
headers: session.service?.authHeaders,
fit: BoxFit.cover,
cacheWidth: cachePixels,
cacheHeight: cachePixels,
@@ -1253,18 +1255,15 @@ class _FilesViewState extends State<FilesView>
);
}
void _openFile(
BuildContext context,
NextcloudItem item,
ServerProvider provider,
) {
void _openFile(BuildContext context, NextcloudItem item) {
final files = context.read<FilesController>();
Navigator.push(
context,
FileViewerScreen.route(item: item, siblings: provider.items),
FileViewerScreen.route(item: item, siblings: files.items),
);
}
void _showCreateMenu(BuildContext context, ServerProvider provider) {
void _showCreateMenu(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
@@ -1293,7 +1292,7 @@ class _FilesViewState extends State<FilesView>
title: const Text('New Folder'),
onTap: () {
Navigator.pop(sheetContext);
_showCreateFolderDialog(context, provider);
_showCreateFolderDialog(context);
},
),
],
@@ -1304,7 +1303,8 @@ class _FilesViewState extends State<FilesView>
);
}
void _showCreateFolderDialog(BuildContext context, ServerProvider provider) {
void _showCreateFolderDialog(BuildContext context) {
final files = context.read<FilesController>();
final controller = TextEditingController();
showDialog(
@@ -1329,7 +1329,7 @@ class _FilesViewState extends State<FilesView>
final name = controller.text.trim();
if (name.isNotEmpty) {
Navigator.pop(context);
final success = await provider.createFolder(name);
final success = await files.createFolder(name);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
+4 -4
View File
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/server_provider.dart';
import '../providers/session_controller.dart';
/// Shown in place of the main shell whenever [ServerProvider.needsUnlock] is
/// Shown in place of the main shell whenever [SessionController.needsUnlock] is
/// true - a fresh app launch with login lock configured, or returning to the
/// foreground after being backgrounded. Prompts automatically on first show
/// and again whenever the app resumes while still locked (e.g. the user
@@ -36,13 +36,13 @@ class _LockScreenViewState extends State<LockScreenView>
if (state != AppLifecycleState.resumed || !mounted || _authenticating) {
return;
}
if (context.read<ServerProvider>().needsUnlock) _attemptUnlock();
if (context.read<SessionController>().needsUnlock) _attemptUnlock();
}
Future<void> _attemptUnlock() async {
if (_authenticating || !mounted) return;
setState(() => _authenticating = true);
await context.read<ServerProvider>().attemptUnlock();
await context.read<SessionController>().attemptUnlock();
if (mounted) setState(() => _authenticating = false);
}
+23 -23
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/saved_account.dart';
import '../providers/server_provider.dart';
import '../providers/session_controller.dart';
import 'login_webview_view.dart';
class LoginView extends StatefulWidget {
@@ -28,7 +28,7 @@ class _LoginViewState extends State<LoginView> {
void initState() {
super.initState();
if (widget.isAddingAccount) {
_originalActiveAccountId = context.read<ServerProvider>().activeAccountId;
_originalActiveAccountId = context.read<SessionController>().activeAccountId;
}
}
@@ -41,13 +41,13 @@ class _LoginViewState extends State<LoginView> {
void _handleContinue() {
if (!_formKey.currentState!.validate()) return;
FocusScope.of(context).unfocus();
context.read<ServerProvider>().startLoginFlow(
context.read<SessionController>().startLoginFlow(
_urlController.text.trim(),
addAccount: widget.isAddingAccount,
);
}
/// Resumes a saved account with one tap (see [ServerProvider.switchAccount]).
/// Resumes a saved account with one tap (see [SessionController.switchAccount]).
/// That can fail silently from the account's own perspective - most often
/// a stored app password that no longer works (revoked server-side, or
/// left over from before a since-fixed bug that deleted it too eagerly on
@@ -55,8 +55,8 @@ class _LoginViewState extends State<LoginView> {
/// silently failing every time - a delete button on the row itself (see
/// [_SavedAccountRow]) is the way out.
Future<void> _continueAsAccount(SavedAccount account) async {
final provider = context.read<ServerProvider>();
final success = await provider.switchAccount(account.id);
final session = context.read<SessionController>();
final success = await session.switchAccount(account.id);
if (success || !mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
@@ -98,25 +98,25 @@ class _LoginViewState extends State<LoginView> {
},
);
if (confirmed != true || !mounted) return;
await context.read<ServerProvider>().removeAccount(account.id);
await context.read<SessionController>().removeAccount(account.id);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final session = context.watch<SessionController>();
final isAwaitingBrowser =
provider.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
final isInitiating = provider.loginFlowStatus == LoginFlowStatus.initiating;
session.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
final isInitiating = session.loginFlowStatus == LoginFlowStatus.initiating;
// A new/refreshed account has just become active - pop back to
// Settings rather than leaving this form sitting on top of it.
if (widget.isAddingAccount &&
!_popped &&
provider.loginFlowStatus == LoginFlowStatus.idle &&
provider.activeAccountId != _originalActiveAccountId) {
session.loginFlowStatus == LoginFlowStatus.idle &&
session.activeAccountId != _originalActiveAccountId) {
_popped = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).pop();
@@ -125,14 +125,14 @@ class _LoginViewState extends State<LoginView> {
// Every login (first account or an additional one) shows its login
// page in LoginWebViewView, a real screen this app owns - see
// ServerProvider.startLoginFlow's doc comment for why. Push it the
// SessionController.startLoginFlow's doc comment for why. Push it the
// moment there's a URL to show; _webViewPushed resets once that route
// pops (cancelled or done) so a retry after cancelling pushes it again.
if (!_webViewPushed &&
isAwaitingBrowser &&
provider.pendingLoginUrl != null) {
session.pendingLoginUrl != null) {
_webViewPushed = true;
final url = provider.pendingLoginUrl!;
final url = session.pendingLoginUrl!;
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) return;
// A plain MaterialPageRoute's animated slide/fade transition can
@@ -173,9 +173,9 @@ class _LoginViewState extends State<LoginView> {
// from here with one tap - no need to repeat
// Login Flow v2.
if (!widget.isAddingAccount &&
provider.accounts.isNotEmpty) ...[
session.accounts.isNotEmpty) ...[
_SavedAccountsSection(
accounts: provider.accounts,
accounts: session.accounts,
onSelect: (account) => _continueAsAccount(account),
onRemove: (account) => _confirmRemoveAccount(account),
),
@@ -201,10 +201,10 @@ class _LoginViewState extends State<LoginView> {
formKey: _formKey,
urlController: _urlController,
isLoading:
isInitiating || provider.isLoading || isAwaitingBrowser,
isInitiating || session.isLoading || isAwaitingBrowser,
errorMessage:
provider.loginFlowStatus == LoginFlowStatus.error
? provider.errorMessage
session.loginFlowStatus == LoginFlowStatus.error
? session.errorMessage
: null,
onContinue: _handleContinue,
theme: theme,
@@ -226,9 +226,9 @@ class _LoginViewState extends State<LoginView> {
// gone - this view could never be popped before "add account" existed,
// so that case wasn't reachable until now.
return PopScope(
canPop: provider.loginFlowStatus != LoginFlowStatus.awaitingBrowser,
canPop: session.loginFlowStatus != LoginFlowStatus.awaitingBrowser,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) provider.cancelLoginFlow();
if (!didPop) session.cancelLoginFlow();
},
child: body,
);
@@ -392,7 +392,7 @@ class _ServerForm extends StatelessWidget {
}
/// Saved accounts a logout left recoverable - tapping one resumes it via
/// [ServerProvider.switchAccount] instead of repeating Login Flow v2.
/// [SessionController.switchAccount] instead of repeating Login Flow v2.
class _SavedAccountsSection extends StatelessWidget {
final List<SavedAccount> accounts;
final ValueChanged<SavedAccount> onSelect;
+5 -5
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../providers/server_provider.dart';
import '../providers/session_controller.dart';
/// Every Login Flow v2 page - first login or an additional account -
/// rendered in Flutter's own WebView rather than a Chrome Custom Tab or
@@ -13,7 +13,7 @@ import '../providers/server_provider.dart';
/// exactly like every other screen in the app - and unlike a Custom Tab,
/// this screen can close itself automatically once login succeeds.
///
/// See ServerProvider.startLoginFlow's doc comment for the full rationale.
/// See SessionController.startLoginFlow's doc comment for the full rationale.
class LoginWebViewView extends StatefulWidget {
final Uri url;
@@ -56,19 +56,19 @@ class _LoginWebViewViewState extends State<LoginWebViewView> {
}
void _cancel() {
context.read<ServerProvider>().cancelLoginFlow();
context.read<SessionController>().cancelLoginFlow();
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
final session = context.watch<SessionController>();
// The login flow finished (success or error) or was cancelled
// elsewhere - close this screen automatically rather than leaving it
// sitting open over a flow that's no longer awaiting the browser.
if (!_popped &&
provider.loginFlowStatus != LoginFlowStatus.awaitingBrowser) {
session.loginFlowStatus != LoginFlowStatus.awaitingBrowser) {
_popped = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).pop();
+42 -37
View File
@@ -4,7 +4,9 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../models/move_copy_result.dart';
import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart';
import '../providers/files_controller.dart';
import '../providers/item_operations.dart';
import '../providers/session_controller.dart';
import '../widgets/breadcrumbs.dart';
import '../widgets/item_icon.dart';
import '../widgets/more_tabs_button.dart';
@@ -18,13 +20,13 @@ import '../widgets/synced_header_scaffold.dart';
/// Destination-folder browser for moving/copying [items] (a multi-select
/// batch from Files or Photos). Visually mirrors `ShareUploadView`'s
/// browser (same chrome/controls/listing), but deliberately does **not**
/// reuse `ServerProvider`'s shared `currentFolderPath`/`pathStack`/`items`
/// reuse `FilesController`'s shared `currentFolderPath`/`pathStack`/`items`
/// navigation state the way that screen does - this is pushed mid-browsing
/// session (the user was already looking at a specific Files-tab folder
/// when they selected items and tapped Move/Copy), so clobbering that
/// shared state here would strand the Files tab in whatever folder this
/// picker last visited. Instead it owns its own local navigation state and
/// fetches through `ServerProvider.fetchFolderListing`, a stateless
/// fetches through `FilesController.fetchFolderListing`, a stateless
/// pass-through that never touches shared fields - `ShareUploadView` gets
/// away with the shared state precisely because it always resets to root
/// and pops all the way to the app root afterward (a cold share-intent
@@ -83,7 +85,9 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
Future<void> _fetch(String path) async {
setState(() => _isLoading = true);
final raw = await context.read<ServerProvider>().fetchFolderListing(path);
final raw = await context.read<FilesController>().fetchFolderListing(
path,
);
if (!mounted) return;
setState(() {
_rawItems = raw;
@@ -109,7 +113,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
return true;
}
/// Client-side mirror of `ServerProvider`'s own authoritative check (see
/// Client-side mirror of `ItemOperations`' own authoritative check (see
/// `_isSelfOrDescendant`) - just for disabling the confirm button with an
/// explanation up front instead of letting the request round-trip and
/// fail.
@@ -123,11 +127,11 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
return false;
}
Future<void> _confirm(ServerProvider provider) async {
Future<void> _confirm(ItemOperations ops) async {
setState(() => _isSubmitting = true);
final result = widget.copy
? await provider.copyItems(widget.items, _currentPath)
: await provider.moveItems(widget.items, _currentPath);
? await ops.copyItems(widget.items, _currentPath)
: await ops.moveItems(widget.items, _currentPath);
if (!mounted) return;
if (result.blockedReason != null) {
@@ -151,7 +155,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
);
if (!mounted) return;
if (choices != null) {
final resolved = await provider.resolveConflicts(
final resolved = await ops.resolveConflicts(
result.conflicts,
_currentPath,
copy: widget.copy,
@@ -171,7 +175,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
).pop(MoveCopyResult(succeeded: succeeded, failed: failed));
}
Widget _buildControlsRow(ServerProvider provider) {
Widget _buildControlsRow(FilesController files) {
return SizedBox(
height: 44,
child: SingleChildScrollView(
@@ -180,28 +184,28 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
children: [
IconButton(
icon: Icon(
provider.filesSortAscending
files.filesSortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: provider.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: provider.toggleFilesSortOrder,
tooltip: files.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: files.toggleFilesSortOrder,
),
SizedBox(
width: 130,
child: SortMenuButton(
field: provider.filesSortField,
onChanged: provider.setFilesSortField,
field: files.filesSortField,
onChanged: files.setFilesSortField,
),
),
ToggleIconButton(
icon: provider.showHiddenFiles
icon: files.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: provider.showHiddenFiles,
onTap: () => provider.toggleShowHiddenFiles(),
isSelected: files.showHiddenFiles,
onTap: () => files.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
@@ -209,14 +213,14 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: provider.storageScope == StorageScope.cloud,
onTap: () => provider.setStorageScope(StorageScope.cloud),
isSelected: files.storageScope == StorageScope.cloud,
onTap: () => files.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: provider.storageScope == StorageScope.external,
onTap: () => provider.setStorageScope(StorageScope.external),
isSelected: files.storageScope == StorageScope.external,
onTap: () => files.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
@@ -226,14 +230,14 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !provider.isGridView,
onTap: () => provider.setGridView(false),
isSelected: !files.isGridView,
onTap: () => files.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: provider.isGridView,
onTap: () => provider.setGridView(true),
isSelected: files.isGridView,
onTap: () => files.setGridView(true),
tooltip: 'Grid view',
),
],
@@ -248,9 +252,10 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
// the listing matches what the Files tab itself would show for this
// folder) but are visually dimmed and inert, same treatment as
// ShareUploadView's own destination browser.
Widget _buildListTile(NextcloudItem item, ServerProvider provider) {
Widget _buildListTile(NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final session = context.watch<SessionController>();
final isFolder = item.isFolder;
return Padding(
@@ -272,7 +277,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
children: [
ItemThumbnail(
item: item,
provider: provider,
service: session.service,
size: 44,
borderRadius: 12,
iconSize: 22,
@@ -312,7 +317,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
);
}
Widget _buildGridCard(NextcloudItem item, ServerProvider provider) {
Widget _buildGridCard(NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isFolder = item.isFolder;
@@ -379,12 +384,13 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final files = context.watch<FilesController>();
final ops = context.read<ItemOperations>();
final hasBreadcrumbs = _pathStack.length > 1;
final currentLabel = _currentPath == '/'
? 'Home'
: _currentPath.split('/').where((s) => s.isNotEmpty).last;
final items = provider.applyFilesDisplayPrefs(_rawItems);
final items = files.applyFilesDisplayPrefs(_rawItems);
final invalidDestination = _destinationIsInvalid();
final controlsColumn = Padding(
@@ -392,7 +398,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildControlsRow(provider),
_buildControlsRow(files),
if (hasBreadcrumbs) ...[
const SizedBox(height: 10),
SizedBox(
@@ -430,7 +436,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
),
),
)
else if (provider.isGridView)
else if (files.isGridView)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverGrid(
@@ -441,7 +447,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
mainAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(items[index], provider);
return _buildGridCard(items[index]);
}, childCount: items.length),
),
)
@@ -450,7 +456,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return _buildListTile(items[index], provider);
return _buildListTile(items[index]);
}, childCount: items.length),
),
),
@@ -465,7 +471,6 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
child: Scaffold(
body: SyncedHeaderScaffold(
scrollController: _scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
contentSlivers: contentSlivers,
),
@@ -499,7 +504,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
child: FilledButton.icon(
onPressed: invalidDestination || _isSubmitting
? null
: () => _confirm(provider),
: () => _confirm(ops),
icon: _isSubmitting
? const SizedBox(
width: 18,
+70 -70
View File
@@ -5,7 +5,11 @@ import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/nextcloud_item.dart';
import '../models/selection_action.dart';
import '../providers/server_provider.dart';
import '../providers/files_controller.dart';
import '../providers/item_operations.dart';
import '../providers/photos_controller.dart';
import '../providers/pick_controller.dart';
import '../providers/session_controller.dart';
import '../services/download_service.dart';
import '../widgets/details/details_sheet.dart';
import '../widgets/more_tabs_button.dart';
@@ -111,10 +115,10 @@ class _PhotosViewState extends State<PhotosView>
/// just the matching/toggle/immediate-confirm branch.
void _handlePickTap(
BuildContext context,
ServerProvider provider,
PickController pick,
NextcloudItem item,
) {
if (!provider.itemMatchesPickFilter(item)) {
if (!pick.itemMatchesPickFilter(item)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("This app can't accept this file type"),
@@ -123,25 +127,25 @@ class _PhotosViewState extends State<PhotosView>
);
return;
}
if (provider.pickRequest!.allowMultiple) {
if (pick.pickRequest!.allowMultiple) {
_toggleSelection(item);
} else {
provider.confirmPick([item]);
pick.confirmPick([item]);
}
}
/// The bulk actions shown in the sticky selection toolbar for the
/// currently-selected items.
List<SelectionAction> _buildSelectionActions(
ServerProvider provider,
PickController pick,
List<NextcloudItem> selected,
) {
if (provider.isPicking) {
if (pick.isPicking) {
return [
SelectionAction(
icon: Icons.check_rounded,
label: 'Use ${selected.length} item(s)',
onTap: () => provider.confirmPick(selected),
onTap: () => pick.confirmPick(selected),
),
];
}
@@ -153,34 +157,34 @@ class _PhotosViewState extends State<PhotosView>
label: selected.every((i) => i.isFavorite)
? 'Remove from favorites'
: 'Favorite',
onTap: () => _favoriteSelected(provider, selected),
onTap: () => _favoriteSelected(selected),
),
SelectionAction(
icon: Icons.share_rounded,
label: 'Share',
onTap: () => selected.length == 1
? ShareSheet.show(context, selected.single)
: _shareSelected(context, provider, selected),
: _shareSelected(context, selected),
),
SelectionAction(
icon: Icons.download_rounded,
label: 'Download',
onTap: () => _downloadSelected(context, provider, selected),
onTap: () => _downloadSelected(context, selected),
),
SelectionAction(
icon: Icons.delete_outline_rounded,
label: 'Delete',
onTap: () => _confirmDeleteSelected(context, provider, selected),
onTap: () => _confirmDeleteSelected(context, selected),
),
SelectionAction(
icon: Icons.copy_rounded,
label: 'Copy',
onTap: () => _moveOrCopySelected(provider, selected, copy: true),
onTap: () => _moveOrCopySelected(selected, copy: true),
),
SelectionAction(
icon: Icons.drive_file_move_rounded,
label: 'Move',
onTap: () => _moveOrCopySelected(provider, selected, copy: false),
onTap: () => _moveOrCopySelected(selected, copy: false),
),
if (selected.length == 1)
SelectionAction(
@@ -195,16 +199,18 @@ class _PhotosViewState extends State<PhotosView>
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final photosController = context.watch<PhotosController>();
final filesController = context.watch<FilesController>();
final pick = context.watch<PickController>();
if (!_requested) {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => provider.fetchAllMedia(),
(_) => photosController.fetchAllMedia(),
);
}
final photos = provider.photoItems;
final photos = photosController.items;
final selectedItems = photos
.where((i) => _selectedIds.contains(i.id))
.toList();
@@ -217,38 +223,38 @@ class _PhotosViewState extends State<PhotosView>
children: [
IconButton(
icon: Icon(
provider.photosSortAscending
photosController.sortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: provider.photosSortAscending
tooltip: photosController.sortAscending
? 'Ascending'
: 'Descending',
onPressed: provider.togglePhotosSortOrder,
onPressed: photosController.toggleSortOrder,
),
Expanded(
child: SortMenuButton(
field: provider.photosSortField,
onChanged: provider.setPhotosSortField,
field: photosController.sortField,
onChanged: photosController.setSortField,
),
),
ToggleIconButton(
icon: provider.showFavoritesOnlyPhotos
icon: photosController.showFavoritesOnly
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
isSelected: provider.showFavoritesOnlyPhotos,
onTap: provider.toggleFavoritesFilterPhotos,
isSelected: photosController.showFavoritesOnly,
onTap: photosController.toggleFavoritesFilter,
tooltip: 'Favorites only',
),
const SizedBox(width: 4),
ToggleIconButton(
icon: provider.showHiddenPhotos
icon: photosController.showHidden
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: provider.showHiddenPhotos,
onTap: () => provider.toggleShowHiddenPhotos(),
isSelected: photosController.showHidden,
onTap: () => photosController.toggleShowHidden(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
@@ -256,14 +262,17 @@ class _PhotosViewState extends State<PhotosView>
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: provider.storageScope == StorageScope.cloud,
onTap: () => provider.setStorageScope(StorageScope.cloud),
isSelected: filesController.storageScope == StorageScope.cloud,
onTap: () =>
filesController.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: provider.storageScope == StorageScope.external,
onTap: () => provider.setStorageScope(StorageScope.external),
isSelected:
filesController.storageScope == StorageScope.external,
onTap: () =>
filesController.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
@@ -283,12 +292,12 @@ class _PhotosViewState extends State<PhotosView>
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (provider.isMediaLoading && photos.isEmpty)
if (photosController.isLoading && photos.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (provider.mediaErrorMessage != null)
else if (photosController.errorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
@@ -312,7 +321,7 @@ class _PhotosViewState extends State<PhotosView>
),
const SizedBox(height: 8),
Text(
provider.mediaErrorMessage!,
photosController.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -320,7 +329,7 @@ class _PhotosViewState extends State<PhotosView>
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: provider.fetchAllMedia,
onPressed: photosController.fetchAllMedia,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
@@ -364,7 +373,7 @@ class _PhotosViewState extends State<PhotosView>
),
delegate: SliverChildBuilderDelegate((context, index) {
final photo = photos[index];
return _buildPhotoTile(context, photo, provider);
return _buildPhotoTile(context, photo);
}, childCount: photos.length),
),
),
@@ -385,11 +394,10 @@ class _PhotosViewState extends State<PhotosView>
},
child: SyncedHeaderScaffold(
scrollController: widget.scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: provider.fetchAllMedia,
onRefresh: photosController.fetchAllMedia,
selectionBar: _isSelecting
? _buildSelectionBar(context, theme, provider, selectedItems)
? _buildSelectionBar(context, theme, pick, selectedItems)
: null,
contentSlivers: contentSlivers,
),
@@ -402,7 +410,7 @@ class _PhotosViewState extends State<PhotosView>
Widget _buildSelectionBar(
BuildContext context,
ThemeData theme,
ServerProvider provider,
PickController pick,
List<NextcloudItem> selectedItems,
) {
return Padding(
@@ -445,7 +453,7 @@ class _PhotosViewState extends State<PhotosView>
mainAxisSize: MainAxisSize.min,
children: [
for (final action in _buildSelectionActions(
provider,
pick,
selectedItems,
))
IconButton(
@@ -463,12 +471,10 @@ class _PhotosViewState extends State<PhotosView>
);
}
Widget _buildPhotoTile(
BuildContext context,
NextcloudItem photo,
ServerProvider provider,
) {
Widget _buildPhotoTile(BuildContext context, NextcloudItem photo) {
final colorScheme = Theme.of(context).colorScheme;
final pick = context.watch<PickController>();
final session = context.watch<SessionController>();
final isSelected = _selectedIds.contains(photo.id);
// No border radius on the tile itself — the outer ClipRRect below is
@@ -476,15 +482,15 @@ class _PhotosViewState extends State<PhotosView>
// floating rounded corner mid-drag (see the same fix in files_view).
final tile = GestureDetector(
onTap: () {
if (provider.isPicking) {
_handlePickTap(context, provider, photo);
if (pick.isPicking) {
_handlePickTap(context, pick, photo);
} else if (_isSelecting) {
_toggleSelection(photo);
} else {
_openLightbox(context, photo, provider);
_openLightbox(context, photo);
}
},
onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple
onLongPress: pick.isPicking && !pick.pickRequest!.allowMultiple
? null
: () => _toggleSelection(photo),
child: ColoredBox(
@@ -507,7 +513,7 @@ class _PhotosViewState extends State<PhotosView>
.round();
return Image.network(
photo.previewUrl!,
headers: provider.service?.authHeaders,
headers: session.service?.authHeaders,
fit: BoxFit.cover,
cacheWidth: cachePixels,
cacheHeight: cachePixels,
@@ -571,25 +577,20 @@ class _PhotosViewState extends State<PhotosView>
);
}
void _openLightbox(
BuildContext context,
NextcloudItem photo,
ServerProvider provider,
) {
void _openLightbox(BuildContext context, NextcloudItem photo) {
final photos = context.read<PhotosController>().items;
Navigator.push(
context,
FileViewerScreen.route(item: photo, siblings: provider.photoItems),
FileViewerScreen.route(item: photo, siblings: photos),
);
}
Future<void> _favoriteSelected(
ServerProvider provider,
List<NextcloudItem> items,
) async {
Future<void> _favoriteSelected(List<NextcloudItem> items) async {
final ops = context.read<ItemOperations>();
final allFavorited = items.every((i) => i.isFavorite);
for (final item in items) {
if (item.isFavorite == allFavorited) {
await provider.toggleItemFavorite(item);
await ops.toggleItemFavorite(item);
}
}
_clearSelection();
@@ -597,9 +598,9 @@ class _PhotosViewState extends State<PhotosView>
Future<void> _shareSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
const SnackBar(
@@ -610,7 +611,7 @@ class _PhotosViewState extends State<PhotosView>
final lines = <String>[];
for (final item in items) {
final link = await provider.createShareLink(item);
final link = await ops.createShareLink(item);
if (link != null) {
lines.add(items.length > 1 ? '${item.name}: $link' : link);
}
@@ -646,13 +647,13 @@ class _PhotosViewState extends State<PhotosView>
/// `FilesView._downloadSelected`'s identical doc comment for why.
Future<void> _downloadSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final session = context.read<SessionController>();
_clearSelection();
final messenger = ScaffoldMessenger.of(context);
try {
await DownloadService.startDownload(provider, items);
await DownloadService.startDownload(session, items);
messenger.showSnackBar(
SnackBar(
content: Text(
@@ -675,7 +676,6 @@ class _PhotosViewState extends State<PhotosView>
Future<void> _confirmDeleteSelected(
BuildContext context,
ServerProvider provider,
List<NextcloudItem> items,
) async {
final confirmed = await showDialog<bool>(
@@ -704,10 +704,11 @@ class _PhotosViewState extends State<PhotosView>
);
if (confirmed != true || !context.mounted) return;
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
var succeeded = 0;
for (final item in items) {
final success = await provider.deleteItem(item.path);
final success = await ops.deleteItem(item.path);
if (success) succeeded++;
}
@@ -721,7 +722,6 @@ class _PhotosViewState extends State<PhotosView>
}
Future<void> _moveOrCopySelected(
ServerProvider provider,
List<NextcloudItem> items, {
required bool copy,
}) async {
+16 -26
View File
@@ -2,7 +2,7 @@ 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';
import '../providers/recent_controller.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart';
@@ -48,7 +48,7 @@ Color _recentItemIconColor(BuildContext context, NextcloudItemType type) {
}
/// Recently modified files across the whole account (not folders), newest
/// first — see [ServerProvider.fetchRecent].
/// first — see [RecentController.fetchAll].
class RecentView extends StatefulWidget {
final ScrollController scrollController;
@@ -65,26 +65,24 @@ class _RecentViewState extends State<RecentView> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final recent = context.watch<RecentController>();
if (!_requested) {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => provider.fetchRecent(),
);
WidgetsBinding.instance.addPostFrameCallback((_) => recent.fetchAll());
}
final items = provider.recentItems;
final items = recent.items;
final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: 16)),
if (provider.isRecentLoading && items.isEmpty)
if (recent.isLoading && items.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (provider.recentErrorMessage != null)
else if (recent.errorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
@@ -108,7 +106,7 @@ class _RecentViewState extends State<RecentView> {
),
const SizedBox(height: 8),
Text(
provider.recentErrorMessage!,
recent.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -116,7 +114,7 @@ class _RecentViewState extends State<RecentView> {
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: provider.fetchRecent,
onPressed: recent.fetchAll,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
@@ -154,7 +152,7 @@ class _RecentViewState extends State<RecentView> {
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = items[index];
return _buildTile(context, item, provider);
return _buildTile(context, item);
}, childCount: items.length),
),
),
@@ -165,18 +163,13 @@ class _RecentViewState extends State<RecentView> {
return SyncedHeaderScaffold(
scrollController: widget.scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: provider.fetchRecent,
onRefresh: recent.fetchAll,
contentSlivers: contentSlivers,
);
}
Widget _buildTile(
BuildContext context,
NextcloudItem item,
ServerProvider provider,
) {
Widget _buildTile(BuildContext context, NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final iconColor = _recentItemIconColor(context, item.type);
@@ -192,7 +185,7 @@ class _RecentViewState extends State<RecentView> {
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => _openFile(context, item, provider),
onTap: () => _openFile(context, item),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
@@ -248,12 +241,9 @@ class _RecentViewState extends State<RecentView> {
);
}
void _openFile(
BuildContext context,
NextcloudItem item,
ServerProvider provider,
) {
final siblings = provider.recentItems.where((i) => i.isMedia).toList();
void _openFile(BuildContext context, NextcloudItem item) {
final recent = context.read<RecentController>();
final siblings = recent.items.where((i) => i.isMedia).toList();
Navigator.push(
context,
FileViewerScreen.route(item: item, siblings: siblings),
+8 -6
View File
@@ -5,7 +5,8 @@ import 'package:path/path.dart' as p;
import 'package:provider/provider.dart';
import '../models/app_tab.dart';
import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart';
import '../providers/files_controller.dart';
import '../providers/settings_controller.dart';
import 'file_viewer_screen.dart';
class SearchView extends StatefulWidget {
@@ -50,13 +51,13 @@ class _SearchViewState extends State<SearchView> {
}
Future<void> _runSearch(String query) async {
final provider = context.read<ServerProvider>();
final files = context.read<FilesController>();
setState(() {
_isSearching = true;
_error = null;
});
try {
final results = await provider.searchFiles(query);
final results = await files.searchFiles(query);
if (!mounted) return;
setState(() {
_results = results;
@@ -98,12 +99,13 @@ class _SearchViewState extends State<SearchView> {
/// browsing before), switches the shell to the Files tab, and closes
/// search. For a file, also opens it once back on Files.
Future<void> _openResult(NextcloudItem item) async {
final provider = context.read<ServerProvider>();
final files = context.read<FilesController>();
final settings = context.read<SettingsController>();
final navigator = Navigator.of(context);
final folderPath = item.isFolder ? item.path : p.dirname(item.path);
await provider.navigateToAbsoluteFolder(folderPath);
provider.requestTab(AppTab.files);
await files.navigateToAbsoluteFolder(folderPath);
settings.requestTab(AppTab.files);
navigator.popUntil((route) => route.isFirst);
if (!item.isFolder) {
+53 -56
View File
@@ -4,7 +4,9 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../models/app_tab.dart';
import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart';
import '../providers/files_controller.dart';
import '../providers/session_controller.dart';
import '../providers/settings_controller.dart';
import '../services/share_intent_service.dart';
import '../services/upload_service.dart';
import '../widgets/breadcrumbs.dart';
@@ -43,7 +45,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
// Shared files have no relationship to wherever the user was last
// browsing, so start the destination picker fresh at the root.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.read<ServerProvider>().navigateToAbsoluteFolder('/');
if (mounted) context.read<FilesController>().navigateToAbsoluteFolder('/');
});
}
@@ -53,11 +55,13 @@ class _ShareUploadViewState extends State<ShareUploadView> {
super.dispose();
}
Future<void> _uploadHere(ServerProvider provider) async {
Future<void> _uploadHere(SessionController session) async {
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
final files = context.read<FilesController>();
final settings = context.read<SettingsController>();
try {
await UploadService.startUpload(provider, widget.files);
await UploadService.startUpload(session, files, widget.files);
} catch (e) {
messenger.showSnackBar(
SnackBar(
@@ -68,7 +72,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
return;
}
provider.requestTab(AppTab.files);
settings.requestTab(AppTab.files);
navigator.popUntil((route) => route.isFirst);
messenger.showSnackBar(
SnackBar(
@@ -82,7 +86,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
);
}
Widget _buildControlsRow(ServerProvider provider) {
Widget _buildControlsRow(FilesController files) {
return SizedBox(
height: 44,
child: SingleChildScrollView(
@@ -91,28 +95,28 @@ class _ShareUploadViewState extends State<ShareUploadView> {
children: [
IconButton(
icon: Icon(
provider.filesSortAscending
files.filesSortAscending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: provider.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: provider.toggleFilesSortOrder,
tooltip: files.filesSortAscending ? 'Ascending' : 'Descending',
onPressed: files.toggleFilesSortOrder,
),
SizedBox(
width: 130,
child: SortMenuButton(
field: provider.filesSortField,
onChanged: provider.setFilesSortField,
field: files.filesSortField,
onChanged: files.setFilesSortField,
),
),
ToggleIconButton(
icon: provider.showHiddenFiles
icon: files.showHiddenFiles
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
isSelected: provider.showHiddenFiles,
onTap: () => provider.toggleShowHiddenFiles(),
isSelected: files.showHiddenFiles,
onTap: () => files.toggleShowHiddenFiles(),
tooltip: 'Show hidden files',
),
const SizedBox(width: 4),
@@ -120,14 +124,14 @@ class _ShareUploadViewState extends State<ShareUploadView> {
children: [
ToggleIconButton(
icon: Symbols.circles_rounded,
isSelected: provider.storageScope == StorageScope.cloud,
onTap: () => provider.setStorageScope(StorageScope.cloud),
isSelected: files.storageScope == StorageScope.cloud,
onTap: () => files.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage',
),
ToggleIconButton(
icon: Symbols.hard_drive_rounded,
isSelected: provider.storageScope == StorageScope.external,
onTap: () => provider.setStorageScope(StorageScope.external),
isSelected: files.storageScope == StorageScope.external,
onTap: () => files.setStorageScope(StorageScope.external),
tooltip: 'External storage',
),
],
@@ -137,24 +141,24 @@ class _ShareUploadViewState extends State<ShareUploadView> {
children: [
ToggleIconButton(
icon: Icons.select_all_rounded,
isSelected: provider.filesTypeFilter == FilesTypeFilter.all,
onTap: () => provider.setFilesTypeFilter(FilesTypeFilter.all),
isSelected: files.filesTypeFilter == FilesTypeFilter.all,
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all),
tooltip: 'Files & folders',
),
ToggleIconButton(
icon: Icons.insert_drive_file_outlined,
isSelected:
provider.filesTypeFilter == FilesTypeFilter.filesOnly,
files.filesTypeFilter == FilesTypeFilter.filesOnly,
onTap: () =>
provider.setFilesTypeFilter(FilesTypeFilter.filesOnly),
files.setFilesTypeFilter(FilesTypeFilter.filesOnly),
tooltip: 'Files only',
),
ToggleIconButton(
icon: Icons.folder_outlined,
isSelected:
provider.filesTypeFilter == FilesTypeFilter.foldersOnly,
files.filesTypeFilter == FilesTypeFilter.foldersOnly,
onTap: () =>
provider.setFilesTypeFilter(FilesTypeFilter.foldersOnly),
files.setFilesTypeFilter(FilesTypeFilter.foldersOnly),
tooltip: 'Folders only',
),
],
@@ -164,14 +168,14 @@ class _ShareUploadViewState extends State<ShareUploadView> {
children: [
ToggleIconButton(
icon: Icons.view_list_rounded,
isSelected: !provider.isGridView,
onTap: () => provider.setGridView(false),
isSelected: !files.isGridView,
onTap: () => files.setGridView(false),
tooltip: 'List view',
),
ToggleIconButton(
icon: Icons.grid_view_rounded,
isSelected: provider.isGridView,
onTap: () => provider.setGridView(true),
isSelected: files.isGridView,
onTap: () => files.setGridView(true),
tooltip: 'Grid view',
),
],
@@ -185,13 +189,11 @@ class _ShareUploadViewState extends State<ShareUploadView> {
// Only folders are valid upload destinations - files still show (so the
// listing matches what the Files tab itself would show for this folder)
// but are visually dimmed and inert rather than hidden outright.
Widget _buildListTile(
BuildContext context,
NextcloudItem item,
ServerProvider provider,
) {
Widget _buildListTile(BuildContext context, NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final files = context.read<FilesController>();
final session = context.watch<SessionController>();
final isFolder = item.isFolder;
return Padding(
@@ -203,9 +205,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
child: Material(
color: colorScheme.surfaceContainerLow,
child: InkWell(
onTap: isFolder
? () => provider.navigateToFolder(item.path)
: null,
onTap: isFolder ? () => files.navigateToFolder(item.path) : null,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -215,7 +215,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
children: [
ItemThumbnail(
item: item,
provider: provider,
service: session.service,
size: 44,
borderRadius: 12,
iconSize: 22,
@@ -255,13 +255,10 @@ class _ShareUploadViewState extends State<ShareUploadView> {
);
}
Widget _buildGridCard(
BuildContext context,
NextcloudItem item,
ServerProvider provider,
) {
Widget _buildGridCard(BuildContext context, NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final files = context.read<FilesController>();
final isFolder = item.isFolder;
final iconColor = getIconColor(context, item.type);
@@ -272,7 +269,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
borderRadius: BorderRadius.circular(20),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: isFolder ? () => provider.navigateToFolder(item.path) : null,
onTap: isFolder ? () => files.navigateToFolder(item.path) : null,
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
@@ -326,13 +323,14 @@ class _ShareUploadViewState extends State<ShareUploadView> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final hasBreadcrumbs = provider.pathStack.length > 1;
final currentPath = provider.pathStack.last;
final files = context.watch<FilesController>();
final session = context.watch<SessionController>();
final hasBreadcrumbs = files.pathStack.length > 1;
final currentPath = files.pathStack.last;
final currentLabel = currentPath == '/'
? 'Home'
: currentPath.split('/').where((s) => s.isNotEmpty).last;
final items = provider.items;
final items = files.items;
// Mirrors FilesView's own controls-row + breadcrumbs sticky header
// exactly (padding, heights) so this reads as the same browser, just
@@ -342,14 +340,14 @@ class _ShareUploadViewState extends State<ShareUploadView> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildControlsRow(provider),
_buildControlsRow(files),
if (hasBreadcrumbs) ...[
const SizedBox(height: 10),
SizedBox(
height: 32,
child: Breadcrumbs(
pathStack: provider.pathStack,
onTap: (index) => provider.navigateToPathIndex(index),
pathStack: files.pathStack,
onTap: (index) => files.navigateToPathIndex(index),
),
),
],
@@ -365,7 +363,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
child: controlsColumn,
),
),
if (provider.isLoading)
if (files.isLoading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
@@ -380,7 +378,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
),
),
)
else if (provider.isGridView)
else if (files.isGridView)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverGrid(
@@ -391,7 +389,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
mainAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate((context, index) {
return _buildGridCard(context, items[index], provider);
return _buildGridCard(context, items[index]);
}, childCount: items.length),
),
)
@@ -400,7 +398,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
return _buildListTile(context, items[index], provider);
return _buildListTile(context, items[index]);
}, childCount: items.length),
),
),
@@ -411,7 +409,6 @@ class _ShareUploadViewState extends State<ShareUploadView> {
return Scaffold(
body: SyncedHeaderScaffold(
scrollController: _scrollController,
provider: provider,
// Same trailing actions as every other tab except "More tabs" -
// there's nowhere useful for it to go while mid-upload (jumping to
// Trash/Shares/etc. would abandon this destination picker), so the
@@ -494,7 +491,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => _uploadHere(provider),
onPressed: () => _uploadHere(session),
icon: const Icon(Icons.upload_rounded),
label: Text('Upload to $currentLabel'),
),
+19 -22
View File
@@ -3,7 +3,7 @@ import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/nextcloud_item.dart';
import '../models/nextcloud_share.dart';
import '../providers/server_provider.dart';
import '../providers/shares_controller.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart';
@@ -60,16 +60,16 @@ class _SharesViewState extends State<SharesView> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final sharesController = context.watch<SharesController>();
if (!_requested) {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => provider.fetchShares(),
(_) => sharesController.fetchAll(),
);
}
final shares = provider.shares;
final shares = sharesController.shares;
final List<Widget> contentSlivers = [
SliverToBoxAdapter(
@@ -80,19 +80,20 @@ class _SharesViewState extends State<SharesView> {
ButtonSegment(value: false, label: Text('Shared by me')),
ButtonSegment(value: true, label: Text('Shared with me')),
],
selected: {provider.sharesWithMe},
onSelectionChanged: (set) => provider.setSharesWithMe(set.first),
selected: {sharesController.sharedWithMe},
onSelectionChanged: (set) =>
sharesController.setSharedWithMe(set.first),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (provider.isSharesLoading && shares.isEmpty)
if (sharesController.isLoading && shares.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (provider.sharesErrorMessage != null)
else if (sharesController.errorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
@@ -116,7 +117,7 @@ class _SharesViewState extends State<SharesView> {
),
const SizedBox(height: 8),
Text(
provider.sharesErrorMessage!,
sharesController.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -124,7 +125,7 @@ class _SharesViewState extends State<SharesView> {
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: provider.fetchShares,
onPressed: sharesController.fetchAll,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
@@ -147,7 +148,7 @@ class _SharesViewState extends State<SharesView> {
),
const SizedBox(height: 12),
Text(
provider.sharesWithMe
sharesController.sharedWithMe
? 'Nothing has been shared with you'
: 'You haven\'t shared anything yet',
style: theme.textTheme.titleMedium?.copyWith(
@@ -164,7 +165,7 @@ class _SharesViewState extends State<SharesView> {
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final share = shares[index];
return _buildShareTile(context, share, provider);
return _buildShareTile(context, share);
}, childCount: shares.length),
),
),
@@ -175,18 +176,13 @@ class _SharesViewState extends State<SharesView> {
return SyncedHeaderScaffold(
scrollController: widget.scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: provider.fetchShares,
onRefresh: sharesController.fetchAll,
contentSlivers: contentSlivers,
);
}
Widget _buildShareTile(
BuildContext context,
NextcloudShare share,
ServerProvider provider,
) {
Widget _buildShareTile(BuildContext context, NextcloudShare share) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final iconColor = colorScheme.primary;
@@ -260,7 +256,7 @@ class _SharesViewState extends State<SharesView> {
IconButton(
icon: Icon(Icons.link_off_rounded, color: colorScheme.error),
tooltip: share.sharedWithMe ? 'Remove' : 'Unshare',
onPressed: () => _confirmUnshare(context, provider, share),
onPressed: () => _confirmUnshare(context, share),
),
],
),
@@ -271,7 +267,6 @@ class _SharesViewState extends State<SharesView> {
Future<void> _confirmUnshare(
BuildContext context,
ServerProvider provider,
NextcloudShare share,
) async {
final confirmed = await showDialog<bool>(
@@ -303,7 +298,9 @@ class _SharesViewState extends State<SharesView> {
if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
final success = await provider.deleteShare(share);
final success = await context.read<SharesController>().deleteShare(
share,
);
messenger.showSnackBar(
SnackBar(
content: Text(
+16 -26
View File
@@ -2,7 +2,7 @@ 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';
import '../providers/trash_controller.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/profile_avatar_button.dart';
import '../widgets/synced_header_scaffold.dart';
@@ -42,26 +42,26 @@ class _TrashViewState extends State<TrashView> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final trashController = context.watch<TrashController>();
if (!_requested) {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => provider.fetchTrash(),
(_) => trashController.fetchAll(),
);
}
final trash = provider.trashItems;
final trash = trashController.items;
final List<Widget> contentSlivers = [
const SliverToBoxAdapter(child: SizedBox(height: 16)),
if (provider.isTrashLoading && trash.isEmpty)
if (trashController.isLoading && trash.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (provider.trashErrorMessage != null)
else if (trashController.errorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
@@ -85,7 +85,7 @@ class _TrashViewState extends State<TrashView> {
),
const SizedBox(height: 8),
Text(
provider.trashErrorMessage!,
trashController.errorMessage!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -93,7 +93,7 @@ class _TrashViewState extends State<TrashView> {
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: provider.fetchTrash,
onPressed: trashController.fetchAll,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry'),
),
@@ -131,7 +131,7 @@ class _TrashViewState extends State<TrashView> {
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = trash[index];
return _buildTrashTile(context, item, provider);
return _buildTrashTile(context, item);
}, childCount: trash.length),
),
),
@@ -142,18 +142,13 @@ class _TrashViewState extends State<TrashView> {
return SyncedHeaderScaffold(
scrollController: widget.scrollController,
provider: provider,
actions: const [MoreTabsButton(), ProfileAvatarButton()],
onRefresh: provider.fetchTrash,
onRefresh: trashController.fetchAll,
contentSlivers: contentSlivers,
);
}
Widget _buildTrashTile(
BuildContext context,
NextcloudItem item,
ServerProvider provider,
) {
Widget _buildTrashTile(BuildContext context, NextcloudItem item) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final iconColor = colorScheme.outline;
@@ -210,7 +205,7 @@ class _TrashViewState extends State<TrashView> {
icon: const Icon(Icons.restore_rounded),
tooltip: 'Restore',
color: colorScheme.primary,
onPressed: () => _restore(context, provider, item),
onPressed: () => _restore(context, item),
),
IconButton(
icon: Icon(
@@ -218,7 +213,7 @@ class _TrashViewState extends State<TrashView> {
color: colorScheme.error,
),
tooltip: 'Delete forever',
onPressed: () => _confirmDeleteForever(context, provider, item),
onPressed: () => _confirmDeleteForever(context, item),
),
],
),
@@ -227,13 +222,9 @@ class _TrashViewState extends State<TrashView> {
);
}
Future<void> _restore(
BuildContext context,
ServerProvider provider,
NextcloudItem item,
) async {
Future<void> _restore(BuildContext context, NextcloudItem item) async {
final messenger = ScaffoldMessenger.of(context);
final success = await provider.restoreTrashItem(item);
final success = await context.read<TrashController>().restore(item);
messenger.showSnackBar(
SnackBar(
content: Text(
@@ -246,7 +237,6 @@ class _TrashViewState extends State<TrashView> {
Future<void> _confirmDeleteForever(
BuildContext context,
ServerProvider provider,
NextcloudItem item,
) async {
final confirmed = await showDialog<bool>(
@@ -276,7 +266,7 @@ class _TrashViewState extends State<TrashView> {
if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
final success = await provider.deleteTrashItemForever(item);
final success = await context.read<TrashController>().deleteForever(item);
messenger.showSnackBar(
SnackBar(
content: Text(