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
@@ -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/item_operations.dart';
/// Per-file activity feed — same list-item look as the account-wide
/// Activity tab (activity_view.dart), scoped to a single item.
@@ -21,8 +21,8 @@ class _DetailsActivityTabState extends State<DetailsActivityTab> {
List<NextcloudActivity> _activities = [];
Future<void> _load() async {
final provider = context.read<ServerProvider>();
final result = await provider.fetchFileActivity(widget.item);
final ops = context.read<ItemOperations>();
final result = await ops.fetchFileActivity(widget.item);
if (!mounted) return;
setState(() {
_activities = result;
@@ -6,7 +6,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import '../../models/nextcloud_file_version.dart';
import '../../models/nextcloud_item.dart';
import '../../providers/server_provider.dart';
import '../../providers/item_operations.dart';
import '../synced_header_scaffold.dart' show formatBytes;
/// File version history — a synthetic "Current version" row (from the
@@ -27,8 +27,8 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
List<NextcloudFileVersion> _versions = [];
Future<void> _load() async {
final provider = context.read<ServerProvider>();
final result = await provider.fetchFileVersions(widget.item);
final ops = context.read<ItemOperations>();
final result = await ops.fetchFileVersions(widget.item);
if (!mounted) return;
setState(() {
_versions = result;
@@ -37,9 +37,9 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
}
Future<void> _restore(NextcloudFileVersion version) async {
final provider = context.read<ServerProvider>();
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
final success = await provider.restoreFileVersion(
final success = await ops.restoreFileVersion(
widget.item,
version.versionLabel,
);
@@ -56,7 +56,7 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
}
Future<void> _download(NextcloudFileVersion version) async {
final provider = context.read<ServerProvider>();
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
try {
final tempDir = await getTemporaryDirectory();
@@ -64,7 +64,7 @@ class _DetailsVersionsTabState extends State<DetailsVersionsTab> {
tempDir.path,
'${version.versionLabel}_${widget.item.name}',
);
final success = await provider.downloadVersion(
final success = await ops.downloadVersion(
widget.item,
version.versionLabel,
tempPath,
+4 -4
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart';
import '../services/nextcloud_service.dart';
/// The icon/color/thumbnail treatment for a file or folder, shared by any
/// screen that lists [NextcloudItem]s the same way the Files tab does (see
@@ -51,7 +51,7 @@ Color getIconColor(BuildContext context, NextcloudItemType type) {
/// the Photos tab.
class ItemThumbnail extends StatelessWidget {
final NextcloudItem item;
final ServerProvider provider;
final NextcloudService? service;
final double size;
final double borderRadius;
final double iconSize;
@@ -59,7 +59,7 @@ class ItemThumbnail extends StatelessWidget {
const ItemThumbnail({
super.key,
required this.item,
required this.provider,
required this.service,
required this.size,
required this.borderRadius,
required this.iconSize,
@@ -92,7 +92,7 @@ class ItemThumbnail extends StatelessWidget {
children: [
Image.network(
item.previewUrl!,
headers: provider.service?.authHeaders,
headers: service?.authHeaders,
fit: BoxFit.cover,
cacheWidth: cachePixels,
cacheHeight: cachePixels,
+8 -6
View File
@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/app_tab.dart';
import '../providers/server_provider.dart';
import '../providers/pick_controller.dart';
import '../providers/settings_controller.dart';
import 'app_tab_view_builder.dart';
/// Opens a tab that's currently hidden from the bottom nav bar as its own
@@ -13,23 +14,24 @@ class MoreTabsButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
final pick = context.watch<PickController>();
final settings = context.watch<SettingsController>();
// While picking (another app's GET_CONTENT request), MainShellView
// already restricts the bottom nav to Files/Photos - this button would
// otherwise be the one remaining way to reach a tab that isn't a valid
// pick source (Trash, Shares, ...), so it hides entirely rather than
// just disabling.
if (provider.isPicking) return const SizedBox.shrink();
if (pick.isPicking) return const SizedBox.shrink();
return PopupMenuButton<AppTab>(
icon: const Icon(Icons.apps_rounded),
tooltip: 'More tabs',
enabled: provider.hiddenTabs.isNotEmpty,
enabled: settings.hiddenTabs.isNotEmpty,
onSelected: (tab) => _openHiddenTab(context, tab),
itemBuilder: (context) => [
for (final tab in provider.tabOrder.where(
(t) => provider.hiddenTabs.contains(t),
for (final tab in settings.tabOrder.where(
(t) => settings.hiddenTabs.contains(t),
))
PopupMenuItem(
value: tab,
+1 -1
View File
@@ -7,7 +7,7 @@ import 'gradual_bottom_sheet.dart';
/// destination) - summarizes every conflict at once rather than prompting
/// per item as they're hit, and lets the user resolve them all the same
/// way (overwrite/keep both) or open a per-item breakdown. Returns the
/// `ConflictChoice` map `ServerProvider.resolveConflicts` expects, or null
/// `ConflictChoice` map `ItemOperations.resolveConflicts` expects, or null
/// if dismissed without choosing.
class MoveCopyConflictSheet extends StatefulWidget {
final List<MoveCopyConflict> conflicts;
+3 -3
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../providers/server_provider.dart';
import '../providers/session_controller.dart';
import '../views/account_view.dart';
/// Top-bar avatar button that opens the account screen. Replaces the old
@@ -20,7 +20,7 @@ class ProfileAvatarButton extends StatelessWidget {
final velocity = details.primaryVelocity ?? 0;
if (velocity.abs() < 250) return;
final provider = context.read<ServerProvider>();
final provider = context.read<SessionController>();
final target = velocity < 0
? await provider.cycleToNextAccount()
: await provider.cycleToPreviousAccount();
@@ -37,7 +37,7 @@ class ProfileAvatarButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<ServerProvider>();
final provider = context.watch<SessionController>();
final colorScheme = Theme.of(context).colorScheme;
final initial = provider.username.isNotEmpty
? provider.username[0].toUpperCase()
+1 -1
View File
@@ -1,6 +1,6 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../providers/server_provider.dart';
import '../providers/settings_controller.dart';
/// Draws a video seek bar in one of the four [MediaProgressBarStyle]
/// presets. Shared by the actual in-player seek bar and the small previews
+24 -21
View File
@@ -7,7 +7,9 @@ import 'package:share_plus/share_plus.dart';
import '../models/nextcloud_item.dart';
import '../models/nextcloud_share.dart';
import '../models/nextcloud_sharee.dart';
import '../providers/server_provider.dart';
import '../providers/item_operations.dart';
import '../providers/session_controller.dart';
import '../providers/sync_status_controller.dart';
import 'details/details_sheet.dart' show DetailsHeader;
import 'gradual_bottom_sheet.dart';
@@ -83,10 +85,10 @@ class _ShareSheetState extends State<ShareSheet> {
}
Future<void> _load() async {
final provider = context.read<ServerProvider>();
final ops = context.read<ItemOperations>();
final results = await Future.wait([
provider.fetchItemShares(widget.item),
provider.fetchInheritedShares(widget.item),
ops.fetchItemShares(widget.item),
ops.fetchInheritedShares(widget.item),
]);
if (!mounted) return;
setState(() {
@@ -103,8 +105,8 @@ class _ShareSheetState extends State<ShareSheet> {
return;
}
setState(() => _isSearching = true);
final provider = context.read<ServerProvider>();
final results = await provider.searchSharees(trimmed);
final ops = context.read<ItemOperations>();
final results = await ops.searchSharees(trimmed);
if (!mounted) return;
setState(() {
_searchResults = results;
@@ -113,9 +115,9 @@ class _ShareSheetState extends State<ShareSheet> {
}
Future<void> _addSharee(NextcloudSharee sharee) async {
final provider = context.read<ServerProvider>();
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
final share = await provider.createShare(
final share = await ops.createShare(
path: widget.item.path,
shareType: sharee.shareTypeValue,
shareWith: sharee.shareWith,
@@ -147,9 +149,9 @@ class _ShareSheetState extends State<ShareSheet> {
final email = _emailController.text.trim();
if (email.isEmpty) return;
setState(() => _isAddingEmail = true);
final provider = context.read<ServerProvider>();
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
final share = await provider.createShare(
final share = await ops.createShare(
path: widget.item.path,
shareType: 4,
shareWith: email,
@@ -179,9 +181,9 @@ class _ShareSheetState extends State<ShareSheet> {
}
Future<void> _createPublicLink() async {
final provider = context.read<ServerProvider>();
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
final share = await provider.createShare(
final share = await ops.createShare(
path: widget.item.path,
shareType: 3,
);
@@ -199,9 +201,9 @@ class _ShareSheetState extends State<ShareSheet> {
}
Future<void> _removeShare(NextcloudShare share) async {
final provider = context.read<ServerProvider>();
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
final success = await provider.deleteShare(share);
final success = await ops.deleteShare(share);
if (!mounted) return;
if (success) {
setState(() => _shares = _shares.where((s) => s.id != share.id).toList());
@@ -231,20 +233,21 @@ class _ShareSheetState extends State<ShareSheet> {
/// users/links) and the only one here that reads the file's actual
/// bytes.
Future<void> _shareFileDirectly() async {
final provider = context.read<ServerProvider>();
if (provider.service == null) return;
final session = context.read<SessionController>();
final sync = context.read<SyncStatusController>();
if (session.service == null) return;
setState(() => _isSharingFile = true);
final messenger = ScaffoldMessenger.of(context);
try {
// Already mirrored locally by device sync? Share that copy straight
// away instead of a fresh WebDAV fetch - see
// ServerProvider.localSyncedFilePath.
// SyncStatusController.localSyncedFilePath.
final tempPath =
await provider.localSyncedFilePath(widget.item) ??
await sync.localSyncedFilePath(widget.item) ??
await () async {
final tempDir = await getTemporaryDirectory();
final path = p.join(tempDir.path, widget.item.name);
await provider.service!.downloadToFile(widget.item.path, path);
await session.service!.downloadToFile(widget.item.path, path);
return path;
}();
if (!mounted) return;
@@ -334,7 +337,7 @@ class _ShareSheetState extends State<ShareSheet> {
}
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>();
final session = context.watch<SessionController>();
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
@@ -352,7 +355,7 @@ class _ShareSheetState extends State<ShareSheet> {
final emailShares = _shares
.where((s) => s.shareType == ShareType.email)
.toList();
final internalLink = internalLinkFor(provider.serverUrl, widget.item.id);
final internalLink = internalLinkFor(session.serverUrl, widget.item.id);
return ListView(
controller: widget.scrollController,
+1 -1
View File
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import '../providers/server_provider.dart';
import '../providers/files_controller.dart';
String sortFieldLabel(FileSortField field) {
switch (field) {
+5 -5
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../providers/server_provider.dart';
import '../providers/settings_controller.dart';
/// Wraps [child] with the user's configured swipe-left/swipe-right actions
/// (favorite, delete, share, or none) — shared between the Files list view
@@ -10,7 +10,7 @@ import '../providers/server_provider.dart';
class SwipeableItem extends StatelessWidget {
final Key itemKey;
final String itemName;
final ServerProvider provider;
final SettingsController settings;
final Future<void> Function() onFavorite;
final Future<void> Function() onShare;
final Future<void> Function() onDelete;
@@ -20,7 +20,7 @@ class SwipeableItem extends StatelessWidget {
super.key,
required this.itemKey,
required this.itemName,
required this.provider,
required this.settings,
required this.onFavorite,
required this.onShare,
required this.onDelete,
@@ -69,8 +69,8 @@ class SwipeableItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final rightAction = provider.swipeRightAction;
final leftAction = provider.swipeLeftAction;
final rightAction = settings.swipeRightAction;
final leftAction = settings.swipeLeftAction;
final direction = _directionFor(leftAction, rightAction);
if (direction == DismissDirection.none) return child;
+27 -37
View File
@@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../models/nextcloud_item.dart';
import '../models/sync_status.dart';
import '../providers/server_provider.dart';
import '../providers/files_controller.dart';
import '../providers/session_controller.dart';
import '../providers/sync_status_controller.dart';
/// Icon/label for the persistent header chip/panel - reflects device-sync
/// status (see `ServerProvider.syncHeaderStatus`), not the WebDAV
/// status (see `SyncStatusController.syncHeaderStatus`), not the WebDAV
/// directory-listing refresh the pull gesture itself triggers (that has
/// its own, separate floating spinner bubble - see
/// `_SyncedHeaderScaffoldState`'s `_isRefreshing`).
@@ -42,7 +45,6 @@ String formatQuota(NextcloudUserQuota quota) {
/// holds the tab's own content slivers.
class SyncedHeaderScaffold extends StatefulWidget {
final ScrollController scrollController;
final ServerProvider provider;
/// Trailing icons in the app bar (e.g. an upload button, the avatar).
final List<Widget> actions;
@@ -53,8 +55,8 @@ class SyncedHeaderScaffold extends StatefulWidget {
final List<Widget> contentSlivers;
/// What a deliberate pull-to-sync should trigger. Defaults to
/// [ServerProvider.refreshData] (the current folder's listing); pass e.g.
/// `provider.fetchTrash` for a tab backed by different data.
/// [FilesController.refreshData] (the current folder's listing); pass
/// e.g. `trashController.fetchAll` for a tab backed by different data.
final Future<void> Function()? onRefresh;
/// When non-null (a tab is mid-selection), this takes over the pinned top
@@ -69,7 +71,6 @@ class SyncedHeaderScaffold extends StatefulWidget {
const SyncedHeaderScaffold({
super.key,
required this.scrollController,
required this.provider,
required this.actions,
required this.contentSlivers,
this.onRefresh,
@@ -118,7 +119,8 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
}
void _startRefresh() {
final refresh = widget.onRefresh ?? widget.provider.refreshData;
final refresh =
widget.onRefresh ?? context.read<FilesController>().refreshData;
setState(() => _isRefreshing = true);
refresh().whenComplete(() {
if (mounted) setState(() => _isRefreshing = false);
@@ -163,7 +165,7 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final provider = widget.provider;
final syncStatus = context.watch<SyncStatusController>();
final Widget leadingWidget;
if (_headerLocked) {
@@ -175,7 +177,7 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
} else if (_isPulling) {
leadingWidget = const SizedBox.shrink();
} else {
leadingWidget = _SyncStatusChip(provider: provider, onTap: _lockOpen);
leadingWidget = _SyncStatusChip(onTap: _lockOpen);
}
final topInset = MediaQuery.of(context).padding.top;
@@ -205,7 +207,7 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
expandedHeight: widget.selectionBar != null
? kToolbarHeight
: (_headerLocked
? 190.0 + provider.syncConflicts.length * 52.0
? 190.0 + syncStatus.syncConflicts.length * 52.0
: kToolbarHeight),
collapsedHeight: kToolbarHeight,
backgroundColor: colorScheme.surfaceContainer,
@@ -225,7 +227,6 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
? null
: FlexibleSpaceBar(
background: _SyncedStretchPanel(
provider: provider,
forceVisible: _headerLocked,
),
),
@@ -287,16 +288,15 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
/// [_SyncedStretchPanel] — tapping it is a discoverable shortcut for pulling
/// the header down to expand that same "Synced" view.
class _SyncStatusChip extends StatelessWidget {
final ServerProvider provider;
final VoidCallback onTap;
const _SyncStatusChip({required this.provider, required this.onTap});
const _SyncStatusChip({required this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final status = provider.syncHeaderStatus;
final status = context.watch<SyncStatusController>().syncHeaderStatus;
final (icon, label) = _syncHeaderDisplay(status);
final iconColor = status == SyncHeaderStatus.alert
? colorScheme.error
@@ -337,36 +337,33 @@ class _SyncStatusChip extends StatelessWidget {
/// [_SyncedHeaderScaffoldState] decides via [forceVisible] whether it stays
/// locked open.
class _SyncedStretchPanel extends StatelessWidget {
final ServerProvider provider;
/// Keeps the panel fully shown even without live overscroll — used once
/// the header has "locked" open after a deliberate pull-down.
final bool forceVisible;
const _SyncedStretchPanel({
required this.provider,
this.forceVisible = false,
});
const _SyncedStretchPanel({this.forceVisible = false});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final topInset = MediaQuery.of(context).padding.top;
final quota = context.watch<FilesController>().quota;
final serverUrl = context.watch<SessionController>().serverUrl;
final syncStatus = context.watch<SyncStatusController>();
return LayoutBuilder(
builder: (context, constraints) {
final settings = context
final spacerSettings = context
.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final maxExtent = settings?.maxExtent ?? constraints.maxHeight;
final maxExtent = spacerSettings?.maxExtent ?? constraints.maxHeight;
final stretch = (constraints.maxHeight - maxExtent).clamp(0.0, 80.0);
// Reaches 1.0 (title fully hidden) at 50px of pull — comfortably
// before the 100px lock threshold.
final progress = forceVisible ? 1.0 : (stretch / 50).clamp(0.0, 1.0);
final quota = provider.quota;
final status = provider.syncHeaderStatus;
final status = syncStatus.syncHeaderStatus;
final (_, statusLabel) = _syncHeaderDisplay(status);
final conflicts = provider.syncConflicts;
final conflicts = syncStatus.syncConflicts;
return Stack(
children: [
@@ -389,13 +386,10 @@ class _SyncedStretchPanel extends StatelessWidget {
fontWeight: FontWeight.w700,
),
),
if (provider.serverUrl.isNotEmpty) ...[
if (serverUrl.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
provider.serverUrl.replaceFirst(
RegExp(r'^https?://'),
'',
),
serverUrl.replaceFirst(RegExp(r'^https?://'), ''),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
@@ -438,10 +432,7 @@ class _SyncedStretchPanel extends StatelessWidget {
for (final conflict in conflicts)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _SyncConflictRow(
conflict: conflict,
provider: provider,
),
child: _SyncConflictRow(conflict: conflict),
),
],
],
@@ -462,9 +453,8 @@ class _SyncedStretchPanel extends StatelessWidget {
/// side), just triggered in-app instead.
class _SyncConflictRow extends StatefulWidget {
final SyncConflictInfo conflict;
final ServerProvider provider;
const _SyncConflictRow({required this.conflict, required this.provider});
const _SyncConflictRow({required this.conflict});
@override
State<_SyncConflictRow> createState() => _SyncConflictRowState();
@@ -476,7 +466,7 @@ class _SyncConflictRowState extends State<_SyncConflictRow> {
Future<void> _resolve(bool useLocal) async {
setState(() => _resolving = true);
try {
await widget.provider.resolveSyncConflict(
await context.read<SyncStatusController>().resolveSyncConflict(
widget.conflict,
useLocal: useLocal,
);