feat: add GalleryHeader/GalleryFooter theme classes (#546)
* chore: Add ImageHeaderTheme and ImageHeaderThemeData to stream_chat_theme.dart This commit adds the following classes to `stream_chat_theme.dart`: 1. `ImageHeaderTheme`, an `InheritedTheme` widget that can wrap other widgets and provide that sub-tree with an `ImageHeaderThemeData` 2. `ImageHeaderThemeData`, a class that allows for the customization of `ImageHeader` widgets * Make ImageHeaderThemeData constructor const * test: initial ImageHeaderThemeData tests Tests the copyWith, ==, and hashcode functions * test: add tests for default ImageHeaderThemeData values Tests the default values of ImageHeaderThemeData that gets built in a StreamChatThemeData against control values. * Update test name and add comments describing control * fix: use ImageHeaderTheme.of(context) instead of StreamChatTheme.of(context) This ensures that ImageHeader will always look for the closest ImageHeaderThemeData in the widget tree. * test: add dark theme default values test * run dartfmt on test file * test: lerping between ImageHeaderThemeData Added tests that lerp from one ImageHeaderThemeData to another (light to dark and dark to light) * test: merging ImageHeaderThemeData Added tests that merge light and dark ImageHeaderThemeData together (light + dark = dark, dark + light = light) * test: add a half-lerp test Added a test that lerps halway between light and dark ImageHeaderThemeData * chore: Add ImageFooterTheme and ImageFooterThemeData to stream_chat_theme.dart This commit adds the following classes to `stream_chat_theme.dart`: 1. `ImageFooterTheme`, an `InheritedTheme` widget that can wrap other widgets and provide that sub-tree with an `ImageFooterThemeData` 2. `ImageFooterThemeData`, a class that allows for the customization of `ImageFooter` widget * chore: apply ImageFooterThemeData to image_footer.dart * fix: make ImageFooterThemeData constructor const * test: add tests for ImageFooterThemeData * add a missing trailing comma * chore: rename ImageHeader & ImageFooter to GalleryHeader and GalleryFooter The respective theme classes and tests have also been renamed * Run dartfmt on test * test: fix failing tests due to renaming
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
|
||||
import 'package:stream_chat_flutter/src/video_thumbnail_image.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||
|
||||
/// Footer widget for media display
|
||||
class GalleryFooter extends StatefulWidget implements PreferredSizeWidget {
|
||||
/// Creates a channel header
|
||||
const GalleryFooter({
|
||||
Key? key,
|
||||
required this.message,
|
||||
this.onBackPressed,
|
||||
this.onTitleTap,
|
||||
this.onImageTap,
|
||||
this.currentPage = 0,
|
||||
this.totalPages = 0,
|
||||
this.mediaAttachments = const [],
|
||||
this.mediaSelectedCallBack,
|
||||
}) : preferredSize = const Size.fromHeight(kToolbarHeight),
|
||||
super(key: key);
|
||||
|
||||
/// Callback to call when pressing the back button.
|
||||
/// By default it calls [Navigator.pop]
|
||||
final VoidCallback? onBackPressed;
|
||||
|
||||
/// Callback to call when the header is tapped.
|
||||
final VoidCallback? onTitleTap;
|
||||
|
||||
/// Callback to call when the image is tapped.
|
||||
final VoidCallback? onImageTap;
|
||||
|
||||
/// Stores the current index of media shown
|
||||
final int currentPage;
|
||||
|
||||
/// Total number of pages of media
|
||||
final int totalPages;
|
||||
|
||||
/// All attachments to show
|
||||
final List<Attachment> mediaAttachments;
|
||||
|
||||
/// Message which attachments are attached to
|
||||
final Message message;
|
||||
|
||||
/// Callback when media is selected
|
||||
final ValueChanged<int>? mediaSelectedCallBack;
|
||||
|
||||
@override
|
||||
_GalleryFooterState createState() => _GalleryFooterState();
|
||||
|
||||
@override
|
||||
final Size preferredSize;
|
||||
}
|
||||
|
||||
class _GalleryFooterState extends State<GalleryFooter> {
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final FocusNode _messageFocusNode = FocusNode();
|
||||
|
||||
final List<Channel> _selectedChannels = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_messageFocusNode.addListener(() {
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const showShareButton = !kIsWeb;
|
||||
final mediaQueryData = MediaQuery.of(context);
|
||||
final galleryFooterThemeData = GalleryFooterTheme.of(context);
|
||||
return SizedBox.fromSize(
|
||||
size: Size(
|
||||
mediaQueryData.size.width,
|
||||
mediaQueryData.padding.bottom + widget.preferredSize.height,
|
||||
),
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: BottomAppBar(
|
||||
color: galleryFooterThemeData.backgroundColor,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (!showShareButton)
|
||||
Container(
|
||||
width: 48,
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: StreamSvgIcon.iconShare(
|
||||
size: 24,
|
||||
color: galleryFooterThemeData.shareIconColor,
|
||||
),
|
||||
onPressed: () async {
|
||||
final attachment =
|
||||
widget.mediaAttachments[widget.currentPage];
|
||||
final url = attachment.imageUrl ??
|
||||
attachment.assetUrl ??
|
||||
attachment.thumbUrl!;
|
||||
final type = attachment.type == 'image'
|
||||
? 'jpg'
|
||||
: url.split('?').first.split('.').last;
|
||||
final request = await HttpClient().getUrl(Uri.parse(url));
|
||||
final response = await request.close();
|
||||
final bytes =
|
||||
await consolidateHttpClientResponseBytes(response);
|
||||
final tmpPath = await getTemporaryDirectory();
|
||||
final filePath = '${tmpPath.path}/${attachment.id}.$type';
|
||||
final file = File(filePath);
|
||||
await file.writeAsBytes(bytes);
|
||||
await Share.shareFiles(
|
||||
[filePath],
|
||||
mimeTypes: [
|
||||
'image/$type',
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
InkWell(
|
||||
onTap: widget.onTitleTap,
|
||||
child: SizedBox(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'${widget.currentPage + 1} of ${widget.totalPages}',
|
||||
style: galleryFooterThemeData.titleTextStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: StreamSvgIcon.iconGrid(
|
||||
color: galleryFooterThemeData.gridIconButtonColor,
|
||||
),
|
||||
onPressed: () => _showPhotosModal(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPhotosModal(context) {
|
||||
final chatThemeData = StreamChatTheme.of(context);
|
||||
final galleryFooterThemeData = GalleryFooterTheme.of(context);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
barrierColor: galleryFooterThemeData.bottomSheetBarrierColor,
|
||||
backgroundColor: galleryFooterThemeData.bottomSheetBackgroundColor,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
builder: (context) {
|
||||
const crossAxisCount = 3;
|
||||
final noOfRowToShowInitially =
|
||||
widget.mediaAttachments.length > crossAxisCount ? 2 : 1;
|
||||
final size = MediaQuery.of(context).size;
|
||||
final initialChildSize =
|
||||
48 + (size.width * noOfRowToShowInitially) / crossAxisCount;
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: initialChildSize / size.height,
|
||||
minChildSize: initialChildSize / size.height,
|
||||
builder: (context, scrollController) => SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Photos',
|
||||
style:
|
||||
galleryFooterThemeData.bottomSheetPhotosTextStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
icon: StreamSvgIcon.close(
|
||||
color:
|
||||
galleryFooterThemeData.bottomSheetCloseIconColor,
|
||||
),
|
||||
onPressed: () => Navigator.maybePop(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Flexible(
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: widget.mediaAttachments.length,
|
||||
padding: const EdgeInsets.all(1),
|
||||
// ignore: lines_longer_than_80_chars
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
mainAxisSpacing: 2,
|
||||
crossAxisSpacing: 2,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
Widget media;
|
||||
final attachment = widget.mediaAttachments[index];
|
||||
if (attachment.type == 'video') {
|
||||
media = InkWell(
|
||||
onTap: () => widget.mediaSelectedCallBack!(index),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
child: VideoThumbnailImage(
|
||||
video: (attachment.file?.path ??
|
||||
attachment.assetUrl)!,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
media = InkWell(
|
||||
onTap: () => widget.mediaSelectedCallBack!(index),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: attachment.imageUrl ??
|
||||
attachment.assetUrl ??
|
||||
attachment.thumbUrl!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
media,
|
||||
if (widget.message.user != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 8,
|
||||
color: chatThemeData
|
||||
.colorTheme.textHighEmphasis
|
||||
.withOpacity(0.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: UserAvatar(
|
||||
user: widget.message.user!,
|
||||
constraints:
|
||||
BoxConstraints.tight(const Size(24, 24)),
|
||||
showOnlineStatus: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Sends the current message
|
||||
Future sendMessage() async {
|
||||
final text = _messageController.text.trim();
|
||||
|
||||
final attachments = widget.message.attachments;
|
||||
|
||||
_messageController.clear();
|
||||
|
||||
for (final channel in _selectedChannels) {
|
||||
final message = Message(
|
||||
text: text,
|
||||
attachments: [attachments[widget.currentPage]],
|
||||
);
|
||||
|
||||
await channel.sendMessage(message);
|
||||
}
|
||||
|
||||
_selectedChannels.clear();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user