Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions lib/core/models/upload_progress.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ class UploadProgressState {
/// Total bytes to upload
final int totalBytes;

/// Real cumulative bytes uploaded so far, when the SDK reports it
/// (chunked uploads). `null` means fall back to the time-based estimate —
/// small/non-chunked uploads emit no per-chunk progress events.
final int? bytesUploaded;

/// When the upload started
final DateTime startedAt;

Expand All @@ -29,6 +34,7 @@ class UploadProgressState {
required this.remoteKey,
required this.fileName,
required this.totalBytes,
this.bytesUploaded,
required this.startedAt,
required this.estimatedDuration,
this.pauseDuration = Duration.zero,
Expand All @@ -44,7 +50,18 @@ class UploadProgressState {
}

/// Progress percentage (0-100), capped at 99% until actually complete.
///
/// Prefers REAL cumulative bytes (`bytesUploaded`) reported by the SDK for
/// chunked uploads; falls back to the time-based estimate when the SDK
/// reports nothing (small/non-chunked uploads). Either way it's capped at
/// 99% until `completeUpload` removes the entry — the SDK's cumulative
/// bytes reach `total` when the last chunk's PUT returns, before the index
/// PUT + forest-flush tail finishes.
double get percentage {
final bytes = bytesUploaded;
if (bytes != null && totalBytes > 0) {
return ((bytes / totalBytes) * 100).clamp(0, 99);
}
if (estimatedDuration.inMilliseconds <= 0) return 0;
final progress = (elapsed.inMilliseconds / estimatedDuration.inMilliseconds) * 100;
return progress.clamp(0, 99); // Cap at 99% until confirmed complete
Expand Down Expand Up @@ -80,6 +97,7 @@ class UploadProgressState {
String? remoteKey,
String? fileName,
int? totalBytes,
int? bytesUploaded,
DateTime? startedAt,
Duration? estimatedDuration,
Duration? pauseDuration,
Expand All @@ -90,6 +108,7 @@ class UploadProgressState {
remoteKey: remoteKey ?? this.remoteKey,
fileName: fileName ?? this.fileName,
totalBytes: totalBytes ?? this.totalBytes,
bytesUploaded: bytesUploaded ?? this.bytesUploaded,
startedAt: startedAt ?? this.startedAt,
estimatedDuration: estimatedDuration ?? this.estimatedDuration,
pauseDuration: pauseDuration ?? this.pauseDuration,
Expand Down
182 changes: 142 additions & 40 deletions lib/core/services/fula_api_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1118,6 +1118,42 @@ class FulaApiService implements FulaApi {
// fula_client handles chunking internally, so these are simplified
// ============================================================================

/// Polls the SDK's [fula.ProgressHandle] every 200ms and forwards REAL
/// cumulative byte progress to [onProgress] while an upload future runs.
/// Returns the timer — cancel it in a `finally`.
///
/// Reported bytes are capped so the percentage never reads 100% before the
/// upload future resolves: the SDK's cumulative bytes reach `total` at the
/// last chunk's PUT, BEFORE the index PUT + forest-flush tail. The caller
/// fires a final `onProgress(total, total)` only after a successful await.
/// Unchanged ticks are skipped so the web tray doesn't repaint needlessly.
/// Polling is best-effort — a transient FRB error never breaks the upload.
Timer? _startProgressPoll(
fula.ProgressHandle? handle,
void Function(UploadProgress)? onProgress,
int fallbackTotal,
) {
if (handle == null || onProgress == null) return null;
int lastReported = -1;
return Timer.periodic(const Duration(milliseconds: 200), (_) async {
try {
final p = await fula.pollProgress(handle: handle);
final total = frbU64ToInt(p.totalBytes) ?? 0;
final uploaded = frbU64ToInt(p.bytesUploaded) ?? 0;
final cap = total > 0 ? (total * 99) ~/ 100 : uploaded;
final shown = uploaded > cap ? cap : uploaded;
if (shown == lastReported) return;
lastReported = shown;
onProgress(UploadProgress(
bytesUploaded: shown,
totalBytes: total > 0 ? total : fallbackTotal,
));
} catch (_) {
// best-effort; the next tick retries
}
});
}

Future<String> uploadLargeFile(
String bucket,
String key,
Expand All @@ -1128,20 +1164,35 @@ class FulaApiService implements FulaApi {
}) async {
_guardLegacyWrite(bucket);
_ensureConfigured();
Timer? poll;
try {
await _ensureForestLoaded(bucket);

// fula_client handles large files automatically
// Progress callback not yet supported in fula_client - upload directly
final result = await fula.putFlat(
client: _client!,
bucket: bucket,
path: key,
data: data.toList(),
contentType: null,
);
// Real per-chunk progress (fula-api 0.6.11): poll the handle while the
// chunked upload runs. Small/non-chunked files emit no events — the bar
// stays at 0% until the completion report below.
final progressHandle =
onProgress != null ? await fula.createProgressHandle() : null;
poll = _startProgressPoll(progressHandle, onProgress, data.length);

// Report completion
final result = progressHandle != null
? await fula.putFlatWithProgress(
client: _client!,
bucket: bucket,
path: key,
data: data.toList(),
contentType: null,
progress: progressHandle,
)
: await fula.putFlat(
client: _client!,
bucket: bucket,
path: key,
data: data.toList(),
contentType: null,
);

// True completion (after the forest flush) -> 100%.
if (onProgress != null) {
onProgress(UploadProgress(
bytesUploaded: data.length,
Expand All @@ -1152,6 +1203,8 @@ class FulaApiService implements FulaApi {
return result.etag;
} catch (e) {
throw FulaApiException('Failed to upload large file: $e');
} finally {
poll?.cancel();
}
}

Expand Down Expand Up @@ -1225,29 +1278,53 @@ class FulaApiService implements FulaApi {
}) async {
_guardLegacyWrite(bucket); // primary content-upload path (sync queue)
_ensureConfigured();
Timer? poll;
try {
await _ensureForestLoaded(bucket);

final fileSize = await fileLength(filePath);

final result = cancelHandle != null
? await fula.putFlatResumableFromPathCancellable(
client: _client!,
bucket: bucket,
path: key,
filePath: filePath,
manifestPath: manifestPath,
contentType: null,
cancel: cancelHandle,
)
: await fula.putFlatResumableFromPath(
client: _client!,
bucket: bucket,
path: key,
filePath: filePath,
manifestPath: manifestPath,
contentType: null,
);
// Real per-chunk progress (fula-api 0.6.11): poll the handle during the
// resumable upload.
final progressHandle =
onProgress != null ? await fula.createProgressHandle() : null;
poll = _startProgressPoll(progressHandle, onProgress, fileSize);

final fula.PutResult result;
if (progressHandle != null) {
// The progress variant requires a CancelHandle; synthesize a
// throwaway one (never triggered) when the caller passed none.
final cancel = cancelHandle ?? await fula.createCancelHandle();
result = await fula.putFlatResumableFromPathWithProgress(
client: _client!,
bucket: bucket,
path: key,
filePath: filePath,
manifestPath: manifestPath,
contentType: null,
cancel: cancel,
progress: progressHandle,
);
} else if (cancelHandle != null) {
result = await fula.putFlatResumableFromPathCancellable(
client: _client!,
bucket: bucket,
path: key,
filePath: filePath,
manifestPath: manifestPath,
contentType: null,
cancel: cancelHandle,
);
} else {
result = await fula.putFlatResumableFromPath(
client: _client!,
bucket: bucket,
path: key,
filePath: filePath,
manifestPath: manifestPath,
contentType: null,
);
}

if (onProgress != null) {
onProgress(UploadProgress(
Expand All @@ -1259,6 +1336,8 @@ class FulaApiService implements FulaApi {
return result.etag;
} catch (e) {
throw FulaApiException('Failed to upload (resumable): $e');
} finally {
poll?.cancel();
}
}

Expand All @@ -1277,21 +1356,42 @@ class FulaApiService implements FulaApi {
void Function(UploadProgress)? onProgress,
}) async {
_ensureConfigured();
Timer? poll;
try {
final fileSize = await fileLength(filePath);

final result = cancelHandle != null
? await fula.resumeFlatUploadFromPathCancellable(
client: _client!,
manifestPath: manifestPath,
filePath: filePath,
cancel: cancelHandle,
)
: await fula.resumeFlatUploadFromPath(
client: _client!,
manifestPath: manifestPath,
filePath: filePath,
);
// Real per-chunk progress (fula-api 0.6.11). Resume SEEDS the SDK
// counter with already-uploaded chunks, so the bar continues mid-way.
final progressHandle =
onProgress != null ? await fula.createProgressHandle() : null;
poll = _startProgressPoll(progressHandle, onProgress, fileSize);

final fula.PutResult result;
if (progressHandle != null) {
// The progress variant requires a CancelHandle; synthesize a
// throwaway one (never triggered) when the caller passed none.
final cancel = cancelHandle ?? await fula.createCancelHandle();
result = await fula.resumeFlatUploadFromPathWithProgress(
client: _client!,
manifestPath: manifestPath,
filePath: filePath,
cancel: cancel,
progress: progressHandle,
);
} else if (cancelHandle != null) {
result = await fula.resumeFlatUploadFromPathCancellable(
client: _client!,
manifestPath: manifestPath,
filePath: filePath,
cancel: cancelHandle,
);
} else {
result = await fula.resumeFlatUploadFromPath(
client: _client!,
manifestPath: manifestPath,
filePath: filePath,
);
}

if (onProgress != null) {
onProgress(UploadProgress(
Expand All @@ -1303,6 +1403,8 @@ class FulaApiService implements FulaApi {
return result.etag;
} catch (e) {
throw FulaApiException('Failed to resume upload: $e');
} finally {
poll?.cancel();
}
}

Expand Down
6 changes: 6 additions & 0 deletions lib/core/services/sync_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1091,6 +1091,9 @@ class SyncService {
_activeSync[task.localPath] = current.copyWith(
bytesTransferred: progress.bytesUploaded,
);
// Real cumulative bytes -> UI progress manager (drives the %).
UploadProgressManager.instance
.updateProgress(task.localPath, progress.bytesUploaded);
},
);
} else {
Expand All @@ -1106,6 +1109,9 @@ class SyncService {
_activeSync[task.localPath] = current.copyWith(
bytesTransferred: progress.bytesUploaded,
);
// Real cumulative bytes -> UI progress manager (drives the %).
UploadProgressManager.instance
.updateProgress(task.localPath, progress.bytesUploaded);
},
);
}
Expand Down
13 changes: 13 additions & 0 deletions lib/core/services/upload_progress_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,19 @@ class UploadProgressManager {
_notifyListeners();
}

/// Update an active upload's REAL cumulative byte progress (chunked
/// uploads, fed from the SDK's `poll_progress`). Makes
/// [UploadProgressState.percentage] reflect actual transfer instead of the
/// time estimate. No-op if the upload isn't tracked (already completed or
/// cancelled). The periodic UI timer ([updateInterval]) picks up the
/// change, so this deliberately does NOT notify — it can be called at a
/// higher frequency than the UI needs to repaint.
void updateProgress(String localPath, int bytesUploaded) {
final upload = _activeUploads[localPath];
if (upload == null) return;
_activeUploads[localPath] = upload.copyWith(bytesUploaded: bytesUploaded);
}

/// Mark an upload as complete and record speed for future estimates.
void completeUpload({
required String localPath,
Expand Down
4 changes: 2 additions & 2 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -678,10 +678,10 @@ packages:
dependency: "direct main"
description:
name: fula_client
sha256: "04a64b5fa821f628f6eb21774b848aa89bd753271f65ac0fb12fb2f38b34c95e"
sha256: ae3a062405c17a2452c18a307a96cdea30cf508d5f2b17494b28af5b440b3297
url: "https://pub.dev"
source: hosted
version: "0.6.10"
version: "0.6.11"
get_it:
dependency: transitive
description:
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ dependencies:
google_sign_in_web: ^1.0.0

# Storage & Files (with Encryption)
fula_client: ^0.6.10
fula_client: ^0.6.11
path_provider: ^2.1.5
file_picker: ^10.3.7
open_filex: ^4.6.0
Expand Down
Loading