Compare commits

...

22 Commits

Author SHA1 Message Date
5e61805db7 💄 Upload overlay auto hide 2025-11-18 22:38:27 +08:00
35b96b0bd2 💄 Optimize downloading and files 2025-11-18 22:21:23 +08:00
c8ad791ff3 💄 Optimize cloud files 2025-11-18 22:06:38 +08:00
1e908502dc Able to open file detail view from lightbox 2025-11-18 21:59:35 +08:00
715ce1a368 File reference list 2025-11-18 21:45:13 +08:00
548c9963ee File list selection select all 2025-11-18 21:32:25 +08:00
db5199438a Selection and batch operations in file list 2025-11-18 21:17:09 +08:00
4409a6fb1e More global filters om file list 2025-11-18 20:33:49 +08:00
26a24b0e41 Pdf viewer zoom 2025-11-18 13:06:08 +08:00
9b948d259b Cached pdf viewer 2025-11-18 13:00:57 +08:00
1f713b5b2b 💄 Make captcha undismissable 2025-11-18 12:57:21 +08:00
f92cfafda4 Downloading file tasks 2025-11-18 01:45:15 +08:00
fa208b44d7 🐛 Fix publisher account name shows wrong 2025-11-18 01:31:15 +08:00
94adecafbb 💄 Optimize file detail view styling 2025-11-18 00:32:26 +08:00
0303ef4a93 💄 Optimize file list again 2025-11-18 00:20:10 +08:00
c2b18ce10b 🐛 Fix file list 2025-11-18 00:04:07 +08:00
0767bb53ce Put clean up recycled files back 2025-11-17 23:53:11 +08:00
b233f9a410 💄 File list loading indicator 2025-11-17 23:10:13 +08:00
256024fb46 💄 Adjust upload overlay auto show and hide logic 2025-11-17 22:57:53 +08:00
4a80aaf24d Unindexed files filter 2025-11-17 22:57:42 +08:00
aafd160c44 🐛 Fix waterfall styling issue 2025-11-17 22:00:51 +08:00
4a800725e3 Zoom image via mosue scroll 2025-11-17 22:00:35 +08:00
26 changed files with 2050 additions and 563 deletions

View File

@@ -1336,5 +1336,8 @@
"fundCreateNewHint": "Create a new fund for your message. Select recipients and amount.",
"amountOfSplits": "Amount of Splits",
"enterNumberOfSplits": "Enter Splits Amount",
"orCreateWith": "Or\ncreate with"
"orCreateWith": "Or\ncreate with",
"unindexedFiles": "Unindexed files",
"folder": "Folder",
"clearCompleted": "Clear Completed"
}

23
lib/models/reference.dart Normal file
View File

@@ -0,0 +1,23 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/models/file.dart';
part 'reference.freezed.dart';
part 'reference.g.dart';
@freezed
sealed class Reference with _$Reference {
const factory Reference({
required String id,
@JsonKey(name: 'file_id') required String fileId,
SnCloudFile? file,
required String usage,
@JsonKey(name: 'resource_id') required String resourceId,
@JsonKey(name: 'expired_at') DateTime? expiredAt,
@JsonKey(name: 'created_at') required DateTime createdAt,
@JsonKey(name: 'updated_at') required DateTime updatedAt,
@JsonKey(name: 'deleted_at') DateTime? deletedAt,
}) = _Reference;
factory Reference.fromJson(Map<String, dynamic> json) =>
_$ReferenceFromJson(json);
}

View File

