Compare commits

...

5 Commits

Author SHA1 Message Date
f254cfa81e ♻️ Refactored the captcha 2025-11-09 14:21:33 +08:00
4927795260 ♻️ Refactored the upload files according to new backend tasks 2025-11-09 14:09:26 +08:00
e4019dadc8 💄 Optimize file upload prograss indicates 2025-11-09 01:59:24 +08:00
5e7d77e1a1 🐛 Fix share sheet error 2025-11-08 20:05:18 +08:00
bfcbed035c ♻️ Refactored file uploading 2025-11-08 20:04:54 +08:00
30 changed files with 1384 additions and 150 deletions

View File

@@ -1087,6 +1087,7 @@
"levelingStage10": "Immortal",
"levelingStage11": "Divine",
"levelingStage12": "Transcendent",
"uploadTasks": "Upload Tasks",
"uploadAttachment": "Upload Attachment",
"attachmentPreview": "Attachment Preview",
"selectPool": "Select Pool",

View File

@@ -1,7 +1,5 @@
PODS:
- Alamofire (5.10.2)
- app_links (6.4.1):
- Flutter
- connectivity_plus (0.0.1):
- Flutter
- croppy (0.0.1):
@@ -52,18 +50,18 @@ PODS:
- Firebase/Messaging (12.4.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 12.4.0)
- firebase_analytics (12.0.3):
- firebase_analytics (12.0.4):
- firebase_core
- FirebaseAnalytics (= 12.4.0)
- Flutter
- firebase_core (4.2.0):
- firebase_core (4.2.1):
- Firebase/CoreOnly (= 12.4.0)
- Flutter
- firebase_crashlytics (5.0.3):
- firebase_crashlytics (5.0.4):
- Firebase/Crashlytics (= 12.4.0)
- firebase_core
- Flutter
- firebase_messaging (16.0.3):
- firebase_messaging (16.0.4):
- Firebase/Messaging (= 12.4.0)
- firebase_core
- Flutter
@@ -265,6 +263,8 @@ PODS:
- PromisesObjC (2.4.0)
- PromisesSwift (2.4.0):
- PromisesObjC (= 2.4.0)
- protocol_handler_ios (0.0.1):
- Flutter
- receive_sharing_intent (1.8.1):
- Flutter
- record_ios (1.1.0):
@@ -323,7 +323,6 @@ PODS:
DEPENDENCIES:
- Alamofire
- app_links (from `.symlinks/plugins/app_links/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- croppy (from `.symlinks/plugins/croppy/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
@@ -358,6 +357,7 @@ DEPENDENCIES:
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`)
- protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- record_ios (from `.symlinks/plugins/record_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
@@ -404,8 +404,6 @@ SPEC REPOS:
- WebRTC-SDK
EXTERNAL SOURCES:
app_links:
:path: ".symlinks/plugins/app_links/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
croppy:
@@ -470,6 +468,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
pointer_interceptor_ios:
:path: ".symlinks/plugins/pointer_interceptor_ios/ios"
protocol_handler_ios:
:path: ".symlinks/plugins/protocol_handler_ios/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
record_ios:
@@ -497,7 +497,6 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
@@ -506,10 +505,10 @@ SPEC CHECKSUMS:
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e
firebase_analytics: 1d024068b1d4707d5ba7a42a12976ddf3316d835
firebase_core: 744984dbbed8b3036abf34f0b98d80f130a7e464
firebase_crashlytics: f3a9a4338ab99b67042f64e9e22e1bf349cb44ed
firebase_messaging: 82c70650c426a0a14873e1acdb9ec2b443c4e8b4
firebase_analytics: 67fbdd9f3c04e55048024f3da21cfc36f05e56cf
firebase_core: f1aafb21c14f497e5498f7ffc4dc63cbb52b2594
firebase_crashlytics: 83c7467d7534975a4d779af43bd226d0a4616464
firebase_messaging: c17a29984eafce4b2997fe078bb0a9e0b06f5dde
FirebaseAnalytics: 0fc2b20091f0ddd21bf73397cf8f0eb5346dc24f
FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3
FirebaseCoreExtension: 7e1f7118ee970e001a8013719fb90950ee5e0018
@@ -553,6 +552,7 @@ SPEC CHECKSUMS:
pointer_interceptor_ios: da06a662d5bfd329602b45b2ab41bc0fb5fdb0f0
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
protocol_handler_ios: 59f23ee71f3ec602d67902ca7f669a80957888d5
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c

View File

@@ -0,0 +1,54 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/models/file.dart';
part 'upload_task.freezed.dart';
part 'upload_task.g.dart';
enum UploadTaskStatus {
pending,
inProgress,
paused,
completed,
failed,
expired,
cancelled,
}
@freezed
sealed class UploadTask with _$UploadTask {
const UploadTask._();
const factory UploadTask({
required String id,
required String taskId,
required String fileName,
required String contentType,
required int fileSize,
required int uploadedBytes,
required int totalChunks,
required int uploadedChunks,
required UploadTaskStatus status,
required DateTime createdAt,
required DateTime updatedAt,
String? errorMessage,
SnCloudFile? result,
String? poolId,
String? bundleId,
String? encryptPassword,
String? expiredAt,
}) = _UploadTask;
factory UploadTask.fromJson(Map<String, dynamic> json) =>
_$UploadTaskFromJson(json);
double get progress => totalChunks > 0 ? uploadedChunks / totalChunks : 0.0;
Duration get estimatedTimeRemaining {
if (uploadedBytes == 0 || fileSize == 0) return Duration.zero;
final remainingBytes = fileSize - uploadedBytes;
final uploadRate =
uploadedBytes / createdAt.difference(DateTime.now()).inSeconds.abs();
if (uploadRate == 0) return Duration.zero;
return Duration(seconds: (remainingBytes / uploadRate).round());
}
}

View File

@@ -0,0 +1,343 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'upload_task.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$UploadTask {
String get id; String get taskId; String get fileName; String get contentType; int get fileSize; int get uploadedBytes; int get totalChunks; int get uploadedChunks; UploadTaskStatus get status; DateTime get createdAt; DateTime get updatedAt; String? get errorMessage; SnCloudFile? get result; String? get poolId; String? get bundleId; String? get encryptPassword; String? get expiredAt;
/// Create a copy of UploadTask
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$UploadTaskCopyWith<UploadTask> get copyWith => _$UploadTaskCopyWithImpl<UploadTask>(this as UploadTask, _$identity);
/// Serializes this UploadTask to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is UploadTask&&(identical(other.id, id) || other.id == id)&&(identical(other.taskId, taskId) || other.taskId == taskId)&&(identical(other.fileName, fileName) || other.fileName == fileName)&&(identical(other.contentType, contentType) || other.contentType == contentType)&&(identical(other.fileSize, fileSize) || other.fileSize == fileSize)&&(identical(other.uploadedBytes, uploadedBytes) || other.uploadedBytes == uploadedBytes)&&(identical(other.totalChunks, totalChunks) || other.totalChunks == totalChunks)&&(identical(other.uploadedChunks, uploadedChunks) || other.uploadedChunks == uploadedChunks)&&(identical(other.status, status) || other.status == status)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.result, result) || other.result == result)&&(identical(other.poolId, poolId) || other.poolId == poolId)&&(identical(other.bundleId, bundleId) || other.bundleId == bundleId)&&(identical(other.encryptPassword, encryptPassword) || other.encryptPassword == encryptPassword)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,taskId,fileName,contentType,fileSize,uploadedBytes,totalChunks,uploadedChunks,status,createdAt,updatedAt,errorMessage,result,poolId,bundleId,encryptPassword,expiredAt);
@override
String toString() {
return 'UploadTask(id: $id, taskId: $taskId, fileName: $fileName, contentType: $contentType, fileSize: $fileSize, uploadedBytes: $uploadedBytes, totalChunks: $totalChunks, uploadedChunks: $uploadedChunks, status: $status, createdAt: $createdAt, updatedAt: $updatedAt, errorMessage: $errorMessage, result: $result, poolId: $poolId, bundleId: $bundleId, encryptPassword: $encryptPassword, expiredAt: $expiredAt)';
}
}
/// @nodoc
abstract mixin class $UploadTaskCopyWith<$Res> {
factory $UploadTaskCopyWith(UploadTask value, $Res Function(UploadTask) _then) = _$UploadTaskCopyWithImpl;
@useResult
$Res call({
String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, UploadTaskStatus status, DateTime createdAt, DateTime updatedAt, String? errorMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt
});
$SnCloudFileCopyWith<$Res>? get result;
}
/// @nodoc
class _$UploadTaskCopyWithImpl<$Res>
implements $UploadTaskCopyWith<$Res> {
_$UploadTaskCopyWithImpl(this._self, this._then);
final UploadTask _self;
final $Res Function(UploadTask) _then;
/// Create a copy of UploadTask
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? taskId = null,Object? fileName = null,Object? contentType = null,Object? fileSize = null,Object? uploadedBytes = null,Object? totalChunks = null,Object? uploadedChunks = null,Object? status = null,Object? createdAt = null,Object? updatedAt = null,Object? errorMessage = freezed,Object? result = freezed,Object? poolId = freezed,Object? bundleId = freezed,Object? encryptPassword = freezed,Object? expiredAt = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,taskId: null == taskId ? _self.taskId : taskId // ignore: cast_nullable_to_non_nullable
as String,fileName: null == fileName ? _self.fileName : fileName // ignore: cast_nullable_to_non_nullable
as String,contentType: null == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable
as String,fileSize: null == fileSize ? _self.fileSize : fileSize // ignore: cast_nullable_to_non_nullable
as int,uploadedBytes: null == uploadedBytes ? _self.uploadedBytes : uploadedBytes // ignore: cast_nullable_to_non_nullable
as int,totalChunks: null == totalChunks ? _self.totalChunks : totalChunks // ignore: cast_nullable_to_non_nullable
as int,uploadedChunks: null == uploadedChunks ? _self.uploadedChunks : uploadedChunks // ignore: cast_nullable_to_non_nullable
as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as UploadTaskStatus,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String?,result: freezed == result ? _self.result : result // ignore: cast_nullable_to_non_nullable
as SnCloudFile?,poolId: freezed == poolId ? _self.poolId : poolId // ignore: cast_nullable_to_non_nullable
as String?,bundleId: freezed == bundleId ? _self.bundleId : bundleId // ignore: cast_nullable_to_non_nullable
as String?,encryptPassword: freezed == encryptPassword ? _self.encryptPassword : encryptPassword // ignore: cast_nullable_to_non_nullable
as String?,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as String?,
));
}
/// Create a copy of UploadTask
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnCloudFileCopyWith<$Res>? get result {
if (_self.result == null) {
return null;
}
return $SnCloudFileCopyWith<$Res>(_self.result!, (value) {
return _then(_self.copyWith(result: value));
});
}
}
/// Adds pattern-matching-related methods to [UploadTask].
extension UploadTaskPatterns on UploadTask {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _UploadTask value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _UploadTask() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _UploadTask value) $default,){
final _that = this;
switch (_that) {
case _UploadTask():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _UploadTask value)? $default,){
final _that = this;
switch (_that) {
case _UploadTask() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, UploadTaskStatus status, DateTime createdAt, DateTime updatedAt, String? errorMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _UploadTask() when $default != null:
return $default(_that.id,_that.taskId,_that.fileName,_that.contentType,_that.fileSize,_that.uploadedBytes,_that.totalChunks,_that.uploadedChunks,_that.status,_that.createdAt,_that.updatedAt,_that.errorMessage,_that.result,_that.poolId,_that.bundleId,_that.encryptPassword,_that.expiredAt);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, UploadTaskStatus status, DateTime createdAt, DateTime updatedAt, String? errorMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt) $default,) {final _that = this;
switch (_that) {
case _UploadTask():
return $default(_that.id,_that.taskId,_that.fileName,_that.contentType,_that.fileSize,_that.uploadedBytes,_that.totalChunks,_that.uploadedChunks,_that.status,_that.createdAt,_that.updatedAt,_that.errorMessage,_that.result,_that.poolId,_that.bundleId,_that.encryptPassword,_that.expiredAt);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, UploadTaskStatus status, DateTime createdAt, DateTime updatedAt, String? errorMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt)? $default,) {final _that = this;
switch (_that) {
case _UploadTask() when $default != null:
return $default(_that.id,_that.taskId,_that.fileName,_that.contentType,_that.fileSize,_that.uploadedBytes,_that.totalChunks,_that.uploadedChunks,_that.status,_that.createdAt,_that.updatedAt,_that.errorMessage,_that.result,_that.poolId,_that.bundleId,_that.encryptPassword,_that.expiredAt);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _UploadTask extends UploadTask {
const _UploadTask({required this.id, required this.taskId, required this.fileName, required this.contentType, required this.fileSize, required this.uploadedBytes, required this.totalChunks, required this.uploadedChunks, required this.status, required this.createdAt, required this.updatedAt, this.errorMessage, this.result, this.poolId, this.bundleId, this.encryptPassword, this.expiredAt}): super._();
factory _UploadTask.fromJson(Map<String, dynamic> json) => _$UploadTaskFromJson(json);
@override final String id;
@override final String taskId;
@override final String fileName;
@override final String contentType;
@override final int fileSize;
@override final int uploadedBytes;
@override final int totalChunks;
@override final int uploadedChunks;
@override final UploadTaskStatus status;
@override final DateTime createdAt;
@override final DateTime updatedAt;
@override final String? errorMessage;
@override final SnCloudFile? result;
@override final String? poolId;
@override final String? bundleId;
@override final String? encryptPassword;
@override final String? expiredAt;
/// Create a copy of UploadTask
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$UploadTaskCopyWith<_UploadTask> get copyWith => __$UploadTaskCopyWithImpl<_UploadTask>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$UploadTaskToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _UploadTask&&(identical(other.id, id) || other.id == id)&&(identical(other.taskId, taskId) || other.taskId == taskId)&&(identical(other.fileName, fileName) || other.fileName == fileName)&&(identical(other.contentType, contentType) || other.contentType == contentType)&&(identical(other.fileSize, fileSize) || other.fileSize == fileSize)&&(identical(other.uploadedBytes, uploadedBytes) || other.uploadedBytes == uploadedBytes)&&(identical(other.totalChunks, totalChunks) || other.totalChunks == totalChunks)&&(identical(other.uploadedChunks, uploadedChunks) || other.uploadedChunks == uploadedChunks)&&(identical(other.status, status) || other.status == status)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.result, result) || other.result == result)&&(identical(other.poolId, poolId) || other.poolId == poolId)&&(identical(other.bundleId, bundleId) || other.bundleId == bundleId)&&(identical(other.encryptPassword, encryptPassword) || other.encryptPassword == encryptPassword)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,taskId,fileName,contentType,fileSize,uploadedBytes,totalChunks,uploadedChunks,status,createdAt,updatedAt,errorMessage,result,poolId,bundleId,encryptPassword,expiredAt);
@override
String toString() {
return 'UploadTask(id: $id, taskId: $taskId, fileName: $fileName, contentType: $contentType, fileSize: $fileSize, uploadedBytes: $uploadedBytes, totalChunks: $totalChunks, uploadedChunks: $uploadedChunks, status: $status, createdAt: $createdAt, updatedAt: $updatedAt, errorMessage: $errorMessage, result: $result, poolId: $poolId, bundleId: $bundleId, encryptPassword: $encryptPassword, expiredAt: $expiredAt)';
}
}
/// @nodoc
abstract mixin class _$UploadTaskCopyWith<$Res> implements $UploadTaskCopyWith<$Res> {
factory _$UploadTaskCopyWith(_UploadTask value, $Res Function(_UploadTask) _then) = __$UploadTaskCopyWithImpl;
@override @useResult
$Res call({
String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, UploadTaskStatus status, DateTime createdAt, DateTime updatedAt, String? errorMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt
});
@override $SnCloudFileCopyWith<$Res>? get result;
}
/// @nodoc
class __$UploadTaskCopyWithImpl<$Res>
implements _$UploadTaskCopyWith<$Res> {
__$UploadTaskCopyWithImpl(this._self, this._then);
final _UploadTask _self;
final $Res Function(_UploadTask) _then;
/// Create a copy of UploadTask
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? taskId = null,Object? fileName = null,Object? contentType = null,Object? fileSize = null,Object? uploadedBytes = null,Object? totalChunks = null,Object? uploadedChunks = null,Object? status = null,Object? createdAt = null,Object? updatedAt = null,Object? errorMessage = freezed,Object? result = freezed,Object? poolId = freezed,Object? bundleId = freezed,Object? encryptPassword = freezed,Object? expiredAt = freezed,}) {
return _then(_UploadTask(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,taskId: null == taskId ? _self.taskId : taskId // ignore: cast_nullable_to_non_nullable
as String,fileName: null == fileName ? _self.fileName : fileName // ignore: cast_nullable_to_non_nullable
as String,contentType: null == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable
as String,fileSize: null == fileSize ? _self.fileSize : fileSize // ignore: cast_nullable_to_non_nullable
as int,uploadedBytes: null == uploadedBytes ? _self.uploadedBytes : uploadedBytes // ignore: cast_nullable_to_non_nullable
as int,totalChunks: null == totalChunks ? _self.totalChunks : totalChunks // ignore: cast_nullable_to_non_nullable
as int,uploadedChunks: null == uploadedChunks ? _self.uploadedChunks : uploadedChunks // ignore: cast_nullable_to_non_nullable
as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as UploadTaskStatus,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String?,result: freezed == result ? _self.result : result // ignore: cast_nullable_to_non_nullable
as SnCloudFile?,poolId: freezed == poolId ? _self.poolId : poolId // ignore: cast_nullable_to_non_nullable
as String?,bundleId: freezed == bundleId ? _self.bundleId : bundleId // ignore: cast_nullable_to_non_nullable
as String?,encryptPassword: freezed == encryptPassword ? _self.encryptPassword : encryptPassword // ignore: cast_nullable_to_non_nullable
as String?,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as String?,
));
}
/// Create a copy of UploadTask
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnCloudFileCopyWith<$Res>? get result {
if (_self.result == null) {
return null;
}
return $SnCloudFileCopyWith<$Res>(_self.result!, (value) {
return _then(_self.copyWith(result: value));
});
}
}
// dart format on

