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
+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,
);