@@ -0,0 +1,319 @@
// 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 'reference.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$Reference {
String get id;@JsonKey(name: 'file_id') String get fileId; SnCloudFile? get file; String get usage;@JsonKey(name: 'resource_id') String get resourceId;@JsonKey(name: 'expired_at') DateTime? get expiredAt;@JsonKey(name: 'created_at') DateTime get createdAt;@JsonKey(name: 'updated_at') DateTime get updatedAt;@JsonKey(name: 'deleted_at') DateTime? get deletedAt;
/// Create a copy of Reference
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ReferenceCopyWith<Reference> get copyWith => _$ReferenceCopyWithImpl<Reference>(this as Reference, _$identity);
/// Serializes this Reference to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is Reference&&(identical(other.id, id) || other.id == id)&&(identical(other.fileId, fileId) || other.fileId == fileId)&&(identical(other.file, file) || other.file == file)&&(identical(other.usage, usage) || other.usage == usage)&&(identical(other.resourceId, resourceId) || other.resourceId == resourceId)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,fileId,file,usage,resourceId,expiredAt,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'Reference(id: $id, fileId: $fileId, file: $file, usage: $usage, resourceId: $resourceId, expiredAt: $expiredAt, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
}
/// @nodoc
abstract mixin class $ReferenceCopyWith<$Res> {
factory $ReferenceCopyWith(Reference value, $Res Function(Reference) _then) = _$ReferenceCopyWithImpl;
@useResult
$Res call({
String id,@JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage,@JsonKey(name: 'resource_id') String resourceId,@JsonKey(name: 'expired_at') DateTime? expiredAt,@JsonKey(name: 'created_at') DateTime createdAt,@JsonKey(name: 'updated_at') DateTime updatedAt,@JsonKey(name: 'deleted_at') DateTime? deletedAt
});
$SnCloudFileCopyWith<$Res>? get file;
}
/// @nodoc
class _$ReferenceCopyWithImpl<$Res>
implements $ReferenceCopyWith<$Res> {
_$ReferenceCopyWithImpl(this._self, this._then);
final Reference _self;
final $Res Function(Reference) _then;
/// Create a copy of Reference
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? fileId = null,Object? file = freezed,Object? usage = null,Object? resourceId = null,Object? expiredAt = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,fileId: null == fileId ? _self.fileId : fileId // ignore: cast_nullable_to_non_nullable
as String,file: freezed == file ? _self.file : file // ignore: cast_nullable_to_non_nullable
as SnCloudFile?,usage: null == usage ? _self.usage : usage // ignore: cast_nullable_to_non_nullable
as String,resourceId: null == resourceId ? _self.resourceId : resourceId // ignore: cast_nullable_to_non_nullable
as String,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime?,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,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
/// Create a copy of Reference
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnCloudFileCopyWith<$Res>? get file {
if (_self.file == null) {
return null;
}
return $SnCloudFileCopyWith<$Res>(_self.file!, (value) {
return _then(_self.copyWith(file: value));
});
}
}
/// Adds pattern-matching-related methods to [Reference].
extension ReferencePatterns on Reference {
/// 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( _Reference value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _Reference() 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( _Reference value) $default,){
final _that = this;
switch (_that) {
case _Reference():
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( _Reference value)? $default,){
final _that = this;
switch (_that) {
case _Reference() 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, @JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage, @JsonKey(name: 'resource_id') String resourceId, @JsonKey(name: 'expired_at') DateTime? expiredAt, @JsonKey(name: 'created_at') DateTime createdAt, @JsonKey(name: 'updated_at') DateTime updatedAt, @JsonKey(name: 'deleted_at') DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _Reference() when $default != null:
return $default(_that.id,_that.fileId,_that.file,_that.usage,_that.resourceId,_that.expiredAt,_that.createdAt,_that.updatedAt,_that.deletedAt);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, @JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage, @JsonKey(name: 'resource_id') String resourceId, @JsonKey(name: 'expired_at') DateTime? expiredAt, @JsonKey(name: 'created_at') DateTime createdAt, @JsonKey(name: 'updated_at') DateTime updatedAt, @JsonKey(name: 'deleted_at') DateTime? deletedAt) $default,) {final _that = this;
switch (_that) {
case _Reference():
return $default(_that.id,_that.fileId,_that.file,_that.usage,_that.resourceId,_that.expiredAt,_that.createdAt,_that.updatedAt,_that.deletedAt);}
}
/// 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, @JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage, @JsonKey(name: 'resource_id') String resourceId, @JsonKey(name: 'expired_at') DateTime? expiredAt, @JsonKey(name: 'created_at') DateTime createdAt, @JsonKey(name: 'updated_at') DateTime updatedAt, @JsonKey(name: 'deleted_at') DateTime? deletedAt)? $default,) {final _that = this;
switch (_that) {
case _Reference() when $default != null:
return $default(_that.id,_that.fileId,_that.file,_that.usage,_that.resourceId,_that.expiredAt,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _Reference implements Reference {
const _Reference({required this.id, @JsonKey(name: 'file_id') required this.fileId, this.file, required this.usage, @JsonKey(name: 'resource_id') required this.resourceId, @JsonKey(name: 'expired_at') this.expiredAt, @JsonKey(name: 'created_at') required this.createdAt, @JsonKey(name: 'updated_at') required this.updatedAt, @JsonKey(name: 'deleted_at') this.deletedAt});
factory _Reference.fromJson(Map<String, dynamic> json) => _$ReferenceFromJson(json);
@override final String id;
@override@JsonKey(name: 'file_id') final String fileId;
@override final SnCloudFile? file;
@override final String usage;
@override@JsonKey(name: 'resource_id') final String resourceId;
@override@JsonKey(name: 'expired_at') final DateTime? expiredAt;
@override@JsonKey(name: 'created_at') final DateTime createdAt;
@override@JsonKey(name: 'updated_at') final DateTime updatedAt;
@override@JsonKey(name: 'deleted_at') final DateTime? deletedAt;
/// Create a copy of Reference
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ReferenceCopyWith<_Reference> get copyWith => __$ReferenceCopyWithImpl<_Reference>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ReferenceToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Reference&&(identical(other.id, id) || other.id == id)&&(identical(other.fileId, fileId) || other.fileId == fileId)&&(identical(other.file, file) || other.file == file)&&(identical(other.usage, usage) || other.usage == usage)&&(identical(other.resourceId, resourceId) || other.resourceId == resourceId)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,fileId,file,usage,resourceId,expiredAt,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'Reference(id: $id, fileId: $fileId, file: $file, usage: $usage, resourceId: $resourceId, expiredAt: $expiredAt, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
}
/// @nodoc
abstract mixin class _$ReferenceCopyWith<$Res> implements $ReferenceCopyWith<$Res> {
factory _$ReferenceCopyWith(_Reference value, $Res Function(_Reference) _then) = __$ReferenceCopyWithImpl;
@override @useResult
$Res call({
String id,@JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage,@JsonKey(name: 'resource_id') String resourceId,@JsonKey(name: 'expired_at') DateTime? expiredAt,@JsonKey(name: 'created_at') DateTime createdAt,@JsonKey(name: 'updated_at') DateTime updatedAt,@JsonKey(name: 'deleted_at') DateTime? deletedAt
});
@override $SnCloudFileCopyWith<$Res>? get file;
}
/// @nodoc
class __$ReferenceCopyWithImpl<$Res>
implements _$ReferenceCopyWith<$Res> {
__$ReferenceCopyWithImpl(this._self, this._then);
final _Reference _self;
final $Res Function(_Reference) _then;
/// Create a copy of Reference
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? fileId = null,Object? file = freezed,Object? usage = null,Object? resourceId = null,Object? expiredAt = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_Reference(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,fileId: null == fileId ? _self.fileId : fileId // ignore: cast_nullable_to_non_nullable
as String,file: freezed == file ? _self.file : file // ignore: cast_nullable_to_non_nullable
as SnCloudFile?,usage: null == usage ? _self.usage : usage // ignore: cast_nullable_to_non_nullable
as String,resourceId: null == resourceId ? _self.resourceId : resourceId // ignore: cast_nullable_to_non_nullable
as String,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime?,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,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
/// Create a copy of Reference
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnCloudFileCopyWith<$Res>? get file {
if (_self.file == null) {
return null;
}
return $SnCloudFileCopyWith<$Res>(_self.file!, (value) {
return _then(_self.copyWith(file: value));
});
}
}
// dart format on

View File

@@ -0,0 +1,41 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'reference.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_Reference _$ReferenceFromJson(Map<String, dynamic> json) => _Reference(
id: json['id'] as String,
fileId: json['file_id'] as String,
file:
json['file'] == null
? null
: SnCloudFile.fromJson(json['file'] as Map<String, dynamic>),
usage: json['usage'] as String,
resourceId: json['resource_id'] as String,
expiredAt:
json['expired_at'] == null
? null
: DateTime.parse(json['expired_at'] as String),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt:
json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
);
Map<String, dynamic> _$ReferenceToJson(_Reference instance) =>
<String, dynamic>{
'id': instance.id,
'file_id': instance.fileId,
'file': instance.file?.toJson(),
'usage': instance.usage,
'resource_id': instance.resourceId,
'expired_at': instance.expiredAt?.toIso8601String(),
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};

View File

@@ -11,12 +11,36 @@ part 'file_list.g.dart';
class CloudFileListNotifier extends _$CloudFileListNotifier
with CursorPagingNotifierMixin<FileListItem> {
String _currentPath = '/';
String? _poolId;
String? _query;
String? _order;
bool _orderDesc = false;
void setPath(String path) {
_currentPath = path;
ref.invalidateSelf();
}
void setPool(String? poolId) {
_poolId = poolId;
ref.invalidateSelf();
}
void setQuery(String? query) {
_query = query;
ref.invalidateSelf();
}
void setOrder(String? order) {
_order = order;
ref.invalidateSelf();
}
void setOrderDesc(bool orderDesc) {
_orderDesc = orderDesc;
ref.invalidateSelf();
}
@override
Future<CursorPagingData<FileListItem>> build() => fetch(cursor: null);
@@ -26,9 +50,25 @@ class CloudFileListNotifier extends _$CloudFileListNotifier
}) async {
final client = ref.read(apiClientProvider);
final queryParameters = <String, String>{'path': _currentPath};
if (_poolId != null) {
queryParameters['pool'] = _poolId!;
}
if (_query != null) {
queryParameters['query'] = _query!;
}
if (_order != null) {
queryParameters['order'] = _order!;
}
queryParameters['orderDesc'] = _orderDesc.toString();
final response = await client.get(
'/drive/index/browse',
queryParameters: {'path': _currentPath},
queryParameters: queryParameters,
);
final List<String> folders =
@@ -58,6 +98,37 @@ Future<Map<String, dynamic>?> billingUsage(Ref ref) async {
@riverpod
class UnindexedFileListNotifier extends _$UnindexedFileListNotifier
with CursorPagingNotifierMixin<FileListItem> {
String? _poolId;
bool _recycled = false;
String? _query;
String? _order;
bool _orderDesc = false;
void setPool(String? poolId) {
_poolId = poolId;
ref.invalidateSelf();
}
void setRecycled(bool recycled) {
_recycled = recycled;
ref.invalidateSelf();
}
void setQuery(String? query) {
_query = query;
ref.invalidateSelf();
}
void setOrder(String? order) {
_order = order;
ref.invalidateSelf();
}
void setOrderDesc(bool orderDesc) {
_orderDesc = orderDesc;
ref.invalidateSelf();
}
@override
Future<CursorPagingData<FileListItem>> build() => fetch(cursor: null);
@@ -70,9 +141,32 @@ class UnindexedFileListNotifier extends _$UnindexedFileListNotifier
final offset = cursor != null ? int.tryParse(cursor) ?? 0 : 0;
const take = 50; // Default page size
final queryParameters = <String, String>{
'take': take.toString(),
'offset': offset.toString(),
};
if (_poolId != null) {
queryParameters['pool'] = _poolId!;
}
if (_recycled) {
queryParameters['recycled'] = _recycled.toString();
}
if (_query != null) {
queryParameters['query'] = _query!;
}
if (_order != null) {
queryParameters['order'] = _order!;
}
queryParameters['orderDesc'] = _orderDesc.toString();
final response = await client.get(
'/drive/index/unindexed',
queryParameters: {'take': take.toString(), 'offset': offset.toString()},
queryParameters: queryParameters,
);
final total = int.tryParse(response.headers.value('x-total') ?? '0') ?? 0;

View File

@@ -45,7 +45,7 @@ final billingQuotaProvider =
// ignore: unused_element
typedef BillingQuotaRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>;
String _$cloudFileListNotifierHash() =>
r'5f2f80357cb31ac6473df5ac2101f9a462004f81';
r'533dfa86f920b60cf7491fb4aeb95ece19e428af';
/// See also [CloudFileListNotifier].
@ProviderFor(CloudFileListNotifier)
@@ -66,7 +66,7 @@ final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
typedef _$CloudFileListNotifier =
AutoDisposeAsyncNotifier<CursorPagingData<FileListItem>>;
String _$unindexedFileListNotifierHash() =>
r'48fc92432a50a562190da5fe8ed0920d171b07b6';
r'afa487d7b956b71b21ca1b073a01364a34ede1d5';
/// See also [UnindexedFileListNotifier].
@ProviderFor(UnindexedFileListNotifier)

View File

@@ -0,0 +1,18 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/models/reference.dart';
import 'package:island/pods/network.dart';
part 'file_references.g.dart';
@riverpod
Future<List<Reference>> fileReferences(
FileReferencesRef ref,
String fileId,
) async {
final client = ref.read(apiClientProvider);
final response = await client.get('/drive/files/$fileId/references');
final list = response.data as List<dynamic>;
return list
.map((json) => Reference.fromJson(json as Map<String, dynamic>))
.toList();
}

View File

@@ -0,0 +1,153 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'file_references.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$fileReferencesHash() => r'464562fbdc9452d8a5ffbd2d9d9343cdb43f1876';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// See also [fileReferences].
@ProviderFor(fileReferences)
const fileReferencesProvider = FileReferencesFamily();
/// See also [fileReferences].
class FileReferencesFamily extends Family<AsyncValue<List<Reference>>> {
/// See also [fileReferences].
const FileReferencesFamily();
/// See also [fileReferences].
FileReferencesProvider call(String fileId) {
return FileReferencesProvider(fileId);
}
@override
FileReferencesProvider getProviderOverride(
covariant FileReferencesProvider provider,
) {
return call(provider.fileId);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'fileReferencesProvider';
}
/// See also [fileReferences].
class FileReferencesProvider
extends AutoDisposeFutureProvider<List<Reference>> {
/// See also [fileReferences].
FileReferencesProvider(String fileId)
: this._internal(
(ref) => fileReferences(ref as FileReferencesRef, fileId),
from: fileReferencesProvider,
name: r'fileReferencesProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$fileReferencesHash,
dependencies: FileReferencesFamily._dependencies,
allTransitiveDependencies:
FileReferencesFamily._allTransitiveDependencies,
fileId: fileId,
);
FileReferencesProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.fileId,
}) : super.internal();
final String fileId;
@override
Override overrideWith(
FutureOr<List<Reference>> Function(FileReferencesRef provider) create,
) {
return ProviderOverride(
origin: this,
override: FileReferencesProvider._internal(
(ref) => create(ref as FileReferencesRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
fileId: fileId,
),
);
}
@override
AutoDisposeFutureProviderElement<List<Reference>> createElement() {
return _FileReferencesProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is FileReferencesProvider && other.fileId == fileId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, fileId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin FileReferencesRef on AutoDisposeFutureProviderRef<List<Reference>> {
/// The parameter `fileId` of this provider.
String get fileId;
}
class _FileReferencesProviderElement
extends AutoDisposeFutureProviderElement<List<Reference>>
with FileReferencesRef {
_FileReferencesProviderElement(super.provider);
@override
String get fileId => (origin as FileReferencesProvider).fileId;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -258,6 +258,24 @@ class UploadTasksNotifier extends StateNotifier<List<DriveTask>> {
}).toList();
}
void updateDownloadProgress(
String taskId,
int downloadedBytes,
int totalBytes,
) {
state =
state.map((task) {
if (task.taskId == taskId) {
return task.copyWith(
fileSize: totalBytes,
uploadedBytes: downloadedBytes,
updatedAt: DateTime.now(),
);
}
return task;
}).toList();
}
void removeTask(String taskId) {
state = state.where((task) => task.taskId != taskId).toList();
}
@@ -275,6 +293,10 @@ class UploadTasksNotifier extends StateNotifier<List<DriveTask>> {
.toList();
}
void clearAllTasks() {
state = [];
}
DriveTask? getTask(String taskId) {
return state.where((task) => task.taskId == taskId).firstOrNull;
}
@@ -291,6 +313,27 @@ class UploadTasksNotifier extends StateNotifier<List<DriveTask>> {
.toList();
}
String addLocalDownloadTask(SnCloudFile item) {
final taskId =
'download-${item.id}-${DateTime.now().millisecondsSinceEpoch}';
final task = DriveTask(
id: taskId,
taskId: taskId,
fileName: item.name,
contentType: item.mimeType ?? '',
fileSize: 0,
uploadedBytes: 0,
totalChunks: 1,
uploadedChunks: 0,
status: DriveTaskStatus.inProgress,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
type: 'FileDownload',
);
state = [...state, task];
return taskId;
}
@override
void dispose() {
_websocketSubscription?.cancel();

View File

@@ -170,6 +170,22 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state) => const AboutScreen(),
),
GoRoute(
name: 'fileDetail',
path: '/files/:id',
builder: (context, state) {
// For now, we'll need to pass the file object through extra
// This will be updated when we modify the file list navigation
final file = state.extra as SnCloudFile?;
if (file != null) {
return FileDetailScreen(item: file);
}
// Fallback - this shouldn't happen in normal flow
Navigator.of(context).pop();
return const SizedBox.shrink();
},
),
// Main tabs with TabsScreen shell
ShellRoute(
navigatorKey: _tabsShellKey,
@@ -427,23 +443,6 @@ final routerProvider = Provider<GoRouter>((ref) {
name: 'files',
path: '/files',
builder: (context, state) => const FileListScreen(),
routes: [
GoRoute(
name: 'fileDetail',
path: ':id',
builder: (context, state) {
// For now, we'll need to pass the file object through extra
// This will be updated when we modify the file list navigation
final file = state.extra as SnCloudFile?;
if (file != null) {
return FileDetailScreen(item: file);
}
// Fallback - this shouldn't happen in normal flow
Navigator.of(context).pop();
return const SizedBox.shrink();
},
),
],
),
// SN-chan tab

View File

@@ -9,6 +9,7 @@ class CaptchaScreen extends ConsumerWidget {
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
isDismissible: false,
builder: (context) => const CaptchaScreen(),
);
}

View File

@@ -12,6 +12,7 @@ class CaptchaScreen extends ConsumerStatefulWidget {
static Future<String?> show(BuildContext context) {
return showModalBottomSheet<String>(
context: context,
isDismissible: false,
isScrollControlled: true,
builder: (context) => const CaptchaScreen(),
);

View File

@@ -6,17 +6,24 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gal/gal.dart';
import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/file_references.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/upload_tasks.dart';
import 'package:island/models/drive_task.dart';
import 'package:island/services/responsive.dart';
import 'package:island/services/time.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/file_info_sheet.dart';
import 'package:island/widgets/content/file_viewer_contents.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:path/path.dart' show extension;
import 'package:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart';
class FileDetailScreen extends HookConsumerWidget {
final SnCloudFile item;
@@ -76,7 +83,7 @@ class FileDetailScreen extends HookConsumerWidget {
}, [animationController]);
return AppScaffold(
isNoBackground: true,
isNoBackground: false,
appBar: AppBar(
elevation: 0,
leading: IconButton(
@@ -86,26 +93,47 @@ class FileDetailScreen extends HookConsumerWidget {
title: Text(item.name.isEmpty ? 'File Details' : item.name),
actions: _buildAppBarActions(context, ref, showInfoSheet),
),
body: AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Row(
children: [
// Main content area
Expanded(child: _buildContent(context, ref, serverUrl)),
// Animated drawer panel
if (isWide)
SizedBox(
height: double.infinity,
width: animation.value * 400, // Max width of 400px
child: Container(
child:
animation.value > 0.1
? FileInfoSheet(item: item, onClose: showInfoSheet)
: const SizedBox.shrink(),
body: LayoutBuilder(
builder: (context, constraints) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Stack(
children: [
// Main content area - resizes with animation
Positioned(
left: 0,
top: 0,
bottom: 0,
width: constraints.maxWidth - animation.value * 400,
child: _buildContent(context, ref, serverUrl),
),
),
],
// Animated drawer panel - overlays
if (isWide)
Positioned(
right: 0,
top: 0,
bottom: 0,
width: 400,
child: Transform.translate(
offset: Offset((1 - animation.value) * 400, 0),
child: SizedBox(
width: 400,
child: Material(
color:
Theme.of(context).colorScheme.surfaceContainer,
elevation: 8,
child: FileInfoSheet(
item: item,
onClose: showInfoSheet,
),
),
),
),
),
],
);
},
);
},
),
@@ -144,6 +172,24 @@ class FileDetailScreen extends HookConsumerWidget {
break;
}
// Add references button
actions.add(
IconButton(
icon: Icon(Icons.link),
onPressed:
() => showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: 'File References',
child: ReferencesList(fileId: item.id),
),
),
),
);
// Always add info button
actions.add(
IconButton(icon: Icon(Icons.info_outline), onPressed: showInfoSheet),
@@ -187,6 +233,8 @@ class FileDetailScreen extends HookConsumerWidget {
}
Future<void> _downloadFile(WidgetRef ref) async {
final taskNotifier = ref.read(uploadTasksProvider.notifier);
final taskId = taskNotifier.addLocalDownloadTask(item);
try {
showSnackBar('Downloading file...');
@@ -202,14 +250,26 @@ class FileDetailScreen extends HookConsumerWidget {
'/drive/files/${item.id}',
filePath,
queryParameters: {'original': true},
onReceiveProgress: (count, total) {
if (total > 0) {
taskNotifier.updateDownloadProgress(taskId, count, total);
taskNotifier.updateTransmissionProgress(taskId, count / total);
}
},
);
await FileSaver.instance.saveFile(
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
file: File(filePath),
);
taskNotifier.updateTaskStatus(taskId, DriveTaskStatus.completed);
showSnackBar('File saved to downloads');
} catch (e) {
taskNotifier.updateTaskStatus(
taskId,
DriveTaskStatus.failed,
errorMessage: e.toString(),
);
showErrorAlert(e);
}
}
@@ -229,3 +289,54 @@ class FileDetailScreen extends HookConsumerWidget {
};
}
}
class ReferencesList extends ConsumerWidget {
const ReferencesList({super.key, required this.fileId});
final String fileId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncReferences = ref.watch(fileReferencesProvider(fileId));
return asyncReferences.when(
data:
(references) => ListView.builder(
itemCount: references.length,
itemBuilder: (context, index) {
final reference = references[index];
return ListTile(
leading: const Icon(Icons.link),
title: Row(
spacing: 6,
children: [
Text(
reference.usage,
style: GoogleFonts.robotoMono(
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
Text(
reference.id,
style: GoogleFonts.robotoMono(fontSize: 13),
),
],
),
subtitle: Row(
spacing: 8,
children: [
Text(reference.createdAt.formatRelative(context)),
const VerticalDivider(width: 1, thickness: 1).height(12),
Text(reference.createdAt.formatSystem()),
],
),
);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(error, _) => Center(child: Text('Error loading references: $error')),
);
}
}

View File

@@ -1,10 +1,12 @@
import 'package:cross_file/cross_file.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/models/file_pool.dart';
import 'package:island/pods/file_list.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart';
@@ -23,6 +25,7 @@ class FileListScreen extends HookConsumerWidget {
// Path navigation state
final currentPath = useState<String>('/');
final mode = useState<FileListMode>(FileListMode.normal);
final selectedPool = useState<SnFilePool?>(null);
final usageAsync = ref.watch(billingUsageProvider);
final quotaAsync = ref.watch(billingQuotaProvider);
@@ -32,7 +35,7 @@ class FileListScreen extends HookConsumerWidget {
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text('Files'),
title: Text('files').tr(),
leading: const PageBackButton(),
actions: [
IconButton(
@@ -55,8 +58,13 @@ class FileListScreen extends HookConsumerWidget {
usage: usage,
quota: quota,
currentPath: currentPath,
selectedPool: selectedPool,
onPickAndUpload:
() => _pickAndUploadFile(ref, currentPath.value),
() => _pickAndUploadFile(
ref,
currentPath.value,
selectedPool.value?.id,
),
onShowCreateDirectory: _showCreateDirectoryDialog,
mode: mode,
viewMode: viewMode,
@@ -70,7 +78,11 @@ class FileListScreen extends HookConsumerWidget {
);
}
Future<void> _pickAndUploadFile(WidgetRef ref, String currentPath) async {
Future<void> _pickAndUploadFile(
WidgetRef ref,
String currentPath,
String? poolId,
) async {
try {
final result = await FilePicker.platform.pickFiles(
allowMultiple: true,
@@ -92,6 +104,7 @@ class FileListScreen extends HookConsumerWidget {
fileData: universalFile,
ref: ref,
path: currentPath,
poolId: poolId,
onProgress: (progress, _) {
// Progress is handled by the upload tasks system
if (progress != null) {

View File

@@ -451,7 +451,7 @@ class PollEditorScreen extends ConsumerWidget {
),
);
if (confirmed == true) {
Navigator.of(context).pop();
if (context.mounted) Navigator.of(context).pop();
}
},
child: Column(

View File

@@ -226,7 +226,7 @@ class AccountName extends StatelessWidget {
children: [
Flexible(
child: Text(
account.nick,
textOverride ?? account.nick,
style: nameStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,

View File

@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gal/gal.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
@@ -171,6 +172,24 @@ class CloudFileLightbox extends HookConsumerWidget {
),
onPressed: showInfoSheet,
),
IconButton(
onPressed: () {
final router = GoRouter.of(context);
Navigator.of(context).pop(context);
Future(() {
router.pushNamed(
'fileDetail',
pathParameters: {'id': item.id},
extra: item,
);
});
},
icon: Icon(
Icons.more_horiz,
color: Colors.white,
shadows: shadow,
),
),
Spacer(),
IconButton(
icon: Icon(

View File

@@ -1,24 +1,17 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/services/time.dart';
import 'package:island/utils/format.dart';
import 'package:island/widgets/alert.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' show extension;
import 'package:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:island/widgets/data_saving_gate.dart';
import 'package:island/widgets/content/file_info_sheet.dart';
import 'file_viewer_contents.dart';
import 'image.dart';
@@ -66,34 +59,6 @@ class CloudFileWidget extends HookConsumerWidget {
);
if (item.mimeType == 'application/pdf') {
Future<void> downloadFile() async {
try {
showSnackBar('Downloading file...');
final client = ref.read(apiClientProvider);
final tempDir = await getTemporaryDirectory();
var extName = extension(item.name).trim();
if (extName.isEmpty) {
extName = item.mimeType?.split('/').lastOrNull ?? 'pdf';
}
final filePath = '${tempDir.path}/${item.id}.$extName';
await client.download(
'/drive/files/${item.id}',
filePath,
queryParameters: {'original': true},
);
await FileSaver.instance.saveFile(
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
file: File(filePath),
);
showSnackBar('File saved to downloads');
} catch (e) {
showErrorAlert(e);
}
}
return Container(
height: 400,
decoration: BoxDecoration(
@@ -166,30 +131,20 @@ class CloudFileWidget extends HookConsumerWidget {
children: [
IconButton(
icon: const Icon(
Symbols.download,
color: Colors.white,
size: 16,
),
onPressed: downloadFile,
padding: EdgeInsets.all(4),
constraints: const BoxConstraints(),
),
IconButton(
icon: const Icon(
Symbols.info,
Symbols.more_horiz,
color: Colors.white,
size: 16,
),
onPressed: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder: (context) => FileInfoSheet(item: item),
context.pushNamed(
'fileDetail',
pathParameters: {'id': item.id},
extra: item,
);
},
padding: EdgeInsets.all(4),
constraints: const BoxConstraints(),
visualDensity: VisualDensity.compact,
),
],
),
@@ -201,34 +156,6 @@ class CloudFileWidget extends HookConsumerWidget {
}
if (item.mimeType?.startsWith('text/') == true) {
Future<void> downloadFile() async {
try {
showSnackBar('Downloading file...');
final client = ref.read(apiClientProvider);
final tempDir = await getTemporaryDirectory();
var extName = extension(item.name).trim();
if (extName.isEmpty) {
extName = item.mimeType?.split('/').lastOrNull ?? 'txt';
}
final filePath = '${tempDir.path}/${item.id}.$extName';
await client.download(
'/drive/files/${item.id}',
filePath,
queryParameters: {'original': true},
);
await FileSaver.instance.saveFile(
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
file: File(filePath),
);
showSnackBar('File saved to downloads');
} catch (e) {
showErrorAlert(e);
}
}
return Container(
height: 400,
decoration: BoxDecoration(
@@ -304,30 +231,20 @@ class CloudFileWidget extends HookConsumerWidget {
children: [
IconButton(
icon: const Icon(
Symbols.download,
color: Colors.white,
size: 16,
),
onPressed: downloadFile,
padding: EdgeInsets.all(4),
constraints: const BoxConstraints(),
),
IconButton(
icon: const Icon(
Symbols.info,
Symbols.more_horiz,
color: Colors.white,
size: 16,
),
onPressed: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder: (context) => FileInfoSheet(item: item),
context.pushNamed(
'fileDetail',
pathParameters: {'id': item.id},
extra: item,
);
},
padding: EdgeInsets.all(4),
constraints: const BoxConstraints(),
visualDensity: VisualDensity.compact,
),
],
),
@@ -356,41 +273,13 @@ class CloudFileWidget extends HookConsumerWidget {
'audio' => AudioFileContent(item: item, uri: uri),
_ => Builder(
builder: (context) {
Future<void> downloadFile() async {
try {
showSnackBar('Downloading file...');
final client = ref.read(apiClientProvider);
final tempDir = await getTemporaryDirectory();
var extName = extension(item.name).trim();
if (extName.isEmpty) {
extName = item.mimeType?.split('/').lastOrNull ?? 'bin';
}
final filePath = '${tempDir.path}/${item.id}.$extName';
await client.download(
'/drive/files/${item.id}',
filePath,
queryParameters: {'original': true},
);
await FileSaver.instance.saveFile(
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
file: File(filePath),
);
showSnackBar('File saved to downloads');
} catch (e) {
showErrorAlert(e);
}
}
return Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 1,
),
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -422,19 +311,12 @@ class CloudFileWidget extends HookConsumerWidget {
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton.icon(
onPressed: downloadFile,
icon: const Icon(Symbols.download),
label: Text('download').tr(),
),
const Gap(8),
TextButton.icon(
onPressed: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder: (context) => FileInfoSheet(item: item),
context.pushNamed(
'fileDetail',
pathParameters: {'id': item.id},
extra: item,
);
},
icon: const Icon(Symbols.info),

View File

@@ -1,8 +1,10 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart';
import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -28,8 +30,64 @@ class PdfFileContent extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final pdfViewer = useMemoized(() => SfPdfViewer.network(uri), [uri]);
return pdfViewer;
final fileFuture = useMemoized(
() => DefaultCacheManager().getSingleFile(uri),
[uri],
);
final pdfController = useMemoized(() => PdfViewerController(), []);
final shadow = [
Shadow(color: Colors.black54, blurRadius: 5.0, offset: Offset(1.0, 1.0)),
];
return FutureBuilder<File>(
future: fileFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(child: Text('Error loading PDF: ${snapshot.error}'));
} else if (snapshot.hasData) {
return Stack(
children: [
SfPdfViewer.file(snapshot.data!, controller: pdfController),
// Controls overlay
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 16,
left: 16,
right: 16,
child: Row(
children: [
IconButton(
icon: Icon(
Icons.remove,
color: Colors.white,
shadows: shadow,
),
onPressed: () {
pdfController.zoomLevel = pdfController.zoomLevel * 0.9;
},
),
IconButton(
icon: Icon(
Icons.add,
color: Colors.white,
shadows: shadow,
),
onPressed: () {
pdfController.zoomLevel = pdfController.zoomLevel * 1.1;
},
),
],
),
),
],
);
}
return const Center(child: Text('No PDF data'));
},
);
}
}
@@ -89,19 +147,39 @@ class ImageFileContent extends HookConsumerWidget {
return Stack(
children: [
Positioned.fill(
child: PhotoView(
backgroundDecoration: BoxDecoration(
color: Colors.black.withOpacity(0.9),
child: Listener(
onPointerSignal: (pointerSignal) {
try {
// Handle mouse wheel zoom - cast to dynamic to access scrollDelta
final delta =
(pointerSignal as dynamic).scrollDelta.dy as double?;
if (delta != null && delta != 0) {
final currentScale = photoViewController.scale ?? 1.0;
// Adjust scale based on scroll direction (invert for natural zoom)
final newScale =
delta > 0 ? currentScale * 0.9 : currentScale * 1.1;
// Clamp scale to reasonable bounds
final clampedScale = newScale.clamp(0.1, 10.0);
photoViewController.scale = clampedScale;
}
} catch (e) {
// Ignore non-scroll events
}
},
child: PhotoView(
backgroundDecoration: BoxDecoration(
color: Colors.black.withOpacity(0.9),
),
controller: photoViewController,
imageProvider: CloudImageWidget.provider(
fileId: item.id,
serverUrl: ref.watch(serverUrlProvider),
original: showOriginal.value,
),
customSize: MediaQuery.of(context).size,
basePosition: Alignment.center,
filterQuality: FilterQuality.high,
),
controller: photoViewController,
imageProvider: CloudImageWidget.provider(
fileId: item.id,
serverUrl: ref.watch(serverUrlProvider),
original: showOriginal.value,
),
customSize: MediaQuery.of(context).size,
basePosition: Alignment.center,
filterQuality: FilterQuality.high,
),
),
// Controls overlay
@@ -245,68 +323,57 @@ class GenericFileContent extends HookConsumerWidget {
}
return Center(
child: Container(
margin: const EdgeInsets.all(32),
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 1,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.insert_drive_file,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.insert_drive_file,
size: 64,
const Gap(16),
Text(
item.name,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const Gap(8),
Text(
formatFileSize(item.size),
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const Gap(16),
Text(
item.name,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
const Gap(24),
Row(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton.icon(
onPressed: downloadFile,
icon: const Icon(Symbols.download),
label: Text('download').tr(),
),
textAlign: TextAlign.center,
),
const Gap(8),
Text(
formatFileSize(item.size),
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
const Gap(16),
OutlinedButton.icon(
onPressed: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder: (context) => FileInfoSheet(item: item),
);
},
icon: const Icon(Symbols.info),
label: Text('info').tr(),
),
),
const Gap(24),
Row(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton.icon(
onPressed: downloadFile,
icon: const Icon(Symbols.download),
label: Text('download'),
),
const Gap(16),
OutlinedButton.icon(
onPressed: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder: (context) => FileInfoSheet(item: item),
);
},
icon: const Icon(Symbols.info),
label: Text('info'),
),
],
),
],
),
],
),
],
),
);
}

View File

@@ -1,10 +1,7 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/pods/network.dart';
import 'package:island/talker.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
@@ -28,28 +25,12 @@ class _UniversalVideoState extends ConsumerState<UniversalVideo> {
VideoController? _videoController;
void _openVideo() async {
final url = widget.uri;
MediaKit.ensureInitialized();
_player = Player();
_videoController = VideoController(_player!);
String? uri;
final inCacheInfo = await DefaultCacheManager().getFileFromCache(url);
if (inCacheInfo == null) {
talker.info('[MediaPlayer] Miss cache: $url');
final token = ref.watch(tokenProvider)?.token;
DefaultCacheManager().downloadFile(
url,
authHeaders: {'Authorization': 'AtField $token'},
);
uri = url;
} else {
uri = inCacheInfo.file.path;
talker.info('[MediaPlayer] Hit cache: $url');
}
_player!.open(Media(uri), play: widget.autoplay);
_player!.open(Media(widget.uri), play: widget.autoplay);
}
@override

View File

@@ -29,7 +29,7 @@ Future<void> _showSetTokenDialog(BuildContext context, WidgetRef ref) async {
decoration: const InputDecoration(
hintText: 'Enter access token',
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
autofocus: true,

File diff suppressed because it is too large Load Diff

View File

@@ -457,7 +457,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
maxLines: 6,
decoration: const InputDecoration(
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
);

View File

@@ -281,8 +281,8 @@ class ComposeFundSheet extends HookConsumerWidget {
// Return the fund that was just created (but not yet paid)
if (context.mounted) {
hideLoadingModal(context);
Navigator.of(context).pop(fund);
}
Navigator.of(context).pop(fund);
return;
}
@@ -327,10 +327,10 @@ class ComposeFundSheet extends HookConsumerWidget {
if (context.mounted) {
hideLoadingModal(context);
Navigator.of(
context,
).pop(updatedFund);
}
Navigator.of(
context,
).pop(updatedFund);
} else {
isPushing.value = false;
}

View File

@@ -1,5 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
@@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/drive_task.dart';
import 'package:island/pods/upload_tasks.dart';
import 'package:island/services/responsive.dart';
import 'package:island/talker.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:styled_widget/styled_widget.dart';
@@ -29,7 +30,49 @@ class UploadOverlay extends HookConsumerWidget {
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt)); // Newest first
final isVisible = activeTasks.isNotEmpty;
final isVisibleOverride = useState<bool?>(null);
final pendingHide = useState(false);
final isExpandedLocal = useState(false);
final autoHideTimer = useState<Timer?>(null);
final allFinished = activeTasks.every(
(task) =>
task.status == DriveTaskStatus.completed ||
task.status == DriveTaskStatus.failed ||
task.status == DriveTaskStatus.cancelled ||
task.status == DriveTaskStatus.expired,
);
// Auto-hide timer effect
useEffect(
() {
autoHideTimer.value?.cancel();
if (allFinished &&
activeTasks.isNotEmpty &&
!isExpandedLocal.value &&
!pendingHide.value) {
talker.info('[UploadOverlay] Setting auto hide timer...');
autoHideTimer.value = Timer(const Duration(seconds: 3), () {
talker.info('[UploadOverlay] Ready to hide!');
pendingHide.value = true;
});
} else {
talker.info('[UploadOverlay] Remove auto hide timer...');
autoHideTimer.value?.cancel();
autoHideTimer.value = null;
}
return null;
},
[
allFinished,
activeTasks.length,
isExpandedLocal.value,
pendingHide.value,
],
);
final isVisible =
(isVisibleOverride.value ?? activeTasks.isNotEmpty) &&
!pendingHide.value;
final slideController = useAnimationController(
duration: const Duration(milliseconds: 300),
);
@@ -63,6 +106,8 @@ class UploadOverlay extends HookConsumerWidget {
position: slideAnimation,
child: _UploadOverlayContent(
activeTasks: activeTasks,
isExpanded: isExpandedLocal.value,
onExpansionChanged: (expanded) => isExpandedLocal.value = expanded,
).padding(bottom: 16 + MediaQuery.of(context).padding.bottom),
),
);
@@ -71,12 +116,17 @@ class UploadOverlay extends HookConsumerWidget {
class _UploadOverlayContent extends HookConsumerWidget {
final List<DriveTask> activeTasks;
final bool isExpanded;
final Function(bool)? onExpansionChanged;
const _UploadOverlayContent({required this.activeTasks});
const _UploadOverlayContent({
required this.activeTasks,
required this.isExpanded,
this.onExpansionChanged,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isExpanded = useState(false);
final animationController = useAnimationController(
duration: const Duration(milliseconds: 200),
initialValue: 0.0,
@@ -91,15 +141,17 @@ class _UploadOverlayContent extends HookConsumerWidget {
);
useEffect(() {
if (isExpanded.value) {
if (isExpanded) {
animationController.forward();
} else {
animationController.reverse();
}
return null;
}, [isExpanded.value]);
}, [isExpanded]);
final isMobile = MediaQuery.of(context).size.width < 600;
final isMobile = !isWideScreen(context);
final taskNotifier = ref.read(uploadTasksProvider.notifier);
return Padding(
padding: EdgeInsets.only(
@@ -108,7 +160,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
right: isMobile ? 16 : 24,
),
child: GestureDetector(
onTap: () => isExpanded.value = !isExpanded.value,
onTap: () => onExpansionChanged?.call(!isExpanded),
child: AnimatedBuilder(
animation: animationController,
builder: (context, child) {
@@ -142,8 +194,8 @@ class _UploadOverlayContent extends HookConsumerWidget {
);
},
child: Icon(
key: ValueKey(isExpanded.value),
isExpanded.value
key: ValueKey(isExpanded),
isExpanded
? Symbols.list_rounded
: _getOverallStatusIcon(activeTasks),
size: 24,
@@ -159,7 +211,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isExpanded.value
isExpanded
? 'uploadTasks'.tr()
: _getOverallStatusText(activeTasks),
style: Theme.of(context)
@@ -169,8 +221,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (!isExpanded.value &&
activeTasks.isNotEmpty)
if (!isExpanded && activeTasks.isNotEmpty)
Text(
_getOverallProgressText(activeTasks),
style: Theme.of(
@@ -187,7 +238,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
),
// Progress indicator (collapsed)
if (!isExpanded.value)
if (!isExpanded)
SizedBox(
width: 32,
height: 32,
@@ -207,14 +258,14 @@ class _UploadOverlayContent extends HookConsumerWidget {
turns: opacityAnimation * 0.5,
duration: const Duration(milliseconds: 200),
child: Icon(
isExpanded.value
isExpanded
? Symbols.expand_more
: Symbols.chevron_right,
size: 20,
),
),
onPressed:
() => isExpanded.value = !isExpanded.value,
() => onExpansionChanged?.call(!isExpanded),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
@@ -223,7 +274,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
),
// Expanded content
if (isExpanded.value)
if (isExpanded)
Expanded(
child: Container(
decoration: BoxDecoration(
@@ -243,7 +294,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
SliverToBoxAdapter(
child: ListTile(
dense: true,
title: const Text('Clear Completed'),
title: const Text('clearCompleted').tr(),
leading: Icon(
Symbols.clear_all,
size: 18,
@@ -253,9 +304,35 @@ class _UploadOverlayContent extends HookConsumerWidget {
).colorScheme.onSurfaceVariant,
),
onTap: () {
ref
.read(uploadTasksProvider.notifier)
.clearCompletedTasks();
taskNotifier.clearCompletedTasks();
onExpansionChanged?.call(false);
},
tileColor:
Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
),
// Clear all tasks button
if (activeTasks.any(
(task) =>
task.status != DriveTaskStatus.completed,
))
SliverToBoxAdapter(
child: ListTile(
dense: true,
title: const Text('Clear All'),
leading: Icon(
Symbols.clear_all,
size: 18,
color:
Theme.of(context).colorScheme.error,
),
onTap: () {
taskNotifier.clearAllTasks();
onExpansionChanged?.call(false);
},
tileColor:
Theme.of(
@@ -318,6 +395,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
IconData _getOverallStatusIcon(List<DriveTask> tasks) {
if (tasks.isEmpty) return Symbols.upload;
final hasDownload = tasks.any((task) => task.type == 'FileDownload');
final hasInProgress = tasks.any(
(task) => task.status == DriveTaskStatus.inProgress,
);
@@ -339,6 +417,9 @@ class _UploadOverlayContent extends HookConsumerWidget {
// Priority order: in progress > pending > paused > failed > completed
if (hasInProgress) {
if (hasDownload) {
return Symbols.download;
}
return Symbols.upload;
} else if (hasPending) {
return Symbols.schedule;
@@ -356,6 +437,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
String _getOverallStatusText(List<DriveTask> tasks) {
if (tasks.isEmpty) return '0 tasks';
final hasDownload = tasks.any((task) => task.type == 'FileDownload');
final hasInProgress = tasks.any(
(task) => task.status == DriveTaskStatus.inProgress,
);
@@ -377,7 +459,11 @@ class _UploadOverlayContent extends HookConsumerWidget {
// Priority order: in progress > pending > paused > failed > completed
if (hasInProgress) {
return '${tasks.length} ${'uploading'.tr()}';
if (hasDownload) {
return '${tasks.length} ${'downloading'.tr()}';
} else {
return '${tasks.length} ${'uploading'.tr()}';
}
} else if (hasPending) {
return '${tasks.length} ${'pending'.tr()}';
} else if (hasPaused) {
@@ -519,7 +605,10 @@ class _UploadTaskTileState extends State<UploadTaskTile>
color = Theme.of(context).colorScheme.secondary;
break;
case DriveTaskStatus.inProgress:
icon = Symbols.upload;
icon =
widget.task.type == 'FileDownload'
? Symbols.download
: Symbols.upload;
color = Theme.of(context).colorScheme.primary;
break;
case DriveTaskStatus.paused:

View File

@@ -458,7 +458,7 @@ class FundClaimDialog extends HookConsumerWidget {
// Remaining amount
Text(
'${fund.remainingAmount.toStringAsFixed(2)} ${fund.currency} / ${remainingSplits} splits',
'${fund.remainingAmount.toStringAsFixed(2)} ${fund.currency} / $remainingSplits splits',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.w500,