View File

@@ -0,0 +1,61 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'upload_task.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_UploadTask _$UploadTaskFromJson(Map<String, dynamic> json) => _UploadTask(
id: json['id'] as String,
taskId: json['task_id'] as String,
fileName: json['file_name'] as String,
contentType: json['content_type'] as String,
fileSize: (json['file_size'] as num).toInt(),
uploadedBytes: (json['uploaded_bytes'] as num).toInt(),
totalChunks: (json['total_chunks'] as num).toInt(),
uploadedChunks: (json['uploaded_chunks'] as num).toInt(),
status: $enumDecode(_$UploadTaskStatusEnumMap, json['status']),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
errorMessage: json['error_message'] as String?,
result:
json['result'] == null
? null
: SnCloudFile.fromJson(json['result'] as Map<String, dynamic>),
poolId: json['pool_id'] as String?,
bundleId: json['bundle_id'] as String?,
encryptPassword: json['encrypt_password'] as String?,
expiredAt: json['expired_at'] as String?,
);
Map<String, dynamic> _$UploadTaskToJson(_UploadTask instance) =>
<String, dynamic>{
'id': instance.id,
'task_id': instance.taskId,
'file_name': instance.fileName,
'content_type': instance.contentType,
'file_size': instance.fileSize,
'uploaded_bytes': instance.uploadedBytes,
'total_chunks': instance.totalChunks,
'uploaded_chunks': instance.uploadedChunks,
'status': _$UploadTaskStatusEnumMap[instance.status]!,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'error_message': instance.errorMessage,
'result': instance.result?.toJson(),
'pool_id': instance.poolId,
'bundle_id': instance.bundleId,
'encrypt_password': instance.encryptPassword,
'expired_at': instance.expiredAt,
};
const _$UploadTaskStatusEnumMap = {
UploadTaskStatus.pending: 'pending',
UploadTaskStatus.inProgress: 'inProgress',
UploadTaskStatus.paused: 'paused',
UploadTaskStatus.completed: 'completed',
UploadTaskStatus.failed: 'failed',
UploadTaskStatus.expired: 'expired',
UploadTaskStatus.cancelled: 'cancelled',
};

