diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index b68baf7..1be0d34 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -733,5 +733,8 @@ "reconnecting": "Reconnecting", "disconnected": "Disconnected", "connected": "Connected", - "repliesLoadMore": "Load more replies" + "repliesLoadMore": "Load more replies", + "attachmentsRecentUploads": "Recent Uploads", + "attachmentsManualInput": "Manual Input", + "crop": "Crop" } diff --git a/lib/models/file.dart b/lib/models/file.dart index 8a6a7b7..9b2ed2e 100644 --- a/lib/models/file.dart +++ b/lib/models/file.dart @@ -12,6 +12,7 @@ sealed class UniversalFile with _$UniversalFile { const factory UniversalFile({ required dynamic data, required UniversalFileType type, + @Default(false) bool isLink, }) = _UniversalFile; factory UniversalFile.fromJson(Map json) => diff --git a/lib/models/file.freezed.dart b/lib/models/file.freezed.dart index cd9ed88..347ea8d 100644 --- a/lib/models/file.freezed.dart +++ b/lib/models/file.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$UniversalFile { - dynamic get data; UniversalFileType get type; + dynamic get data; UniversalFileType get type; bool get isLink; /// Create a copy of UniversalFile /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -28,16 +28,16 @@ $UniversalFileCopyWith get copyWith => _$UniversalFileCopyWithImp @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)&&(identical(other.isLink, isLink) || other.isLink == isLink)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type); +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type,isLink); @override String toString() { - return 'UniversalFile(data: $data, type: $type)'; + return 'UniversalFile(data: $data, type: $type, isLink: $isLink)'; } @@ -48,7 +48,7 @@ abstract mixin class $UniversalFileCopyWith<$Res> { factory $UniversalFileCopyWith(UniversalFile value, $Res Function(UniversalFile) _then) = _$UniversalFileCopyWithImpl; @useResult $Res call({ - dynamic data, UniversalFileType type + dynamic data, UniversalFileType type, bool isLink }); @@ -65,11 +65,12 @@ class _$UniversalFileCopyWithImpl<$Res> /// Create a copy of UniversalFile /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? data = freezed,Object? type = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? data = freezed,Object? type = null,Object? isLink = null,}) { return _then(_self.copyWith( data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable as dynamic,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable -as UniversalFileType, +as UniversalFileType,isLink: null == isLink ? _self.isLink : isLink // ignore: cast_nullable_to_non_nullable +as bool, )); } @@ -151,10 +152,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( dynamic data, UniversalFileType type)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( dynamic data, UniversalFileType type, bool isLink)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _UniversalFile() when $default != null: -return $default(_that.data,_that.type);case _: +return $default(_that.data,_that.type,_that.isLink);case _: return orElse(); } @@ -172,10 +173,10 @@ return $default(_that.data,_that.type);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( dynamic data, UniversalFileType type) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( dynamic data, UniversalFileType type, bool isLink) $default,) {final _that = this; switch (_that) { case _UniversalFile(): -return $default(_that.data,_that.type);} +return $default(_that.data,_that.type,_that.isLink);} } /// A variant of `when` that fallback to returning `null` /// @@ -189,10 +190,10 @@ return $default(_that.data,_that.type);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( dynamic data, UniversalFileType type)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( dynamic data, UniversalFileType type, bool isLink)? $default,) {final _that = this; switch (_that) { case _UniversalFile() when $default != null: -return $default(_that.data,_that.type);case _: +return $default(_that.data,_that.type,_that.isLink);case _: return null; } @@ -204,11 +205,12 @@ return $default(_that.data,_that.type);case _: @JsonSerializable() class _UniversalFile extends UniversalFile { - const _UniversalFile({required this.data, required this.type}): super._(); + const _UniversalFile({required this.data, required this.type, this.isLink = false}): super._(); factory _UniversalFile.fromJson(Map json) => _$UniversalFileFromJson(json); @override final dynamic data; @override final UniversalFileType type; +@override@JsonKey() final bool isLink; /// Create a copy of UniversalFile /// with the given fields replaced by the non-null parameter values. @@ -223,16 +225,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)&&(identical(other.isLink, isLink) || other.isLink == isLink)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type); +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type,isLink); @override String toString() { - return 'UniversalFile(data: $data, type: $type)'; + return 'UniversalFile(data: $data, type: $type, isLink: $isLink)'; } @@ -243,7 +245,7 @@ abstract mixin class _$UniversalFileCopyWith<$Res> implements $UniversalFileCopy factory _$UniversalFileCopyWith(_UniversalFile value, $Res Function(_UniversalFile) _then) = __$UniversalFileCopyWithImpl; @override @useResult $Res call({ - dynamic data, UniversalFileType type + dynamic data, UniversalFileType type, bool isLink }); @@ -260,11 +262,12 @@ class __$UniversalFileCopyWithImpl<$Res> /// Create a copy of UniversalFile /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? data = freezed,Object? type = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? data = freezed,Object? type = null,Object? isLink = null,}) { return _then(_UniversalFile( data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable as dynamic,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable -as UniversalFileType, +as UniversalFileType,isLink: null == isLink ? _self.isLink : isLink // ignore: cast_nullable_to_non_nullable +as bool, )); } diff --git a/lib/models/file.g.dart b/lib/models/file.g.dart index d006853..4fe0117 100644 --- a/lib/models/file.g.dart +++ b/lib/models/file.g.dart @@ -10,12 +10,14 @@ _UniversalFile _$UniversalFileFromJson(Map json) => _UniversalFile( data: json['data'], type: $enumDecode(_$UniversalFileTypeEnumMap, json['type']), + isLink: json['is_link'] as bool? ?? false, ); Map _$UniversalFileToJson(_UniversalFile instance) => { 'data': instance.data, 'type': _$UniversalFileTypeEnumMap[instance.type]!, + 'is_link': instance.isLink, }; const _$UniversalFileTypeEnumMap = { diff --git a/lib/pods/call.g.dart b/lib/pods/call.g.dart index a3b23e9..d7901c1 100644 --- a/lib/pods/call.g.dart +++ b/lib/pods/call.g.dart @@ -6,7 +6,7 @@ part of 'call.dart'; // RiverpodGenerator // ************************************************************************** -String _$callNotifierHash() => r'333a1cd566a339644c83932e15dae03f1c5cc24b'; +String _$callNotifierHash() => r'18fb807f067eecd3ea42631c1426c3e5f1fb4280'; /// See also [CallNotifier]. @ProviderFor(CallNotifier) diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 05b9363..2b693f1 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -1070,6 +1070,10 @@ class _ChatInput extends HookConsumerWidget { item: attachments[idx], onRequestUpload: () => onUploadAttachment(idx), onDelete: () => onDeleteAttachment(idx), + onUpdate: (value) { + attachments[idx] = value; + onAttachmentsChanged(attachments); + }, onMove: (delta) => onMoveAttachment(idx, delta), ); }, diff --git a/lib/screens/posts/compose.dart b/lib/screens/posts/compose.dart index 60fb41f..f8125ed 100644 --- a/lib/screens/posts/compose.dart +++ b/lib/screens/posts/compose.dart @@ -238,6 +238,8 @@ class PostComposeScreen extends HookConsumerWidget { onRequestUpload: () => ComposeLogic.uploadAttachment(ref, state, idx), onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx), + onUpdate: + (value) => ComposeLogic.updateAttachment(state, value, idx), onMove: (delta) { state.attachments.value = ComposeLogic.moveAttachment( state.attachments.value, @@ -265,6 +267,9 @@ class PostComposeScreen extends HookConsumerWidget { () => ComposeLogic.uploadAttachment(ref, state, idx), onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx), + onUpdate: + (value) => + ComposeLogic.updateAttachment(state, value, idx), onMove: (delta) { state.attachments.value = ComposeLogic.moveAttachment( state.attachments.value, diff --git a/lib/screens/posts/compose_article.dart b/lib/screens/posts/compose_article.dart index 87ff9e5..91424fb 100644 --- a/lib/screens/posts/compose_article.dart +++ b/lib/screens/posts/compose_article.dart @@ -308,6 +308,13 @@ class ArticleComposeScreen extends HookConsumerWidget { state, idx, ), + onUpdate: + (value) => + ComposeLogic.updateAttachment( + state, + value, + idx, + ), onDelete: () => ComposeLogic.deleteAttachment( ref, diff --git a/lib/services/file.dart b/lib/services/file.dart index 31c8699..3e441a9 100644 --- a/lib/services/file.dart +++ b/lib/services/file.dart @@ -15,6 +15,7 @@ Future cropImage( BuildContext context, { required XFile image, List? allowedAspectRatios, + bool replacePath = false, }) async { final result = await showMaterialImageCropper( context, @@ -34,7 +35,7 @@ Future cropImage( croppedFile.dispose(); return XFile.fromData( croppedBytes.buffer.asUint8List(), - path: image.path, + path: !replacePath ? image.path : null, mimeType: image.mimeType, ); } diff --git a/lib/widgets/content/attachment_preview.dart b/lib/widgets/content/attachment_preview.dart index 730eacf..3ddcad1 100644 --- a/lib/widgets/content/attachment_preview.dart +++ b/lib/widgets/content/attachment_preview.dart @@ -6,9 +6,11 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:island/models/file.dart'; +import 'package:island/services/file.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; +import 'package:super_context_menu/super_context_menu.dart'; class AttachmentPreview extends StatelessWidget { final UniversalFile item; @@ -16,6 +18,7 @@ class AttachmentPreview extends StatelessWidget { final Function(int)? onMove; final Function? onDelete; final Function? onInsert; + final Function(UniversalFile)? onUpdate; final Function? onRequestUpload; const AttachmentPreview({ super.key, @@ -24,6 +27,7 @@ class AttachmentPreview extends StatelessWidget { this.onRequestUpload, this.onMove, this.onDelete, + this.onUpdate, this.onInsert, }); @@ -37,217 +41,249 @@ class AttachmentPreview extends StatelessWidget { : 1.0; if (ratio == 0) ratio = 1.0; - return AspectRatio( - aspectRatio: ratio, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Stack( - fit: StackFit.expand, + final contentWidget = ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + color: Theme.of(context).colorScheme.surfaceContainer, + child: Column( children: [ - Container( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - child: Builder( - builder: (context) { - if (item.isOnCloud) { - return CloudFileWidget(item: item.data); - } else if (item.data is XFile) { - if (item.type == UniversalFileType.image) { - final file = item.data as XFile; - if (file.path.isEmpty) { - return FutureBuilder( - future: file.readAsBytes(), - builder: (context, snapshot) { - if (snapshot.hasData) { - return Image.memory(snapshot.data!); - } - return const Center( - child: CircularProgressIndicator(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + color: Colors.black.withOpacity(0.5), + child: Material( + color: Colors.transparent, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (onDelete != null) + InkWell( + borderRadius: BorderRadius.circular(8), + child: Icon( + item.isLink ? Symbols.link_off : Symbols.delete, + size: 14, + color: Colors.white, + ).padding(horizontal: 8, vertical: 6), + onTap: () { + onDelete?.call(); + }, + ), + if (onDelete != null && onMove != null) + SizedBox( + height: 26, + child: const VerticalDivider( + width: 0.3, + color: Colors.white, + thickness: 0.3, + ), + ).padding(horizontal: 2), + if (onMove != null) + InkWell( + borderRadius: BorderRadius.circular(8), + child: const Icon( + Symbols.keyboard_arrow_up, + size: 14, + color: Colors.white, + ).padding(horizontal: 8, vertical: 6), + onTap: () { + onMove?.call(-1); + }, + ), + if (onMove != null) + InkWell( + borderRadius: BorderRadius.circular(8), + child: const Icon( + Symbols.keyboard_arrow_down, + size: 14, + color: Colors.white, + ).padding(horizontal: 8, vertical: 6), + onTap: () { + onMove?.call(1); + }, + ), + if (onInsert != null) + InkWell( + borderRadius: BorderRadius.circular(8), + child: const Icon( + Symbols.add, + size: 14, + color: Colors.white, + ).padding(horizontal: 8, vertical: 6), + onTap: () { + onInsert?.call(); + }, + ), + ], + ), + ), + ), + ), + if (onRequestUpload != null) + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => onRequestUpload?.call(), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + color: Colors.black.withOpacity(0.5), + padding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: + (item.isOnCloud) + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Symbols.cloud, + size: 16, + color: Colors.white, + ), + const Gap(8), + Text( + 'On-cloud', + style: TextStyle(color: Colors.white), + ), + ], + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Symbols.cloud_off, + size: 16, + color: Colors.white, + ), + const Gap(8), + Text( + 'On-device', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ), + ), + ], + ).padding(horizontal: 12, vertical: 8), + AspectRatio( + aspectRatio: ratio, + child: Stack( + fit: StackFit.expand, + children: [ + Builder( + key: ValueKey(item.hashCode), + builder: (context) { + if (item.isOnCloud) { + return CloudFileWidget(item: item.data); + } else if (item.data is XFile) { + final file = item.data as XFile; + if (file.path.isEmpty) { + return FutureBuilder( + future: file.readAsBytes(), + builder: (context, snapshot) { + if (snapshot.hasData) { + return Image.memory(snapshot.data!); + } + return const Center( + child: CircularProgressIndicator(), + ); + }, + ); + } + + switch (item.type) { + case UniversalFileType.image: + return kIsWeb + ? Image.network(file.path) + : Image.file(File(file.path)); + default: + return Column( + children: [ + const Icon(Symbols.document_scanner), + Text(file.name), + ], ); - }, - ); + } + } else if (item is List || item is Uint8List) { + switch (item.type) { + case UniversalFileType.image: + return Image.memory(item.data); + default: + return Column( + children: [const Icon(Symbols.document_scanner)], + ); + } } - return kIsWeb - ? Image.network(file.path) - : Image.file(File(file.path)); - } else { - return Center( - child: Text( - 'Preview is not supported for ${item.type}', - textAlign: TextAlign.center, + return Placeholder(); + }, + ), + if (progress != null) + Positioned.fill( + child: Container( + color: Colors.black.withOpacity(0.3), + padding: EdgeInsets.symmetric( + horizontal: 40, + vertical: 16, ), - ); - } - } else if (item is List || item is Uint8List) { - if (item.type == UniversalFileType.image) { - return Image.memory(item.data); - } else { - return Center( - child: Text( - 'Preview is not supported for ${item.type}', - textAlign: TextAlign.center, - ), - ); - } - } - return Placeholder(); - }, - ), - ), - if (progress != null) - Positioned.fill( - child: Container( - color: Colors.black.withOpacity(0.3), - padding: EdgeInsets.symmetric(horizontal: 40, vertical: 16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (progress != null) - Text( - '${progress!.toStringAsFixed(2)}%', - style: TextStyle(color: Colors.white), - ) - else - Text( - 'uploading'.tr(), - style: TextStyle(color: Colors.white), - ), - Gap(6), - Center( - child: LinearProgressIndicator( - value: progress != null ? progress! / 100.0 : null, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (progress != null) + Text( + '${progress!.toStringAsFixed(2)}%', + style: TextStyle(color: Colors.white), + ) + else + Text( + 'uploading'.tr(), + style: TextStyle(color: Colors.white), + ), + Gap(6), + Center( + child: LinearProgressIndicator( + value: + progress != null ? progress! / 100.0 : null, + ), + ), + ], ), ), - ], - ), - ), - ), - Positioned( - left: 8, - top: 8, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Container( - color: Colors.black.withOpacity(0.5), - child: Material( - color: Colors.transparent, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (onDelete != null) - InkWell( - borderRadius: BorderRadius.circular(8), - child: const Icon( - Symbols.delete, - size: 14, - color: Colors.white, - ).padding(horizontal: 8, vertical: 6), - onTap: () { - onDelete?.call(); - }, - ), - if (onDelete != null && onMove != null) - SizedBox( - height: 26, - child: const VerticalDivider( - width: 0.3, - color: Colors.white, - thickness: 0.3, - ), - ).padding(horizontal: 2), - if (onMove != null) - InkWell( - borderRadius: BorderRadius.circular(8), - child: const Icon( - Symbols.keyboard_arrow_up, - size: 14, - color: Colors.white, - ).padding(horizontal: 8, vertical: 6), - onTap: () { - onMove?.call(-1); - }, - ), - if (onMove != null) - InkWell( - borderRadius: BorderRadius.circular(8), - child: const Icon( - Symbols.keyboard_arrow_down, - size: 14, - color: Colors.white, - ).padding(horizontal: 8, vertical: 6), - onTap: () { - onMove?.call(1); - }, - ), - if (onInsert != null) - InkWell( - borderRadius: BorderRadius.circular(8), - child: const Icon( - Symbols.add, - size: 14, - color: Colors.white, - ).padding(horizontal: 8, vertical: 6), - onTap: () { - onInsert?.call(); - }, - ), - ], ), - ), - ), + ], ), ), - if (onRequestUpload != null) - Positioned( - top: 8, - right: 8, - child: InkWell( - borderRadius: BorderRadius.circular(8), - onTap: () => onRequestUpload?.call(), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Container( - color: Colors.black.withOpacity(0.5), - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: - (item.isOnCloud) - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Symbols.cloud, - size: 16, - color: Colors.white, - ), - const Gap(8), - Text( - 'On-cloud', - style: TextStyle(color: Colors.white), - ), - ], - ) - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Symbols.cloud_off, - size: 16, - color: Colors.white, - ), - const Gap(8), - Text( - 'On-device', - style: TextStyle(color: Colors.white), - ), - ], - ), - ), - ), - ), - ), ], ), ), ); + + return ContextMenuWidget( + menuProvider: + (MenuRequest request) => Menu( + children: [ + if (item.isOnDevice && item.type == UniversalFileType.image) + MenuAction( + title: 'crop'.tr(), + image: MenuImage.icon(Symbols.crop), + callback: () async { + final result = await cropImage( + context, + image: item.data, + replacePath: true, + ); + if (result == null) return; + onUpdate?.call(item.copyWith(data: result)); + }, + ), + ], + ), + child: contentWidget, + ); } } diff --git a/lib/widgets/content/cloud_file_collection.dart b/lib/widgets/content/cloud_file_collection.dart index 8f5bf51..3ddced6 100644 --- a/lib/widgets/content/cloud_file_collection.dart +++ b/lib/widgets/content/cloud_file_collection.dart @@ -112,51 +112,57 @@ class CloudFileList extends HookConsumerWidget { constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth), child: AspectRatio( aspectRatio: calculateAspectRatio(), - child: CarouselView( - padding: padding, - itemSnapping: true, - itemExtent: math.min( - MediaQuery.of(context).size.width * 0.85, - maxWidth * 0.85, - ), - shape: RoundedRectangleBorder( - borderRadius: const BorderRadius.all(Radius.circular(16)), - ), - children: [ - for (var i = 0; i < files.length; i++) - Stack( - children: [ - _CloudFileListEntry( - file: files[i], - heroTag: heroTags[i], - isImage: files[i].mimeType?.startsWith('image') ?? false, - disableZoomIn: disableZoomIn, - ), - Positioned( - bottom: 12, - left: 16, - child: Text('${i + 1}/${files.length}') - .textColor(Colors.white) - .textShadow( - color: Colors.black54, - offset: Offset(1, 1), - blurRadius: 3, - ), - ), - ], + child: Padding( + padding: padding ?? EdgeInsets.zero, + child: CarouselView( + itemSnapping: true, + itemExtent: math.min( + math.min( + MediaQuery.of(context).size.width * 0.75, + maxWidth * 0.75, ), - ], - onTap: (i) { - if (!(files[i].mimeType?.startsWith('image') ?? false)) { - return; - } - if (!disableZoomIn) { - context.pushTransparentRoute( - CloudFileZoomIn(item: files[i], heroTag: heroTags[i]), - rootNavigator: true, - ); - } - }, + 640, + ), + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(16)), + ), + children: [ + for (var i = 0; i < files.length; i++) + Stack( + children: [ + _CloudFileListEntry( + file: files[i], + heroTag: heroTags[i], + isImage: + files[i].mimeType?.startsWith('image') ?? false, + disableZoomIn: disableZoomIn, + ), + Positioned( + bottom: 12, + left: 16, + child: Text('${i + 1}/${files.length}') + .textColor(Colors.white) + .textShadow( + color: Colors.black54, + offset: Offset(1, 1), + blurRadius: 3, + ), + ), + ], + ), + ], + onTap: (i) { + if (!(files[i].mimeType?.startsWith('image') ?? false)) { + return; + } + if (!disableZoomIn) { + context.pushTransparentRoute( + CloudFileZoomIn(item: files[i], heroTag: heroTags[i]), + rootNavigator: true, + ); + } + }, + ), ), ), ); diff --git a/lib/widgets/context_menu.dart b/lib/widgets/context_menu.dart deleted file mode 100644 index 416860f..0000000 --- a/lib/widgets/context_menu.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -typedef ContextMenuBuilder = - Widget Function(BuildContext context, Offset offset); - -class ContextMenuRegion extends HookWidget { - final Offset? mobileAnchor; - final Widget child; - final ContextMenuBuilder contextMenuBuilder; - const ContextMenuRegion({ - super.key, - required this.child, - required this.contextMenuBuilder, - this.mobileAnchor, - }); - - @override - Widget build(BuildContext context) { - final contextMenuController = useMemoized(() => ContextMenuController()); - final mobileOffset = useState(null); - - bool canBeTouchScreen = switch (defaultTargetPlatform) { - TargetPlatform.android || TargetPlatform.iOS => true, - _ => false, - }; - - void showMenu(Offset position) { - contextMenuController.show( - context: context, - contextMenuBuilder: (BuildContext context) { - return contextMenuBuilder(context, position); - }, - ); - } - - void hideMenu() { - contextMenuController.remove(); - } - - void onSecondaryTapUp(TapUpDetails details) { - showMenu(details.globalPosition); - } - - void onTap() { - if (!contextMenuController.isShown) { - return; - } - hideMenu(); - } - - void onLongPressStart(LongPressStartDetails details) { - mobileOffset.value = details.globalPosition; - } - - void onLongPress() { - assert(mobileOffset.value != null); - showMenu(mobileAnchor ?? mobileOffset.value!); - mobileOffset.value = null; - } - - useEffect(() { - return () { - hideMenu(); - }; - }, []); - - return TapRegion( - behavior: HitTestBehavior.opaque, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onSecondaryTapUp: onSecondaryTapUp, - onTap: onTap, - onLongPress: canBeTouchScreen ? onLongPress : null, - onLongPressStart: canBeTouchScreen ? onLongPressStart : null, - child: child, - ), - onTapOutside: (_) { - hideMenu(); - }, - ); - } -} diff --git a/lib/widgets/post/compose_link_attachments.dart b/lib/widgets/post/compose_link_attachments.dart new file mode 100644 index 0000000..71e4d21 --- /dev/null +++ b/lib/widgets/post/compose_link_attachments.dart @@ -0,0 +1,193 @@ +import 'package:easy_localization/easy_localization.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/pods/network.dart'; +import 'package:island/widgets/content/cloud_files.dart'; +import 'package:island/widgets/content/sheet.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; +import 'package:styled_widget/styled_widget.dart'; + +part 'compose_link_attachments.g.dart'; + +@riverpod +class CloudFileListNotifier extends _$CloudFileListNotifier + with CursorPagingNotifierMixin { + @override + Future> build() => fetch(cursor: null); + + @override + Future> fetch({required String? cursor}) async { + final client = ref.read(apiClientProvider); + final offset = cursor == null ? 0 : int.parse(cursor); + final take = 20; + + final queryParameters = {'offset': offset, 'take': take}; + + final response = await client.get( + '/drive/files/me', + queryParameters: queryParameters, + ); + + final List items = + (response.data as List) + .map((e) => SnCloudFile.fromJson(e as Map)) + .toList(); + final total = int.parse(response.headers.value('X-Total') ?? '0'); + + final hasMore = offset + items.length < total; + final nextCursor = hasMore ? (offset + items.length).toString() : null; + + return CursorPagingData( + items: items, + hasMore: hasMore, + nextCursor: nextCursor, + ); + } +} + +class ComposeLinkAttachment extends HookConsumerWidget { + const ComposeLinkAttachment({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final idController = useTextEditingController(); + final errorMessage = useState(null); + + return SheetScaffold( + heightFactor: 0.6, + titleText: 'linkAttachment'.tr(), + child: DefaultTabController( + length: 2, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TabBar( + tabs: [ + Tab(text: 'attachmentsRecentUploads'.tr()), + Tab(text: 'attachmentsManualInput'.tr()), + ], + ), + Expanded( + child: TabBarView( + children: [ + PagingHelperView( + provider: cloudFileListNotifierProvider, + futureRefreshable: cloudFileListNotifierProvider.future, + notifierRefreshable: cloudFileListNotifierProvider.notifier, + contentBuilder: + (data, widgetCount, endItemView) => ListView.builder( + padding: EdgeInsets.zero, + itemCount: widgetCount, + itemBuilder: (context, index) { + if (index == widgetCount - 1) { + return endItemView; + } + + final item = data.items[index]; + final itemType = + item.mimeType?.split('/').firstOrNull; + return ListTile( + leading: ClipRRect( + borderRadius: const BorderRadius.all( + Radius.circular(8), + ), + child: SizedBox( + height: 48, + width: 48, + child: switch (itemType) { + 'image' => CloudImageWidget(file: item), + 'audio' => + const Icon( + Symbols.audio_file, + fill: 1, + ).center(), + 'video' => + const Icon( + Symbols.video_file, + fill: 1, + ).center(), + _ => + const Icon( + Symbols.body_system, + fill: 1, + ).center(), + }, + ), + ), + title: + item.name.isEmpty + ? Text('untitled').tr().italic() + : Text(item.name), + onTap: () { + Navigator.pop(context, item); + }, + ); + }, + ), + ), + SingleChildScrollView( + child: Column( + children: [ + TextField( + controller: idController, + decoration: InputDecoration( + labelText: 'fileId'.tr(), + helperText: 'fileIdHint'.tr(), + helperMaxLines: 3, + errorText: errorMessage.value, + border: OutlineInputBorder(), + ), + onTapOutside: + (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ), + const Gap(16), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + icon: const Icon(Symbols.add), + label: Text('add'.tr()), + onPressed: () async { + final fileId = idController.text.trim(); + if (fileId.isEmpty) { + errorMessage.value = 'fileIdCannotBeEmpty'.tr(); + return; + } + + try { + final client = ref.read(apiClientProvider); + final response = await client.get( + '/drive/files/$fileId/info', + ); + final SnCloudFile cloudFile = + SnCloudFile.fromJson(response.data); + + if (context.mounted) { + Navigator.of(context).pop(cloudFile); + } + } catch (e) { + errorMessage.value = 'failedToFetchFile'.tr( + args: [e.toString()], + ); + } + }, + ), + ), + ], + ).padding(horizontal: 24, vertical: 24), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/post/compose_link_attachments.g.dart b/lib/widgets/post/compose_link_attachments.g.dart new file mode 100644 index 0000000..b58e6fc --- /dev/null +++ b/lib/widgets/post/compose_link_attachments.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'compose_link_attachments.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$cloudFileListNotifierHash() => + r'e2c8a076a9e635c7b43a87d00f78775427ba6334'; + +/// See also [CloudFileListNotifier]. +@ProviderFor(CloudFileListNotifier) +final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider< + CloudFileListNotifier, + CursorPagingData +>.internal( + CloudFileListNotifier.new, + name: r'cloudFileListNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$cloudFileListNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$CloudFileListNotifier = + AutoDisposeAsyncNotifier>; +// 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 diff --git a/lib/widgets/post/compose_recorder.dart b/lib/widgets/post/compose_recorder.dart index 83727d7..f64d85c 100644 --- a/lib/widgets/post/compose_recorder.dart +++ b/lib/widgets/post/compose_recorder.dart @@ -126,7 +126,7 @@ class ComposeRecorder extends HookConsumerWidget { ), ), ), - ), + ).padding(horizontal: 24), const Gap(12), IconButton.filled( onPressed: recording.value ? stopRecord : startRecord, diff --git a/lib/widgets/post/compose_shared.dart b/lib/widgets/post/compose_shared.dart index ab306ea..ae9c0fe 100644 --- a/lib/widgets/post/compose_shared.dart +++ b/lib/widgets/post/compose_shared.dart @@ -3,7 +3,6 @@ import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:island/models/file.dart'; @@ -14,11 +13,9 @@ import 'package:island/pods/network.dart'; import 'package:island/services/file.dart'; import 'package:island/services/compose_storage_db.dart'; import 'package:island/widgets/alert.dart'; -import 'package:island/widgets/content/sheet.dart'; +import 'package:island/widgets/post/compose_link_attachments.dart'; import 'package:island/widgets/post/compose_recorder.dart'; -import 'package:material_symbols_icons/symbols.dart'; import 'package:pasteboard/pasteboard.dart'; -import 'package:styled_widget/styled_widget.dart'; import 'package:textfield_tags/textfield_tags.dart'; import 'dart:async'; import 'dart:developer'; @@ -424,88 +421,39 @@ class ComposeLogic { ComposeState state, BuildContext context, ) async { - final TextEditingController idController = TextEditingController(); - String? errorMessage; - - await showModalBottomSheet( + final cloudFile = await showModalBottomSheet( context: context, - builder: (BuildContext dialogContext) { - return StatefulBuilder( - builder: (context, setState) { - return SheetScaffold( - titleText: 'linkAttachment'.tr(), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextField( - controller: idController, - decoration: InputDecoration( - labelText: 'fileId'.tr(), - helperText: 'fileIdHint'.tr(), - helperMaxLines: 3, - errorText: errorMessage, - border: OutlineInputBorder(), - ), - ), - const Gap(16), - Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - icon: const Icon(Symbols.add), - label: Text('add'.tr()), - onPressed: () async { - final fileId = idController.text.trim(); - if (fileId.isEmpty) { - setState(() { - errorMessage = 'fileIdCannotBeEmpty'.tr(); - }); - return; - } - - try { - final client = ref.read(apiClientProvider); - final response = await client.get( - '/drive/files/$fileId/info', - ); - final SnCloudFile cloudFile = SnCloudFile.fromJson( - response.data, - ); - - state.attachments.value = [ - ...state.attachments.value, - UniversalFile( - data: cloudFile, - type: switch (cloudFile.mimeType - ?.split('/') - .firstOrNull) { - 'image' => UniversalFileType.image, - 'video' => UniversalFileType.video, - 'audio' => UniversalFileType.audio, - _ => UniversalFileType.file, - }, - ), - ]; - if (context.mounted) { - Navigator.of(dialogContext).pop(); - } - } catch (e) { - setState(() { - errorMessage = 'failedToFetchFile'.tr( - args: [e.toString()], - ); - }); - } - }, - ), - ), - ], - ).padding(horizontal: 24, vertical: 24), - ); - }, - ); - }, + useRootNavigator: true, + isScrollControlled: true, + builder: (context) => ComposeLinkAttachment(), ); + if (cloudFile == null) return; + + state.attachments.value = [ + ...state.attachments.value, + UniversalFile( + data: cloudFile, + type: switch (cloudFile.mimeType?.split('/').firstOrNull) { + 'image' => UniversalFileType.image, + 'video' => UniversalFileType.video, + 'audio' => UniversalFileType.audio, + _ => UniversalFileType.file, + }, + isLink: true, + ), + ]; + } + + static void updateAttachment( + ComposeState state, + UniversalFile value, + int index, + ) { + state.attachments.value = + state.attachments.value.mapIndexed((idx, ele) { + if (idx == index) return value; + return ele; + }).toList(); } static Future uploadAttachment( @@ -581,7 +529,7 @@ class ComposeLogic { int index, ) async { final attachment = state.attachments.value[index]; - if (attachment.isOnCloud) { + if (attachment.isOnCloud && !attachment.isLink) { final client = ref.watch(apiClientProvider); await client.delete('/drive/files/${attachment.data.id}'); } diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 212e97c..047aba7 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -9,7 +9,6 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/embed.dart'; import 'package:island/models/post.dart'; -import 'package:island/pods/config.dart'; import 'package:island/pods/network.dart'; import 'package:island/pods/translate.dart'; import 'package:island/pods/userinfo.dart'; @@ -179,7 +178,7 @@ class PostActionableItem extends HookConsumerWidget { callback: () { showShareSheetLink( context: context, - link: '${ref.read(serverUrlProvider)}/posts/${item.id}', + link: 'https://solian.app/posts/${item.id}', title: 'sharePost'.tr(), toSystem: true, ); @@ -410,7 +409,9 @@ class PostItem extends HookConsumerWidget { if (!isFullPost && item.type == 1) Container( decoration: BoxDecoration( - border: Border.all(color: Theme.of(context).dividerColor), + border: Border.all( + color: Theme.of(context).dividerColor.withOpacity(0.5), + ), borderRadius: const BorderRadius.all(Radius.circular(16)), ), padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), @@ -596,7 +597,7 @@ Widget _buildReferencePost( color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5), borderRadius: BorderRadius.circular(12), border: Border.all( - color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + color: Theme.of(context).dividerColor.withOpacity(0.5), ), ), child: Column( @@ -912,7 +913,9 @@ class PostReplyPreview extends HookConsumerWidget { padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerLow, - border: Border.all(color: Theme.of(context).dividerColor), + border: Border.all( + color: Theme.of(context).dividerColor.withOpacity(0.5), + ), borderRadius: BorderRadius.all(Radius.circular(8)), ), child: Column( diff --git a/lib/widgets/publisher/publisher_card.dart b/lib/widgets/publisher/publisher_card.dart index a9abc64..0d31242 100644 --- a/lib/widgets/publisher/publisher_card.dart +++ b/lib/widgets/publisher/publisher_card.dart @@ -83,7 +83,7 @@ class PublisherCard extends ConsumerWidget { color: Colors.white, fontWeight: FontWeight.bold, ), - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, ), ], diff --git a/lib/widgets/realm/realm_card.dart b/lib/widgets/realm/realm_card.dart index dba37d2..2d00520 100644 --- a/lib/widgets/realm/realm_card.dart +++ b/lib/widgets/realm/realm_card.dart @@ -86,7 +86,7 @@ class RealmCard extends ConsumerWidget { color: Colors.white, fontWeight: FontWeight.bold, ), - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, ), ], diff --git a/lib/widgets/web_article_card.dart b/lib/widgets/web_article_card.dart index 403a5e1..a99d119 100644 --- a/lib/widgets/web_article_card.dart +++ b/lib/widgets/web_article_card.dart @@ -93,7 +93,7 @@ class WebArticleCard extends StatelessWidget { fontWeight: FontWeight.bold, height: 1.3, ), - maxLines: showDetails ? 3 : 2, + maxLines: showDetails ? 3 : 1, overflow: TextOverflow.ellipsis, ), if (showDetails && @@ -125,6 +125,8 @@ class WebArticleCard extends StatelessWidget { fontSize: 9, color: Colors.white70, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ], ),