Implement blurhash for images and avatars, start basic full screen image page

This commit is contained in:
John Wesley 2024-05-29 10:53:02 -04:00
parent 5a8f6bd8e7
commit 7ed1afbfdd
17 changed files with 239 additions and 88 deletions

View File

@ -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<String, Object?>?),
image: kbinGetImage(json['image'] as Map<String, Object?>?),
body: json['body'] as String?,
lang: json['lang'] as String,
upvotes: json['favourites'] as int?,

30
lib/src/models/image.dart Normal file
View File

@ -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<String, Object?> 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,
);
}

View File

@ -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<UserModel> 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<String, Object?>?),
icon: kbinGetImage(json['icon'] as Map<String, Object?>?),
description: json['description'] as String?,
rules: json['rules'] as String?,
moderators: ((json['moderators'] ?? []) as List<dynamic>)
@ -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<String, Object?> json) => MagazineModel(
id: json['magazineId'] as int,
name: json['name'] as String,
icon: kbinGetImageUrl(json['icon'] as Map<String, Object?>?),
icon: kbinGetImage(json['icon'] as Map<String, Object?>?),
);
factory MagazineModel.fromLemmy(Map<String, Object?> json) => MagazineModel(
id: json['id'] as int,
name: lemmyGetActorName(json),
icon: json['icon'] as String?,
icon: lemmyGetImage(json['icon'] as String?),
);
}

View File

@ -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<String, Object?>),
title: json['title'] as String,
url: json['url'] as String?,
image: kbinGetImageUrl(json['image'] as Map<String, Object?>?),
image: kbinGetImage(json['image'] as Map<String, Object?>?),
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<String, Object?>?),
image: kbinGetImage(json['image'] as Map<String, Object?>?),
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,

View File

@ -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<String, Object?>?),
cover: kbinGetImageUrl(json['cover'] as Map<String, Object?>?),
avatar: kbinGetImage(json['avatar'] as Map<String, Object?>?),
cover: kbinGetImage(json['cover'] as Map<String, Object?>?),
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<String, Object?> json) => UserModel(
id: json['userId'] as int,
name: kbinNormalizeUsername(json['username'] as String),
avatar: kbinGetImageUrl(json['avatar'] as Map<String, Object?>?),
avatar: kbinGetImage(json['avatar'] as Map<String, Object?>?),
);
factory UserModel.fromLemmy(Map<String, Object?> json) => UserModel(
id: json['id'] as int,
name: lemmyGetActorName(json),
avatar: json['avatar'] as String?,
avatar: lemmyGetImage(json['avatar'] as String?),
);
}

View File

@ -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<UserScreen> {
),
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,
),

View File

@ -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<ProfileEditScreen> {
: widget.user.cover != null
? _deleteCover
? null
: Image.network(
: AdvancedImage(
widget.user.cover!,
width: double.infinity,
fit: BoxFit.cover,
fit: BoxFit.fitWidth,
)
: null,
),

View File

@ -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<String, Object?> pagination) {
: null;
}
String? kbinGetImageUrl(Map<String, Object?>? image) {
return image == null ? null : image['storageUrl'] as String;
ImageModel? kbinGetImage(Map<String, Object?>? json) {
return json == null ? null : ImageModel.fromKbin(json);
}
ImageModel? lemmyGetImage(String? json) {
return json == null ? null : ImageModel.fromLemmy(json);
}
String kbinNormalizeUsername(String username) {

View File

@ -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<Object>
: null),
radius: radius,
),
);
}
}

View File

@ -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<ContentItem> {
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<ContentItem> {
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

View File

@ -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

107
lib/src/widgets/image.dart Normal file
View File

@ -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>[
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,
),
),
),
],
),
);
}
}

View File

@ -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<String, DetailedMagazineModel> magazineMentionCache = {};
class MentionWidgetState extends State<MentionWidget> {
late String _displayName;
String? _icon;
ImageModel? _icon;
void Function()? _onClick;
@override

View File

@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
blurhash_ffi
media_kit_native_event_loop
)

View File

@ -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:

View File

@ -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

View File

@ -12,6 +12,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
blurhash_ffi
media_kit_native_event_loop
)