From 7ed1afbfdd5ccab36667fee07e6c5273948db9b4 Mon Sep 17 00:00:00 2001 From: John Wesley Date: Wed, 29 May 2024 10:53:02 -0400 Subject: [PATCH] Implement blurhash for images and avatars, start basic full screen image page --- lib/src/models/comment.dart | 5 +- lib/src/models/image.dart | 30 +++++ lib/src/models/magazine.dart | 13 ++- lib/src/models/post.dart | 9 +- lib/src/models/user.dart | 19 ++-- lib/src/screens/explore/user_screen.dart | 6 +- .../screens/profile/profile_edit_screen.dart | 6 +- lib/src/utils/models.dart | 10 +- lib/src/widgets/avatar.dart | 29 +++-- lib/src/widgets/content_item.dart | 76 +++++-------- lib/src/widgets/display_name.dart | 3 +- lib/src/widgets/image.dart | 107 ++++++++++++++++++ .../widgets/markdown/markdown_mention.dart | 3 +- linux/flutter/generated_plugins.cmake | 1 + pubspec.lock | 8 ++ pubspec.yaml | 1 + windows/flutter/generated_plugins.cmake | 1 + 17 files changed, 239 insertions(+), 88 deletions(-) create mode 100644 lib/src/models/image.dart create mode 100644 lib/src/widgets/image.dart diff --git a/lib/src/models/comment.dart b/lib/src/models/comment.dart index ae4521f..67bc5a3 100644 --- a/lib/src/models/comment.dart +++ b/lib/src/models/comment.dart @@ -1,4 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:interstellar/src/models/image.dart'; import 'package:interstellar/src/models/magazine.dart'; import 'package:interstellar/src/models/post.dart'; import 'package:interstellar/src/models/user.dart'; @@ -46,7 +47,7 @@ class CommentModel with _$CommentModel { required int postId, required int? rootId, required int? parentId, - required String? image, + required ImageModel? image, required String? body, required String? lang, required int? upvotes, @@ -71,7 +72,7 @@ class CommentModel with _$CommentModel { postId: (json['entryId'] ?? json['postId']) as int, rootId: json['rootId'] as int?, parentId: json['parentId'] as int?, - image: kbinGetImageUrl(json['image'] as Map?), + image: kbinGetImage(json['image'] as Map?), body: json['body'] as String?, lang: json['lang'] as String, upvotes: json['favourites'] as int?, diff --git a/lib/src/models/image.dart b/lib/src/models/image.dart new file mode 100644 index 0000000..b8bc9b5 --- /dev/null +++ b/lib/src/models/image.dart @@ -0,0 +1,30 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'image.freezed.dart'; + +@freezed +class ImageModel with _$ImageModel { + const factory ImageModel({ + required String src, + required String? altText, + required String? blurHash, + required int? blurHashWidth, + required int? blurHashHeight, + }) = _ImageModel; + + factory ImageModel.fromKbin(Map json) => ImageModel( + src: (json['storageUrl'] ?? json['sourceUrl']) as String, + altText: json['altText'] as String?, + blurHash: json['blurHash'] as String?, + blurHashWidth: json['width'] as int?, + blurHashHeight: json['height'] as int?, + ); + + factory ImageModel.fromLemmy(String json) => ImageModel( + src: json, + altText: null, + blurHash: null, + blurHashWidth: null, + blurHashHeight: null, + ); +} diff --git a/lib/src/models/magazine.dart b/lib/src/models/magazine.dart index e677225..5bec8eb 100644 --- a/lib/src/models/magazine.dart +++ b/lib/src/models/magazine.dart @@ -1,4 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:interstellar/src/models/image.dart'; import 'package:interstellar/src/models/user.dart'; import 'package:interstellar/src/utils/models.dart'; import 'package:interstellar/src/widgets/markdown/markdown_mention.dart'; @@ -38,7 +39,7 @@ class DetailedMagazineModel with _$DetailedMagazineModel { required int id, required String name, required String title, - required String? icon, + required ImageModel? icon, required String? description, required String? rules, required List moderators, @@ -57,7 +58,7 @@ class DetailedMagazineModel with _$DetailedMagazineModel { id: json['magazineId'] as int, name: json['name'] as String, title: json['title'] as String, - icon: kbinGetImageUrl(json['icon'] as Map?), + icon: kbinGetImage(json['icon'] as Map?), description: json['description'] as String?, rules: json['rules'] as String?, moderators: ((json['moderators'] ?? []) as List) @@ -86,7 +87,7 @@ class DetailedMagazineModel with _$DetailedMagazineModel { id: lemmyCommunity['id'] as int, name: lemmyGetActorName(lemmyCommunity), title: lemmyCommunity['title'] as String, - icon: lemmyCommunity['icon'] as String?, + icon: lemmyGetImage(lemmyCommunity['icon'] as String?), description: lemmyCommunity['description'] as String?, rules: null, moderators: [], @@ -111,18 +112,18 @@ class MagazineModel with _$MagazineModel { const factory MagazineModel({ required int id, required String name, - required String? icon, + required ImageModel? icon, }) = _MagazineModel; factory MagazineModel.fromKbin(Map json) => MagazineModel( id: json['magazineId'] as int, name: json['name'] as String, - icon: kbinGetImageUrl(json['icon'] as Map?), + icon: kbinGetImage(json['icon'] as Map?), ); factory MagazineModel.fromLemmy(Map json) => MagazineModel( id: json['id'] as int, name: lemmyGetActorName(json), - icon: json['icon'] as String?, + icon: lemmyGetImage(json['icon'] as String?), ); } diff --git a/lib/src/models/post.dart b/lib/src/models/post.dart index 9b4ba50..cb4bf3a 100644 --- a/lib/src/models/post.dart +++ b/lib/src/models/post.dart @@ -1,5 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:interstellar/src/models/domain.dart'; +import 'package:interstellar/src/models/image.dart'; import 'package:interstellar/src/models/magazine.dart'; import 'package:interstellar/src/models/user.dart'; import 'package:interstellar/src/utils/models.dart'; @@ -52,7 +53,7 @@ class PostModel with _$PostModel { required DomainModel? domain, required String? title, required String? url, - required String? image, + required ImageModel? image, required String? body, required String? lang, required int numComments, @@ -79,7 +80,7 @@ class PostModel with _$PostModel { domain: DomainModel.fromKbin(json['domain'] as Map), title: json['title'] as String, url: json['url'] as String?, - image: kbinGetImageUrl(json['image'] as Map?), + image: kbinGetImage(json['image'] as Map?), body: json['body'] as String?, lang: json['lang'] as String, numComments: json['numComments'] as int, @@ -108,7 +109,7 @@ class PostModel with _$PostModel { domain: null, title: null, url: null, - image: kbinGetImageUrl(json['image'] as Map?), + image: kbinGetImage(json['image'] as Map?), body: json['body'] as String, lang: json['lang'] as String, numComments: json['comments'] as int, @@ -141,7 +142,7 @@ class PostModel with _$PostModel { domain: null, title: lemmyPost['name'] as String, url: lemmyPost['url'] as String?, - image: lemmyPost['thumbnail_url'] as String?, + image: lemmyGetImage(lemmyPost['thumbnail_url'] as String?), body: lemmyPost['body'] as String?, lang: null, numComments: lemmyCounts['comments'] as int, diff --git a/lib/src/models/user.dart b/lib/src/models/user.dart index 4c606f8..23bd402 100644 --- a/lib/src/models/user.dart +++ b/lib/src/models/user.dart @@ -1,4 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:interstellar/src/models/image.dart'; import 'package:interstellar/src/utils/models.dart'; import 'package:interstellar/src/widgets/markdown/markdown_mention.dart'; @@ -28,8 +29,8 @@ class DetailedUserModel with _$DetailedUserModel { required int id, required String name, required String? displayName, - required String? avatar, - required String? cover, + required ImageModel? avatar, + required ImageModel? cover, required DateTime createdAt, required bool isBot, required String? about, @@ -44,8 +45,8 @@ class DetailedUserModel with _$DetailedUserModel { id: json['userId'] as int, name: kbinNormalizeUsername(json['username'] as String), displayName: null, - avatar: kbinGetImageUrl(json['avatar'] as Map?), - cover: kbinGetImageUrl(json['cover'] as Map?), + avatar: kbinGetImage(json['avatar'] as Map?), + cover: kbinGetImage(json['cover'] as Map?), createdAt: DateTime.parse(json['createdAt'] as String), isBot: json['isBot'] as bool, about: json['about'] as String?, @@ -68,8 +69,8 @@ class DetailedUserModel with _$DetailedUserModel { id: lemmyPerson['id'] as int, name: lemmyGetActorName(lemmyPerson), displayName: lemmyPerson['display_name'] as String?, - avatar: lemmyPerson['avatar'] as String?, - cover: lemmyPerson['banner'] as String?, + avatar: lemmyGetImage(lemmyPerson['avatar'] as String?), + cover: lemmyGetImage(lemmyPerson['banner'] as String?), createdAt: DateTime.parse(lemmyPerson['published'] as String), isBot: lemmyPerson['bot_account'] as bool, about: lemmyPerson['bio'] as String?, @@ -86,19 +87,19 @@ class UserModel with _$UserModel { const factory UserModel({ required int id, required String name, - required String? avatar, + required ImageModel? avatar, }) = _UserModel; factory UserModel.fromKbin(Map json) => UserModel( id: json['userId'] as int, name: kbinNormalizeUsername(json['username'] as String), - avatar: kbinGetImageUrl(json['avatar'] as Map?), + avatar: kbinGetImage(json['avatar'] as Map?), ); factory UserModel.fromLemmy(Map json) => UserModel( id: json['id'] as int, name: lemmyGetActorName(json), - avatar: json['avatar'] as String?, + avatar: lemmyGetImage(json['avatar'] as String?), ); } diff --git a/lib/src/screens/explore/user_screen.dart b/lib/src/screens/explore/user_screen.dart index 3e5730c..89135f0 100644 --- a/lib/src/screens/explore/user_screen.dart +++ b/lib/src/screens/explore/user_screen.dart @@ -16,6 +16,7 @@ import 'package:interstellar/src/screens/profile/profile_edit_screen.dart'; import 'package:interstellar/src/screens/settings/settings_controller.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/avatar.dart'; +import 'package:interstellar/src/widgets/image.dart'; import 'package:interstellar/src/widgets/loading_template.dart'; import 'package:interstellar/src/widgets/markdown/markdown.dart'; import 'package:interstellar/src/widgets/markdown/markdown_editor.dart'; @@ -137,10 +138,9 @@ class _UserScreenState extends State { ), height: user.cover == null ? 100 : null, child: user.cover != null - ? Image.network( + ? AdvancedImage( user.cover!, - width: double.infinity, - fit: BoxFit.cover, + fit: BoxFit.fitWidth, ) : null, ), diff --git a/lib/src/screens/profile/profile_edit_screen.dart b/lib/src/screens/profile/profile_edit_screen.dart index c4a7c1d..cb15c36 100644 --- a/lib/src/screens/profile/profile_edit_screen.dart +++ b/lib/src/screens/profile/profile_edit_screen.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:interstellar/src/models/user.dart'; +import 'package:interstellar/src/widgets/image.dart'; import 'package:interstellar/src/widgets/markdown/markdown_editor.dart'; import 'package:provider/provider.dart'; @@ -125,10 +126,9 @@ class _ProfileEditScreen extends State { : widget.user.cover != null ? _deleteCover ? null - : Image.network( + : AdvancedImage( widget.user.cover!, - width: double.infinity, - fit: BoxFit.cover, + fit: BoxFit.fitWidth, ) : null, ), diff --git a/lib/src/utils/models.dart b/lib/src/utils/models.dart index f956b01..2014bd9 100644 --- a/lib/src/utils/models.dart +++ b/lib/src/utils/models.dart @@ -1,3 +1,5 @@ +import 'package:interstellar/src/models/image.dart'; + DateTime? optionalDateTime(String? value) => value == null ? null : DateTime.parse(value); @@ -7,8 +9,12 @@ String? kbinCalcNextPaginationPage(Map pagination) { : null; } -String? kbinGetImageUrl(Map? image) { - return image == null ? null : image['storageUrl'] as String; +ImageModel? kbinGetImage(Map? json) { + return json == null ? null : ImageModel.fromKbin(json); +} + +ImageModel? lemmyGetImage(String? json) { + return json == null ? null : ImageModel.fromLemmy(json); } String kbinNormalizeUsername(String username) { diff --git a/lib/src/widgets/avatar.dart b/lib/src/widgets/avatar.dart index b412c0f..791674a 100644 --- a/lib/src/widgets/avatar.dart +++ b/lib/src/widgets/avatar.dart @@ -1,23 +1,32 @@ +import 'package:blurhash_ffi/blurhash_ffi.dart'; import 'package:flutter/material.dart'; +import 'package:interstellar/src/models/image.dart'; class Avatar extends StatelessWidget { - final String? url; + final ImageModel? image; final double? radius; final double? borderRadius; - const Avatar(this.url, {super.key, this.radius, this.borderRadius}); + const Avatar(this.image, {super.key, this.radius, this.borderRadius}); @override Widget build(BuildContext context) { return CircleAvatar( - radius: radius != null && borderRadius != null ? radius! + borderRadius! : radius, - backgroundColor: radius == null || borderRadius == null ? Colors.transparent : null, - child: CircleAvatar( - backgroundColor: Colors.transparent, - foregroundImage: url != null ? NetworkImage(url!) : null, - backgroundImage: url == null ? const AssetImage('assets/icons/logo.png') : null, - radius: radius, - ) + radius: radius != null && borderRadius != null + ? radius! + borderRadius! + : radius, + backgroundColor: + radius == null || borderRadius == null ? Colors.transparent : null, + child: CircleAvatar( + backgroundColor: Colors.transparent, + foregroundImage: image == null ? null : NetworkImage(image!.src), + backgroundImage: image == null + ? const AssetImage('assets/icons/logo.png') + : (image!.blurHash != null + ? BlurhashFfiImage(image!.blurHash!) as ImageProvider + : null), + radius: radius, + ), ); } } diff --git a/lib/src/widgets/content_item.dart b/lib/src/widgets/content_item.dart index 416b18a..18e51b1 100644 --- a/lib/src/widgets/content_item.dart +++ b/lib/src/widgets/content_item.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:interstellar/src/models/image.dart'; import 'package:interstellar/src/screens/explore/domain_screen.dart'; import 'package:interstellar/src/screens/explore/magazine_screen.dart'; import 'package:interstellar/src/screens/explore/user_screen.dart'; @@ -6,6 +7,7 @@ import 'package:interstellar/src/screens/settings/settings_controller.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/blur.dart'; import 'package:interstellar/src/widgets/display_name.dart'; +import 'package:interstellar/src/widgets/image.dart'; import 'package:interstellar/src/widgets/markdown/markdown.dart'; import 'package:interstellar/src/widgets/markdown/markdown_editor.dart'; import 'package:interstellar/src/widgets/open_webpage.dart'; @@ -18,7 +20,7 @@ class ContentItem extends StatefulWidget { final String originInstance; final String? title; - final String? image; + final ImageModel? image; final Uri? link; final Uri? video; final String? body; @@ -32,12 +34,12 @@ class ContentItem extends StatefulWidget { final bool isOC; final String? user; - final String? userIcon; + final ImageModel? userIcon; final int? userIdOnClick; final int? opUserId; final String? magazine; - final String? magazineIcon; + final ImageModel? magazineIcon; final int? magazineIdOnClick; final String? domain; @@ -118,30 +120,6 @@ class _ContentItemState extends State { TextEditingController? _editTextController; final MenuController _menuController = MenuController(); - _onImageClick(BuildContext context) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => Scaffold( - extendBodyBehindAppBar: true, - appBar: AppBar( - title: widget.title != null ? Text(widget.title!) : null, - backgroundColor: const Color(0x66000000), - ), - body: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: InteractiveViewer( - child: Image.network(widget.image!), - ), - ) - ], - ), - ), - ), - ); - } - @override Widget build(BuildContext context) { final Widget? userWidget = widget.user != null @@ -212,33 +190,37 @@ class _ContentItemState extends State { final double rightImageSize = hasWideSize ? 128 : 64; + final imageOpenTitle = widget.title ?? widget.body ?? ''; + final imageWidget = widget.image == null ? null : Wrapper( - shouldWrap: widget.video == null, - parentBuilder: (child) => InkWell( - onTap: () => _onImageClick(context), - child: child, - ), - child: Wrapper( - shouldWrap: widget.isNSFW, - parentBuilder: (child) => Blur(child), - child: isRightImage - ? Image.network( + shouldWrap: widget.isNSFW, + parentBuilder: (child) => Blur(child), + child: isRightImage + ? SizedBox( + height: rightImageSize, + width: rightImageSize, + child: AdvancedImage( widget.image!, - height: rightImageSize, - width: rightImageSize, fit: BoxFit.cover, - ) - : (widget.isPreview - ? Image.network( + openTitle: imageOpenTitle, + ), + ) + : (widget.isPreview + ? SizedBox( + height: 160, + width: double.infinity, + child: AdvancedImage( widget.image!, - height: 160, - width: double.infinity, fit: BoxFit.cover, - ) - : Image.network(widget.image!)), - ), + openTitle: imageOpenTitle, + ), + ) + : AdvancedImage( + widget.image!, + openTitle: imageOpenTitle, + )), ); final titleStyle = hasWideSize diff --git a/lib/src/widgets/display_name.dart b/lib/src/widgets/display_name.dart index 9ef2e81..ff3bfad 100644 --- a/lib/src/widgets/display_name.dart +++ b/lib/src/widgets/display_name.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:interstellar/src/models/image.dart'; import 'package:interstellar/src/screens/settings/settings_controller.dart'; import 'package:interstellar/src/widgets/avatar.dart'; import 'package:provider/provider.dart'; @@ -7,7 +8,7 @@ class DisplayName extends StatelessWidget { const DisplayName(this.name, {super.key, this.icon, this.onTap}); final String name; - final String? icon; + final ImageModel? icon; final void Function()? onTap; @override diff --git a/lib/src/widgets/image.dart b/lib/src/widgets/image.dart new file mode 100644 index 0000000..2ec36b6 --- /dev/null +++ b/lib/src/widgets/image.dart @@ -0,0 +1,107 @@ +import 'dart:math'; + +import 'package:blurhash_ffi/blurhash_ffi.dart'; +import 'package:flutter/material.dart'; +import 'package:interstellar/src/models/image.dart'; +import 'package:interstellar/src/widgets/wrapper.dart'; + +class AdvancedImage extends StatelessWidget { + final ImageModel image; + final BoxFit fit; + final String? openTitle; + + const AdvancedImage( + this.image, { + super.key, + this.fit = BoxFit.contain, + this.openTitle, + }); + + @override + Widget build(BuildContext context) { + final blurHashSizeFactor = image.blurHash == null + ? null + : sqrt(1080 / (image.blurHashWidth! * image.blurHashHeight!)); + + return Wrapper( + shouldWrap: openTitle != null, + parentBuilder: (child) => GestureDetector( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => AdvancedImagePage( + image, + title: openTitle!, + ), + ), + ); + }, + child: child, + ), + child: image.blurHash == null + ? Image.network( + image.src, + fit: fit, + ) + : BlurhashFfi( + hash: image.blurHash!, + decodingWidth: + (blurHashSizeFactor! * image.blurHashWidth!).ceil(), + decodingHeight: + (blurHashSizeFactor * image.blurHashHeight!).ceil(), + image: image.src, + imageFit: fit, + ), + ); + } +} + +class AdvancedImagePage extends StatelessWidget { + final ImageModel image; + final String title; + + const AdvancedImagePage(this.image, {super.key, required this.title}); + + @override + Widget build(BuildContext context) { + const shadows = [ + Shadow(color: Colors.black, blurRadius: 1.0, offset: Offset(0, 1)) + ]; + + final titleStyle = + Theme.of(context).textTheme.titleLarge!.copyWith(shadows: shadows); + + return Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + title: Text(title, style: titleStyle), + iconTheme: const IconThemeData( + color: Colors.white, + shadows: shadows, + ), + backgroundColor: Colors.transparent, + ), + body: Stack( + children: [ + Positioned.fill( + child: InteractiveViewer( + child: AdvancedImage(image), + ), + ), + if (image.altText != null) + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + image.altText!, + textAlign: TextAlign.center, + style: titleStyle, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/widgets/markdown/markdown_mention.dart b/lib/src/widgets/markdown/markdown_mention.dart index 6ed244c..cf0e721 100644 --- a/lib/src/widgets/markdown/markdown_mention.dart +++ b/lib/src/widgets/markdown/markdown_mention.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart' as mdf; +import 'package:interstellar/src/models/image.dart'; import 'package:interstellar/src/models/magazine.dart'; import 'package:interstellar/src/models/user.dart'; import 'package:interstellar/src/screens/explore/magazine_screen.dart'; @@ -135,7 +136,7 @@ Map magazineMentionCache = {}; class MentionWidgetState extends State { late String _displayName; - String? _icon; + ImageModel? _icon; void Function()? _onClick; @override diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 5758011..afd056a 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + blurhash_ffi media_kit_native_event_loop ) diff --git a/pubspec.lock b/pubspec.lock index 4f50f72..e67c150 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + blurhash_ffi: + dependency: "direct main" + description: + name: blurhash_ffi + sha256: "941868602bb3bc34b0a7d630e4bf0e88e4c93af36090fe1cc0d63021c9a46cb3" + url: "https://pub.dev" + source: hosted + version: "1.2.6" boolean_selector: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8cdf62a..9546e5a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: dynamic_color: ^1.7.0 markdown: ^7.2.2 expandable: ^5.0.1 + blurhash_ffi: ^1.2.6 dev_dependencies: flutter_lints: ^3.0.2 diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 78465b6..504e7dd 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -12,6 +12,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + blurhash_ffi media_kit_native_event_loop )