feat: add loading and refresh handling for images

This commit is contained in:
Joey Boerwinkel 2025-03-11 10:34:10 +01:00 committed by FlutterJoey
parent 3f1caa912b
commit f286e7fb79
2 changed files with 140 additions and 20 deletions

View file

@ -147,6 +147,7 @@ class MessageTheme {
this.borderColor, this.borderColor,
this.textColor, this.textColor,
this.timeTextColor, this.timeTextColor,
this.imageBackgroundColor,
this.borderRadius, this.borderRadius,
this.messageAlignment, this.messageAlignment,
this.messageSidePadding, this.messageSidePadding,
@ -163,6 +164,7 @@ class MessageTheme {
borderColor: theme.colorScheme.primary, borderColor: theme.colorScheme.primary,
textColor: theme.colorScheme.onPrimary, textColor: theme.colorScheme.onPrimary,
timeTextColor: theme.colorScheme.onPrimary, timeTextColor: theme.colorScheme.onPrimary,
imageBackgroundColor: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
textAlignment: TextAlign.start, textAlignment: TextAlign.start,
messageSidePadding: 144.0, messageSidePadding: 144.0,
@ -201,6 +203,12 @@ class MessageTheme {
/// Defaults to [ThemeData.colorScheme.primaryColor] /// Defaults to [ThemeData.colorScheme.primaryColor]
final Color? borderColor; final Color? borderColor;
/// The color of the background when an image is loading, the image is
/// transparent or there is an error.
///
/// Defaults to [ThemeData.colorScheme.secondaryContainer]
final Color? imageBackgroundColor;
/// The border radius of the message container /// The border radius of the message container
/// Defaults to [BorderRadius.circular(12)] /// Defaults to [BorderRadius.circular(12)]
final BorderRadius? borderRadius; final BorderRadius? borderRadius;
@ -231,6 +239,7 @@ class MessageTheme {
Color? borderColor, Color? borderColor,
Color? textColor, Color? textColor,
Color? timeTextColor, Color? timeTextColor,
Color? imageBackgroundColor,
BorderRadius? borderRadius, BorderRadius? borderRadius,
double? messageSidePadding, double? messageSidePadding,
TextAlign? messageAlignment, TextAlign? messageAlignment,
@ -245,6 +254,7 @@ class MessageTheme {
borderColor: borderColor ?? this.borderColor, borderColor: borderColor ?? this.borderColor,
textColor: textColor ?? this.textColor, textColor: textColor ?? this.textColor,
timeTextColor: timeTextColor ?? this.timeTextColor, timeTextColor: timeTextColor ?? this.timeTextColor,
imageBackgroundColor: imageBackgroundColor ?? this.imageBackgroundColor,
borderRadius: borderRadius ?? this.borderRadius, borderRadius: borderRadius ?? this.borderRadius,
messageSidePadding: messageSidePadding ?? this.messageSidePadding, messageSidePadding: messageSidePadding ?? this.messageSidePadding,
messageAlignment: messageAlignment ?? this.messageAlignment, messageAlignment: messageAlignment ?? this.messageAlignment,
@ -262,6 +272,8 @@ class MessageTheme {
borderColor: borderColor ?? other.borderColor, borderColor: borderColor ?? other.borderColor,
textColor: textColor ?? other.textColor, textColor: textColor ?? other.textColor,
timeTextColor: timeTextColor ?? other.timeTextColor, timeTextColor: timeTextColor ?? other.timeTextColor,
imageBackgroundColor:
imageBackgroundColor ?? other.imageBackgroundColor,
borderRadius: borderRadius ?? other.borderRadius, borderRadius: borderRadius ?? other.borderRadius,
messageSidePadding: messageSidePadding ?? other.messageSidePadding, messageSidePadding: messageSidePadding ?? other.messageSidePadding,
messageAlignment: messageAlignment ?? other.messageAlignment, messageAlignment: messageAlignment ?? other.messageAlignment,

View file

@ -1,3 +1,5 @@
import "dart:async";
import "package:chat_repository_interface/chat_repository_interface.dart"; import "package:chat_repository_interface/chat_repository_interface.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_accessibility/flutter_accessibility.dart"; import "package:flutter_accessibility/flutter_accessibility.dart";
@ -278,6 +280,7 @@ class _ChatMessageBubble extends StatelessWidget {
_DefaultChatImage( _DefaultChatImage(
message: message, message: message,
messageTheme: messageTheme, messageTheme: messageTheme,
options: options,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
], ],
@ -314,40 +317,121 @@ class _ChatMessageBubble extends StatelessWidget {
} }
} }
class _DefaultChatImage extends StatelessWidget { class _DefaultChatImage extends StatefulWidget {
const _DefaultChatImage({ const _DefaultChatImage({
required this.message, required this.message,
required this.messageTheme, required this.messageTheme,
required this.options,
}); });
final MessageModel message; final MessageModel message;
final ChatOptions options;
final MessageTheme messageTheme; final MessageTheme messageTheme;
@override
State<_DefaultChatImage> createState() => _DefaultChatImageState();
}
/// Exception thrown when the image builder fails to recognize the image
class InvalidImageUrlException implements Exception {}
class _DefaultChatImageState extends State<_DefaultChatImage>
with AutomaticKeepAliveClientMixin {
late ImageProvider provider;
late Completer imageLoadingCompleter;
void _preloadImage() {
WidgetsBinding.instance.addPostFrameCallback((_) async {
var uri = Uri.tryParse(widget.message.imageUrl ?? "");
if (uri == null) {
imageLoadingCompleter.completeError(InvalidImageUrlException());
return;
}
provider = widget.options.imageProviderResolver(
context,
uri,
);
if (!mounted) return;
await precacheImage(
provider,
context,
onError: imageLoadingCompleter.completeError,
);
imageLoadingCompleter.complete();
});
}
void _refreshImage() {
setState(() {
imageLoadingCompleter = Completer();
});
_preloadImage();
}
@override
void initState() {
super.initState();
imageLoadingCompleter = Completer();
_preloadImage();
}
@override
void didUpdateWidget(covariant _DefaultChatImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.message.imageUrl != widget.message.imageUrl) {
_refreshImage();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var chatScope = ChatScope.of(context); super.build(context);
var options = chatScope.options;
var textTheme = Theme.of(context).textTheme; var theme = Theme.of(context);
var imageUrl = message.imageUrl!;
var asyncImageBuilder = FutureBuilder<void>(
future: imageLoadingCompleter.future,
builder: (context, snapshot) => switch (snapshot.connectionState) {
ConnectionState.waiting => Center(
child: CircularProgressIndicator(
color: widget.messageTheme.textColor,
),
),
ConnectionState.done when !snapshot.hasError => Image(
image: provider,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
_DefaultMessageImageError(
messageTheme: widget.messageTheme,
onRefresh: _refreshImage,
),
),
_ => _DefaultMessageImageError(
messageTheme: widget.messageTheme,
onRefresh: _refreshImage,
),
},
);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: ClipRRect( child: LayoutBuilder(
borderRadius: BorderRadius.circular(12), builder: (context, constraints) => ConstrainedBox(
child: AnimatedSize( constraints: BoxConstraints.tightForFinite(
duration: const Duration(milliseconds: 300), width: constraints.maxWidth,
child: Image( height: constraints.maxWidth,
image: ),
options.imageProviderResolver(context, Uri.parse(imageUrl)), child: ClipRRect(
fit: BoxFit.fitWidth, borderRadius: BorderRadius.circular(12),
errorBuilder: (context, error, stackTrace) => Text( child: ColoredBox(
// TODO(Jacques): Non-replaceable text color: widget.messageTheme.imageBackgroundColor ??
"Something went wrong with loading the image", theme.colorScheme.secondaryContainer,
style: textTheme.bodyLarge?.copyWith( child: asyncImageBuilder,
color: messageTheme.textColor,
),
), ),
), ),
), ),
@ -355,6 +439,30 @@ class _DefaultChatImage extends StatelessWidget {
), ),
); );
} }
@override
bool get wantKeepAlive => true;
}
class _DefaultMessageImageError extends StatelessWidget {
const _DefaultMessageImageError({
required this.messageTheme,
required this.onRefresh,
});
final MessageTheme messageTheme;
final VoidCallback onRefresh;
@override
Widget build(BuildContext context) => Center(
child: IconButton(
onPressed: onRefresh,
icon: Icon(
Icons.refresh,
color: messageTheme.textColor,
),
),
);
} }
/// A container for the chat message that provides a decoration around the /// A container for the chat message that provides a decoration around the