mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
- Implemented ChatScrollController to manage scroll behavior and visibility of jump-to-bottom button. - Added functionality to automatically scroll to the bottom when the keyboard opens. - Created JumpToBottomButton widget that appears when the user scrolls up, allowing quick navigation back to the bottom of the chat.
202 lines
4.7 KiB
Dart
202 lines
4.7 KiB
Dart
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
class GifMessage extends StatefulWidget {
|
|
final String url;
|
|
final Color backgroundColor;
|
|
final Color fallbackTextColor;
|
|
final double maxSize;
|
|
|
|
const GifMessage({
|
|
super.key,
|
|
required this.url,
|
|
required this.backgroundColor,
|
|
required this.fallbackTextColor,
|
|
this.maxSize = 200,
|
|
});
|
|
|
|
@override
|
|
State<GifMessage> createState() => _GifMessageState();
|
|
}
|
|
|
|
class _GifMessageState extends State<GifMessage> {
|
|
ImageStream? _imageStream;
|
|
ImageStreamListener? _listener;
|
|
ui.Image? _image;
|
|
Object? _error;
|
|
bool _isLoading = true;
|
|
bool _isPaused = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_resolveImage();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant GifMessage oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.url != widget.url) {
|
|
_unsubscribe();
|
|
_image = null;
|
|
_error = null;
|
|
_isLoading = true;
|
|
_isPaused = false;
|
|
_resolveImage();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_unsubscribe();
|
|
super.dispose();
|
|
}
|
|
|
|
void _resolveImage() {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_error = null;
|
|
});
|
|
final provider = NetworkImage(widget.url);
|
|
final stream = provider.resolve(ImageConfiguration.empty);
|
|
_imageStream = stream;
|
|
_listener = ImageStreamListener(
|
|
(imageInfo, _) {
|
|
if (_isPaused) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_image = imageInfo.image;
|
|
_isLoading = false;
|
|
});
|
|
},
|
|
onError: (error, _) {
|
|
setState(() {
|
|
_error = error;
|
|
_isLoading = false;
|
|
});
|
|
},
|
|
);
|
|
stream.addListener(_listener!);
|
|
}
|
|
|
|
void _retryLoad() {
|
|
_unsubscribe();
|
|
_image = null;
|
|
_isPaused = false;
|
|
_resolveImage();
|
|
}
|
|
|
|
void _unsubscribe() {
|
|
if (_imageStream != null && _listener != null) {
|
|
_imageStream!.removeListener(_listener!);
|
|
}
|
|
}
|
|
|
|
void _togglePause() {
|
|
if (_error != null) {
|
|
_retryLoad();
|
|
return;
|
|
}
|
|
if (_image == null) {
|
|
if (!_isLoading) {
|
|
_retryLoad();
|
|
}
|
|
return;
|
|
}
|
|
setState(() {
|
|
_isPaused = !_isPaused;
|
|
});
|
|
if (_listener == null || _imageStream == null) {
|
|
return;
|
|
}
|
|
if (_isPaused) {
|
|
_imageStream!.removeListener(_listener!);
|
|
} else {
|
|
_imageStream!.addListener(_listener!);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Calculate display size based on image aspect ratio
|
|
// Use 4:3 placeholder aspect ratio during loading to minimize layout shifts
|
|
double displayWidth = widget.maxSize;
|
|
double displayHeight = widget.maxSize * 0.75;
|
|
|
|
if (_image != null) {
|
|
final imageWidth = _image!.width.toDouble();
|
|
final imageHeight = _image!.height.toDouble();
|
|
final aspectRatio = imageWidth / imageHeight;
|
|
|
|
// Fit within maxSize, calculating dimensions from aspect ratio
|
|
if (aspectRatio >= 1) {
|
|
// Wider than tall: constrain by width
|
|
displayWidth = widget.maxSize;
|
|
displayHeight = displayWidth / aspectRatio;
|
|
} else {
|
|
// Taller than wide: constrain by height
|
|
displayHeight = widget.maxSize;
|
|
displayWidth = displayHeight * aspectRatio;
|
|
}
|
|
}
|
|
|
|
Widget content;
|
|
|
|
if (_error != null) {
|
|
content = Center(
|
|
child: Text(
|
|
"Can't load GIF\nTap to retry",
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 12, color: widget.fallbackTextColor),
|
|
),
|
|
);
|
|
} else if (_isLoading && _image == null) {
|
|
content = const Center(
|
|
child: SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
);
|
|
} else if (_image == null) {
|
|
content = Center(
|
|
child: Text(
|
|
'Tap to load GIF',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 12, color: widget.fallbackTextColor),
|
|
),
|
|
);
|
|
} else {
|
|
content = RawImage(
|
|
image: _image,
|
|
fit: BoxFit.contain,
|
|
width: displayWidth,
|
|
height: displayHeight,
|
|
);
|
|
}
|
|
|
|
return GestureDetector(
|
|
onTap: _togglePause,
|
|
child: Container(
|
|
color: widget.backgroundColor,
|
|
width: displayWidth,
|
|
height: displayHeight,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
content,
|
|
if (_isPaused && _image != null)
|
|
Container(
|
|
color: Colors.black.withValues(alpha: 0.2),
|
|
child: const Center(
|
|
child: Icon(Icons.pause, color: Colors.white70, size: 28),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|