Files
iiEsaywebUIapp/lib/core/services/attachment_upload_queue.dart

353 lines
9.7 KiB
Dart
Raw Normal View History

2025-08-10 01:20:45 +05:30
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
2025-09-25 22:36:42 +05:30
import '../utils/debug_logger.dart';
void debugPrint(String? message, {int? wrapWidth}) {
if (message == null) return;
DebugLogger.fromLegacy(message, scope: 'attachments/queue');
}
2025-08-10 01:20:45 +05:30
/// Status of a queued attachment upload
enum QueuedAttachmentStatus { pending, uploading, completed, failed, cancelled }
/// Metadata for a queued attachment
class QueuedAttachment {
final String id; // local queue id
final String filePath;
final String fileName;
final int fileSize;
final String? mimeType;
final String? checksum;
final DateTime enqueuedAt;
// Upload state
int retryCount;
DateTime? nextRetryAt;
QueuedAttachmentStatus status;
String? lastError;
String? fileId; // server-side file id once uploaded
QueuedAttachment({
required this.id,
required this.filePath,
required this.fileName,
required this.fileSize,
this.mimeType,
this.checksum,
DateTime? enqueuedAt,
this.retryCount = 0,
this.nextRetryAt,
this.status = QueuedAttachmentStatus.pending,
this.lastError,
this.fileId,
}) : enqueuedAt = enqueuedAt ?? DateTime.now();
Map<String, dynamic> toJson() => {
'id': id,
'filePath': filePath,
'fileName': fileName,
'fileSize': fileSize,
'mimeType': mimeType,
'checksum': checksum,
'enqueuedAt': enqueuedAt.toIso8601String(),
'retryCount': retryCount,
'nextRetryAt': nextRetryAt?.toIso8601String(),
'status': status.name,
'lastError': lastError,
'fileId': fileId,
};
factory QueuedAttachment.fromJson(Map<String, dynamic> json) =>
QueuedAttachment(
id: json['id'] as String,
filePath: json['filePath'] as String,
fileName: json['fileName'] as String,
fileSize: (json['fileSize'] as num).toInt(),
mimeType: json['mimeType'] as String?,
checksum: json['checksum'] as String?,
enqueuedAt:
DateTime.tryParse(json['enqueuedAt'] ?? '') ?? DateTime.now(),
retryCount: (json['retryCount'] as num?)?.toInt() ?? 0,
nextRetryAt: json['nextRetryAt'] != null
? DateTime.tryParse(json['nextRetryAt'])
: null,
status: QueuedAttachmentStatus.values.firstWhere(
(e) => e.name == json['status'],
orElse: () => QueuedAttachmentStatus.pending,
),
lastError: json['lastError'] as String?,
fileId: json['fileId'] as String?,
);
QueuedAttachment copyWith({
int? retryCount,
DateTime? nextRetryAt,
QueuedAttachmentStatus? status,
String? lastError,
String? fileId,
}) => QueuedAttachment(
id: id,
filePath: filePath,
fileName: fileName,
fileSize: fileSize,
mimeType: mimeType,
checksum: checksum,
enqueuedAt: enqueuedAt,
retryCount: retryCount ?? this.retryCount,
nextRetryAt: nextRetryAt ?? this.nextRetryAt,
status: status ?? this.status,
lastError: lastError ?? this.lastError,
fileId: fileId ?? this.fileId,
);
}
typedef UploadCallback =
Future<String> Function(String filePath, String fileName);
typedef AttachmentsEventCallback = void Function(List<QueuedAttachment> queue);
/// A lightweight background queue to upload attachments when back online.
class AttachmentUploadQueue {
static final AttachmentUploadQueue _instance =
AttachmentUploadQueue._internal();
factory AttachmentUploadQueue() => _instance;
AttachmentUploadQueue._internal();
static const String _prefsKey = 'attachment_upload_queue';
static const int _maxRetries = 4;
static const Duration _baseRetryDelay = Duration(seconds: 5);
static const Duration _maxRetryDelay = Duration(minutes: 5);
SharedPreferences? _prefs;
final List<QueuedAttachment> _queue = [];
Timer? _retryTimer;
bool _isProcessing = false;
// Dependencies
UploadCallback? _onUpload;
AttachmentsEventCallback? _onQueueChanged;
// Streams
final _queueController = StreamController<List<QueuedAttachment>>.broadcast();
Stream<List<QueuedAttachment>> get queueStream => _queueController.stream;
List<QueuedAttachment> get queue => List.unmodifiable(_queue);
Future<void> initialize({
required UploadCallback onUpload,
AttachmentsEventCallback? onQueueChanged,
}) async {
_onUpload = onUpload;
_onQueueChanged = onQueueChanged;
_prefs ??= await SharedPreferences.getInstance();
await _load();
_startPeriodicProcessing();
debugPrint(
'DEBUG: AttachmentUploadQueue initialized with ${_queue.length} items',
);
}
Future<String> enqueue({
required String filePath,
required String fileName,
required int fileSize,
String? mimeType,
String? checksum,
}) async {
final id = DateTime.now().microsecondsSinceEpoch.toString();
final item = QueuedAttachment(
id: id,
filePath: filePath,
fileName: fileName,
fileSize: fileSize,
mimeType: mimeType,
checksum: checksum,
status: QueuedAttachmentStatus.pending,
);
_queue.add(item);
await _save();
_notify();
_processSafe();
return id;
}
Future<void> processQueue() async {
if (_isProcessing) return;
if (_onUpload == null) return;
_isProcessing = true;
try {
// Quick network probe using Dio HEAD to common health path if possible
final dio = Dio();
try {
await dio.head('/api/health').timeout(const Duration(seconds: 3));
} catch (_) {
// Best effort; continue and let upload fail if actually offline
}
final now = DateTime.now();
final pending = _queue.where(
(e) =>
(e.status == QueuedAttachmentStatus.pending ||
e.status == QueuedAttachmentStatus.failed) &&
(e.nextRetryAt == null || now.isAfter(e.nextRetryAt!)),
);
for (final item in List<QueuedAttachment>.from(pending)) {
await _processSingle(item);
}
} finally {
_isProcessing = false;
}
}
Future<void> _processSingle(QueuedAttachment item) async {
if (_onUpload == null) return;
try {
_update(item.id, item.copyWith(status: QueuedAttachmentStatus.uploading));
final fileId = await _onUpload!.call(item.filePath, item.fileName);
_update(
item.id,
item.copyWith(
status: QueuedAttachmentStatus.completed,
fileId: fileId,
retryCount: 0,
nextRetryAt: null,
lastError: null,
),
);
await _save();
_notify();
debugPrint(
'DEBUG: Attachment ${item.id} uploaded successfully (fileId=$fileId)',
);
} catch (e) {
final retries = item.retryCount + 1;
if (retries >= _maxRetries) {
_update(
item.id,
item.copyWith(
status: QueuedAttachmentStatus.failed,
retryCount: retries,
lastError: e.toString(),
),
);
await _save();
_notify();
debugPrint(
'WARNING: Attachment ${item.id} failed after $_maxRetries attempts',
);
return;
}
final delay = _retryDelayWithJitter(retries);
_update(
item.id,
item.copyWith(
status: QueuedAttachmentStatus.pending,
retryCount: retries,
nextRetryAt: DateTime.now().add(delay),
lastError: e.toString(),
),
);
await _save();
_notify();
debugPrint(
'DEBUG: Scheduled retry for attachment ${item.id} in ${delay.inSeconds}s',
);
}
}
Duration _retryDelayWithJitter(int retryCount) {
final base = _baseRetryDelay.inMilliseconds;
final exp = min(
base * pow(2, retryCount - 1),
_maxRetryDelay.inMilliseconds.toDouble(),
).toInt();
final jitter = Random().nextInt(1000); // up to 1s jitter
return Duration(milliseconds: exp + jitter);
}
void _startPeriodicProcessing() {
_retryTimer?.cancel();
_retryTimer = Timer.periodic(
const Duration(seconds: 10),
(_) => _processSafe(),
);
// Also kick once after a short delay
Timer(const Duration(milliseconds: 500), _processSafe);
}
void _processSafe() {
// Fire and forget
unawaited(processQueue());
}
void _update(String id, QueuedAttachment updated) {
final idx = _queue.indexWhere((e) => e.id == id);
if (idx != -1) {
_queue[idx] = updated;
}
}
Future<void> remove(String id) async {
_queue.removeWhere((e) => e.id == id);
await _save();
_notify();
}
Future<void> retry(String id) async {
final idx = _queue.indexWhere((e) => e.id == id);
if (idx == -1) return;
_queue[idx] = _queue[idx].copyWith(
status: QueuedAttachmentStatus.pending,
retryCount: 0,
nextRetryAt: null,
lastError: null,
);
await _save();
_notify();
_processSafe();
}
Future<void> clearFailed() async {
_queue.removeWhere((e) => e.status == QueuedAttachmentStatus.failed);
await _save();
_notify();
}
Future<void> clearAll() async {
_queue.clear();
await _save();
_notify();
}
// Utilities
Future<void> _load() async {
final jsonStr = (_prefs ?? await SharedPreferences.getInstance()).getString(
_prefsKey,
);
if (jsonStr == null || jsonStr.isEmpty) return;
final list = (jsonDecode(jsonStr) as List).cast<Map<String, dynamic>>();
_queue
..clear()
..addAll(list.map(QueuedAttachment.fromJson));
}
Future<void> _save() async {
final prefs = _prefs ?? await SharedPreferences.getInstance();
final list = _queue.map((e) => e.toJson()).toList(growable: false);
await prefs.setString(_prefsKey, jsonEncode(list));
}
void _notify() {
_onQueueChanged?.call(queue);
_queueController.add(queue);
}
}