View File

@@ -28,7 +28,7 @@ class MessagesNotifier extends _$MessagesNotifier {
late final SnChatMember _identity;
final Map<String, LocalChatMessage> _pendingMessages = {};
final Map<String, Map<int, double>> _fileUploadProgress = {};
final Map<String, Map<int, double?>> _fileUploadProgress = {};
int? _totalCount;
String? _searchQuery;
bool? _withLinks;
@@ -438,7 +438,7 @@ class MessagesNotifier extends _$MessagesNotifier {
SnChatMessage? editingTo,
SnChatMessage? forwardingTo,
SnChatMessage? replyingTo,
Function(String, Map<int, double>)? onProgress,
Function(String, Map<int, double?>)? onProgress,
}) async {
final nonce = const Uuid().v4();
talker.log('Sending message with nonce $nonce');
@@ -474,7 +474,7 @@ class MessagesNotifier extends _$MessagesNotifier {
fileData: attachments[idx],
client: ref.read(apiClientProvider),
onProgress: (progress, _) {
_fileUploadProgress[localMessage.id]?[idx] = progress;
_fileUploadProgress[localMessage.id]?[idx] = progress ?? 0.0;
onProgress?.call(
localMessage.id,
_fileUploadProgress[localMessage.id] ?? {},

View File

@@ -6,7 +6,7 @@ part of 'messages_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$messagesNotifierHash() => r'6adefd9152cdd686c2a863964993f24c42d405b5';
String _$messagesNotifierHash() => r'b1d5d583199941d55dfdc707e1a22eec9616b7f1';
/// Copied from Dart SDK
class _SystemHash {

317
lib/pods/upload_tasks.dart Normal file
View File

@@ -0,0 +1,317 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:cross_file/cross_file.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/models/upload_task.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/services/file_uploader.dart';
final uploadTasksProvider =
StateNotifierProvider<UploadTasksNotifier, List<UploadTask>>(
(ref) => UploadTasksNotifier(ref),
);
class UploadTasksNotifier extends StateNotifier<List<UploadTask>> {
final Ref ref;
StreamSubscription? _websocketSubscription;
UploadTasksNotifier(this.ref) : super([]) {
_listenToWebSocket();
}
void _listenToWebSocket() {
final WebSocketService websocketService = ref.read(websocketProvider);
_websocketSubscription = websocketService.dataStream.listen(
_handleWebSocketPacket,
);
}
void _handleWebSocketPacket(dynamic packet) {
if (packet.type.startsWith('upload.')) {
final data = packet.data;
if (data == null) return;
final taskId = data['task_id'] as String?;
if (taskId == null) return;
switch (packet.type) {
case 'upload.progress':
_handleProgressUpdate(taskId, data);
break;
case 'upload.completed':
_handleUploadCompleted(taskId, data);
break;
case 'upload.failed':
_handleUploadFailed(taskId, data);
break;
}
}
}
void _handleProgressUpdate(String taskId, Map<String, dynamic> data) {
final uploadedChunks = data['chunksUploaded'] as int? ?? 0;
final uploadedBytes =
(data['progress'] as num? ?? 0.0) /
100.0 *
(data['fileSize'] as int? ?? 0);
state =
state.map((task) {
if (task.taskId == taskId) {
return task.copyWith(
uploadedChunks: uploadedChunks,
uploadedBytes: uploadedBytes.toInt(),
status: UploadTaskStatus.inProgress,
updatedAt: DateTime.now(),
);
}
return task;
}).toList();
}
void _handleUploadCompleted(String taskId, Map<String, dynamic> data) {
final fileData = data['file'];
if (fileData != null) {
// Assuming the file data comes in the expected format
// You might need to adjust this based on the actual API response
}
state =
state.map((task) {
if (task.taskId == taskId) {
return task.copyWith(
status: UploadTaskStatus.completed,
uploadedChunks: task.totalChunks,
uploadedBytes: task.fileSize,
updatedAt: DateTime.now(),
);
}
return task;
}).toList();
}
void _handleUploadFailed(String taskId, Map<String, dynamic> data) {
final errorMessage = data['error'] as String? ?? 'Upload failed';
state =
state.map((task) {
if (task.taskId == taskId) {
return task.copyWith(
status: UploadTaskStatus.failed,
errorMessage: errorMessage,
updatedAt: DateTime.now(),
);
}
return task;
}).toList();
}
void addUploadTask(UploadTask task) {
state = [...state, task];
}
void updateTaskStatus(
String taskId,
UploadTaskStatus status, {
String? errorMessage,
}) {
state =
state.map((task) {
if (task.taskId == taskId) {
return task.copyWith(
status: status,
errorMessage: errorMessage,
updatedAt: DateTime.now(),
);
}
return task;
}).toList();
}
void removeTask(String taskId) {
state = state.where((task) => task.taskId != taskId).toList();
}
UploadTask? getTask(String taskId) {
return state.where((task) => task.taskId == taskId).firstOrNull;
}
List<UploadTask> getActiveTasks() {
return state
.where(
(task) =>
task.status == UploadTaskStatus.pending ||
task.status == UploadTaskStatus.inProgress ||
task.status == UploadTaskStatus.paused,
)
.toList();
}
@override
void dispose() {
_websocketSubscription?.cancel();
super.dispose();
}
}
// Provider for the enhanced FileUploader that integrates with upload tasks
final enhancedFileUploaderProvider = Provider<EnhancedFileUploader>((ref) {
final dio = ref.watch(apiClientProvider);
return EnhancedFileUploader(dio, ref);
});
class EnhancedFileUploader extends FileUploader {
final Ref ref;
EnhancedFileUploader(super.client, this.ref);
/// Reads the next chunk from a stream subscription.
Future<Uint8List> _readNextChunkFromStream(
StreamSubscription<List<int>> subscription,
int size,
) async {
final completer = Completer<Uint8List>();
final buffer = <int>[];
int remaining = size;
void onData(List<int> data) {
buffer.addAll(data);
remaining -= data.length;
if (remaining <= 0) {
subscription.pause();
completer.complete(Uint8List.fromList(buffer.sublist(0, size)));
}
}
void onDone() {
if (!completer.isCompleted) {
completer.complete(Uint8List.fromList(buffer));
}
}
subscription.onData(onData);
subscription.onDone(onDone);
return completer.future;
}
@override
Future<SnCloudFile> uploadFile({
required dynamic fileData,
required String fileName,
required String contentType,
String? poolId,
String? bundleId,
String? encryptPassword,
String? expiredAt,
int? customChunkSize,
Function(double? progress, Duration estimate)? onProgress,
}) async {
// Step 1: Create upload task
onProgress?.call(null, Duration.zero);
final createResponse = await createUploadTask(
fileData: fileData,
fileName: fileName,
contentType: contentType,
poolId: poolId,
bundleId: bundleId,
encryptPassword: encryptPassword,
expiredAt: expiredAt,
chunkSize: customChunkSize,
);
if (createResponse['file_exists'] == true) {
// File already exists, return the existing file
return SnCloudFile.fromJson(createResponse['file']);
}
final taskId = createResponse['task_id'] as String;
final chunkSize = createResponse['chunk_size'] as int;
final chunksCount = createResponse['chunks_count'] as int;
int totalSize;
if (fileData is XFile) {
totalSize = await fileData.length();
} else if (fileData is Uint8List) {
totalSize = fileData.length;
} else {
throw ArgumentError('Invalid fileData type');
}
// Create upload task and add to state
final uploadTask = UploadTask(
id: DateTime.now().millisecondsSinceEpoch.toString(),
taskId: taskId,
fileName: fileName,
contentType: contentType,
fileSize: totalSize,
uploadedBytes: 0,
totalChunks: chunksCount,
uploadedChunks: 0,
status: UploadTaskStatus.pending,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
poolId: poolId,
bundleId: bundleId,
encryptPassword: encryptPassword,
expiredAt: expiredAt,
);
ref.read(uploadTasksProvider.notifier).addUploadTask(uploadTask);
// Step 2: Upload chunks
int bytesUploaded = 0;
if (fileData is XFile) {
// Use stream for XFile
final subscription = fileData.openRead().listen(null);
subscription.pause();
for (int i = 0; i < chunksCount; i++) {
subscription.resume();
final chunkData = await _readNextChunkFromStream(
subscription,
chunkSize,
);
await uploadChunk(
taskId: taskId,
chunkIndex: i,
chunkData: chunkData,
onSendProgress: (sent, total) {
final overallProgress = (bytesUploaded + sent) / totalSize;
onProgress?.call(overallProgress, Duration.zero);
},
);
bytesUploaded += chunkData.length;
}
subscription.cancel();
} else if (fileData is Uint8List) {
// Use old way for Uint8List
final chunks = <Uint8List>[];
for (int i = 0; i < fileData.length; i += chunkSize) {
final end =
i + chunkSize > fileData.length ? fileData.length : i + chunkSize;
chunks.add(Uint8List.fromList(fileData.sublist(i, end)));
}
// Upload each chunk
for (int i = 0; i < chunks.length; i++) {
await uploadChunk(
taskId: taskId,
chunkIndex: i,
chunkData: chunks[i],
onSendProgress: (sent, total) {
final overallProgress = (bytesUploaded + sent) / totalSize;
onProgress?.call(overallProgress, Duration.zero);
},
);
bytesUploaded += chunks[i].length;
}
} else {
throw ArgumentError('Invalid fileData type');
}
// Step 3: Complete upload
onProgress?.call(null, Duration.zero);
return await completeUpload(taskId);
}
}

View File

@@ -84,9 +84,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
'accountPasswordChange'.tr(),
);
if (!confirm || !context.mounted) return;
final captchaTk = await Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
final captchaTk = await CaptchaScreen.show(context);
if (captchaTk == null) return;
try {
if (context.mounted) showLoadingModal(context);

View File

@@ -2,9 +2,17 @@ import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/screens/auth/captcha.config.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/sheet.dart';
class CaptchaScreen extends ConsumerWidget {
static Future<String?> show(BuildContext context) {
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
builder: (context) => const CaptchaScreen(),
);
}
const CaptchaScreen({super.key});
@override
@@ -13,9 +21,9 @@ class CaptchaScreen extends ConsumerWidget {
if (!captchaUrl.hasValue) return Center(child: CircularProgressIndicator());
return AppScaffold(
appBar: AppBar(title: Text("Anti-Robot")),
body: InAppWebView(
return SheetScaffold(
titleText: "Anti-Robot",
child: InAppWebView(
initialUrlRequest: URLRequest(
url: WebUri('${captchaUrl.value}?redirect_uri=solian://captcha'),
),

View File

@@ -4,11 +4,19 @@ import 'dart:ui_web' as ui;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/config.dart';
import 'package:island/screens/auth/captcha.config.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:web/web.dart' as web;
import 'package:flutter/material.dart';
class CaptchaScreen extends ConsumerStatefulWidget {
static Future<String?> show(BuildContext context) {
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
builder: (context) => const CaptchaScreen(),
);
}
const CaptchaScreen({super.key});
@override
@@ -61,9 +69,9 @@ class _CaptchaScreenState extends ConsumerState<CaptchaScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(title: Text("Anti-Robot")),
body:
return SheetScaffold(
titleText: "Anti-Robot",
child:
_isInitialized
? HtmlElementView(viewType: 'captcha-iframe')
: Center(child: CircularProgressIndicator()),

View File

@@ -38,9 +38,7 @@ class CreateAccountScreen extends HookConsumerWidget {
void performAction() async {
if (!formKey.currentState!.validate()) return;
final captchaTk = await Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
final captchaTk = await CaptchaScreen.show(context);
if (captchaTk == null) return;
if (!context.mounted) return;

View File

@@ -523,9 +523,7 @@ class _LoginLookupScreen extends HookConsumerWidget {
showErrorAlert('loginResetPasswordHint'.tr());
return;
}
final captchaTk = await Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
final captchaTk = await CaptchaScreen.show(context);
if (captchaTk == null) return;
isBusy.value = true;
try {

View File

@@ -2,6 +2,7 @@ import "dart:async";
import "dart:math" as math;
import "package:easy_localization/easy_localization.dart";
import "package:file_picker/file_picker.dart";
import "package:image_picker/image_picker.dart";
import "package:flutter/material.dart";
import "package:go_router/go_router.dart";
import "package:flutter_hooks/flutter_hooks.dart";
@@ -148,7 +149,7 @@ class ChatRoomScreen extends HookConsumerWidget {
final messageForwardingTo = useState<SnChatMessage?>(null);
final messageEditingTo = useState<SnChatMessage?>(null);
final attachments = useState<List<UniversalFile>>([]);
final attachmentProgress = useState<Map<String, Map<int, double>>>({});
final attachmentProgress = useState<Map<String, Map<int, double?>>>({});
// Selection mode state
final isSelectionMode = useState<bool>(false);
@@ -181,16 +182,13 @@ class ChatRoomScreen extends HookConsumerWidget {
}, [scrollController]);
Future<void> pickPhotoMedia() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: true,
allowCompression: false,
);
if (result == null || result.count == 0) return;
final ImagePicker picker = ImagePicker();
final List<XFile> results = await picker.pickMultiImage();
if (results.isEmpty) return;
attachments.value = [
...attachments.value,
...result.files.map(
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.image),
...results.map(
(xfile) => UniversalFile(data: xfile, type: UniversalFileType.image),
),
];
}
@@ -573,7 +571,7 @@ class ChatRoomScreen extends HookConsumerWidget {
onProgress: (progress, _) {
attachmentProgress.value = {
...attachmentProgress.value,
'chat-upload': {index: progress},
'chat-upload': {index: progress ?? 0.0},
};
},
).future;

View File

@@ -306,7 +306,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
],
),
children: [
ValueListenableBuilder<Map<int, double>>(
ValueListenableBuilder<Map<int, double?>>(
valueListenable: state.attachmentProgress,
builder: (context, progressMap, _) {
return Wrap(

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:convert/convert.dart';
import 'package:cross_file/cross_file.dart';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
@@ -21,9 +22,51 @@ class FileUploader {
return digest.toString();
}
/// Calculates the MD5 hash from a stream.
Future<String> _calculateFileHashFromStream(Stream<List<int>> stream) async {
final accumulator = AccumulatorSink<Digest>();
final converter = md5.startChunkedConversion(accumulator);
await for (final chunk in stream) {
converter.add(chunk);
}
converter.close();
final digest = accumulator.events.single;
return digest.toString();
}
/// Reads the next chunk from a stream subscription.
Future<Uint8List> _readNextChunk(
StreamSubscription<List<int>> subscription,
int size,
) async {
final completer = Completer<Uint8List>();
final buffer = <int>[];
int remaining = size;
void onData(List<int> data) {
buffer.addAll(data);
remaining -= data.length;
if (remaining <= 0) {
subscription.pause();
completer.complete(Uint8List.fromList(buffer.sublist(0, size)));
}
}
void onDone() {
if (!completer.isCompleted) {
completer.complete(Uint8List.fromList(buffer));
}
}
subscription.onData(onData);
subscription.onDone(onDone);
return completer.future;
}
/// Creates an upload task for the given file.
Future<Map<String, dynamic>> createUploadTask({
required Uint8List bytes,
required dynamic fileData,
required String fileName,
required String contentType,
String? poolId,
@@ -32,8 +75,17 @@ class FileUploader {
String? expiredAt,
int? chunkSize,
}) async {
final hash = _calculateFileHash(bytes);
final fileSize = bytes.length;
String hash;
int fileSize;
if (fileData is XFile) {
fileSize = await fileData.length();
hash = await _calculateFileHashFromStream(fileData.openRead());
} else if (fileData is Uint8List) {
hash = _calculateFileHash(fileData);
fileSize = fileData.length;
} else {
throw ArgumentError('Invalid fileData type');
}
final response = await _client.post(
'/drive/files/upload/create',
@@ -58,6 +110,7 @@ class FileUploader {
required String taskId,
required int chunkIndex,
required Uint8List chunkData,
ProgressCallback? onSendProgress,
}) async {
final formData = FormData.fromMap({
'chunk': MultipartFile.fromBytes(
@@ -69,6 +122,7 @@ class FileUploader {
await _client.post(
'/drive/files/upload/chunk/$taskId/$chunkIndex',
data: formData,
onSendProgress: onSendProgress,
);
}
@@ -81,7 +135,7 @@ class FileUploader {
/// Uploads a file in chunks using the multi-part API.
Future<SnCloudFile> uploadFile({
required Uint8List bytes,
required dynamic fileData,
required String fileName,
required String contentType,
String? poolId,
@@ -89,10 +143,12 @@ class FileUploader {
String? encryptPassword,
String? expiredAt,
int? customChunkSize,
Function(double? progress, Duration estimate)? onProgress,
}) async {
// Step 1: Create upload task
onProgress?.call(null, Duration.zero);
final createResponse = await createUploadTask(
bytes: bytes,
fileData: fileData,
fileName: fileName,
contentType: contentType,
poolId: poolId,
@@ -110,27 +166,64 @@ class FileUploader {
final taskId = createResponse['task_id'] as String;
final chunkSize = createResponse['chunk_size'] as int;
final chunksCount = createResponse['chunks_count'] as int;
int totalSize;
if (fileData is XFile) {
totalSize = await fileData.length();
} else if (fileData is Uint8List) {
totalSize = fileData.length;
} else {
throw ArgumentError('Invalid fileData type');
}
// Step 2: Upload chunks
final chunks = <Uint8List>[];
for (int i = 0; i < bytes.length; i += chunkSize) {
final end = i + chunkSize > bytes.length ? bytes.length : i + chunkSize;
chunks.add(Uint8List.fromList(bytes.sublist(i, end)));
}
int bytesUploaded = 0;
if (fileData is XFile) {
// Use stream for XFile
final subscription = fileData.openRead().listen(null);
subscription.pause();
for (int i = 0; i < chunksCount; i++) {
subscription.resume();
final chunkData = await _readNextChunk(subscription, chunkSize);
await uploadChunk(
taskId: taskId,
chunkIndex: i,
chunkData: chunkData,
onSendProgress: (sent, total) {
final overallProgress = (bytesUploaded + sent) / totalSize;
onProgress?.call(overallProgress, Duration.zero);
},
);
bytesUploaded += chunkData.length;
}
subscription.cancel();
} else if (fileData is Uint8List) {
// Use old way for Uint8List
final chunks = <Uint8List>[];
for (int i = 0; i < fileData.length; i += chunkSize) {
final end =
i + chunkSize > fileData.length ? fileData.length : i + chunkSize;
chunks.add(Uint8List.fromList(fileData.sublist(i, end)));
}
// Ensure we have the correct number of chunks
if (chunks.length != chunksCount) {
throw Exception(
'Chunk count mismatch: expected $chunksCount, got ${chunks.length}',
);
}
// Upload each chunk
for (int i = 0; i < chunks.length; i++) {
await uploadChunk(taskId: taskId, chunkIndex: i, chunkData: chunks[i]);
// Upload each chunk
for (int i = 0; i < chunks.length; i++) {
await uploadChunk(
taskId: taskId,
chunkIndex: i,
chunkData: chunks[i],
onSendProgress: (sent, total) {
final overallProgress = (bytesUploaded + sent) / totalSize;
onProgress?.call(overallProgress, Duration.zero);
},
);
bytesUploaded += chunks[i].length;
}
} else {
throw ArgumentError('Invalid fileData type');
}
// Step 3: Complete upload
onProgress?.call(null, Duration.zero);
return await completeUpload(taskId);
}
@@ -139,7 +232,7 @@ class FileUploader {
required Dio client,
String? poolId,
FileUploadMode? mode,
Function(double progress, Duration estimate)? onProgress,
Function(double? progress, Duration estimate)? onProgress,
}) {
final completer = Completer<SnCloudFile?>();
@@ -173,7 +266,7 @@ class FileUploader {
await exif.writeAttributes(gpsAttributes);
})
.then(
(_) => _processUpload(
(_) => _processUploadWithEnhancedUploader(
fileData,
client,
poolId,
@@ -183,7 +276,7 @@ class FileUploader {
)
.catchError((e) {
debugPrint('Error removing GPS EXIF data: $e');
return _processUpload(
return _processUploadWithEnhancedUploader(
fileData,
client,
poolId,
@@ -196,16 +289,22 @@ class FileUploader {
}
}
_processUpload(fileData, client, poolId, onProgress, completer);
_processUploadWithEnhancedUploader(
fileData,
client,
poolId,
onProgress,
completer,
);
return completer;
}
// Helper method to process the upload
static Completer<SnCloudFile?> _processUpload(
// Helper method to process the upload with enhanced uploader
static Completer<SnCloudFile?> _processUploadWithEnhancedUploader(
UniversalFile fileData,
Dio client,
String? poolId,
Function(double progress, Duration estimate)? onProgress,
Function(double? progress, Duration estimate)? onProgress,
Completer<SnCloudFile?> completer,
) {
String actualMimetype = getMimeType(fileData);
@@ -216,23 +315,15 @@ class FileUploader {
final data = fileData.data;
if (data is XFile) {
// Read bytes from XFile
data
.readAsBytes()
.then((readBytes) {
_performUpload(
bytes: readBytes,
fileName: fileData.displayName ?? data.name,
contentType: actualMimetype,
client: client,
poolId: poolId,
onProgress: onProgress,
completer: completer,
);
})
.catchError((e) {
completer.completeError(e);
});
_performUploadWithEnhancedUploader(
fileData: data,
fileName: fileData.displayName ?? data.name,
contentType: actualMimetype,
client: client,
poolId: poolId,
onProgress: onProgress,
completer: completer,
);
return completer;
} else if (data is List<int> || data is Uint8List) {
bytes = data is List<int> ? Uint8List.fromList(data) : data;
@@ -251,8 +342,8 @@ class FileUploader {
}
if (bytes != null) {
_performUpload(
bytes: bytes,
_performUploadWithEnhancedUploader(
fileData: bytes,
fileName: actualFilename,
contentType: actualMimetype,
client: client,
@@ -267,28 +358,29 @@ class FileUploader {
// Helper method to perform the actual upload
static void _performUpload({
required Uint8List bytes,
required dynamic fileData,
required String fileName,
required String contentType,
required Dio client,
String? poolId,
Function(double progress, Duration estimate)? onProgress,
Function(double? progress, Duration estimate)? onProgress,
required Completer<SnCloudFile?> completer,
}) {
final uploader = FileUploader(client);
// Call progress start
onProgress?.call(0.0, Duration.zero);
onProgress?.call(null, Duration.zero);
uploader
.uploadFile(
bytes: bytes,
fileData: fileData,
fileName: fileName,
contentType: contentType,
poolId: poolId,
onProgress: onProgress,
)
.then((result) {
// Call progress end
onProgress?.call(1.0, Duration.zero);
onProgress?.call(null, Duration.zero);
completer.complete(result);
})
.catchError((e) {
@@ -297,6 +389,30 @@ class FileUploader {
});
}
// Helper method to perform the actual upload with enhanced uploader
static void _performUploadWithEnhancedUploader({
required dynamic fileData,
required String fileName,
required String contentType,
required Dio client,
String? poolId,
Function(double? progress, Duration estimate)? onProgress,
required Completer<SnCloudFile?> completer,
}) {
// Use the enhanced uploader from Riverpod context
// This will be called from a context where we have access to Riverpod
// For now, fall back to the regular uploader
_performUpload(
fileData: fileData,
fileName: fileName,
contentType: contentType,
client: client,
poolId: poolId,
onProgress: onProgress,
completer: completer,
);
}
/// Gets the MIME type of a UniversalFile.
static String getMimeType(UniversalFile file, {bool useFallback = true}) {
final data = file.data;

View File

@@ -13,6 +13,7 @@ import 'package:island/route.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/upload_overlay.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart';
@@ -198,6 +199,7 @@ class WindowScaffold extends HookConsumerWidget {
],
),
_WebSocketIndicator(),
const UploadOverlay(),
],
),
),
@@ -213,7 +215,11 @@ class WindowScaffold extends HookConsumerWidget {
actions: <Type, Action<Intent>>{PopIntent: PopAction(ref)},
child: Stack(
fit: StackFit.expand,
children: [Positioned.fill(child: child), _WebSocketIndicator()],
children: [
Positioned.fill(child: child),
_WebSocketIndicator(),
const UploadOverlay(),
],
),
),
);

View File

@@ -44,7 +44,7 @@ class ChatInput extends HookConsumerWidget {
final Function(int) onDeleteAttachment;
final Function(int, int) onMoveAttachment;
final Function(List<UniversalFile>) onAttachmentsChanged;
final Map<String, Map<int, double>> attachmentProgress;
final Map<String, Map<int, double?>> attachmentProgress;
const ChatInput({
super.key,

View File

@@ -40,7 +40,7 @@ class MessageItem extends HookConsumerWidget {
final LocalChatMessage message;
final bool isCurrentUser;
final Function(String action)? onAction;
final Map<int, double>? progress;
final Map<int, double?>? progress;
final bool showAvatar;
final Function(String messageId) onJump;
final bool isSelectionMode;
@@ -689,7 +689,7 @@ class MessageHoverActionMenu extends StatelessWidget {
class MessageItemDisplayBubble extends HookConsumerWidget {
final LocalChatMessage message;
final bool isCurrentUser;
final Map<int, double>? progress;
final Map<int, double?>? progress;
final bool showAvatar;
final Function(String messageId) onJump;
final String? translatedText;
@@ -821,7 +821,7 @@ class MessageItemDisplayBubble extends HookConsumerWidget {
class MessageItemDisplayIRC extends HookConsumerWidget {
final LocalChatMessage message;
final bool isCurrentUser;
final Map<int, double>? progress;
final Map<int, double?>? progress;
final bool showAvatar;
final Function(String messageId) onJump;
final String? translatedText;
@@ -949,7 +949,7 @@ class MessageItemDisplayIRC extends HookConsumerWidget {
class MessageItemDisplayDiscord extends HookConsumerWidget {
final LocalChatMessage message;
final bool isCurrentUser;
final Map<int, double>? progress;
final Map<int, double?>? progress;
final bool showAvatar;
final Function(String messageId) onJump;
final String? translatedText;
@@ -1238,7 +1238,7 @@ class MessageQuoteWidget extends HookConsumerWidget {
}
class FileUploadProgressWidget extends StatelessWidget {
final Map<int, double>? progress;
final Map<int, double?>? progress;
final Color textColor;
final bool hasContent;
@@ -1266,7 +1266,9 @@ class FileUploadProgressWidget extends StatelessWidget {
'fileUploadingProgress'.tr(
args: [
(entry.key + 1).toString(),
(entry.value * 100).toStringAsFixed(1),
entry.value != null
? (entry.value! * 100).toStringAsFixed(1)
: '0.0',
],
),
style: TextStyle(

View File

@@ -104,9 +104,7 @@ class CheckInWidget extends HookConsumerWidget {
} catch (err) {
if (err is DioException) {
if (err.response?.statusCode == 423 && context.mounted) {
final captchaTk = await Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
final captchaTk = await CaptchaScreen.show(context);
if (captchaTk == null) return;
return await checkIn(captchatTk: captchaTk);
}

View File

@@ -411,10 +411,7 @@ class AttachmentPreview extends HookConsumerWidget {
),
Gap(6),
Center(
child: LinearProgressIndicator(
value:
progress != null ? progress! / 100.0 : null,
),
child: LinearProgressIndicator(value: progress),
),
],
),

View File

@@ -112,23 +112,28 @@ class CloudFilePicker extends HookConsumerWidget {
void pickImage() async {
showLoadingModal(context);
final result = await FilePicker.platform.pickFiles(
allowMultiple: allowMultiple,
type: FileType.image,
);
if (result == null || result.files.isEmpty) {
final ImagePicker picker = ImagePicker();
List<XFile> results;
if (allowMultiple) {
results = await picker.pickMultiImage();
} else {
final XFile? result = await picker.pickImage(
source: ImageSource.gallery,
);
results = result != null ? [result] : [];
}
if (results.isEmpty) {
if (context.mounted) hideLoadingModal(context);
return;
}
final newFiles =
result.files.map((e) {
final xfile =
e.bytes != null
? XFile.fromData(e.bytes!, name: e.name)
: XFile(e.path!);
return UniversalFile(data: xfile, type: UniversalFileType.image);
}).toList();
results
.map(
(xfile) =>
UniversalFile(data: xfile, type: UniversalFileType.image),
)
.toList();
if (!allowMultiple) {
files.value = newFiles;

View File

@@ -131,7 +131,7 @@ class ArticleComposeAttachments extends ConsumerWidget {
],
),
children: [
ValueListenableBuilder<Map<int, double>>(
ValueListenableBuilder<Map<int, double?>>(
valueListenable: state.attachmentProgress,
builder: (context, progressMap, _) {
return Wrap(

View File

@@ -33,7 +33,7 @@ class ComposeState {
final TextEditingController slugController;
final ValueNotifier<int> visibility;
final ValueNotifier<List<UniversalFile>> attachments;
final ValueNotifier<Map<int, double>> attachmentProgress;
final ValueNotifier<Map<int, double?>> attachmentProgress;
final ValueNotifier<SnPublisher?> currentPublisher;
final ValueNotifier<bool> submitting;
final ValueNotifier<List<SnPostCategory>> categories;
@@ -402,16 +402,13 @@ class ComposeLogic {
}
static Future<void> pickPhotoMedia(WidgetRef ref, ComposeState state) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: true,
allowCompression: false,
);
if (result == null || result.count == 0) return;
final ImagePicker picker = ImagePicker();
final List<XFile> results = await picker.pickMultiImage();
if (results.isEmpty) return;
state.attachments.value = [
...state.attachments.value,
...result.files.map(
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.image),
...results.map(
(xfile) => UniversalFile(data: xfile, type: UniversalFileType.image),
),
];
}
@@ -523,7 +520,7 @@ class ComposeLogic {
onProgress: (progress, _) {
state.attachmentProgress.value = {
...state.attachmentProgress.value,
index: progress,
index: progress ?? 0.0,
};
},
).future;

View File

@@ -6,7 +6,7 @@ part of 'post_list.dart';
// RiverpodGenerator
// **************************************************************************
String _$postListNotifierHash() => r'8241120dc3c2004387c6cf881e5cb9224cbd3a97';
String _$postListNotifierHash() => r'bfc3d652dffc5ff3a94a6c3d04aac65354fe63b5';
/// Copied from Dart SDK
class _SystemHash {

View File

@@ -246,7 +246,8 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
onProgress: (progress, _) {
if (mounted) {
setState(() {
_fileUploadProgress[messageId]?[idx] = progress;
_fileUploadProgress[messageId]?[idx] =
progress ?? 0.0;
});
}
},
@@ -306,7 +307,7 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
// Navigate to chat if requested
if (shouldNavigate == true && mounted) {
context.push('/sphere/chat/${chatRoom.id}');
context.push('/chat/${chatRoom.id}');
}
}
} catch (e) {

View File

@@ -0,0 +1,327 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/upload_task.dart';
import 'package:island/pods/upload_tasks.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:easy_localization/easy_localization.dart';
class UploadOverlay extends HookConsumerWidget {
const UploadOverlay({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final uploadTasks = ref.watch(uploadTasksProvider);
final activeTasks =
uploadTasks
.where(
(task) =>
task.status == UploadTaskStatus.pending ||
task.status == UploadTaskStatus.inProgress ||
task.status == UploadTaskStatus.paused,
)
.toList();
if (activeTasks.isEmpty) {
return const SizedBox.shrink();
}
return Positioned(
bottom: 16,
right: 16,
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
color: Theme.of(context).colorScheme.surfaceContainer,
child: Container(
width: 320,
constraints: BoxConstraints(maxHeight: 400),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: Row(
children: [
Icon(
Symbols.upload,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'uploadTasks'.tr(),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Text(
'${activeTasks.length}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
),
),
// Task list
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: activeTasks.length,
itemBuilder: (context, index) {
final task = activeTasks[index];
return UploadTaskTile(task: task);
},
),
),
],
),
),
),
);
}
}
class UploadTaskTile extends HookConsumerWidget {
final UploadTask task;
const UploadTaskTile({super.key, required this.task});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isExpanded = useState(false);
return InkWell(
onTap: () => isExpanded.value = !isExpanded.value,
child: Container(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Status icon
_buildStatusIcon(context),
const SizedBox(width: 8),
// File info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.fileName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
_formatFileSize(task.fileSize),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
// Progress indicator
const SizedBox(width: 8),
SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(
value: task.progress,
strokeWidth: 3,
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
),
),
// Expand/collapse button
IconButton(
icon: Icon(
isExpanded.value
? Symbols.expand_less
: Symbols.expand_more,
size: 16,
),
onPressed: () => isExpanded.value = !isExpanded.value,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
// Expanded details
if (isExpanded.value) ...[
const SizedBox(height: 8),
_buildExpandedDetails(context),
],
],
),
),
);
}
Widget _buildStatusIcon(BuildContext context) {
IconData icon;
Color color;
switch (task.status) {
case UploadTaskStatus.pending:
icon = Symbols.schedule;
color = Theme.of(context).colorScheme.secondary;
break;
case UploadTaskStatus.inProgress:
icon = Symbols.upload;
color = Theme.of(context).colorScheme.primary;
break;
case UploadTaskStatus.paused:
icon = Symbols.pause_circle;
color = Theme.of(context).colorScheme.tertiary;
break;
case UploadTaskStatus.completed:
icon = Symbols.check_circle;
color = Colors.green;
break;
case UploadTaskStatus.failed:
icon = Symbols.error;
color = Theme.of(context).colorScheme.error;
break;
case UploadTaskStatus.cancelled:
icon = Symbols.cancel;
color = Theme.of(context).colorScheme.error;
break;
case UploadTaskStatus.expired:
icon = Symbols.timer_off;
color = Theme.of(context).colorScheme.error;
break;
}
return Icon(icon, size: 16, color: color);
}
Widget _buildExpandedDetails(BuildContext context) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Progress text
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${(task.progress * 100).toStringAsFixed(1)}%',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
),
Text(
'${task.uploadedChunks}/${task.totalChunks} chunks',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
const SizedBox(height: 4),
// Progress bar
LinearProgressIndicator(
value: task.progress,
backgroundColor: Theme.of(context).colorScheme.surface,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
// Speed and ETA
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatBytesPerSecond(task),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
if (task.status == UploadTaskStatus.inProgress)
Text(
'ETA: ${_formatDuration(task.estimatedTimeRemaining)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
// Error message if failed
if (task.errorMessage != null) ...[
const SizedBox(height: 4),
Text(
task.errorMessage!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
],
),
);
}
String _formatFileSize(int bytes) {
if (bytes >= 1073741824) {
return '${(bytes / 1073741824).toStringAsFixed(1)} GB';
} else if (bytes >= 1048576) {
return '${(bytes / 1048576).toStringAsFixed(1)} MB';
} else if (bytes >= 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
} else {
return '$bytes bytes';
}
}
String _formatBytesPerSecond(UploadTask task) {
if (task.uploadedBytes == 0) return '0 B/s';
final elapsedSeconds = DateTime.now().difference(task.createdAt).inSeconds;
if (elapsedSeconds == 0) return '0 B/s';
final bytesPerSecond = task.uploadedBytes / elapsedSeconds;
return '${_formatFileSize(bytesPerSecond.toInt())}/s';
}
String _formatDuration(Duration duration) {
if (duration.inHours > 0) {
return '${duration.inHours}h ${duration.inMinutes.remainder(60)}m';
} else if (duration.inMinutes > 0) {
return '${duration.inMinutes}m ${duration.inSeconds.remainder(60)}s';
} else {
return '${duration.inSeconds}s';
}
}
}

View File

@@ -19,19 +19,19 @@ PODS:
- Firebase/Messaging (12.4.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 12.4.0)
- firebase_analytics (12.0.3):
- firebase_analytics (12.0.4):
- firebase_core
- FirebaseAnalytics (= 12.4.0)
- FlutterMacOS
- firebase_core (4.2.0):
- firebase_core (4.2.1):
- Firebase/CoreOnly (~> 12.4.0)
- FlutterMacOS
- firebase_crashlytics (5.0.3):
- firebase_crashlytics (5.0.4):
- Firebase/CoreOnly (~> 12.4.0)
- Firebase/Crashlytics (~> 12.4.0)
- firebase_core
- FlutterMacOS
- firebase_messaging (16.0.3):
- firebase_messaging (16.0.4):
- Firebase/CoreOnly (~> 12.4.0)
- Firebase/Messaging (~> 12.4.0)
- firebase_core
@@ -416,10 +416,10 @@ SPEC CHECKSUMS:
file_saver: e35bd97de451dde55ff8c38862ed7ad0f3418d0f
file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7
Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e
firebase_analytics: d876586269c1d8d2b3dcac085bc2d97c62abc9df
firebase_core: d81d1a44df95699ce074ae63d8cb43e9df21e142
firebase_crashlytics: 723622cc39a9fa7320585424f5864c5699893ce1
firebase_messaging: 31f412ae5a54e02d1c46d467969f7ad92c4b81ec
firebase_analytics: 09241c4796c1c42a02349ef8bf30025f5b640f0e
firebase_core: e054894ab56033ef9bcbe2d9eac9395e5306e2fc
firebase_crashlytics: c2438b5f5bdcacf59d0eaee5852c6b0ab09dab77
firebase_messaging: 373ac3a56e5aa37bb9aff4127f700aa5973c1168
FirebaseAnalytics: 0fc2b20091f0ddd21bf73397cf8f0eb5346dc24f
FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3
FirebaseCoreExtension: 7e1f7118ee970e001a8013719fb90950ee5e0018

View File

@@ -282,7 +282,7 @@ packages:
source: hosted
version: "4.1.0"
convert:
dependency: transitive
dependency: "direct main"
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
@@ -1185,10 +1185,10 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: d8f590a69729f719177ea68eb1e598295e8dbc41bbc247fed78b2c8a25660d7c
sha256: c92d18e1fe994cb06d48aa786c46b142a5633067e8297cff6b5a3ac742620104
url: "https://pub.dev"
source: hosted
version: "16.3.0"
version: "17.0.0"
google_fonts:
dependency: "direct main"
description:

View File

@@ -38,7 +38,7 @@ dependencies:
cupertino_icons: ^1.0.8
flutter_hooks: ^0.21.3+1
hooks_riverpod: ^2.6.1
go_router: ^16.3.0
go_router: ^17.0.0
styled_widget: ^0.4.1
shared_preferences: ^2.5.3
flutter_riverpod: ^2.6.1
@@ -165,6 +165,7 @@ dependencies:
dio_smart_retry: ^7.0.1
flutter_expandable_fab: ^2.5.2
event_bus: ^2.0.1
convert: ^3.1.2
dev_dependencies:
flutter_test: