♻️ Better image loading animation and more commonly used blurhash

This commit is contained in:
2026-01-02 18:32:37 +08:00
parent f1f5113b01
commit 78c1a284a5
44 changed files with 2043 additions and 2185 deletions

View File

@@ -577,6 +577,9 @@ class ProfilePictureWidget extends ConsumerWidget {
final serverUrl = ref.watch(serverUrlProvider);
final String? id = file?.id ?? fileId;
final meta = file?.fileMeta is Map ? (file!.fileMeta as Map) : const {};
final blurHash = meta['blur'] as String?;
final fallback = Icon(
fallbackIcon ?? Symbols.account_circle,
size: radius,
@@ -590,6 +593,7 @@ class ProfilePictureWidget extends ConsumerWidget {
placeholder: fallback,
content: () => UniversalImage(
uri: '$serverUrl/drive/files/$id',
blurHash: blurHash,
fit: BoxFit.cover,
),
);
@@ -625,14 +629,14 @@ class ProfilePictureWidget extends ConsumerWidget {
}
class SplitAvatarWidget extends ConsumerWidget {
final List<String?> filesId;
final List<SnCloudFile?> files;
final double radius;
final IconData fallbackIcon;
final Color? fallbackColor;
const SplitAvatarWidget({
super.key,
required this.filesId,
required this.files,
this.radius = 20,
this.fallbackIcon = Symbols.account_circle,
this.fallbackColor,
@@ -640,17 +644,17 @@ class SplitAvatarWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
if (filesId.isEmpty) {
if (files.isEmpty) {
return ProfilePictureWidget(
fileId: null,
file: null,
radius: radius,
fallbackIcon: fallbackIcon,
fallbackColor: fallbackColor,
);
}
if (filesId.length == 1) {
if (files.length == 1) {
return ProfilePictureWidget(
fileId: filesId[0],
file: files[0],
radius: radius,
fallbackIcon: fallbackIcon,
fallbackColor: fallbackColor,
@@ -665,32 +669,32 @@ class SplitAvatarWidget extends ConsumerWidget {
color: Theme.of(context).colorScheme.primaryContainer,
child: Stack(
children: [
if (filesId.length == 2)
if (files.length == 2)
Row(
children: [
Expanded(
child: _buildQuadrant(context, filesId[0], ref, radius),
child: _buildQuadrant(context, files[0], ref, radius),
),
Expanded(
child: _buildQuadrant(context, filesId[1], ref, radius),
child: _buildQuadrant(context, files[1], ref, radius),
),
],
)
else if (filesId.length == 3)
else if (files.length == 3)
Row(
children: [
Column(
children: [
Expanded(
child: _buildQuadrant(context, filesId[0], ref, radius),
child: _buildQuadrant(context, files[0], ref, radius),
),
Expanded(
child: _buildQuadrant(context, filesId[1], ref, radius),
child: _buildQuadrant(context, files[1], ref, radius),
),
],
),
Expanded(
child: _buildQuadrant(context, filesId[2], ref, radius),
child: _buildQuadrant(context, files[2], ref, radius),
),
],
)
@@ -701,20 +705,10 @@ class SplitAvatarWidget extends ConsumerWidget {
child: Row(
children: [
Expanded(
child: _buildQuadrant(
context,
filesId[0],
ref,
radius,
),
child: _buildQuadrant(context, files[0], ref, radius),
),
Expanded(
child: _buildQuadrant(
context,
filesId[1],
ref,
radius,
),
child: _buildQuadrant(context, files[1], ref, radius),
),
],
),
@@ -723,22 +717,17 @@ class SplitAvatarWidget extends ConsumerWidget {
child: Row(
children: [
Expanded(
child: _buildQuadrant(
context,
filesId[2],
ref,
radius,
),
child: _buildQuadrant(context, files[2], ref, radius),
),
Expanded(
child: filesId.length > 4
child: files.length > 4
? Container(
color: Theme.of(
context,
).colorScheme.primaryContainer,
child: Center(
child: Text(
'+${filesId.length - 3}',
'+${files.length - 3}',
style: TextStyle(
fontSize: radius * 0.4,
color: Theme.of(
@@ -748,12 +737,7 @@ class SplitAvatarWidget extends ConsumerWidget {
),
),
)
: _buildQuadrant(
context,
filesId[3],
ref,
radius,
),
: _buildQuadrant(context, files[3], ref, radius),
),
],
),
@@ -768,11 +752,11 @@ class SplitAvatarWidget extends ConsumerWidget {
Widget _buildQuadrant(
BuildContext context,
String? fileId,
SnCloudFile? file,
WidgetRef ref,
double radius,
) {
if (fileId == null) {
if (file == null) {
return Container(
width: radius,
height: radius,
@@ -787,7 +771,7 @@ class SplitAvatarWidget extends ConsumerWidget {
}
final serverUrl = ref.watch(serverUrlProvider);
final uri = '$serverUrl/drive/files/$fileId';
final uri = '$serverUrl/drive/files/${file.id}';
return SizedBox(
width: radius,

View File

@@ -1,9 +1,10 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_svg/flutter_svg.dart';
class UniversalImage extends StatelessWidget {
class UniversalImage extends HookWidget {
final String uri;
final String? blurHash;
final BoxFit fit;
@@ -27,6 +28,7 @@ class UniversalImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final loaded = useState(false);
final isSvgImage = isSvg || uri.toLowerCase().endsWith('.svg');
if (isSvgImage) {
@@ -35,9 +37,8 @@ class UniversalImage extends StatelessWidget {
fit: fit,
width: width,
height: height,
placeholderBuilder:
(BuildContext context) =>
Center(child: CircularProgressIndicator()),
placeholderBuilder: (BuildContext context) =>
Center(child: CircularProgressIndicator()),
);
}
@@ -46,8 +47,9 @@ class UniversalImage extends StatelessWidget {
if (width != null && height != null && !noCacheOptimization) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
cacheWidth = width != null ? (width! * devicePixelRatio).round() : null;
cacheHeight =
height != null ? (height! * devicePixelRatio).round() : null;
cacheHeight = height != null
? (height! * devicePixelRatio).round()
: null;
}
return SizedBox(
@@ -66,21 +68,72 @@ class UniversalImage extends StatelessWidget {
memCacheWidth: cacheWidth,
progressIndicatorBuilder: (context, url, progress) {
return Center(
child: CircularProgressIndicator(value: progress.progress),
child: AnimatedCircularProgressIndicator(
value: progress.progress,
color: Colors.white.withOpacity(0.5),
),
);
},
errorWidget:
(context, url, error) =>
useFallbackImage
? Image.asset(
'assets/images/media-offline.jpg',
fit: BoxFit.cover,
key: Key('image-broke-$uri'),
)
: SizedBox.shrink(),
imageBuilder: (context, imageProvider) {
Future(() => loaded.value = true);
return AnimatedOpacity(
opacity: loaded.value ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Image(
image: imageProvider,
fit: fit,
width: width,
height: height,
),
);
},
errorWidget: (context, url, error) => useFallbackImage
? Image.asset(
'assets/images/media-offline.jpg',
fit: BoxFit.cover,
key: Key('image-broke-$uri'),
)
: SizedBox.shrink(),
),
],
),
);
}
}
class AnimatedCircularProgressIndicator extends HookWidget {
final double? value;
final Color? color;
final double strokeWidth;
final Duration duration;
const AnimatedCircularProgressIndicator({
super.key,
this.value,
this.color,
this.strokeWidth = 4.0,
this.duration = const Duration(milliseconds: 200),
});
@override
Widget build(BuildContext context) {
final animationController = useAnimationController(duration: duration);
final animation = useAnimation(
Tween<double>(begin: 0.0, end: value ?? 0.0).animate(
CurvedAnimation(parent: animationController, curve: Curves.linear),
),
);
useEffect(() {
animationController.animateTo(value ?? 0.0);
return null;
}, [value]);
return CircularProgressIndicator(
value: animation,
color: color,
strokeWidth: strokeWidth,
backgroundColor: Colors.transparent,
);
}
}