This commit is contained in:
2025-06-30 16:24:20 +08:00
commit 68de5eb048
454 changed files with 56921 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_status.dart'
if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_status.dart';
import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/common_utils.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/image_screen.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart';
import 'package:tencent_cloud_chat_uikit/data_services/core/core_services_implements.dart';
import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart';
import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart';
class Avatar extends TIMUIKitStatelessWidget {
final String faceUrl;
final String showName;
final bool isFromLocalAsset;
final CoreServicesImpl coreService = serviceLocator<CoreServicesImpl>();
final BorderRadius? borderRadius;
final V2TimUserStatus? onlineStatus;
final int? type; // 1 c2c 2 group
final bool isShowBigWhenClick;
final TUISelfInfoViewModel selfInfoViewModel = serviceLocator<TUISelfInfoViewModel>();
Avatar(
{Key? key,
required this.faceUrl,
this.onlineStatus,
required this.showName,
this.isShowBigWhenClick = false,
this.isFromLocalAsset = false,
this.borderRadius,
this.type = 1})
: super(key: key);
Widget getImageWidget(BuildContext context, TUITheme theme) {
Widget defaultAvatar() {
if (type == 1) {
return Image.asset(
TencentUtils.checkString(selfInfoViewModel.globalConfig?.defaultAvatarAssetPath) ??
'images/default_c2c_head.png',
fit: BoxFit.cover,
package:
selfInfoViewModel.globalConfig?.defaultAvatarAssetPath != null ? null : 'tencent_cloud_chat_uikit');
} else {
return Image.asset(
TencentUtils.checkString(selfInfoViewModel.globalConfig?.defaultAvatarAssetPath) ??
'images/default_group_head.png',
fit: BoxFit.cover,
package:
selfInfoViewModel.globalConfig?.defaultAvatarAssetPath != null ? null : 'tencent_cloud_chat_uikit');
}
}
// final emptyAvatarBuilder = coreService.emptyAvatarBuilder;
if (faceUrl != "") {
if (isFromLocalAsset) {
return Image.asset(
faceUrl,
fit: BoxFit.cover,
);
}
return CachedNetworkImage(
imageUrl: faceUrl,
fadeInDuration: const Duration(milliseconds: 0),
errorWidget: (BuildContext context, String c, dynamic s) {
return defaultAvatar();
},
);
} else {
return defaultAvatar();
}
}
ImageProvider getImageProvider() {
ImageProvider defaultAvatar() {
if (type == 1) {
return Image.asset(
TencentUtils.checkString(selfInfoViewModel.globalConfig?.defaultAvatarAssetPath) ??
'images/default_c2c_head.png',
fit: BoxFit.cover,
package:
selfInfoViewModel.globalConfig?.defaultAvatarAssetPath != null ? null : 'tencent_cloud_chat_uikit')
.image;
} else {
return Image.asset(
TencentUtils.checkString(selfInfoViewModel.globalConfig?.defaultAvatarAssetPath) ??
'images/default_group_head.png',
fit: BoxFit.cover,
package:
selfInfoViewModel.globalConfig?.defaultAvatarAssetPath != null ? null : 'tencent_cloud_chat_uikit')
.image;
}
}
if (faceUrl != "") {
if (isFromLocalAsset) {
return Image.asset(faceUrl).image;
}
return CachedNetworkImageProvider(
faceUrl,
);
} else {
return defaultAvatar();
}
}
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
final TUITheme theme = value.theme;
return Stack(
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
if (isShowBigWhenClick)
GestureDetector(
onTap: () {
Navigator.of(context).push(
PageRouteBuilder(
opaque: false, // set to false
pageBuilder: (_, __, ___) => ImageScreen(imageProvider: getImageProvider(), heroTag: faceUrl),
),
);
},
child: Hero(
tag: faceUrl,
child: ClipRRect(
borderRadius: borderRadius ??
selfInfoViewModel.globalConfig?.defaultAvatarBorderRadius ??
BorderRadius.circular(4.8),
child: getImageWidget(context, theme),
),
),
),
if (!isShowBigWhenClick)
ClipRRect(
borderRadius:
borderRadius ?? selfInfoViewModel.globalConfig?.defaultAvatarBorderRadius ?? BorderRadius.circular(4.8),
child: getImageWidget(context, theme),
),
if (onlineStatus?.statusType != null && onlineStatus?.statusType != 0)
Positioned(
bottom: -4,
right: -4,
child: Container(
width: 12,
height: 12,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 2.0,
),
color: onlineStatus?.statusType == 1
? theme.conversationItemOnlineStatusBgColor
: theme.conversationItemOfflineStatusBgColor,
),
child: null,
),
),
],
);
}
}
+115
View File
@@ -0,0 +1,115 @@
import 'package:azlistview_all_platforms/azlistview_all_platforms.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart';
import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart';
import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart';
class AZListViewContainer extends StatefulWidget {
final List<ISuspensionBeanImpl>? memberList;
final Widget Function(BuildContext context, int index) itemBuilder;
final Widget Function(BuildContext context, int index)? susItemBuilder;
final bool isShowIndexBar;
const AZListViewContainer(
{Key? key,
required this.memberList,
required this.itemBuilder,
this.isShowIndexBar = true,
this.susItemBuilder})
: super(key: key);
@override
State<StatefulWidget> createState() => _AZListViewContainerState();
}
class _AZListViewContainerState extends TIMUIKitState<AZListViewContainer> {
List<ISuspensionBeanImpl>? _list;
addShowSuspension(List<ISuspensionBeanImpl> curList) {
for (int i = 0; i < curList.length; i++) {
if (i == 0 || curList[i].tagIndex != curList[i - 1].tagIndex) {
curList[i].isShowSuspension = true;
}
}
return curList;
}
static Widget getSusItem(BuildContext context, String tag,
{double susHeight = 40}) {
final theme = Provider.of<TUIThemeViewModel>(context).theme;
return Container(
height: susHeight,
width: MediaQuery.of(context).size.width,
padding: const EdgeInsets.only(left: 16.0),
color: theme.weakDividerColor,
alignment: Alignment.centerLeft,
child: Text(
tag,
softWrap: true,
style: TextStyle(
fontSize: 14.0,
color: theme.weakTextColor,
),
),
);
}
@override
void initState() {
super.initState();
setState(() {
_list = addShowSuspension(widget.memberList!);
});
}
@override
void didUpdateWidget(covariant AZListViewContainer oldWidget) {
super.didUpdateWidget(oldWidget);
setState(() {
_list = addShowSuspension(widget.memberList!);
});
}
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
final isDesktopScreen =
TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop;
return ChangeNotifierProvider.value(
value: serviceLocator<TUIThemeViewModel>(),
child: Consumer<TUIThemeViewModel>(
builder: (context, tuiTheme, child) => AzListView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics()),
data: _list!,
itemCount: _list!.length,
itemBuilder: widget.itemBuilder,
indexBarData: (!isDesktopScreen && widget.isShowIndexBar)
? SuspensionUtil.getTagIndexList(_list!)
.where((element) => element != "@")
.toList()
: [],
susItemBuilder: (BuildContext context, int index) {
if (widget.susItemBuilder != null) {
return widget.susItemBuilder!(context, index);
}
ISuspensionBeanImpl model = _list![index];
if (model.getSuspensionTag() == "@") {
return Container();
}
return getSusItem(context, model.getSuspensionTag());
})));
}
}
class ISuspensionBeanImpl<T> extends ISuspensionBean {
String tagIndex;
T memberInfo;
ISuspensionBeanImpl({required this.tagIndex, required this.memberInfo});
@override
String getSuspensionTag() => tagIndex;
}
+39
View File
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart';
class CenterPlayButton extends TIMUIKitStatelessWidget {
CenterPlayButton({
Key? key,
required this.show,
required this.isPlaying,
this.onPressed,
}) : super(key: key);
final bool show;
final bool isPlaying;
final VoidCallback? onPressed;
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
return Material(
color: Colors.transparent,
child: Container(
color: Colors.transparent,
child: Center(
child: AnimatedOpacity(
opacity: show ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: GestureDetector(
child: IconButton(
iconSize: 86,
icon: Image.asset('images/play.png', package: 'tencent_cloud_chat_uikit'),
onPressed: onPressed,
),
),
),
),
),
);
}
}
+91
View File
@@ -0,0 +1,91 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart';
import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart';
class ColumnMenuItem {
String label;
VoidCallback onClick;
Widget? icon;
ColumnMenuItem({required this.label, required this.onClick, this.icon});
}
class TUIKitColumnMenu extends StatefulWidget {
const TUIKitColumnMenu({Key? key, required this.data, this.padding}) : super(key: key);
final List<ColumnMenuItem> data;
final EdgeInsetsGeometry? padding;
@override
State<StatefulWidget> createState() => TUIKitColumnMenuState();
}
class TUIKitColumnMenuState extends TIMUIKitState<TUIKitColumnMenu> {
List<Widget> renderMenuItems(TUITheme theme) {
return widget.data
.map(
(item) => Material(
color: Colors.white,
child: InkWell(
onTap: () {
item.onClick();
},
child: Container(
padding: widget.padding ?? const EdgeInsets.all(10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.icon != null) item.icon!,
if (item.icon != null) const SizedBox(
height: 4,
width: 6,
),
Text(
item.label,
style: TextStyle(
decoration: TextDecoration.none,
color: theme.darkTextColor,
fontSize: 14,
),
)
],
),
),
),
),
)
.toList();
}
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
final theme = value.theme;
return Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(10)),
),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: min(MediaQuery.of(context).size.width * 0.7, 350),
),
child: Table(
columnWidths: const <int, TableColumnWidth>{
0: IntrinsicColumnWidth(),
},
children: <TableRow>[
...renderMenuItems(theme).map((e) => TableRow(
children: <Widget>[
e
]
))
]
),
),
);
}
}
+306
View File
@@ -0,0 +1,306 @@
import 'package:azlistview_all_platforms/azlistview_all_platforms.dart';
import 'package:flutter/material.dart';
import 'package:lpinyin/lpinyin.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart'
if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_friend_info.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart'
if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_status.dart'
if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_user_status.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart';
import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_friendship_view_model.dart';
import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/az_list_view.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/radio_button.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart';
import 'package:tencent_cloud_chat_uikit/theme/color.dart';
import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart';
class ContactList extends StatefulWidget {
final List<V2TimFriendInfo> contactList;
final bool isCanSelectMemberItem;
final bool isCanSlidableDelete;
final Function(List<V2TimFriendInfo> selectedMember)? onSelectedMemberItemChange;
final Function()? handleSlidableDelte;
final Color? bgColor;
/// tap联系人列表项回调
final void Function(V2TimFriendInfo item)? onTapItem;
/// 顶部列表
final List<TopListItem>? topList;
/// 顶部列表项构造器
final Widget? Function(TopListItem item)? topListItemBuilder;
/// Control if shows the online status for each user on its avatar.
final bool isShowOnlineStatus;
final int? maxSelectNum;
final List<V2TimGroupMemberFullInfo?>? groupMemberList;
/// the builder for the empty item, especially when there is no contact
final Widget Function(BuildContext context)? emptyBuilder;
final String? currentItem;
const ContactList({
Key? key,
required this.contactList,
this.isCanSelectMemberItem = false,
this.onSelectedMemberItemChange,
this.isCanSlidableDelete = false,
this.handleSlidableDelte,
this.onTapItem,
this.bgColor,
this.topList,
this.topListItemBuilder,
this.isShowOnlineStatus = false,
this.maxSelectNum,
this.groupMemberList,
this.emptyBuilder,
this.currentItem,
}) : super(key: key);
@override
State<StatefulWidget> createState() => _ContactListState();
}
class _ContactListState extends TIMUIKitState<ContactList> {
List<V2TimFriendInfo> selectedMember = [];
final TUIFriendShipViewModel friendShipViewModel = serviceLocator<TUIFriendShipViewModel>();
_getShowName(V2TimFriendInfo item) {
final friendRemark = item.friendRemark ?? "";
final nickName = item.userProfile?.nickName ?? "";
final userID = item.userID;
final showName = nickName != "" ? nickName : userID;
return friendRemark != "" ? friendRemark : showName;
}
List<ISuspensionBeanImpl> _getShowList(List<V2TimFriendInfo> memberList) {
final List<ISuspensionBeanImpl> showList = List.empty(growable: true);
for (var i = 0; i < memberList.length; i++) {
final item = memberList[i];
final showName = _getShowName(item);
String pinyin = PinyinHelper.getPinyinE(showName);
String tag = pinyin.substring(0, 1).toUpperCase();
if (RegExp("[A-Z]").hasMatch(tag)) {
showList.add(ISuspensionBeanImpl(memberInfo: item, tagIndex: tag));
} else {
showList.add(ISuspensionBeanImpl(memberInfo: item, tagIndex: "#"));
}
}
SuspensionUtil.sortListBySuspensionTag(showList);
return showList;
}
bool selectedMemberIsOverFlow() {
if (widget.maxSelectNum == null) {
return false;
}
return selectedMember.length >= widget.maxSelectNum!;
}
Widget _buildItem(TUITheme theme, V2TimFriendInfo item) {
final showName = _getShowName(item);
final faceUrl = item.userProfile?.faceUrl ?? "";
final V2TimUserStatus? onlineStatus = widget.isShowOnlineStatus
? friendShipViewModel.userStatusList
.firstWhere((element) => element.userID == item.userID, orElse: () => V2TimUserStatus(statusType: 0))
: null;
bool disabled = false;
if (widget.groupMemberList != null && widget.groupMemberList!.isNotEmpty) {
disabled = ((widget.groupMemberList?.indexWhere((element) => element?.userID == item.userID)) ?? -1) > -1;
}
final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop;
return Container(
padding: const EdgeInsets.only(top: 8, left: 16, right: 12),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))),
child: Row(
children: [
if (widget.isCanSelectMemberItem)
Container(
margin: const EdgeInsets.only(right: 12, bottom: 8),
child: CheckBoxButton(
disabled: disabled,
isChecked: selectedMember.contains(item),
onChanged: (isChecked) {
if (isChecked) {
if (selectedMemberIsOverFlow()) {
selectedMember = [item];
setState(() {});
return;
}
selectedMember.add(item);
} else {
selectedMember.remove(item);
}
if (widget.onSelectedMemberItemChange != null) {
widget.onSelectedMemberItemChange!(selectedMember);
}
setState(() {});
},
),
),
Container(
padding: const EdgeInsets.only(bottom: 12),
margin: const EdgeInsets.only(right: 12),
child: SizedBox(
height: isDesktopScreen ? 30 : 40,
width: isDesktopScreen ? 30 : 40,
child: Avatar(onlineStatus: onlineStatus, faceUrl: faceUrl, showName: showName),
),
),
Expanded(
child: Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(top: 10, bottom: 20, right: 28),
child: Text(
showName,
style: TextStyle(color: Colors.black, fontSize: isDesktopScreen ? 14 : 18),
),
)),
],
),
);
}
Widget generateTopItem(memberInfo) {
final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop;
if (widget.topListItemBuilder != null) {
final customWidget = widget.topListItemBuilder!(memberInfo);
if (customWidget != null) {
return customWidget;
}
}
return InkWell(
onTap: () {
if (memberInfo.onTap != null) {
memberInfo.onTap!();
}
},
child: Container(
padding: const EdgeInsets.only(top: 8, left: 16),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: hexToColor("DBDBDB")))),
child: Row(
children: [
Container(
height: isDesktopScreen ? 30 : 40,
width: isDesktopScreen ? 30 : 40,
margin: const EdgeInsets.only(right: 12, bottom: 12),
child: memberInfo.icon,
),
Expanded(
child: Container(
padding: const EdgeInsets.only(top: 10, bottom: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
memberInfo.name,
style: TextStyle(color: hexToColor("111111"), fontSize: isDesktopScreen ? 14 : 18),
),
Expanded(child: Container()),
// if (item.id == "newContact")
// const TIMUIKitUnreadCount(),
Container(
margin: const EdgeInsets.only(right: 16),
child: Icon(
Icons.keyboard_arrow_right,
color: hexToColor('BBBBBB'),
),
)
],
),
))
],
),
));
}
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
final TUITheme theme = value.theme;
final showList = _getShowList(widget.contactList);
final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop;
if (widget.topList != null && widget.topList!.isNotEmpty) {
final topList = widget.topList!.map((e) => ISuspensionBeanImpl(memberInfo: e, tagIndex: '@')).toList();
showList.insertAll(0, topList);
}
if (widget.contactList.isEmpty) {
return Column(
children: [
...showList.map((e) => generateTopItem(e.memberInfo)).toList(),
Expanded(child: widget.emptyBuilder != null ? widget.emptyBuilder!(context) : Container())
],
);
}
return AZListViewContainer(
memberList: showList,
itemBuilder: (context, index) {
final memberInfo = showList[index].memberInfo;
if (memberInfo is TopListItem) {
return generateTopItem(memberInfo);
} else {
return Material(
color: (isDesktopScreen)
? (widget.currentItem == memberInfo.userProfile.userID
? theme.conversationItemChooseBgColor
: widget.bgColor)
: null,
child: InkWell(
onTap: () {
if (widget.isCanSelectMemberItem) {
if (selectedMember.contains(memberInfo)) {
selectedMember.remove(memberInfo);
} else {
if (selectedMemberIsOverFlow()) {
selectedMember = [memberInfo];
setState(() {});
return;
}
selectedMember.add(memberInfo);
}
if (widget.onSelectedMemberItemChange != null) {
widget.onSelectedMemberItemChange!(selectedMember);
}
setState(() {});
return;
}
if (widget.onTapItem != null) {
widget.onTapItem!(memberInfo);
}
},
child: _buildItem(theme, memberInfo),
),
);
}
},
);
}
}
class TopListItem {
final String name;
final String id;
final Widget? icon;
final Function()? onTap;
TopListItem({required this.name, required this.id, this.icon, this.onTap});
}
@@ -0,0 +1,240 @@
// ignore_for_file: prefer_final_fields
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_easyrefresh/easy_refresh.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart';
/// 球脉冲Header
class CustomizeBallPulseHeader extends Header {
/// Key
final Key? key;
/// color
final Color? color;
/// background color
final Color? backgroundColor;
final LinkHeaderNotifier linkNotifier = LinkHeaderNotifier();
CustomizeBallPulseHeader({
this.key,
this.color = Colors.blue,
this.backgroundColor = Colors.transparent,
bool enableHapticFeedback = true,
bool enableInfiniteRefresh = false,
}) : super(
extent: 70.0,
triggerDistance: 70.0,
float: false,
enableHapticFeedback: enableHapticFeedback,
enableInfiniteRefresh: enableInfiniteRefresh,
);
@override
Widget contentBuilder(
BuildContext context,
RefreshMode refreshState,
double pulledExtent,
double refreshTriggerPullDistance,
double refreshIndicatorExtent,
AxisDirection axisDirection,
bool float,
Duration? completeDuration,
bool enableInfiniteRefresh,
bool success,
bool noMore) {
// 不能为水平方向
assert(
axisDirection == AxisDirection.down ||
axisDirection == AxisDirection.up,
'Widget cannot be horizontal');
linkNotifier.contentBuilder(
context,
refreshState,
pulledExtent,
refreshTriggerPullDistance,
refreshIndicatorExtent,
axisDirection,
float,
completeDuration,
enableInfiniteRefresh,
success,
noMore);
return BallPulseHeaderWidget(
key: key,
color: color,
backgroundColor: backgroundColor,
linkNotifier: linkNotifier,
);
}
}
/// 球脉冲组件
class BallPulseHeaderWidget extends StatefulWidget {
/// 颜色
final Color? color;
/// 背景颜色
final Color? backgroundColor;
final LinkHeaderNotifier linkNotifier;
const BallPulseHeaderWidget({
Key? key,
this.color,
this.backgroundColor,
required this.linkNotifier,
}) : super(key: key);
@override
BallPulseHeaderWidgetState createState() {
return BallPulseHeaderWidgetState();
}
}
class BallPulseHeaderWidgetState extends TIMUIKitState<BallPulseHeaderWidget> {
RefreshMode get _refreshState => widget.linkNotifier.refreshState;
double get _indicatorExtent => widget.linkNotifier.refreshIndicatorExtent;
bool get _noMore => widget.linkNotifier.noMore;
// 球大小
double _ballSize1 = 0.0, _ballSize2 = 0.0, _ballSize3 = 0.0;
// 动画阶段
int animationPhase = 1;
// 动画过渡时间
Duration _ballSizeDuration = const Duration(milliseconds: 200);
// 是否运行动画
bool _isAnimated = false;
// 循环动画
void _loopAnimated() {
Future.delayed(_ballSizeDuration, () {
if (!mounted) return;
if (_isAnimated) {
setState(() {
if (animationPhase == 1) {
_ballSize1 = 11.0;
_ballSize2 = 4.0;
_ballSize3 = 11.0;
} else if (animationPhase == 2) {
_ballSize1 = 18.0;
_ballSize2 = 11.0;
_ballSize3 = 4.0;
} else if (animationPhase == 3) {
_ballSize1 = 11.0;
_ballSize2 = 18.0;
_ballSize3 = 11.0;
} else {
_ballSize1 = 4.0;
_ballSize2 = 11.0;
_ballSize3 = 18.0;
}
});
animationPhase++;
animationPhase = animationPhase >= 5 ? 1 : animationPhase;
_loopAnimated();
} else {
setState(() {
_ballSize1 = 0.0;
_ballSize2 = 0.0;
_ballSize3 = 0.0;
});
animationPhase = 1;
}
});
}
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
if (_noMore) return Container();
// 开启动画
if (_refreshState == RefreshMode.done ||
_refreshState == RefreshMode.inactive) {
_isAnimated = false;
} else if (!_isAnimated) {
_isAnimated = true;
_ballSize1 = 4.0;
_ballSize2 = 11.0;
_ballSize3 = 18.0;
_loopAnimated();
}
return Stack(
children: <Widget>[
Positioned(
top: 0.0,
bottom: 0.0,
left: 0.0,
right: 0.0,
child: Container(
alignment: Alignment.center,
height: _indicatorExtent,
color: widget.backgroundColor,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
SizedBox(
width: 20.0,
height: 20.0,
child: Center(
child: ClipOval(
child: AnimatedContainer(
color: widget.color,
height: _ballSize1,
width: _ballSize1,
duration: _ballSizeDuration,
),
),
),
),
Container(
width: 5.0,
),
SizedBox(
width: 20.0,
height: 20.0,
child: Center(
child: ClipOval(
child: AnimatedContainer(
color: widget.color,
height: _ballSize2,
width: _ballSize2,
duration: _ballSizeDuration,
),
),
),
),
Container(
width: 5.0,
),
SizedBox(
width: 20.0,
height: 20.0,
child: Center(
child: ClipOval(
child: AnimatedContainer(
color: widget.color,
height: _ballSize3,
width: _ballSize3,
duration: _ballSizeDuration,
),
),
),
),
],
),
),
),
],
);
}
}
+97
View File
@@ -0,0 +1,97 @@
import 'dart:math';
import 'package:flutter/material.dart';
class TUIKitDragArea extends StatefulWidget {
final Widget child;
final Offset? initOffset;
final VoidCallback? closeFun;
final bool isAllowDrag;
final Color? backgroundColor;
const TUIKitDragArea(
{Key? key,
required this.child,
this.initOffset,
this.closeFun,
this.isAllowDrag = false, this.backgroundColor})
: super(key: key);
@override
_DragAreaStateStateful createState() => _DragAreaStateStateful();
}
class _DragAreaStateStateful extends State<TUIKitDragArea> {
late Offset position;
double prevScale = 1;
double scale = 1;
@override
void initState() {
super.initState();
position = widget.initOffset ?? const Offset(0, 200);
}
void updateScale(double zoom) => setState(() => scale = prevScale * zoom);
void commitScale() => setState(() => prevScale = scale);
void updatePosition(Offset newPosition) {
final maxY = MediaQuery.of(context).size.height - 100;
final maxX = MediaQuery.of(context).size.width - 100;
final rebuildPosition = Offset(
max(0, min(newPosition.dx, maxX)), max(0, min(newPosition.dy, maxY)));
position = rebuildPosition;
}
@override
Widget build(BuildContext context) {
return Container(
color: widget.backgroundColor,
child: Stack(
children: [
Row(
children: [
Expanded(
child: Column(
children: [
Expanded(
child: GestureDetector(
onTap: () {
if (widget.closeFun != null) {
widget.closeFun!();
}
},
child: Container(
color: Colors.transparent,
),
)
)
],
))
],
),
if (widget.isAllowDrag)
Positioned(
left: position.dx,
top: position.dy,
child: Draggable(
maxSimultaneousDrags: 1,
feedback: widget.child,
childWhenDragging: Container(),
onDragEnd: (details) => updatePosition(details.offset),
child: widget.child,
),
),
if (!widget.isAllowDrag)
Positioned(
left: position.dx,
top: position.dy,
child: widget.child,
)
],
),
);
}
}
+21
View File
@@ -0,0 +1,21 @@
class Emoji {
String name;
int unicode;
Emoji({required this.name, required this.unicode});
factory Emoji.fromJson(Map<String, dynamic> json) {
return Emoji(
name: json['name'],
unicode: json['unicode'],
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['name'] = name;
data['unicode'] = unicode;
return data;
}
}
@@ -0,0 +1,827 @@
// ignore_for_file: curly_braces_in_flow_control_structures
import 'package:flutter/rendering.dart';
import 'dart:math' as math;
class _LimitRunMetrics {
_LimitRunMetrics(this.mainAxisExtent, this.crossAxisExtent, this.childCount);
final double mainAxisExtent;
final double crossAxisExtent;
final int childCount;
}
/// Parent data for use with [RenderWrap].
class LimitWrapParentData extends ContainerBoxParentData<RenderBox> {
int _runIndex = 0;
bool _isHide = false;
}
/// Displays its children in multiple horizontal or vertical runs.
///
/// A [RenderWrap] lays out each child and attempts to place the child adjacent
/// to the previous child in the main axis, given by [direction], leaving
/// [spacing] space in between. If there is not enough space to fit the child,
/// [RenderWrap] creates a new _run_ adjacent to the existing children in the
/// cross axis.
///
/// After all the children have been allocated to runs, the children within the
/// runs are positioned according to the [alignment] in the main axis and
/// according to the [crossAxisAlignment] in the cross axis.
///
/// The runs themselves are then positioned in the cross axis according to the
/// [runSpacing] and [runAlignment].runAlignment
class ExtendedRenderWrap extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, LimitWrapParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, LimitWrapParentData> {
/// Creates a wrap render object.
///
/// By default, the wrap layout is horizontal and both the children and the
/// runs are aligned to the start.
ExtendedRenderWrap({
List<RenderBox>? children,
Axis direction = Axis.horizontal,
WrapAlignment alignment = WrapAlignment.start,
double spacing = 0.0,
WrapAlignment runAlignment = WrapAlignment.start,
double runSpacing = 0.0,
WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.start,
TextDirection? textDirection,
VerticalDirection verticalDirection = VerticalDirection.down,
Clip clipBehavior = Clip.none,
int maxLines = 1,
bool hasOverflow = false,
}) : assert(maxLines >= 1),
_direction = direction,
_alignment = alignment,
_spacing = spacing,
_runAlignment = runAlignment,
_runSpacing = runSpacing,
_crossAxisAlignment = crossAxisAlignment,
_textDirection = textDirection,
_verticalDirection = verticalDirection,
_maxLines = maxLines,
_hasOverflow = hasOverflow,
_clipBehavior = clipBehavior {
addAll(children);
}
/// The direction to use as the main axis.
///
/// For example, if [direction] is [Axis.horizontal], the default, the
/// children are placed adjacent to one another in a horizontal run until the
/// available horizontal space is consumed, at which point a subsequent
/// children are placed in a new run vertically adjacent to the previous run.
Axis get direction => _direction;
Axis _direction;
set direction(Axis value) {
if (_direction == value) return;
_direction = value;
markNeedsLayout();
}
/// How the children within a run should be placed in the main axis.
///
/// For example, if [alignment] is [WrapAlignment.center], the children in
/// each run are grouped together in the center of their run in the main axis.
///
/// Defaults to [WrapAlignment.start].
///
/// See also:
///
/// * [runAlignment], which controls how the runs are placed relative to each
/// other in the cross axis.
/// * [crossAxisAlignment], which controls how the children within each run
/// are placed relative to each other in the cross axis.
WrapAlignment get alignment => _alignment;
WrapAlignment _alignment;
set alignment(WrapAlignment value) {
if (_alignment == value) return;
_alignment = value;
markNeedsLayout();
}
/// How much space to place between children in a run in the main axis.
///
/// For example, if [spacing] is 10.0, the children will be spaced at least
/// 10.0 logical pixels apart in the main axis.
///
/// If there is additional free space in a run (e.g., because the wrap has a
/// minimum size that is not filled or because some runs are longer than
/// others), the additional free space will be allocated according to the
/// [alignment].
///
/// Defaults to 0.0.
double get spacing => _spacing;
double _spacing;
set spacing(double value) {
if (_spacing == value) return;
_spacing = value;
markNeedsLayout();
}
int get maxLines => _maxLines;
int _maxLines;
set maxLines(int value) {
assert(value >= 1);
if (_maxLines == value) return;
_maxLines = value;
markNeedsLayout();
}
bool get hasOverflow => _hasOverflow;
bool _hasOverflow;
set hasOverflow(bool value) {
if (_hasOverflow == value) return;
_hasOverflow = value;
markNeedsLayout();
}
/// How the runs themselves should be placed in the cross axis.
///
/// For example, if [runAlignment] is [WrapAlignment.center], the runs are
/// grouped together in the center of the overall [RenderWrap] in the cross
/// axis.
///
/// Defaults to [WrapAlignment.start].
///
/// See also:
///
/// * [alignment], which controls how the children within each run are placed
/// relative to each other in the main axis.
/// * [crossAxisAlignment], which controls how the children within each run
/// are placed relative to each other in the cross axis.
WrapAlignment get runAlignment => _runAlignment;
WrapAlignment _runAlignment;
set runAlignment(WrapAlignment value) {
if (_runAlignment == value) return;
_runAlignment = value;
markNeedsLayout();
}
/// How much space to place between the runs themselves in the cross axis.
///
/// For example, if [runSpacing] is 10.0, the runs will be spaced at least
/// 10.0 logical pixels apart in the cross axis.
///
/// If there is additional free space in the overall [RenderWrap] (e.g.,
/// because the wrap has a minimum size that is not filled), the additional
/// free space will be allocated according to the [runAlignment].
///
/// Defaults to 0.0.
double get runSpacing => _runSpacing;
double _runSpacing;
set runSpacing(double value) {
if (_runSpacing == value) return;
_runSpacing = value;
markNeedsLayout();
}
/// How the children within a run should be aligned relative to each other in
/// the cross axis.
///
/// For example, if this is set to [WrapCrossAlignment.end], and the
/// [direction] is [Axis.horizontal], then the children within each
/// run will have their bottom edges aligned to the bottom edge of the run.
///
/// Defaults to [WrapCrossAlignment.start].
///
/// See also:
///
/// * [alignment], which controls how the children within each run are placed
/// relative to each other in the main axis.
/// * [runAlignment], which controls how the runs are placed relative to each
/// other in the cross axis.
WrapCrossAlignment get crossAxisAlignment => _crossAxisAlignment;
WrapCrossAlignment _crossAxisAlignment;
set crossAxisAlignment(WrapCrossAlignment value) {
if (_crossAxisAlignment == value) return;
_crossAxisAlignment = value;
markNeedsLayout();
}
/// Determines the order to lay children out horizontally and how to interpret
/// `start` and `end` in the horizontal direction.
///
/// If the [direction] is [Axis.horizontal], this controls the order in which
/// children are positioned (left-to-right or right-to-left), and the meaning
/// of the [alignment] property's [WrapAlignment.start] and
/// [WrapAlignment.end] values.
///
/// If the [direction] is [Axis.horizontal], and either the
/// [alignment] is either [WrapAlignment.start] or [WrapAlignment.end], or
/// there's more than one child, then the [textDirection] must not be null.
///
/// If the [direction] is [Axis.vertical], this controls the order in
/// which runs are positioned, the meaning of the [runAlignment] property's
/// [WrapAlignment.start] and [WrapAlignment.end] values, as well as the
/// [crossAxisAlignment] property's [WrapCrossAlignment.start] and
/// [WrapCrossAlignment.end] values.
///
/// If the [direction] is [Axis.vertical], and either the
/// [runAlignment] is either [WrapAlignment.start] or [WrapAlignment.end], the
/// [crossAxisAlignment] is either [WrapCrossAlignment.start] or
/// [WrapCrossAlignment.end], or there's more than one child, then the
/// [textDirection] must not be null.
TextDirection? get textDirection => _textDirection;
TextDirection? _textDirection;
set textDirection(TextDirection? value) {
if (_textDirection != value) {
_textDirection = value;
markNeedsLayout();
}
}
/// Determines the order to lay children out vertically and how to interpret
/// `start` and `end` in the vertical direction.
///
/// If the [direction] is [Axis.vertical], this controls which order children
/// are painted in (down or up), the meaning of the [alignment] property's
/// [WrapAlignment.start] and [WrapAlignment.end] values.
///
/// If the [direction] is [Axis.vertical], and either the [alignment]
/// is either [WrapAlignment.start] or [WrapAlignment.end], or there's
/// more than one child, then the [verticalDirection] must not be null.
///
/// If the [direction] is [Axis.horizontal], this controls the order in which
/// runs are positioned, the meaning of the [runAlignment] property's
/// [WrapAlignment.start] and [WrapAlignment.end] values, as well as the
/// [crossAxisAlignment] property's [WrapCrossAlignment.start] and
/// [WrapCrossAlignment.end] values.
///
/// If the [direction] is [Axis.horizontal], and either the
/// [runAlignment] is either [WrapAlignment.start] or [WrapAlignment.end], the
/// [crossAxisAlignment] is either [WrapCrossAlignment.start] or
/// [WrapCrossAlignment.end], or there's more than one child, then the
/// [verticalDirection] must not be null.
VerticalDirection get verticalDirection => _verticalDirection;
VerticalDirection _verticalDirection;
set verticalDirection(VerticalDirection value) {
if (_verticalDirection != value) {
_verticalDirection = value;
markNeedsLayout();
}
}
/// {@macro flutter.material.Material.clipBehavior}
///
/// Defaults to [Clip.none], and must not be null.
Clip get clipBehavior => _clipBehavior;
Clip _clipBehavior = Clip.none;
set clipBehavior(Clip value) {
if (value != _clipBehavior) {
_clipBehavior = value;
markNeedsPaint();
markNeedsSemanticsUpdate();
}
}
bool get _debugHasNecessaryDirections {
if (firstChild != null && lastChild != firstChild) {
// i.e. there's more than one child
switch (direction) {
case Axis.horizontal:
assert(textDirection != null,
'Horizontal $runtimeType with multiple children has a null textDirection, so the layout order is undefined.');
break;
case Axis.vertical:
break;
}
}
if (alignment == WrapAlignment.start || alignment == WrapAlignment.end) {
switch (direction) {
case Axis.horizontal:
assert(textDirection != null,
'Horizontal $runtimeType with alignment $alignment has a null textDirection, so the alignment cannot be resolved.');
break;
case Axis.vertical:
break;
}
}
if (runAlignment == WrapAlignment.start ||
runAlignment == WrapAlignment.end) {
switch (direction) {
case Axis.horizontal:
break;
case Axis.vertical:
assert(textDirection != null,
'Vertical $runtimeType with runAlignment $runAlignment has a null textDirection, so the alignment cannot be resolved.');
break;
}
}
if (crossAxisAlignment == WrapCrossAlignment.start ||
crossAxisAlignment == WrapCrossAlignment.end) {
switch (direction) {
case Axis.horizontal:
break;
case Axis.vertical:
assert(textDirection != null,
'Vertical $runtimeType with crossAxisAlignment $crossAxisAlignment has a null textDirection, so the alignment cannot be resolved.');
break;
}
}
return true;
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! LimitWrapParentData)
child.parentData = LimitWrapParentData();
}
@override
double computeMinIntrinsicWidth(double height) {
switch (direction) {
case Axis.horizontal:
double width = 0.0;
RenderBox? child = firstChild;
while (child != null) {
width = math.max(width, child.getMinIntrinsicWidth(double.infinity));
child = childAfter(child);
}
return width;
case Axis.vertical:
return computeDryLayout(BoxConstraints(maxHeight: height)).width;
}
}
@override
double computeMaxIntrinsicWidth(double height) {
switch (direction) {
case Axis.horizontal:
double width = 0.0;
RenderBox? child = firstChild;
while (child != null) {
width += child.getMaxIntrinsicWidth(double.infinity);
child = childAfter(child);
}
return width;
case Axis.vertical:
return computeDryLayout(BoxConstraints(maxHeight: height)).width;
}
}
@override
double computeMinIntrinsicHeight(double width) {
switch (direction) {
case Axis.horizontal:
return computeDryLayout(BoxConstraints(maxWidth: width)).height;
case Axis.vertical:
double height = 0.0;
RenderBox? child = firstChild;
while (child != null) {
height =
math.max(height, child.getMinIntrinsicHeight(double.infinity));
child = childAfter(child);
}
return height;
}
}
@override
double computeMaxIntrinsicHeight(double width) {
switch (direction) {
case Axis.horizontal:
return computeDryLayout(BoxConstraints(maxWidth: width)).height;
case Axis.vertical:
double height = 0.0;
RenderBox? child = firstChild;
while (child != null) {
height += child.getMaxIntrinsicHeight(double.infinity);
child = childAfter(child);
}
return height;
}
}
@override
double? computeDistanceToActualBaseline(TextBaseline baseline) {
return defaultComputeDistanceToHighestActualBaseline(baseline);
}
double _getMainAxisExtent(Size childSize) {
switch (direction) {
case Axis.horizontal:
return childSize.width;
case Axis.vertical:
return childSize.height;
}
}
double _getCrossAxisExtent(Size childSize) {
switch (direction) {
case Axis.horizontal:
return childSize.height;
case Axis.vertical:
return childSize.width;
}
}
Offset _getOffset(double mainAxisOffset, double crossAxisOffset) {
switch (direction) {
case Axis.horizontal:
return Offset(mainAxisOffset, crossAxisOffset);
case Axis.vertical:
return Offset(crossAxisOffset, mainAxisOffset);
}
}
double _getChildCrossAxisOffset(bool flipCrossAxis, double runCrossAxisExtent,
double childCrossAxisExtent) {
final double freeSpace = runCrossAxisExtent - childCrossAxisExtent;
switch (crossAxisAlignment) {
case WrapCrossAlignment.start:
return flipCrossAxis ? freeSpace : 0.0;
case WrapCrossAlignment.end:
return flipCrossAxis ? 0.0 : freeSpace;
case WrapCrossAlignment.center:
return freeSpace / 2.0;
}
}
bool _hasVisualOverflow = false;
@override
Size computeDryLayout(BoxConstraints constraints) {
return _computeDryLayout(constraints);
}
Size _computeDryLayout(BoxConstraints constraints,
[ChildLayouter layoutChild = ChildLayoutHelper.dryLayoutChild]) {
late BoxConstraints childConstraints;
double mainAxisLimit = 0.0;
switch (direction) {
case Axis.horizontal:
childConstraints = BoxConstraints(maxWidth: constraints.maxWidth);
mainAxisLimit = constraints.maxWidth;
break;
case Axis.vertical:
childConstraints = BoxConstraints(maxHeight: constraints.maxHeight);
mainAxisLimit = constraints.maxHeight;
break;
}
double mainAxisExtent = 0.0;
double crossAxisExtent = 0.0;
double runMainAxisExtent = 0.0;
double runCrossAxisExtent = 0.0;
int childCount = 0;
RenderBox? child = firstChild;
while (child != null) {
final Size childSize = layoutChild(child, childConstraints);
final double childMainAxisExtent = _getMainAxisExtent(childSize);
final double childCrossAxisExtent = _getCrossAxisExtent(childSize);
// There must be at least one child before we move on to the next run.
if (childCount > 0 &&
runMainAxisExtent + childMainAxisExtent + spacing > mainAxisLimit) {
mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent);
crossAxisExtent += runCrossAxisExtent + runSpacing;
runMainAxisExtent = 0.0;
runCrossAxisExtent = 0.0;
childCount = 0;
}
runMainAxisExtent += childMainAxisExtent;
runCrossAxisExtent = math.max(runCrossAxisExtent, childCrossAxisExtent);
if (childCount > 0) runMainAxisExtent += spacing;
childCount += 1;
child = childAfter(child);
}
crossAxisExtent += runCrossAxisExtent;
mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent);
switch (direction) {
case Axis.horizontal:
return constraints.constrain(Size(mainAxisExtent, crossAxisExtent));
case Axis.vertical:
return constraints.constrain(Size(crossAxisExtent, mainAxisExtent));
}
}
@override
void performLayout() {
final BoxConstraints constraints = this.constraints;
assert(_debugHasNecessaryDirections);
_hasVisualOverflow = false;
RenderBox? child = firstChild;
if (child == null) {
size = constraints.smallest;
return;
}
BoxConstraints? childConstraints;
double mainAxisLimit = 0.0;
bool flipMainAxis = false;
bool flipCrossAxis = false;
switch (direction) {
case Axis.horizontal:
childConstraints = BoxConstraints(maxWidth: constraints.maxWidth);
mainAxisLimit = constraints.maxWidth;
if (textDirection == TextDirection.rtl) flipMainAxis = true;
if (verticalDirection == VerticalDirection.up) flipCrossAxis = true;
break;
case Axis.vertical:
childConstraints = BoxConstraints(maxHeight: constraints.maxHeight);
mainAxisLimit = constraints.maxHeight;
if (verticalDirection == VerticalDirection.up) flipMainAxis = true;
if (textDirection == TextDirection.rtl) flipCrossAxis = true;
break;
}
final double spacing = this.spacing;
final double runSpacing = this.runSpacing;
List<_LimitRunMetrics> runMetrics = <_LimitRunMetrics>[];
double mainAxisExtent = 0.0;
double crossAxisExtent = 0.0;
double runMainAxisExtent = 0.0;
double runCrossAxisExtent = 0.0;
int childCount = 0;
int currentRowNumber = 1;
bool isNeedHideOverflow = true;
while (child != null) {
if (currentRowNumber > maxLines && !hasOverflow) {
child.layout(const BoxConstraints(maxWidth: 0, maxHeight: 0),
parentUsesSize: true);
final LimitWrapParentData childParentData =
child.parentData as LimitWrapParentData;
child = childParentData.nextSibling;
continue;
} else {
child.layout(childConstraints, parentUsesSize: true);
}
double childMainAxisExtent = _getMainAxisExtent(child.size);
double childCrossAxisExtent = _getCrossAxisExtent(child.size);
final LimitWrapParentData childParentData =
child.parentData as LimitWrapParentData;
childParentData._isHide = false;
bool needCalculateSpace = true;
if (hasOverflow) {
lastChild!.layout(childConstraints, parentUsesSize: true);
final double overflowMainAxisExtent =
_getMainAxisExtent(lastChild!.size);
if (isNeedHideOverflow &&
currentRowNumber == 1 &&
childParentData.nextSibling == null) {
lastChild!.layout(const BoxConstraints(maxWidth: 0, maxHeight: 0),
parentUsesSize: true);
childMainAxisExtent = _getMainAxisExtent(child.size);
}
if (currentRowNumber > maxLines &&
childParentData.nextSibling != null) {
needCalculateSpace = false;
isNeedHideOverflow = false;
childParentData._isHide = true;
child.layout(const BoxConstraints(maxWidth: 0, maxHeight: 0),
parentUsesSize: true);
childMainAxisExtent = _getMainAxisExtent(child.size);
childCrossAxisExtent = _getCrossAxisExtent(child.size);
}
if (childCount > 0 &&
runMainAxisExtent +
spacing * 2 +
childMainAxisExtent +
overflowMainAxisExtent >
mainAxisLimit) {
if (crossAxisExtent + runCrossAxisExtent + childCrossAxisExtent >
(childCrossAxisExtent * maxLines + spacing * (maxLines - 1))) {
if (childParentData.nextSibling != null) {
needCalculateSpace = false;
isNeedHideOverflow = false;
childParentData._isHide = true;
child.layout(const BoxConstraints(maxWidth: 0, maxHeight: 0),
parentUsesSize: true);
childMainAxisExtent = _getMainAxisExtent(child.size);
childCrossAxisExtent = _getCrossAxisExtent(child.size);
}
}
if (runMainAxisExtent + spacing + childMainAxisExtent >
mainAxisLimit) {
mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent);
crossAxisExtent += runCrossAxisExtent;
if (runMetrics.isNotEmpty) crossAxisExtent += runSpacing;
runMetrics.add(_LimitRunMetrics(
runMainAxisExtent, runCrossAxisExtent, childCount));
runMainAxisExtent = 0.0;
runCrossAxisExtent = 0.0;
childCount = 0;
currentRowNumber++;
}
}
} else if (childCount > 0 &&
runMainAxisExtent + spacing + childMainAxisExtent > mainAxisLimit) {
mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent);
crossAxisExtent += runCrossAxisExtent;
if (runMetrics.isNotEmpty) crossAxisExtent += runSpacing;
runMetrics.add(_LimitRunMetrics(
runMainAxisExtent, runCrossAxisExtent, childCount));
runMainAxisExtent = 0.0;
runCrossAxisExtent = 0.0;
childCount = 0;
currentRowNumber++;
if (currentRowNumber > maxLines) {
child.layout(const BoxConstraints(maxWidth: 0, maxHeight: 0),
parentUsesSize: true);
final LimitWrapParentData childParentData =
child.parentData as LimitWrapParentData;
child = childParentData.nextSibling;
continue;
}
}
runMainAxisExtent += childMainAxisExtent;
if (childCount > 0 && needCalculateSpace) runMainAxisExtent += spacing;
runCrossAxisExtent = math.max(runCrossAxisExtent, childCrossAxisExtent);
childCount += 1;
childParentData._runIndex = runMetrics.length;
child = childParentData.nextSibling;
}
if (childCount > 0) {
mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent);
crossAxisExtent += runCrossAxisExtent;
if (runMetrics.isNotEmpty) crossAxisExtent += runSpacing;
runMetrics.add(
_LimitRunMetrics(runMainAxisExtent, runCrossAxisExtent, childCount));
}
final int runCount = runMetrics.length;
assert(runCount > 0);
double containerMainAxisExtent = 0.0;
double containerCrossAxisExtent = 0.0;
switch (direction) {
case Axis.horizontal:
size = constraints.constrain(Size(mainAxisExtent, crossAxisExtent));
containerMainAxisExtent = size.width;
containerCrossAxisExtent = size.height;
break;
case Axis.vertical:
size = constraints.constrain(Size(crossAxisExtent, mainAxisExtent));
containerMainAxisExtent = size.height;
containerCrossAxisExtent = size.width;
break;
}
_hasVisualOverflow = containerMainAxisExtent < mainAxisExtent ||
containerCrossAxisExtent < crossAxisExtent;
final double crossAxisFreeSpace =
math.max(0.0, containerCrossAxisExtent - crossAxisExtent);
double runLeadingSpace = 0.0;
double runBetweenSpace = 0.0;
switch (runAlignment) {
case WrapAlignment.start:
break;
case WrapAlignment.end:
runLeadingSpace = crossAxisFreeSpace;
break;
case WrapAlignment.center:
runLeadingSpace = crossAxisFreeSpace / 2.0;
break;
case WrapAlignment.spaceBetween:
runBetweenSpace =
runCount > 1 ? crossAxisFreeSpace / (runCount - 1) : 0.0;
break;
case WrapAlignment.spaceAround:
runBetweenSpace = crossAxisFreeSpace / runCount;
runLeadingSpace = runBetweenSpace / 2.0;
break;
case WrapAlignment.spaceEvenly:
runBetweenSpace = crossAxisFreeSpace / (runCount + 1);
runLeadingSpace = runBetweenSpace;
break;
}
runBetweenSpace += runSpacing;
double crossAxisOffset = flipCrossAxis
? containerCrossAxisExtent - runLeadingSpace
: runLeadingSpace;
child = firstChild;
for (int i = 0; i < runCount; ++i) {
final _LimitRunMetrics metrics = runMetrics[i];
final double runMainAxisExtent = metrics.mainAxisExtent;
final double runCrossAxisExtent = metrics.crossAxisExtent;
final int childCount = metrics.childCount;
final double mainAxisFreeSpace =
math.max(0.0, containerMainAxisExtent - runMainAxisExtent);
double childLeadingSpace = 0.0;
double childBetweenSpace = 0.0;
switch (alignment) {
case WrapAlignment.start:
break;
case WrapAlignment.end:
childLeadingSpace = mainAxisFreeSpace;
break;
case WrapAlignment.center:
childLeadingSpace = mainAxisFreeSpace / 2.0;
break;
case WrapAlignment.spaceBetween:
childBetweenSpace =
childCount > 1 ? mainAxisFreeSpace / (childCount - 1) : 0.0;
break;
case WrapAlignment.spaceAround:
childBetweenSpace = mainAxisFreeSpace / childCount;
childLeadingSpace = childBetweenSpace / 2.0;
break;
case WrapAlignment.spaceEvenly:
childBetweenSpace = mainAxisFreeSpace / (childCount + 1);
childLeadingSpace = childBetweenSpace;
break;
}
childBetweenSpace += spacing;
double childMainPosition = flipMainAxis
? containerMainAxisExtent - childLeadingSpace
: childLeadingSpace;
if (flipCrossAxis) crossAxisOffset -= runCrossAxisExtent;
while (child != null) {
final LimitWrapParentData childParentData =
child.parentData as LimitWrapParentData;
if (childParentData._runIndex != i) break;
final double childMainAxisExtent = _getMainAxisExtent(child.size);
final double childCrossAxisExtent = _getCrossAxisExtent(child.size);
final double childCrossAxisOffset = _getChildCrossAxisOffset(
flipCrossAxis, runCrossAxisExtent, childCrossAxisExtent);
if (flipMainAxis) childMainPosition -= childMainAxisExtent;
childParentData.offset = _getOffset(
childMainPosition, crossAxisOffset + childCrossAxisOffset);
if (flipMainAxis)
childMainPosition -= childBetweenSpace;
else
childMainPosition += childMainAxisExtent +
(childParentData._isHide ? 0 : childBetweenSpace);
child = childParentData.nextSibling;
}
if (flipCrossAxis)
crossAxisOffset -= runBetweenSpace;
else
crossAxisOffset += runCrossAxisExtent + runBetweenSpace;
}
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
@override
void paint(PaintingContext context, Offset offset) {
// ignore: todo
// TODO(ianh): move the debug flex overflow paint logic somewhere common so
// it can be reused here
if (_hasVisualOverflow && clipBehavior != Clip.none) {
_clipRectLayer.layer = context.pushClipRect(
needsCompositing,
offset,
Offset.zero & size,
defaultPaint,
clipBehavior: clipBehavior,
oldLayer: _clipRectLayer.layer,
);
} else {
_clipRectLayer.layer = null;
defaultPaint(context, offset);
}
}
final LayerHandle<ClipRectLayer> _clipRectLayer =
LayerHandle<ClipRectLayer>();
@override
void dispose() {
_clipRectLayer.layer = null;
super.dispose();
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(EnumProperty<Axis>('direction', direction));
properties.add(EnumProperty<WrapAlignment>('alignment', alignment));
properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<WrapAlignment>('runAlignment', runAlignment));
properties.add(DoubleProperty('runSpacing', runSpacing));
properties.add(DoubleProperty('crossAxisAlignment', runSpacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection,
defaultValue: null));
properties.add(EnumProperty<VerticalDirection>(
'verticalDirection', verticalDirection,
defaultValue: VerticalDirection.down));
}
}
@@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/extended_wrap/extended_render_wrap.dart';
class ExtendedWrap extends MultiChildRenderObjectWidget {
/// Creates a wrap layout.
///
/// By default, the wrap layout is horizontal and both the children and the
/// runs are aligned to the start.
///
/// The [textDirection] argument defaults to the ambient [Directionality], if
/// any. If there is no ambient directionality, and a text direction is going
/// to be necessary to decide which direction to lay the children in or to
/// disambiguate `start` or `end` values for the main or cross axis
/// directions, the [textDirection] must not be null.
final int maxLines;
final Widget? overflowWidget;
ExtendedWrap({
Key? key,
this.direction = Axis.horizontal,
this.alignment = WrapAlignment.start,
this.spacing = 0.0,
this.runAlignment = WrapAlignment.start,
this.runSpacing = 0.0,
this.crossAxisAlignment = WrapCrossAlignment.start,
this.textDirection,
this.verticalDirection = VerticalDirection.down,
this.clipBehavior = Clip.none,
this.maxLines = 1,
this.overflowWidget,
List<Widget> children = const <Widget>[],
}) : assert(maxLines >= 1),
super(key: key, children: [
...children,
if (overflowWidget != null) overflowWidget
]);
/// The direction to use as the main axis.
///
/// For example, if [direction] is [Axis.horizontal], the default, the
/// children are placed adjacent to one another in a horizontal run until the
/// available horizontal space is consumed, at which point a subsequent
/// children are placed in a new run vertically adjacent to the previous run.
final Axis direction;
/// How the children within a run should be placed in the main axis.
///
/// For example, if [alignment] is [WrapAlignment.center], the children in
/// each run are grouped together in the center of their run in the main axis.
///
/// Defaults to [WrapAlignment.start].
///
/// See also:
///
/// * [runAlignment], which controls how the runs are placed relative to each
/// other in the cross axis.
/// * [crossAxisAlignment], which controls how the children within each run
/// are placed relative to each other in the cross axis.
final WrapAlignment alignment;
/// How much space to place between children in a run in the main axis.
///
/// For example, if [spacing] is 10.0, the children will be spaced at least
/// 10.0 logical pixels apart in the main axis.
///
/// If there is additional free space in a run (e.g., because the wrap has a
/// minimum size that is not filled or because some runs are longer than
/// others), the additional free space will be allocated according to the
/// [alignment].
///
/// Defaults to 0.0.
final double spacing;
/// How the runs themselves should be placed in the cross axis.
///
/// For example, if [runAlignment] is [WrapAlignment.center], the runs are
/// grouped together in the center of the overall [Wrap] in the cross axis.
///
/// Defaults to [WrapAlignment.start].
///
/// See also:
///
/// * [alignment], which controls how the children within each run are placed
/// relative to each other in the main axis.
/// * [crossAxisAlignment], which controls how the children within each run
/// are placed relative to each other in the cross axis.
final WrapAlignment runAlignment;
/// How much space to place between the runs themselves in the cross axis.
///
/// For example, if [runSpacing] is 10.0, the runs will be spaced at least
/// 10.0 logical pixels apart in the cross axis.
///
/// If there is additional free space in the overall [Wrap] (e.g., because
/// the wrap has a minimum size that is not filled), the additional free space
/// will be allocated according to the [runAlignment].
///
/// Defaults to 0.0.
final double runSpacing;
/// How the children within a run should be aligned relative to each other in
/// the cross axis.
///
/// For example, if this is set to [WrapCrossAlignment.end], and the
/// [direction] is [Axis.horizontal], then the children within each
/// run will have their bottom edges aligned to the bottom edge of the run.
///
/// Defaults to [WrapCrossAlignment.start].
///
/// See also:
///
/// * [alignment], which controls how the children within each run are placed
/// relative to each other in the main axis.
/// * [runAlignment], which controls how the runs are placed relative to each
/// other in the cross axis.
final WrapCrossAlignment crossAxisAlignment;
/// Determines the order to lay children out horizontally and how to interpret
/// `start` and `end` in the horizontal direction.
///
/// Defaults to the ambient [Directionality].
///
/// If the [direction] is [Axis.horizontal], this controls order in which the
/// children are positioned (left-to-right or right-to-left), and the meaning
/// of the [alignment] property's [WrapAlignment.start] and
/// [WrapAlignment.end] values.
///
/// If the [direction] is [Axis.horizontal], and either the
/// [alignment] is either [WrapAlignment.start] or [WrapAlignment.end], or
/// there's more than one child, then the [textDirection] (or the ambient
/// [Directionality]) must not be null.
///
/// If the [direction] is [Axis.vertical], this controls the order in which
/// runs are positioned, the meaning of the [runAlignment] property's
/// [WrapAlignment.start] and [WrapAlignment.end] values, as well as the
/// [crossAxisAlignment] property's [WrapCrossAlignment.start] and
/// [WrapCrossAlignment.end] values.
///
/// If the [direction] is [Axis.vertical], and either the
/// [runAlignment] is either [WrapAlignment.start] or [WrapAlignment.end], the
/// [crossAxisAlignment] is either [WrapCrossAlignment.start] or
/// [WrapCrossAlignment.end], or there's more than one child, then the
/// [textDirection] (or the ambient [Directionality]) must not be null.
final TextDirection? textDirection;
/// Determines the order to lay children out vertically and how to interpret
/// `start` and `end` in the vertical direction.
///
/// If the [direction] is [Axis.vertical], this controls which order children
/// are painted in (down or up), the meaning of the [alignment] property's
/// [WrapAlignment.start] and [WrapAlignment.end] values.
///
/// If the [direction] is [Axis.vertical], and either the [alignment]
/// is either [WrapAlignment.start] or [WrapAlignment.end], or there's
/// more than one child, then the [verticalDirection] must not be null.
///
/// If the [direction] is [Axis.horizontal], this controls the order in which
/// runs are positioned, the meaning of the [runAlignment] property's
/// [WrapAlignment.start] and [WrapAlignment.end] values, as well as the
/// [crossAxisAlignment] property's [WrapCrossAlignment.start] and
/// [WrapCrossAlignment.end] values.
///
/// If the [direction] is [Axis.horizontal], and either the
/// [runAlignment] is either [WrapAlignment.start] or [WrapAlignment.end], the
/// [crossAxisAlignment] is either [WrapCrossAlignment.start] or
/// [WrapCrossAlignment.end], or there's more than one child, then the
/// [verticalDirection] must not be null.
final VerticalDirection verticalDirection;
/// {@macro flutter.material.Material.clipBehavior}
///
/// Defaults to [Clip.none].
final Clip clipBehavior;
@override
ExtendedRenderWrap createRenderObject(BuildContext context) {
return ExtendedRenderWrap(
direction: direction,
alignment: alignment,
spacing: spacing,
runAlignment: runAlignment,
runSpacing: runSpacing,
crossAxisAlignment: crossAxisAlignment,
textDirection: textDirection ?? Directionality.maybeOf(context),
verticalDirection: verticalDirection,
clipBehavior: clipBehavior,
maxLines: maxLines,
hasOverflow: (overflowWidget != null));
}
@override
void updateRenderObject(
BuildContext context, ExtendedRenderWrap renderObject) {
renderObject
..direction = direction
..alignment = alignment
..spacing = spacing
..runAlignment = runAlignment
..runSpacing = runSpacing
..crossAxisAlignment = crossAxisAlignment
..textDirection = textDirection ?? Directionality.maybeOf(context)
..verticalDirection = verticalDirection
..maxLines = maxLines
// ..overflowWidget = overflowWidget
..hasOverflow = (overflowWidget != null)
..clipBehavior = clipBehavior;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(EnumProperty<Axis>('direction', direction));
properties.add(EnumProperty<WrapAlignment>('alignment', alignment));
properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<WrapAlignment>('runAlignment', runAlignment));
properties.add(DoubleProperty('runSpacing', runSpacing));
properties.add(DoubleProperty('crossAxisAlignment', runSpacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection,
defaultValue: null));
properties.add(EnumProperty<VerticalDirection>(
'verticalDirection', verticalDirection,
defaultValue: VerticalDirection.down));
properties.add(IntProperty('maxLines', maxLines));
}
}
+204
View File
@@ -0,0 +1,204 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'
if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart';
import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart';
import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart';
import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart';
import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/message.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/recent_conversation_list.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart';
import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart';
GlobalKey<_ForwardMessageScreenState> forwardMessageScreenKey = GlobalKey();
class ForwardMessageScreen extends StatefulWidget {
final bool isMergerForward;
final ConvType conversationType;
final TUIChatSeparateViewModel model;
final VoidCallback? onClose;
const ForwardMessageScreen(
{Key? key, this.isMergerForward = false, required this.conversationType, required this.model, this.onClose})
: super(key: key);
@override
State<StatefulWidget> createState() => _ForwardMessageScreenState();
}
class _ForwardMessageScreenState extends TIMUIKitState<ForwardMessageScreen> {
final TUIChatGlobalModel model = serviceLocator<TUIChatGlobalModel>();
final TUISelfInfoViewModel _selfInfoViewModel = serviceLocator<TUISelfInfoViewModel>();
List<V2TimConversation> _conversationList = [];
bool isMultiSelect = false;
String _getMergerMessageTitle() {
if (widget.conversationType == ConvType.c2c) {
final option1 =
(_selfInfoViewModel.loginInfo?.nickName != null && _selfInfoViewModel.loginInfo!.nickName!.isNotEmpty)
? _selfInfoViewModel.loginInfo?.nickName
: _selfInfoViewModel.loginInfo?.userID;
// Chat History for xx
return TIM_t_para("{{option1}}的聊天记录", "$option1的聊天记录")(option1: option1);
} else {
return TIM_t("群聊的聊天记录");
}
}
List<String> _getAbstractList() {
return widget.model.getSelectedMessageList().map((e) {
final sender = (e.nickName != null && e.nickName!.isNotEmpty) ? e.nickName : e.sender;
return "$sender: ${model.abstractMessageBuilder != null ? model.abstractMessageBuilder!(e) : MessageUtils.getAbstractMessageAsync(e, [])}";
}).toList();
}
handleForwardMessage() async {
var confirmResult = await _showConfirmForwardDialog(context);
if (confirmResult == null) {
return;
}
if (widget.isMergerForward) {
await widget.model.sendMergerMessage(
conversationList: _conversationList,
title: _getMergerMessageTitle(),
abstractList: _getAbstractList(),
context: context,
);
} else {
await widget.model.sendForwardMessage(conversationList: _conversationList);
}
widget.model.updateMultiSelectStatus(false);
if (widget.onClose != null) {
// widget.onClose!();
} else {
Navigator.pop(context);
}
}
// 弹出转发确认对话框
Future<bool?> _showConfirmForwardDialog(BuildContext context) {
return showDialog<bool>(
context: context,
builder: (context) {
return CupertinoAlertDialog(
title: Text(TIM_t("您确定进行转发吗?")),
actions: [
CupertinoDialogAction(
child: Text(TIM_t("确定")),
onPressed: () {
Navigator.of(context).pop(true);
},
),
CupertinoDialogAction(
child: Text(TIM_t("取消")),
isDestructiveAction: true,
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
}
@override
void dispose() {
super.dispose();
widget.model.updateMultiSelectStatus(false);
}
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop;
final TUITheme theme = value.theme;
if (isDesktopScreen) {
isMultiSelect = true;
return RecentForwardList(
isMultiSelect: isMultiSelect,
onChanged: (conversationList) {
_conversationList = conversationList;
if (!isMultiSelect) {
handleForwardMessage();
}
},
);
}
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: Text(
isMultiSelect ? TIM_t("选择多个会话") : TIM_t("选择一个会话"),
style: TextStyle(
color: theme.appbarTextColor,
fontSize: 17,
),
),
shadowColor: theme.weakBackgroundColor,
backgroundColor: theme.appbarBgColor ?? theme.primaryColor,
leadingWidth: 80,
leading: TextButton(
onPressed: () {
if (isMultiSelect) {
setState(() {
isMultiSelect = false;
_conversationList = [];
});
} else {
widget.model.updateMultiSelectStatus(false);
if (widget.onClose != null) {
widget.onClose!();
} else {
Navigator.pop(context);
}
}
},
child: Text(
TIM_t("取消"),
style: TextStyle(
color: theme.appbarTextColor,
fontSize: 14,
),
),
),
actions: [
TextButton(
onPressed: () {
if (!isMultiSelect) {
setState(() {
isMultiSelect = true;
});
} else {
handleForwardMessage();
}
},
child: Text(
!isMultiSelect ? TIM_t("多选") : TIM_t("完成"),
style: TextStyle(
color: theme.appbarTextColor,
fontSize: 14,
),
),
)
],
),
body: RecentForwardList(
isMultiSelect: isMultiSelect,
onChanged: (conversationList) {
_conversationList = conversationList;
if (!isMultiSelect) {
handleForwardMessage();
}
},
),
);
}
}
+475
View File
@@ -0,0 +1,475 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:extended_image/extended_image.dart';
Map<Object?, GestureDetails?> _gestureDetailsCache =
<Object?, GestureDetails?>{};
///clear the gesture details
void clearGestureDetailsCache() {
_gestureDetailsCache.clear();
}
/// scale idea from https://github.com/flutter/flutter/blob/master/examples/layers/widgets/gestures.dart
/// zoom image
class GesturedImage extends ExtendedImageGesture {
const GesturedImage(ExtendedImageState extendedImageState,
{ImageBuilderForGesture? imageBuilder,
CanScaleImage? canScaleImage,
Key? key})
: super(extendedImageState,
imageBuilder: imageBuilder, canScaleImage: canScaleImage, key: key);
@override
GesturedImageState createState() => GesturedImageState();
}
class GesturedImageState extends ExtendedImageGestureState {
///details for gesture
GestureDetails? _gestureDetails;
late Offset _normalizedOffset;
double? _startingScale;
late Offset _startingOffset;
Offset? _pointerDownPosition;
late GestureAnimation _gestureAnimation;
GestureConfig? _gestureConfig;
ExtendedImageGesturePageViewState? _pageViewState;
@override
ExtendedImageSlidePageState? get extendedImageSlidePageState =>
widget.extendedImageState.slidePageState;
@override
GestureDetails? get gestureDetails => _gestureDetails;
@override
set gestureDetails(GestureDetails? value) {
if (mounted) {
setState(() {
_gestureDetails = value;
_gestureConfig?.gestureDetailsIsChanged?.call(_gestureDetails);
});
}
}
@override
GestureConfig? get imageGestureConfig => _gestureConfig;
@override
Offset? get pointerDownPosition => _pointerDownPosition;
@override
Widget build(BuildContext context) {
if (_gestureConfig!.cacheGesture) {
_gestureDetailsCache[widget.extendedImageState.imageStreamKey] =
_gestureDetails;
}
Widget image = ExtendedRawImage(
image: widget.extendedImageState.extendedImageInfo?.image,
width: widget.extendedImageState.imageWidget.width,
height: widget.extendedImageState.imageWidget.height,
scale: widget.extendedImageState.extendedImageInfo?.scale ?? 1.0,
color: widget.extendedImageState.imageWidget.color,
colorBlendMode: widget.extendedImageState.imageWidget.colorBlendMode,
fit: widget.extendedImageState.imageWidget.fit,
alignment: widget.extendedImageState.imageWidget.alignment,
repeat: widget.extendedImageState.imageWidget.repeat,
centerSlice: widget.extendedImageState.imageWidget.centerSlice,
matchTextDirection:
widget.extendedImageState.imageWidget.matchTextDirection,
invertColors: widget.extendedImageState.invertColors,
filterQuality: widget.extendedImageState.imageWidget.filterQuality,
beforePaintImage: widget.extendedImageState.imageWidget.beforePaintImage,
afterPaintImage: widget.extendedImageState.imageWidget.afterPaintImage,
gestureDetails: _gestureDetails,
);
if (extendedImageSlidePageState != null) {
image = widget.extendedImageState.imageWidget.heroBuilderForSlidingPage
?.call(image) ??
image;
if (extendedImageSlidePageState!.widget.slideType ==
SlideType.onlyImage) {
image = Transform.translate(
offset: extendedImageSlidePageState!.offset,
child: Transform.scale(
scale: extendedImageSlidePageState!.scale,
child: image,
),
);
}
}
image = widget.imageBuilder?.call(image) ?? image;
image = GestureDetector(
onScaleStart: handleScaleStart,
onScaleUpdate: handleScaleUpdate,
onScaleEnd: handleScaleEnd,
onDoubleTap: _handleDoubleTap,
child: image,
behavior: _gestureConfig?.hitTestBehavior,
);
image = Listener(
child: image,
onPointerDown: _handlePointerDown,
onPointerSignal: _handlePointerSignal,
behavior: _gestureConfig!.hitTestBehavior,
);
return image;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_pageViewState = null;
if (_gestureConfig!.inPageView) {
_pageViewState =
context.findAncestorStateOfType<ExtendedImageGesturePageViewState>();
_pageViewState?.extendedImageGestureState = this;
}
}
@override
void didUpdateWidget(GesturedImage oldWidget) {
super.didUpdateWidget(oldWidget);
_initGestureConfig();
_pageViewState = null;
if (_gestureConfig!.inPageView) {
_pageViewState =
context.findAncestorStateOfType<ExtendedImageGesturePageViewState>();
_pageViewState?.extendedImageGestureState = this;
}
}
@override
void dispose() {
_gestureAnimation.stop();
_gestureAnimation.dispose();
_pageViewState?.extendedImageGestureStates.remove(this);
super.dispose();
}
@override
void handleDoubleTap({double? scale, Offset? doubleTapPosition}) {
doubleTapPosition ??= _pointerDownPosition;
scale ??= _gestureConfig!.initialScale;
//scale = scale.clamp(_gestureConfig.minScale, _gestureConfig.maxScale);
handleScaleStart(ScaleStartDetails(focalPoint: doubleTapPosition!));
handleScaleUpdate(ScaleUpdateDetails(
focalPoint: doubleTapPosition,
scale: scale / _startingScale!,
focalPointDelta: Offset.zero,
));
if (scale < _gestureConfig!.minScale || scale > _gestureConfig!.maxScale) {
handleScaleEnd(ScaleEndDetails());
}
}
@override
void initState() {
super.initState();
_initGestureConfig();
}
@override
void reset() {
_gestureConfig = widget
.extendedImageState.imageWidget.initGestureConfigHandler
?.call(widget.extendedImageState) ??
GestureConfig();
gestureDetails = GestureDetails(
totalScale: _gestureConfig!.initialScale,
offset: Offset.zero,
initialAlignment: _gestureConfig!.initialAlignment,
);
}
@override
void slide() {
if (mounted) {
setState(() {
_gestureDetails!.slidePageOffset = extendedImageSlidePageState?.offset;
});
}
}
void _handleDoubleTap() {
if (widget.extendedImageState.imageWidget.onDoubleTap != null) {
widget.extendedImageState.imageWidget.onDoubleTap!(this);
return;
}
if (!mounted) {
return;
}
gestureDetails = GestureDetails(
offset: Offset.zero,
totalScale: _gestureConfig!.initialScale,
);
}
void _handlePointerDown(PointerDownEvent pointerDownEvent) {
_pointerDownPosition = pointerDownEvent.position;
_gestureAnimation.stop();
_pageViewState?.extendedImageGestureState = this;
}
void _handlePointerSignal(PointerSignalEvent event) {
if (event is PointerScrollEvent && event.kind == PointerDeviceKind.mouse) {
handleScaleStart(ScaleStartDetails(focalPoint: event.position));
final double dy = event.scrollDelta.dy;
final double dx = event.scrollDelta.dx;
handleScaleUpdate(ScaleUpdateDetails(
focalPoint: event.position,
scale: 1.0 +
_reverseIf((dy.abs() > dx.abs() ? dy : dx) *
_gestureConfig!.speed /
1000.0),
focalPointDelta: Offset.zero));
handleScaleEnd(ScaleEndDetails());
}
}
@override
void handleScaleEnd(ScaleEndDetails details) {
if (extendedImageSlidePageState != null &&
extendedImageSlidePageState!.isSliding) {
extendedImageSlidePageState!.endSlide(details);
return;
}
if (_pageViewState != null && _pageViewState!.isDraging) {
_pageViewState!.onDragEnd(DragEndDetails(
velocity: details.velocity,
primaryVelocity:
_pageViewState!.widget.scrollDirection == Axis.horizontal
? details.velocity.pixelsPerSecond.dx
: details.velocity.pixelsPerSecond.dy,
));
return;
}
//animate back to maxScale if gesture exceeded the maxScale specified
if (_gestureDetails!.totalScale!.greaterThan(_gestureConfig!.maxScale)) {
final double velocity =
(_gestureDetails!.totalScale! - _gestureConfig!.maxScale) /
_gestureConfig!.maxScale;
_gestureAnimation.animationScale(
_gestureDetails!.totalScale, _gestureConfig!.maxScale, velocity);
return;
}
//animate back to minScale if gesture fell smaller than the minScale specified
if (_gestureDetails!.totalScale!.lessThan(_gestureConfig!.minScale)) {
final double velocity =
(_gestureConfig!.minScale - _gestureDetails!.totalScale!) /
_gestureConfig!.minScale;
_gestureAnimation.animationScale(
_gestureDetails!.totalScale, _gestureConfig!.minScale, velocity);
return;
}
if (_gestureDetails!.actionType == ActionType.pan) {
// get magnitude from gesture velocity
final double magnitude = details.velocity.pixelsPerSecond.distance;
// do a significant magnitude
if (magnitude.greaterThanOrEqualTo(minMagnitude)) {
final Offset direction = details.velocity.pixelsPerSecond /
magnitude *
_gestureConfig!.inertialSpeed;
_gestureAnimation.animationOffset(
_gestureDetails!.offset, _gestureDetails!.offset! + direction);
}
}
}
@override
void handleScaleStart(ScaleStartDetails details) {
_gestureAnimation.stop();
_normalizedOffset = (details.focalPoint - _gestureDetails!.offset!) /
_gestureDetails!.totalScale!;
_startingScale = _gestureDetails!.totalScale;
_startingOffset = details.focalPoint;
}
@override
void handleScaleUpdate(ScaleUpdateDetails details) {
// 取消原组件对totalScale的判断。这样scale大于1也能执行slidePage的slide方法
if (extendedImageSlidePageState != null &&
_gestureDetails!.userOffset &&
_gestureDetails!.actionType == ActionType.pan) {
final Offset totalDelta = details.focalPointDelta;
bool updateGesture = false;
if (!extendedImageSlidePageState!.isSliding) {
if (totalDelta.dx != 0 &&
totalDelta.dx.abs().greaterThan(totalDelta.dy.abs())) {
if (_gestureDetails!.computeHorizontalBoundary) {
if (totalDelta.dx > 0) {
updateGesture = _gestureDetails!.boundary.left;
} else {
updateGesture = _gestureDetails!.boundary.right;
}
} else {
updateGesture = true;
}
}
if (totalDelta.dy != 0 &&
totalDelta.dy.abs().greaterThan(totalDelta.dx.abs())) {
if (_gestureDetails!.computeVerticalBoundary) {
if (totalDelta.dy < 0) {
updateGesture = _gestureDetails!.boundary.bottom;
} else {
updateGesture = _gestureDetails!.boundary.top;
}
} else {
updateGesture = true;
}
}
} else {
updateGesture = true;
}
if (details.focalPointDelta.distance.greaterThan(minGesturePageDelta) &&
updateGesture) {
extendedImageSlidePageState!.slide(
details.focalPointDelta,
extendedImageGestureState: this,
);
}
}
if (extendedImageSlidePageState != null &&
extendedImageSlidePageState!.isSliding) {
return;
}
// totalScale > 1 and page view is starting to move
if (_pageViewState != null) {
final ExtendedImageGesturePageViewState pageViewState = _pageViewState!;
final Axis axis = pageViewState.widget.scrollDirection;
final bool movePage = _pageViewState!.isDraging ||
(details.pointerCount == 1 &&
details.scale == 1 &&
_gestureDetails!.movePage(details.focalPointDelta, axis));
if (movePage) {
if (!pageViewState.isDraging) {
pageViewState
.onDragDown(DragDownDetails(globalPosition: details.focalPoint));
pageViewState.onDragStart(
DragStartDetails(globalPosition: details.focalPoint));
//assert(!pageViewState.isDraging);
}
Offset delta = details.focalPointDelta;
delta =
axis == Axis.horizontal ? Offset(delta.dx, 0) : Offset(0, delta.dy);
pageViewState.onDragUpdate(DragUpdateDetails(
globalPosition: details.focalPoint,
delta: delta,
primaryDelta: axis == Axis.horizontal ? delta.dx : delta.dy,
));
return;
}
}
final double? scale = widget.canScaleImage(_gestureDetails)
? clampScale(
_startingScale! * details.scale * _gestureConfig!.speed,
_gestureConfig!.animationMinScale,
_gestureConfig!.animationMaxScale)
: _gestureDetails!.totalScale;
//Round the scale to three points after comma to prevent shaking
//scale = roundAfter(scale, 3);
//no more zoom
if (details.scale != 1.0 &&
((_gestureDetails!.totalScale!
.equalTo(_gestureConfig!.animationMinScale) &&
scale!.lessThanOrEqualTo(_gestureDetails!.totalScale!)) ||
(_gestureDetails!.totalScale!
.equalTo(_gestureConfig!.animationMaxScale) &&
scale!.greaterThanOrEqualTo(_gestureDetails!.totalScale!)))) {
return;
}
final Offset offset = (details.scale == 1.0
? details.focalPoint * _gestureConfig!.speed
: _startingOffset) -
_normalizedOffset * scale!;
if (mounted &&
(offset != _gestureDetails!.offset ||
scale != _gestureDetails!.totalScale)) {
gestureDetails = GestureDetails(
offset: offset,
totalScale: scale,
gestureDetails: _gestureDetails,
actionType: details.scale != 1.0 ? ActionType.zoom : ActionType.pan);
}
}
void _initGestureConfig() {
final double? initialScale = _gestureConfig?.initialScale;
final InitialAlignment? initialAlignment = _gestureConfig?.initialAlignment;
_gestureConfig = widget
.extendedImageState.imageWidget.initGestureConfigHandler
?.call(widget.extendedImageState) ??
GestureConfig();
if (_gestureDetails == null ||
initialScale != _gestureConfig!.initialScale ||
initialAlignment != _gestureConfig!.initialAlignment) {
_gestureDetails = GestureDetails(
totalScale: _gestureConfig!.initialScale,
offset: Offset.zero,
initialAlignment: _gestureConfig!.initialAlignment,
);
}
if (_gestureConfig!.cacheGesture) {
final GestureDetails? cache =
_gestureDetailsCache[widget.extendedImageState.imageStreamKey];
if (cache != null) {
_gestureDetails = cache;
}
}
_gestureDetails ??= GestureDetails(
totalScale: _gestureConfig!.initialScale,
offset: Offset.zero,
);
_gestureAnimation = GestureAnimation(this, offsetCallBack: (Offset value) {
gestureDetails = GestureDetails(
offset: value,
totalScale: _gestureDetails!.totalScale,
gestureDetails: _gestureDetails);
}, scaleCallBack: (double scale) {
gestureDetails = GestureDetails(
offset: _gestureDetails!.offset,
totalScale: scale,
gestureDetails: _gestureDetails,
actionType: ActionType.zoom,
userOffset: false);
});
}
double _reverseIf(double scaleDetal) {
if (_gestureConfig?.reverseMousePointerScrollDirection ?? false) {
return -scaleDetal;
} else {
return scaleDetal;
}
}
}
+307
View File
@@ -0,0 +1,307 @@
// ignore_for_file: must_be_immutable
import 'package:azlistview_all_platforms/azlistview_all_platforms.dart';
import 'package:flutter/material.dart';
import 'package:flutter_slidable_plus_plus/flutter_slidable_plus_plus.dart';
import 'package:lpinyin/lpinyin.dart';
import 'package:provider/provider.dart';
import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart';
import 'package:tencent_cloud_chat_sdk/enum/group_member_role.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart'
if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/optimize_utils.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/az_list_view.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/radio_button.dart';
import 'package:tencent_cloud_chat_uikit/theme/color.dart';
import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart';
import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart';
class GroupProfileMemberList extends StatefulWidget {
static String AT_ALL_USER_ID = "__kImSDK_MesssageAtALL__";
final List<V2TimGroupMemberFullInfo?> memberList;
final Function(String userID)? removeMember;
final bool canSlideDelete;
final bool canSelectMember;
final bool canAtAll;
// when the @ need filter some group types
final String? groupType;
final Function(List<V2TimGroupMemberFullInfo> selectedMember)? onSelectedMemberChange;
// notice: onTapMemberItem and onSelectedMemberChange use together will triger together
final Function(V2TimGroupMemberFullInfo memberInfo, TapDownDetails? tapDetails)? onTapMemberItem;
// When sliding to the bottom bar callBack
final Function()? touchBottomCallBack;
final int? maxSelectNum;
Widget? customTopArea;
GroupProfileMemberList({
Key? key,
required this.memberList,
this.groupType,
this.removeMember,
this.canSlideDelete = true,
this.canSelectMember = false,
this.canAtAll = false,
this.onSelectedMemberChange,
this.onTapMemberItem,
this.customTopArea,
this.touchBottomCallBack,
this.maxSelectNum,
}) : super(key: key);
@override
State<StatefulWidget> createState() => _GroupProfileMemberListState();
}
class _GroupProfileMemberListState extends TIMUIKitState<GroupProfileMemberList> {
List<V2TimGroupMemberFullInfo> selectedMemberList = [];
_getShowName(V2TimGroupMemberFullInfo? item) {
final friendRemark = item?.friendRemark ?? "";
final nameCard = item?.nameCard ?? "";
final nickName = item?.nickName ?? "";
final userID = item?.userID ?? "";
return friendRemark.isNotEmpty
? friendRemark
: nameCard.isNotEmpty
? nameCard
: nickName.isNotEmpty
? nickName
: userID;
}
List<ISuspensionBeanImpl> _getShowList(List<V2TimGroupMemberFullInfo?> memberList) {
final List<ISuspensionBeanImpl> showList = List.empty(growable: true);
for (var i = 0; i < memberList.length; i++) {
final item = memberList[i];
final showName = _getShowName(item);
if (item?.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_OWNER ||
item?.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_ADMIN) {
showList.add(ISuspensionBeanImpl(memberInfo: item, tagIndex: "@"));
} else {
String pinyin = PinyinHelper.getPinyinE(showName);
String tag = pinyin.substring(0, 1).toUpperCase();
if (RegExp("[A-Z]").hasMatch(tag)) {
showList.add(ISuspensionBeanImpl(memberInfo: item, tagIndex: tag));
} else {
showList.add(ISuspensionBeanImpl(memberInfo: item, tagIndex: "#"));
}
}
}
SuspensionUtil.sortListBySuspensionTag(showList);
// add @everyone item
if (widget.canAtAll) {
final canAtGroupType = ["Work", "Public", "Meeting"];
if (canAtGroupType.contains(widget.groupType)) {
showList.insert(
0,
ISuspensionBeanImpl(
memberInfo:
V2TimGroupMemberFullInfo(userID: GroupProfileMemberList.AT_ALL_USER_ID, nickName: TIM_t("所有人")),
tagIndex: ""));
}
}
return showList;
}
Widget _buildListItem(BuildContext context, V2TimGroupMemberFullInfo memberInfo) {
final theme = Provider.of<TUIThemeViewModel>(context).theme;
final isDesktopScreen = TUIKitScreenUtils.getFormFactor() == DeviceType.Desktop;
final isGroupMember = memberInfo.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_MEMBER;
return Container(
color: Colors.white,
child: Slidable(
endActionPane: widget.canSlideDelete && isGroupMember
? ActionPane(motion: const DrawerMotion(), children: [
SlidableAction(
onPressed: (_) {
if (widget.removeMember != null) {
widget.removeMember!(memberInfo.userID);
}
},
flex: 1,
backgroundColor: theme.cautionColor ?? CommonColor.cautionColor,
autoClose: true,
label: TIM_t("删除"),
)
])
: null,
child: Column(children: [
ListTile(
tileColor: Colors.black,
title: Row(
children: [
if (widget.canSelectMember)
Container(
margin: const EdgeInsets.only(right: 10),
child: CheckBoxButton(
onChanged: (isChecked) {
if (isChecked) {
if (widget.maxSelectNum != null && selectedMemberList.length >= widget.maxSelectNum!) {
return;
}
selectedMemberList.add(memberInfo);
} else {
selectedMemberList.removeWhere((element) => element.userID == memberInfo.userID);
}
if (widget.onSelectedMemberChange != null) {
widget.onSelectedMemberChange!(selectedMemberList);
}
setState(() {});
},
isChecked: selectedMemberList
.where((element) => element.userID == memberInfo.userID)
.toList()
.isNotEmpty),
),
Container(
width: isDesktopScreen ? 30 : 36,
height: isDesktopScreen ? 30 : 36,
margin: const EdgeInsets.only(right: 10),
child: Avatar(
faceUrl: memberInfo.faceUrl ?? "",
showName: _getShowName(memberInfo),
type: 1,
),
),
Text(_getShowName(memberInfo), style: TextStyle(fontSize: isDesktopScreen ? 14 : 16)),
memberInfo.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_OWNER
? Container(
margin: const EdgeInsets.only(left: 5),
child: Text(TIM_t("群主"),
style: TextStyle(
color: theme.ownerColor,
fontSize: isDesktopScreen ? 10 : 12,
)),
padding: const EdgeInsets.fromLTRB(5, 0, 5, 0),
decoration: BoxDecoration(
border: Border.all(color: theme.ownerColor ?? CommonColor.ownerColor, width: 1),
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
),
)
: memberInfo.role == GroupMemberRoleType.V2TIM_GROUP_MEMBER_ROLE_ADMIN
? Container(
margin: const EdgeInsets.only(left: 5),
child: Text(TIM_t("管理员"),
style: TextStyle(
color: theme.adminColor,
fontSize: 12,
)),
padding: const EdgeInsets.fromLTRB(5, 0, 5, 0),
decoration: BoxDecoration(
border: Border.all(color: theme.adminColor ?? CommonColor.adminColor, width: 1),
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
),
)
: Container()
],
),
onTap: () {
if (widget.onTapMemberItem != null) {
widget.onTapMemberItem!(memberInfo, null);
}
if (widget.canSelectMember) {
final isChecked = selectedMemberList.contains(memberInfo);
if (isChecked) {
selectedMemberList.remove(memberInfo);
} else {
if (widget.maxSelectNum != null && selectedMemberList.length >= widget.maxSelectNum!) {
return;
}
selectedMemberList.add(memberInfo);
}
if (widget.onSelectedMemberChange != null) {
widget.onSelectedMemberChange!(selectedMemberList);
}
setState(() {});
}
},
),
Divider(thickness: 1, indent: 74, endIndent: 0, color: theme.weakBackgroundColor, height: 0)
])));
}
static Widget getSusItem(BuildContext context, TUITheme theme, String tag, {double susHeight = 40}) {
if (tag == '@') {
tag = TIM_t("群主、管理员");
}
return Container(
height: susHeight,
width: MediaQuery.of(context).size.width,
padding: const EdgeInsets.only(left: 16.0),
color: theme.weakBackgroundColor,
alignment: Alignment.centerLeft,
child: Text(
tag,
softWrap: true,
style: TextStyle(
fontSize: 14.0,
color: theme.darkTextColor,
),
),
);
}
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
final TUITheme theme = value.theme;
final isDesktopScreen = TUIKitScreenUtils.getFormFactor() == DeviceType.Desktop;
final throteFunction = OptimizeUtils.throttle((ScrollNotification notification) {
final pixels = notification.metrics.pixels;
// 总像素高度
final maxScrollExtent = notification.metrics.maxScrollExtent;
// 滑动百分比
final progress = pixels / maxScrollExtent;
if (progress >= 0.9 && widget.touchBottomCallBack != null) {
widget.touchBottomCallBack!();
}
}, 300);
final showList = _getShowList(widget.memberList);
return Container(
color: isDesktopScreen ? null : theme.weakBackgroundColor,
child: SafeArea(
child: Column(
children: [
widget.customTopArea != null ? widget.customTopArea! : Container(),
Expanded(
child: NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
throteFunction(notification);
return true;
},
child: (showList.isEmpty)
? Center(
child: Text(TIM_t("暂无群成员")),
)
: Container(
padding: isDesktopScreen ? const EdgeInsets.symmetric(horizontal: 16) : null,
child: AZListViewContainer(
memberList: showList,
susItemBuilder: (context, index) {
final model = showList[index];
return getSusItem(context, theme, model.getSuspensionTag());
},
itemBuilder: (context, index) {
final memberInfo = showList[index].memberInfo as V2TimGroupMemberFullInfo;
return _buildListItem(context, memberInfo);
}),
),
))
],
)),
);
}
}
+106
View File
@@ -0,0 +1,106 @@
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart';
/// make hero better when slide out
class HeroWidget extends StatefulWidget {
const HeroWidget(
{required this.child,
required this.tag,
required this.slidePagekey,
this.slideType = SlideType.onlyImage,
Key? key})
: super(key: key);
final Widget child;
final SlideType slideType;
final Object tag;
final GlobalKey<ExtendedImageSlidePageState> slidePagekey;
@override
_HeroWidgetState createState() => _HeroWidgetState();
}
class _HeroWidgetState extends TIMUIKitState<HeroWidget> {
RectTween? _rectTween;
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
return Hero(
tag: widget.tag,
createRectTween: (Rect? begin, Rect? end) {
_rectTween = RectTween(begin: begin, end: end);
return _rectTween!;
},
// make hero better when slide out
flightShuttleBuilder: (BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection flightDirection,
BuildContext fromHeroContext,
BuildContext toHeroContext) {
// make hero more smoothly
final Hero hero = (flightDirection == HeroFlightDirection.pop
? fromHeroContext.widget
: toHeroContext.widget) as Hero;
if (_rectTween == null) {
return hero;
}
if (flightDirection == HeroFlightDirection.pop) {
final bool fixTransform = widget.slideType == SlideType.onlyImage &&
(widget.slidePagekey.currentState!.offset != Offset.zero ||
widget.slidePagekey.currentState!.scale != 1.0);
final Widget toHeroWidget = (toHeroContext.widget as Hero).child;
return AnimatedBuilder(
animation: animation,
builder: (BuildContext buildContext, Widget? child) {
Widget animatedBuilderChild = hero.child;
// make hero more smoothly
animatedBuilderChild = Stack(
clipBehavior: Clip.antiAlias,
alignment: Alignment.center,
children: <Widget>[
Opacity(
opacity: 1 - animation.value,
child: UnconstrainedBox(
child: SizedBox(
width: _rectTween!.begin!.width,
height: _rectTween!.begin!.height,
child: toHeroWidget,
),
),
),
Opacity(
opacity: animation.value,
child: animatedBuilderChild,
)
],
);
// fix transform when slide out
if (fixTransform) {
final Tween<Offset> offsetTween = Tween<Offset>(
begin: Offset.zero,
end: widget.slidePagekey.currentState!.offset);
final Tween<double> scaleTween = Tween<double>(
begin: 1.0, end: widget.slidePagekey.currentState!.scale);
animatedBuilderChild = Transform.translate(
offset: offsetTween.evaluate(animation),
child: Transform.scale(
scale: scaleTween.evaluate(animation),
child: animatedBuilderChild,
),
);
}
return animatedBuilderChild;
},
);
}
return hero.child;
},
child: widget.child,
);
}
}
+298
View File
@@ -0,0 +1,298 @@
import 'dart:math';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:loading_animation_widget/loading_animation_widget.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/gestured_image.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/image_hero.dart';
typedef DoubleClickAnimationListener = void Function();
class ImageScreen extends StatefulWidget {
const ImageScreen(
{required this.imageProvider,
required this.heroTag,
this.downloadFn,
this.messageID,
Key? key})
: super(key: key);
final ImageProvider imageProvider;
final String heroTag;
final String? messageID;
final Future<void> Function()? downloadFn;
@override
State<StatefulWidget> createState() {
return _ImageScreenState();
}
}
class _ImageScreenState extends TIMUIKitState<ImageScreen>
with TickerProviderStateMixin {
Animation<double>? _doubleClickAnimation;
late DoubleClickAnimationListener _doubleClickAnimationListener;
late AnimationController _doubleClickAnimationController;
List<double> doubleTapScales = <double>[1.0, 2.0];
double currentScale = 1.0;
double fittedScale = 1.0;
double initialScale = 1.0;
bool isLoading = false;
GlobalKey<ExtendedImageSlidePageState> slidePageKey =
GlobalKey<ExtendedImageSlidePageState>();
GlobalKey<ExtendedImageGestureState> extendedImageGestureKey =
GlobalKey<ExtendedImageGestureState>();
void close() {
slidePageKey.currentState!.popPage();
Navigator.pop(context);
}
@override
void initState() {
super.initState();
// 允许横屏
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
_doubleClickAnimationController = AnimationController(
duration: const Duration(milliseconds: 150), vsync: this);
}
@override
void dispose() {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
_doubleClickAnimationController.dispose();
super.dispose();
}
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
return OrientationBuilder(builder: ((context, orientation) {
if (extendedImageGestureKey.currentState != null) {
extendedImageGestureKey.currentState!.reset();
}
return Material(
color: Colors.transparent,
child: Container(
color: Colors.transparent,
constraints: BoxConstraints.expand(
height: MediaQuery.of(context).size.height,
),
child: Stack(
alignment: Alignment.center,
children: [
Positioned(
top: 0,
left: 0,
bottom: 0,
right: 0,
child: ExtendedImageSlidePage(
key: slidePageKey,
slideAxis: SlideAxis.both,
slidePageBackgroundHandler: (Offset offset, Size size) {
if (orientation == Orientation.landscape) {
return Colors.black;
}
double opacity = 0.0;
opacity = offset.distance /
(Offset(size.width, size.height).distance / 2.0);
return Colors.black
.withOpacity(min(1.0, max(1.0 - opacity, 0.0)));
},
slideType: SlideType.onlyImage,
slideEndHandler: (
Offset offset, {
ExtendedImageSlidePageState? state,
ScaleEndDetails? details,
}) {
final vy = details?.velocity.pixelsPerSecond.dy ?? 0;
final oy = offset.dy;
if (vy > 300 || oy > 100) {
return true;
}
return null;
},
child: GestureDetector(
onTap: close,
child: HeroWidget(
tag: widget.heroTag,
slidePagekey: slidePageKey,
child: ExtendedImage(
image: widget.imageProvider,
extendedImageGestureKey:
extendedImageGestureKey,
enableSlideOutPage: true,
// fit: BoxFit.scaleDown,
initGestureConfigHandler: (state) {
return GestureConfig(
minScale: 0.8,
animationMinScale: 0.6,
maxScale: 2 * fittedScale,
animationMaxScale: 2.5 * fittedScale,
speed: 1.0,
inertialSpeed: 100.0,
initialScale: initialScale,
initialAlignment:
InitialAlignment.topCenter,
hitTestBehavior: HitTestBehavior.opaque,
);
},
loadStateChanged: (ExtendedImageState state) {
switch (state.extendedImageLoadState) {
case LoadState.loading:
return Container(
color: Colors.black,
child: const Center(
child: CircularProgressIndicator(
color: Colors.white))
);
case LoadState.completed:
final screenHeight =
MediaQuery.of(context).size.height;
final screenWidth =
MediaQuery.of(context).size.width;
final imgHeight = state.extendedImageInfo
?.image.height ??
1;
final imgWidth = state
.extendedImageInfo?.image.width ??
0;
final imgRatio = imgWidth / imgHeight;
final screenRatio =
screenWidth / screenHeight;
final fitWidthScale =
screenRatio / imgRatio;
if (screenRatio > imgRatio) {
// Long Image
// initialScale = fitWidthScale;
fittedScale = fitWidthScale;
doubleTapScales[1] = fitWidthScale;
} else {
fittedScale =
1 / fitWidthScale; // fittedHeight
doubleTapScales[1] = 1 / fitWidthScale;
}
return GesturedImage(state,
key: extendedImageGestureKey);
case LoadState.failed:
break;
}
return null;
},
onDoubleTap: (ExtendedImageGestureState state) {
///you can use define pointerDownPosition as you can,
///default value is double tap pointer down position.
final Offset? pointerDownPosition =
state.pointerDownPosition;
final double? begin =
state.gestureDetails!.totalScale;
double end;
//remove old
_doubleClickAnimation?.removeListener(
_doubleClickAnimationListener);
//stop pre
_doubleClickAnimationController.stop();
//reset to use
_doubleClickAnimationController.reset();
if (begin == doubleTapScales[0]) {
end = doubleTapScales[1];
} else {
end = doubleTapScales[0];
}
_doubleClickAnimationListener = () {
//outputLogger.i(_animation.value);
state.handleDoubleTap(
scale: _doubleClickAnimation!.value,
doubleTapPosition: pointerDownPosition);
};
_doubleClickAnimation =
_doubleClickAnimationController.drive(
Tween<double>(
begin: begin, end: end));
_doubleClickAnimation!.addListener(
_doubleClickAnimationListener);
_doubleClickAnimationController.forward();
},
mode: ExtendedImageMode.gesture,
)),
),
),
),
Positioned(
left: 10,
bottom: 50,
child: SizedBox(
width: 48,
height: 48,
child: IconButton(
icon: Image.asset(
'images/close.png',
package: 'tencent_cloud_chat_uikit',
),
iconSize: 30,
onPressed: close,
),
)),
if (widget.downloadFn != null)
Positioned(
right: 10,
bottom: 50,
child: SizedBox(
width: 48,
height: 48,
child: IconButton(
icon: Image.asset(
'images/download.png',
package: 'tencent_cloud_chat_uikit',
),
iconSize: 30,
onPressed: () async {
setState(() {
isLoading = true;
});
await widget.downloadFn!();
Future.delayed(const Duration(milliseconds: 200),(){
setState(() {
isLoading = false;
});
});
},
),
),
),
if (isLoading)
Container(
child: LoadingAnimationWidget.staggeredDotsWave(
size: 35,
color: Colors.white,
),
padding: const EdgeInsets.all(30),
decoration: const BoxDecoration(
color: Color(0xB22b2b2b),
borderRadius: BorderRadius.all(Radius.circular(20)),
),
),
])),
);
}));
}
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:flutter/widgets.dart';
class KeepAliveWrapper extends StatefulWidget {
const KeepAliveWrapper({
Key? key,
this.keepAlive = true,
required this.child,
}) : super(key: key);
final bool keepAlive;
final Widget child;
@override
_KeepAliveWrapperState createState() => _KeepAliveWrapperState();
}
class _KeepAliveWrapperState extends State<KeepAliveWrapper> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return RepaintBoundary(
child: widget.child,
);
}
@override
void didUpdateWidget(covariant KeepAliveWrapper oldWidget) {
if (oldWidget.keepAlive != widget.keepAlive) {
// keepAlive 状态需要更新,实现在 AutomaticKeepAliveClientMixin 中
updateKeepAlive();
}
super.didUpdateWidget(oldWidget);
}
@override
bool get wantKeepAlive => widget.keepAlive;
}
@@ -0,0 +1,8 @@
/// Extensions on [Uri]
extension UriX on Uri {
/// Return the URI adding the http scheme if it is missing
Uri get withScheme {
if (hasScheme) return this;
return Uri.parse('http://${toString()}');
}
}
@@ -0,0 +1,90 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:link_preview_generator_for_us/link_preview_generator.dart';
import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'
if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart';
import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart';
import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/common/extensions.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/link_preview_entry.dart';
import 'package:url_launcher/url_launcher.dart';
class LinkUtils {
static RegExp urlReg = RegExp(
r"([hH][tT]{2}[pP]:\/\/|[hH][tT]{2}[pP][sS]:\/\/|[wW]{3}.|[wW][aA][pP].|[fF][tT][pP].|[fF][iI][lL][eE].)[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]");
/// Get all the URL from a text message
static List<String> getURLMatches(String textMessage) {
final matches = urlReg.allMatches(textMessage).toList();
List<String> urlMatches = [];
for (Match m in matches) {
String match = m.group(0) ?? "";
urlMatches.add(match);
}
return urlMatches;
}
/// Launch URL
static Future<void> launchURL(BuildContext context, String url) async {
try {
await launchUrl(
Uri.parse(url).withScheme,
mode: LaunchMode.externalApplication,
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(TIM_t("无法打开URL"))), // Cannot launch the url
);
}
}
/// Get color
static Color hexToColor(String hexString) {
return Color(int.parse(hexString, radix: 16)).withAlpha(255);
}
/// Get the URL preview information
static Future<List<LocalCustomDataModel>> getURLPreview(List<String> urlMatches) async {
// Request for preview information for all URL links synchronously
final List<LocalCustomDataModel> urlPreview = await Future.wait(urlMatches.map((e) async {
String url = e;
if (!e.contains("http")) {
url = 'http://$e';
}
final WebInfo info = await LinkPreviewForUs.scrapeFromURL(url);
return LocalCustomDataModel(url: e, title: info.title, image: info.image, description: info.description);
}));
return urlPreview;
}
/// save the link info to local and call updating the message on UI, only works with [onUpdateMessage]
static Future<void> saveToLocalAndUpdate(
V2TimMessage message, LocalCustomDataModel previewItem, ValueChanged<V2TimMessage> onUpdateMessage) async {
if (message.msgID != null) {
String saveInfo = LinkPreviewEntry.linkInfoToString(previewItem);
final currentInfo = message.localCustomData;
if (currentInfo != null && currentInfo.isNotEmpty) {
final Map<String, dynamic> data = json.decode(currentInfo);
data['url'] = previewItem.url;
data['image'] = previewItem.image;
data['title'] = previewItem.title;
data['description'] = previewItem.description;
saveInfo = json.encode(data);
}
message.localCustomData = saveInfo;
if (saveInfo != currentInfo) {
final result = await TencentImSDKPlugin.v2TIMManager.v2TIMMessageManager
.setLocalCustomData(msgID: message.msgID!, localCustomData: saveInfo);
if (result.code == 0) {
onUpdateMessage(message);
}
}
}
}
}
@@ -0,0 +1,31 @@
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/emoji_text.dart';
import 'package:tim_ui_kit_sticker_plugin/utils/tim_custom_face_data.dart';
RegExp emojiExp = RegExp(r'\[([\u4e00-\u9fa5A-Za-z0-9]+)\]');
String mdTextCompiler(String originalText, {
bool isUseQQPackage = false,
bool isUseTencentCloudChatPackage = false,
bool isUseTencentCloudChatPackageOldKeys = false,
List<CustomEmojiFaceData> customEmojiStickerList = const [],
}) {
String text = originalText;
final EmojiUtil emojiUtil = EmojiUtil(
isUseQQPackage: isUseQQPackage,
isUseTencentCloudChatPackage: isUseTencentCloudChatPackage,
isUseTencentCloudChatPackageOldKeys: isUseTencentCloudChatPackageOldKeys,
customEmojiStickerList: customEmojiStickerList);
text = text.replaceAllMapped(emojiExp, (match) {
String key = match.group(0)!;
// Check if the emoji exists in the emoji map
if (emojiUtil.emojiMap.containsKey(key)) {
String assetPath = emojiUtil.emojiMap[key]!;
return '![sticker](resource:$assetPath#22x22)';
}
return key;
});
return text;
}
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'
if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/common/utils.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/widgets/link_preview.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/widgets/link_text.dart';
import 'package:tim_ui_kit_sticker_plugin/utils/tim_custom_face_data.dart';
import 'models/link_preview_content.dart';
class LinkPreviewEntry {
/// get the text message with hyperlinks
static LinkPreviewText? getHyperlinksText(String messageText, bool isMarkdown,
{Function(String)? onLinkTap,
bool isEnableTextSelection = false,
bool isUseQQPackage = false,
bool isUseTencentCloudChatPackage = false,
bool isUseTencentCloudChatPackageOldKeys = false,
List<CustomEmojiFaceData> customEmojiStickerList = const []}) {
return ({TextStyle? style}) {
return isMarkdown
? LinkTextMarkdown(
isUseQQPackage: isUseQQPackage,
isUseTencentCloudChatPackage: isUseTencentCloudChatPackage,
isUseTencentCloudChatPackageOldKeys: isUseTencentCloudChatPackageOldKeys,
customEmojiStickerList: customEmojiStickerList,
isEnableTextSelection: isEnableTextSelection,
messageText: addSpaceAfterLeftBracket(addSpaceBeforeHttp(replaceSingleNewlineWithTwo(messageText))),
style: style,
onLinkTap: onLinkTap)
: LinkText(
isEnableTextSelection: isEnableTextSelection,
messageText: messageText,
style: style,
onLinkTap: onLinkTap,
isUseQQPackage: isUseQQPackage,
isUseTencentCloudChatPackage: isUseTencentCloudChatPackage,
isUseTencentCloudChatPackageOldKeys: isUseTencentCloudChatPackageOldKeys,
customEmojiStickerList: customEmojiStickerList);
};
}
static String addSpaceAfterLeftBracket(String inputText) {
return inputText.splitMapJoin(
RegExp(r'<\w+[^<>]*>'),
onMatch: (match) {
return match.group(0)!.replaceFirst('<', '< ');
},
onNonMatch: (text) => text,
);
}
static String replaceSingleNewlineWithTwo(String inputText) {
return inputText.split('\n').join('\n\n');
}
static String addSpaceBeforeHttp(String inputText) {
return inputText.splitMapJoin(
RegExp(r'http'),
onMatch: (match) {
return ' http';
},
onNonMatch: (text) => text,
);
}
/// get the [LinkPreviewContent] with preview widget and website information for the first link.
/// If you provide `onUpdateMessage(String linkInfoJson)`, it can save the link info to local custom data than call updating the message on UI automatically.
static Future<LinkPreviewContent?> getFirstLinkPreviewContent(
{required V2TimMessage message, ValueChanged<V2TimMessage>? onUpdateMessage}) async {
final String? messageText = message.textElem?.text;
if (messageText == null) {
return null;
}
final List<String> urlMatches = LinkUtils.getURLMatches(messageText);
if (urlMatches.isEmpty) {
return null;
}
final List<LocalCustomDataModel?> previewItemList = await LinkUtils.getURLPreview([urlMatches[0]]);
if (previewItemList.isNotEmpty) {
final LocalCustomDataModel previewItem = previewItemList.first!;
if (onUpdateMessage != null) {
LinkUtils.saveToLocalAndUpdate(message, previewItem, onUpdateMessage);
}
return LinkPreviewContent(
linkInfo: previewItem,
linkPreviewWidget: LinkPreviewWidget(linkPreview: previewItem),
);
} else {
return null;
}
}
/// get the [LinkPreviewContent] with preview widget and website information for all the links
static Future<List<LinkPreviewContent?>?> getAllLinkPreviewContent(V2TimMessage message) async {
final String? messageText = message.textElem?.text;
if (messageText == null) {
return null;
}
final List<String> urlMatches = LinkUtils.getURLMatches(messageText);
if (urlMatches.isEmpty) {
return [];
}
final List<LocalCustomDataModel> previewItemList = await LinkUtils.getURLPreview([urlMatches[0]]);
if (previewItemList.isNotEmpty) {
final List<LinkPreviewContent?> resultList = previewItemList
.map((e) => LinkPreviewContent(
linkInfo: e,
linkPreviewWidget: LinkPreviewWidget(linkPreview: e),
))
.toList();
return resultList;
} else {
return [];
}
}
static String linkInfoToString(LocalCustomDataModel linkInfo) {
return linkInfo.toString();
}
}
@@ -0,0 +1,57 @@
import 'dart:convert';
import 'package:flutter/cupertino.dart';
typedef LinkPreviewText = Widget Function({TextStyle? style});
class LocalCustomDataModel {
final String? description;
final String? image;
final String? url;
final String? title;
String? translatedText;
LocalCustomDataModel(
{this.description, this.image, this.url, this.title, this.translatedText});
Map<String, String?> toMap() {
final Map<String, String?> data = {};
data['url'] = url;
data['image'] = image;
data['title'] = title;
data['description'] = description;
data['translatedText'] = translatedText;
return data;
}
LocalCustomDataModel.fromMap(Map map)
: description = map['description'],
image = map['image'],
url = map['url'],
translatedText = map['translatedText'],
title = map['title'];
@override
String toString() {
return json.encode(toMap());
}
bool isLinkPreviewEmpty() {
if ((image == null || image!.isEmpty) &&
(title == null || title!.isEmpty) &&
(description == null || description!.isEmpty)) {
return true;
}
return false;
}
}
class LinkPreviewContent {
const LinkPreviewContent({
this.linkInfo,
this.linkPreviewWidget,
});
final LocalCustomDataModel? linkInfo;
final Widget? linkPreviewWidget;
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_stateless_widget.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/common/utils.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/models/link_preview_content.dart';
class LinkPreviewWidget extends TIMStatelessWidget {
final LocalCustomDataModel linkPreview;
const LinkPreviewWidget({Key? key, required this.linkPreview})
: super(key: key);
@override
Widget timBuild(BuildContext context) {
if (linkPreview.isLinkPreviewEmpty()) {
return Container();
}
return GestureDetector(
onTap: () {
if(linkPreview.url != null){
LinkUtils.launchURL(context, linkPreview.url!);
}
},
child: Container(
padding: const EdgeInsets.only(top: 8, bottom: 8, left: 8, right: 8),
decoration: const BoxDecoration(
color: Color(0x19696969),
borderRadius: BorderRadius.all(Radius.circular(6)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (linkPreview.title != null && linkPreview.title!.isNotEmpty)
Text(
linkPreview.title!,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14.0,
color: Color(0xFF444444),
fontWeight: FontWeight.w400),
),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
if (linkPreview.image != null && linkPreview.image!.isNotEmpty)
Container(
margin: const EdgeInsets.only(right: 8),
child: SizedBox(
height: 40,
width: 40,
child: Image.network(linkPreview.image!),
),
),
if (linkPreview.description != null &&
linkPreview.description!.isNotEmpty)
Expanded(
child: Text(
linkPreview.description!,
style: const TextStyle(
fontSize: 12.0, color: Color(0xFF999999)),
)),
if ((linkPreview.description == null ||
linkPreview.description!.isEmpty) &&
linkPreview.title != null &&
linkPreview.title!.isNotEmpty)
Expanded(
child: Text(
linkPreview.title!,
style: const TextStyle(
fontSize: 12.0, color: Color(0xFF999999)),
)),
],
)
],
),
),
);
}
}
@@ -0,0 +1,187 @@
// ignore_for_file: deprecated_member_use
import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart';
import 'package:extended_text/extended_text.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_stateless_widget.dart';
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/http_text.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/compiler/md_text.dart';
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/DefaultSpecialTextSpanBuilder.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/link_preview/common/utils.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:tim_ui_kit_sticker_plugin/utils/tim_custom_face_data.dart';
typedef ImageBuilder = Widget Function(
Uri uri, String? imageDirectory, double? width, double? height);
class LinkTextMarkdown extends TIMStatelessWidget {
/// Callback for when link is tapped
final void Function(String)? onLinkTap;
/// message text
final String messageText;
/// text style for default words
final TextStyle? style;
final bool? isEnableTextSelection;
final bool isUseQQPackage;
final bool isUseTencentCloudChatPackage;
final bool isUseTencentCloudChatPackageOldKeys;
final List<CustomEmojiFaceData> customEmojiStickerList;
const LinkTextMarkdown(
{Key? key,
required this.messageText,
this.isUseQQPackage = false,
this.isUseTencentCloudChatPackage = false,
this.isUseTencentCloudChatPackageOldKeys = false,
this.customEmojiStickerList = const [],
this.isEnableTextSelection,
this.onLinkTap,
this.style})
: super(key: key);
@override
Widget timBuild(BuildContext context) {
return MarkdownBody(
data: mdTextCompiler(messageText,
isUseTencentCloudChatPackage: isUseTencentCloudChatPackage,
customEmojiStickerList: customEmojiStickerList),
selectable: isEnableTextSelection ?? false,
styleSheet: MarkdownStyleSheet.fromTheme(ThemeData(
textTheme: TextTheme(
bodyMedium: style ?? const TextStyle(fontSize: 16.0))))
.copyWith(
a: TextStyle(color: LinkUtils.hexToColor("015fff")),
),
extensionSet: md.ExtensionSet.gitHubWeb,
onTapLink: (
String link,
String? href,
String title,
) {
if (onLinkTap != null) {
onLinkTap!(href ?? "");
} else {
LinkUtils.launchURL(context, href ?? "");
}
},
);
}
}
class LinkText extends TIMStatelessWidget {
/// Callback for when link is tapped
final void Function(String)? onLinkTap;
/// message text
final String messageText;
/// text style for default words
final TextStyle? style;
final bool isUseQQPackage;
final bool isUseTencentCloudChatPackage;
final bool isUseTencentCloudChatPackageOldKeys;
final List<CustomEmojiFaceData> customEmojiStickerList;
final bool? isEnableTextSelection;
const LinkText(
{Key? key,
required this.messageText,
this.onLinkTap,
this.isEnableTextSelection,
this.style,
this.isUseQQPackage = false,
this.isUseTencentCloudChatPackage = false,
this.isUseTencentCloudChatPackageOldKeys = false,
this.customEmojiStickerList = const []})
: super(key: key);
String _getContentSpan(String text, BuildContext context) {
List<InlineSpan> _contentList = [];
String contentData = PlatformUtils().isWeb ? '\u200B' : "";
Iterable<RegExpMatch> matches = LinkUtils.urlReg.allMatches(text);
int index = 0;
for (RegExpMatch match in matches) {
String c = text.substring(match.start, match.end);
if (match.start == index) {
index = match.end;
}
if (index < match.start) {
String a = text.substring(index, match.start);
index = match.end;
contentData += a;
_contentList.add(
TextSpan(text: a),
);
}
if (LinkUtils.urlReg.hasMatch(c)) {
contentData += HttpText.flag + c + HttpText.flag;
_contentList.add(TextSpan(
text: c,
style: TextStyle(color: LinkUtils.hexToColor("015fff")),
recognizer: TapGestureRecognizer()
..onTap = () {
if (onLinkTap != null) {
onLinkTap!(text.substring(match.start, match.end));
} else {
LinkUtils.launchURL(
context, text.substring(match.start, match.end));
}
}));
} else {
contentData += c;
_contentList.add(
TextSpan(text: c, style: style ?? const TextStyle(fontSize: 16.0)),
);
}
}
if (index < text.length) {
String a = text.substring(index, text.length);
contentData += a;
_contentList.add(
TextSpan(text: a, style: style ?? const TextStyle(fontSize: 16.0)),
);
}
return contentData;
}
@override
Widget timBuild(BuildContext context) {
return ExtendedText(_getContentSpan(messageText, context), softWrap: true,
onSpecialTextTap: (dynamic parameter) {
if (parameter.toString().startsWith(HttpText.flag)) {
if (onLinkTap != null) {
onLinkTap!((parameter.toString()).replaceAll(HttpText.flag, ''));
} else {
LinkUtils.launchURL(
context, (parameter.toString()).replaceAll(HttpText.flag, ''));
}
}
},
style: style ?? const TextStyle(fontSize: 16.0),
specialTextSpanBuilder: DefaultSpecialTextSpanBuilder(
isUseQQPackage: isUseQQPackage,
isUseTencentCloudChatPackage: isUseTencentCloudChatPackage,
isUseTencentCloudChatPackageOldKeys: isUseTencentCloudChatPackageOldKeys,
customEmojiStickerList: customEmojiStickerList,
showAtBackground: true,
));
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:flutter/widgets.dart';
class Loading extends StatefulWidget {
final double width;
final double height;
const Loading({Key? key, this.width = 14, this.height = 14})
: super(key: key);
@override
State<StatefulWidget> createState() => _LoadingState();
}
class _LoadingState extends State<Loading> with TickerProviderStateMixin {
late AnimationController _animationController;
@override
initState() {
// 初始化旋转动画
_animationController =
AnimationController(duration: const Duration(seconds: 1), vsync: this);
_animationController.forward();
_animationController.addStatusListener((status) {
if (status == AnimationStatus.completed) {
_animationController.reset();
_animationController.forward();
}
});
super.initState();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RotationTransition(
turns: Tween(begin: .0, end: .9).animate(_animationController),
child: Image.asset(
"images/message_sending.png",
package: 'tencent_cloud_chat_uikit',
height: widget.width,
width: widget.height,
),
);
}
}
+332
View File
@@ -0,0 +1,332 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:loading_animation_widget/loading_animation_widget.dart';
import 'package:provider/provider.dart';
import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart';
import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'
if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/common_utils.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart';
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKItMessageList/tim_uikit_chat_history_message_list_item.dart';
import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart';
import 'package:tencent_cloud_chat_uikit/data_services/message/message_services.dart';
import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart';
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/main.dart';
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_face_elem.dart';
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/tim_uikit_cloud_custom_data.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart';
import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart';
import 'package:tencent_cloud_chat_uikit/theme/tui_theme_view_model.dart';
class MergerMessageScreen extends StatefulWidget {
final TUIChatSeparateViewModel model;
final String msgID;
final MessageItemBuilder? messageItemBuilder;
const MergerMessageScreen({Key? key, required this.model, required this.msgID, this.messageItemBuilder})
: super(key: key);
@override
State<StatefulWidget> createState() => MergerMessageScreenState();
}
class MergerMessageScreenState extends TIMUIKitState<MergerMessageScreen> {
final MessageService _messageService = serviceLocator<MessageService>();
List<V2TimMessage> messageList = [];
@override
initState() {
super.initState();
initMessageList();
}
void initMessageList() async {
final mergerMessageList = await _messageService.downloadMergerMessage(msgID: widget.msgID);
setState(() {
messageList = mergerMessageList ?? [];
});
}
bool isReplyMessage(V2TimMessage message) {
final hasCustomData = message.cloudCustomData != null && message.cloudCustomData != "";
if (hasCustomData) {
try {
final CloudCustomData messageCloudCustomData = CloudCustomData.fromJson(
json.decode(TencentUtils.checkString(message.cloudCustomData) != null ? message.cloudCustomData! : "{}"));
if (messageCloudCustomData.messageReply != null) {
MessageRepliedData.fromJson(messageCloudCustomData.messageReply!);
return true;
}
return false;
} catch (error) {
return false;
}
}
return false;
}
Widget _getMsgItem(V2TimMessage message) {
final type = message.elemType;
final isFromSelf = message.isSelf ?? true;
switch (type) {
case MessageElemType.V2TIM_ELEM_TYPE_CUSTOM:
if (widget.messageItemBuilder?.customMessageItemBuilder != null) {
return widget.messageItemBuilder!.customMessageItemBuilder!(
message,
false,
() {},
)!;
}
return Text(TIM_t("[自定义]"));
case MessageElemType.V2TIM_ELEM_TYPE_SOUND:
if (widget.messageItemBuilder?.soundMessageItemBuilder != null) {
return widget.messageItemBuilder!.soundMessageItemBuilder!(
message,
false,
() {},
)!;
}
return TIMUIKitSoundElem(
chatModel: widget.model,
isShowMessageReaction: false,
message: message,
soundElem: message.soundElem!,
msgID: message.msgID ?? "",
isFromSelf: isFromSelf,
localCustomInt: message.localCustomInt);
case MessageElemType.V2TIM_ELEM_TYPE_TEXT:
if (isReplyMessage(message)) {
if (widget.messageItemBuilder?.textReplyMessageItemBuilder != null) {
return widget.messageItemBuilder!.textReplyMessageItemBuilder!(
message,
false,
() {},
)!;
}
return TIMUIKitReplyElem(
isShowMessageReaction: false,
chatModel: widget.model,
message: message,
scrollToIndex: () {},
clearJump: () {});
}
if (widget.messageItemBuilder?.textMessageItemBuilder != null) {
return widget.messageItemBuilder!.textMessageItemBuilder!(
message,
false,
() {},
)!;
}
return TIMUIKitTextElem(
chatModel: widget.model,
message: message,
isFromSelf: message.isSelf ?? true,
clearJump: () {},
isShowJump: false,
isShowMessageReaction: false,
);
case MessageElemType.V2TIM_ELEM_TYPE_FACE:
if (widget.messageItemBuilder?.faceMessageItemBuilder != null) {
return widget.messageItemBuilder!.faceMessageItemBuilder!(
message,
false,
() {},
)!;
}
return TIMUIKitFaceElem(
model: widget.model,
isShowJump: false,
isShowMessageReaction: false,
path: message.faceElem?.data ?? "",
message: message);
case MessageElemType.V2TIM_ELEM_TYPE_FILE:
if (widget.messageItemBuilder?.fileMessageItemBuilder != null) {
return widget.messageItemBuilder!.fileMessageItemBuilder!(
message,
false,
() {},
)!;
}
return TIMUIKitFileElem(
chatModel: widget.model,
isShowMessageReaction: false,
message: message,
messageID: message.msgID,
fileElem: message.fileElem,
isSelf: isFromSelf,
isShowJump: false);
case MessageElemType.V2TIM_ELEM_TYPE_IMAGE:
if (widget.messageItemBuilder?.imageMessageItemBuilder != null) {
return widget.messageItemBuilder!.imageMessageItemBuilder!(
message,
false,
() {},
)!;
}
return TIMUIKitImageElem(
chatModel: widget.model,
isShowMessageReaction: false,
message: message,
isFrom: "merger",
key: Key("${message.seq}_${message.timestamp}"),
);
case MessageElemType.V2TIM_ELEM_TYPE_VIDEO:
if (widget.messageItemBuilder?.videoMessageItemBuilder != null) {
return widget.messageItemBuilder!.videoMessageItemBuilder!(
message,
false,
() {},
)!;
}
return TIMUIKitVideoElem(message, chatModel: widget.model, isFrom: "merger", isShowMessageReaction: false);
case MessageElemType.V2TIM_ELEM_TYPE_LOCATION:
if (widget.messageItemBuilder?.locationMessageItemBuilder != null) {
return widget.messageItemBuilder!.locationMessageItemBuilder!(
message,
false,
() {},
)!;
}
return Text(TIM_t("[位置]"));
case MessageElemType.V2TIM_ELEM_TYPE_MERGER:
if (widget.messageItemBuilder?.mergerMessageItemBuilder != null) {
return widget.messageItemBuilder!.mergerMessageItemBuilder!(
message,
false,
() {},
)!;
}
return TIMUIKitMergerElem(
model: widget.model,
isShowJump: false,
isShowMessageReaction: false,
message: message,
mergerElem: message.mergerElem!,
isSelf: isFromSelf,
messageID: message.msgID!);
default:
return Text(TIM_t("未知消息"));
}
}
double getMaxWidth(BuildContext context) {
final size = MediaQuery.of(context).size;
final width = size.width;
return width - 150;
}
Widget _itemBuilder(V2TimMessage message, BuildContext context) {
final faceUrl = message.faceUrl ?? "";
final showName = message.nickName ?? message.userID ?? "";
final theme = Provider.of<TUIThemeViewModel>(context).theme;
final isSelf = message.isSelf ?? false;
return Container(
margin: const EdgeInsets.only(top: 20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: isSelf ? MainAxisAlignment.end : MainAxisAlignment.start,
children: [
if (!isSelf)
SizedBox(
width: 40,
height: 40,
child: Avatar(faceUrl: faceUrl, showName: showName),
),
if (!isSelf)
const SizedBox(
width: 12,
),
Column(
crossAxisAlignment: isSelf ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
Text(showName, style: TextStyle(fontSize: 12, color: theme.weakTextColor)),
const SizedBox(
height: 4,
),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: getMaxWidth(context)),
child: _getMsgItem(message),
)
],
),
if (isSelf)
const SizedBox(
width: 12,
),
if (isSelf)
SizedBox(
width: 40,
height: 40,
child: Avatar(faceUrl: faceUrl, showName: showName),
),
],
),
);
}
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
final TUITheme theme = value.theme;
final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop;
Widget messageListPage() {
return messageList.isEmpty
? Row(
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
LoadingAnimationWidget.staggeredDotsWave(
color: theme.weakTextColor ?? Colors.grey,
size: 48,
),
const SizedBox(height: 20),
Text(TIM_t("消息列表加载中"))
],
))
],
)
: Container(
padding: isDesktopScreen ? null : const EdgeInsets.all(16),
child: ListView.builder(
shrinkWrap: true,
itemCount: messageList.length,
itemBuilder: (context, index) {
final message = messageList[index];
return _itemBuilder(message, context);
},
),
);
}
return TUIKitScreenUtils.getDeviceWidget(
context: context,
desktopWidget: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: messageListPage(),
),
defaultWidget: Scaffold(
appBar: AppBar(
title: Text(
TIM_t("聊天记录"),
style: TextStyle(color: theme.appbarTextColor, fontSize: 17),
),
shadowColor: theme.weakDividerColor,
backgroundColor: theme.appbarBgColor ?? theme.primaryColor,
iconTheme: IconThemeData(
color: theme.appbarTextColor,
)),
body: messageListPage(),
));
}
}
+385
View File
@@ -0,0 +1,385 @@
import 'package:extended_text/extended_text.dart';
import 'package:flutter/material.dart';
// ignore: unused_import
import 'package:provider/provider.dart';
import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart';
import 'package:tencent_cloud_chat_sdk/enum/get_group_message_read_member_list_filter.dart';
import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_info.dart'
if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_info.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'
if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart';
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitTextField/special_text/DefaultSpecialTextSpanBuilder.dart';
import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_chat_separate_view_model.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/message.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/time_ago.dart';
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_face_elem.dart';
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_file_elem.dart';
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_image_elem.dart';
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_sound_elem.dart';
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_chat_video_elem.dart';
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKitMessageItem/tim_uikit_merger_message_elem.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart';
import 'package:tencent_cloud_chat_uikit/theme/color.dart';
import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart';
class MessageReadReceipt extends StatefulWidget {
final V2TimMessage messageItem;
final int unreadCount;
final int readCount;
final void Function(String userID, TapDownDetails tapDetails)? onTapAvatar;
final TUIChatSeparateViewModel model;
const MessageReadReceipt(
{Key? key,
required this.messageItem,
required this.unreadCount,
required this.readCount,
this.onTapAvatar,
required this.model})
: super(key: key);
@override
State<StatefulWidget> createState() => _MessageReadReceiptState();
}
class _MessageReadReceiptState extends TIMUIKitState<MessageReadReceipt> {
bool readMemberIsFinished = false;
bool unreadMemberIsFinished = false;
int readMemberListNextSeq = 0;
int unreadMemberListNextSeq = 0;
List<V2TimGroupMemberInfo> readMemberList = [];
List<V2TimGroupMemberInfo> unreadMemberList = [];
int currentIndex = 0;
_getUnreadMemberList() async {
final unReadMemberRes = await widget.model.getGroupMessageReadMemberList(widget.messageItem.msgID!,
GetGroupMessageReadMemberListFilter.V2TIM_GROUP_MESSAGE_READ_MEMBERS_FILTER_UNREAD, unreadMemberListNextSeq);
if (unReadMemberRes.code == 0) {
final res = unReadMemberRes.data;
if (res != null) {
unreadMemberList = [...unreadMemberList, ...res.memberInfoList];
unreadMemberIsFinished = res.isFinished;
unreadMemberListNextSeq = res.nextSeq;
}
}
setState(() {});
}
_getReadMemberList() async {
final readMemberRes = await widget.model.getGroupMessageReadMemberList(
widget.messageItem.msgID!,
GetGroupMessageReadMemberListFilter.V2TIM_GROUP_MESSAGE_READ_MEMBERS_FILTER_READ,
readMemberListNextSeq,
);
if (readMemberRes.code == 0) {
final res = readMemberRes.data;
if (res != null) {
readMemberList = [...readMemberList, ...res.memberInfoList];
readMemberIsFinished = res.isFinished;
readMemberListNextSeq = res.nextSeq;
}
}
setState(() {});
}
@override
void initState() {
super.initState();
_getReadMemberList();
_getUnreadMemberList();
}
Widget _getMsgItem(V2TimMessage message) {
final type = message.elemType;
final isFromSelf = message.isSelf ?? true;
switch (type) {
case MessageElemType.V2TIM_ELEM_TYPE_CUSTOM:
return Text(TIM_t("[自定义]"));
case MessageElemType.V2TIM_ELEM_TYPE_SOUND:
return TIMUIKitSoundElem(
isShowMessageReaction: false,
chatModel: widget.model,
message: message,
soundElem: message.soundElem!,
msgID: message.msgID ?? "",
isFromSelf: isFromSelf,
localCustomInt: message.localCustomInt);
case MessageElemType.V2TIM_ELEM_TYPE_TEXT:
return ExtendedText(message.textElem!.text!,
softWrap: true,
style: const TextStyle(fontSize: 16),
specialTextSpanBuilder: DefaultSpecialTextSpanBuilder(
isUseQQPackage: widget.model.chatConfig.stickerPanelConfig?.useQQStickerPackage ?? true,
isUseTencentCloudChatPackage:
widget.model.chatConfig.stickerPanelConfig?.useTencentCloudChatStickerPackage ?? true,
isUseTencentCloudChatPackageOldKeys:
widget.model.chatConfig.stickerPanelConfig?.useTencentCloudChatStickerPackageOldKeys ?? false,
showAtBackground: true,
checkHttpLink: true,
));
// return Text(message.textElem!.text!);
case MessageElemType.V2TIM_ELEM_TYPE_FACE:
return TIMUIKitFaceElem(
isShowMessageReaction: false,
model: widget.model,
isShowJump: false,
path: message.faceElem?.data ?? "",
message: message);
case MessageElemType.V2TIM_ELEM_TYPE_FILE:
return TIMUIKitFileElem(
chatModel: widget.model,
isShowMessageReaction: false,
message: message,
messageID: message.msgID,
fileElem: message.fileElem,
isSelf: isFromSelf,
isShowJump: false,
);
case MessageElemType.V2TIM_ELEM_TYPE_IMAGE:
return TIMUIKitImageElem(
chatModel: widget.model,
isShowMessageReaction: false,
message: message,
isFrom: "merger",
key: Key("${message.seq}_${message.timestamp}"),
);
case MessageElemType.V2TIM_ELEM_TYPE_VIDEO:
return TIMUIKitVideoElem(message, chatModel: widget.model, isShowMessageReaction: false, isFrom: "merger");
case MessageElemType.V2TIM_ELEM_TYPE_LOCATION:
return Text(TIM_t("[位置]"));
case MessageElemType.V2TIM_ELEM_TYPE_MERGER:
return TIMUIKitMergerElem(
isShowMessageReaction: false,
model: widget.model,
isShowJump: false,
message: message,
mergerElem: message.mergerElem!,
isSelf: isFromSelf,
messageID: message.msgID!);
default:
return Text(TIM_t("未知消息"));
}
}
_getShowName(V2TimGroupMemberInfo item) {
final friendRemark = item.friendRemark ?? "";
final nickName = item.nickName ?? "";
final userID = item.userID;
final showName = nickName != "" ? nickName : userID;
return friendRemark != "" ? friendRemark : showName;
}
Widget _memberItemBuilder(V2TimGroupMemberInfo item, TUITheme theme) {
final faceUrl = item.faceUrl ?? '';
final showName = _getShowName(item);
final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop;
return InkWell(
onTapDown: (details) {
if (widget.onTapAvatar != null) {
widget.onTapAvatar!(item.userID!, details);
}
},
child: Container(
padding: const EdgeInsets.only(top: 10, left: 16),
child: Row(
children: [
Container(
height: isDesktopScreen ? 30 : 40,
width: isDesktopScreen ? 30 : 40,
margin: EdgeInsets.only(right: 12, bottom: isDesktopScreen ? 6 : 0),
child: Avatar(faceUrl: faceUrl, showName: showName),
),
Expanded(
child: Container(
alignment: Alignment.centerLeft,
padding: EdgeInsets.only(top: 10, bottom: isDesktopScreen ? 14 : 19, right: 28),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))),
child: Text(
showName,
style: TextStyle(color: Colors.black, fontSize: isDesktopScreen ? 14 : 18),
),
)),
],
),
),
);
}
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
final TUITheme theme = value.theme;
final option1 = widget.readCount;
final option2 = widget.unreadCount;
final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop;
Widget pageBody() {
return Container(
color: isDesktopScreen ? null : Colors.white,
child: Column(
children: [
// The top part of the message content
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height / 2,
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(MessageUtils.getDisplayName(widget.messageItem)),
const SizedBox(width: 8),
Text(
TimeAgo().getTimeForMessage(widget.messageItem.timestamp ?? 0),
softWrap: true,
style: TextStyle(fontSize: 12, color: theme.weakTextColor),
)
],
),
const SizedBox(height: 6),
// message content
_getMsgItem(widget.messageItem),
],
),
),
),
),
// divider
Container(
height: 8,
color: theme.weakBackgroundColor,
),
// The bottom part shows the read/unread list
Expanded(
child: Column(
children: [
// read/unread switch button
Row(
children: <Widget>[
Expanded(
flex: 1,
child: InkWell(
onTap: () {
currentIndex = 0;
setState(() {});
},
child: Container(
height: isDesktopScreen ? 40 : 50.0,
alignment: Alignment.bottomCenter,
padding: EdgeInsets.only(bottom: isDesktopScreen ? 8 : 12),
decoration: BoxDecoration(
color: Colors.white,
border: Border(
bottom: BorderSide(
width: 2, color: currentIndex == 0 ? theme.primaryColor! : Colors.white))),
child: Text(
TIM_t_para("{{option1}}人已读", "$option1人已读")(option1: option1),
style: TextStyle(
color: currentIndex != 0 ? theme.weakTextColor : Colors.black,
fontSize: isDesktopScreen ? 14 : 18,
),
),
),
),
),
Expanded(
flex: 1,
child: InkWell(
onTap: () {
currentIndex = 1;
setState(() {});
},
child: Container(
alignment: Alignment.bottomCenter,
height: isDesktopScreen ? 40 : 50.0,
padding: EdgeInsets.only(bottom: isDesktopScreen ? 8 : 12),
decoration: BoxDecoration(
color: Colors.white,
border: Border(
bottom: BorderSide(
width: 2, color: currentIndex == 1 ? theme.primaryColor! : Colors.white))),
child: Text(
TIM_t_para("{{option2}}人未读", "$option2人未读")(option2: option2),
style: TextStyle(
color: currentIndex != 1 ? theme.weakTextColor : Colors.black,
fontSize: isDesktopScreen ? 14 : 18,
),
),
),
),
),
],
),
Container(
height: 1,
decoration: BoxDecoration(
border:
Border(bottom: BorderSide(color: theme.weakDividerColor ?? CommonColor.weakDividerColor))),
),
// member list
Expanded(
child: IndexedStack(
index: currentIndex,
children: [
ListView.builder(
shrinkWrap: false,
itemCount: readMemberList.length,
itemBuilder: (context, index) {
if (!readMemberIsFinished && index == readMemberList.length - 5) {
_getReadMemberList();
}
return _memberItemBuilder(readMemberList[index], theme);
},
),
ListView.builder(
shrinkWrap: false,
itemCount: unreadMemberList.length,
itemBuilder: (context, index) {
if (!unreadMemberIsFinished && index == unreadMemberList.length - 5) {
_getUnreadMemberList();
}
return _memberItemBuilder(unreadMemberList[index], theme);
},
),
],
),
),
],
),
),
],
),
);
}
return TUIKitScreenUtils.getDeviceWidget(
context: context,
desktopWidget: pageBody(),
defaultWidget: DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: Text(
TIM_t("消息详情"),
style: TextStyle(color: theme.appbarTextColor, fontSize: 17),
),
shadowColor: theme.weakDividerColor,
backgroundColor: theme.appbarBgColor ?? theme.primaryColor,
iconTheme: IconThemeData(
color: theme.appbarTextColor,
)),
body: pageBody()),
));
}
}
+68
View File
@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart';
import 'package:tencent_cloud_chat_uikit/theme/color.dart';
import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart';
class CheckBoxButton extends TIMUIKitStatelessWidget {
final bool isChecked;
final Function(bool isChecked)? onChanged;
final bool disabled;
final bool onlyShow;
final double? size;
CheckBoxButton(
{this.disabled = false,
Key? key,
this.size,
this.onlyShow = false,
required this.isChecked,
this.onChanged})
: super(key: key);
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
final TUITheme theme = value.theme;
BoxDecoration boxDecoration = !isChecked
? BoxDecoration(
border: Border.all(color: hexToColor("888888")),
shape: BoxShape.circle,
color: Colors.white)
: BoxDecoration(shape: BoxShape.circle, color: theme.primaryColor);
if (disabled) {
boxDecoration =
const BoxDecoration(shape: BoxShape.circle, color: Colors.grey);
}
return Center(
child: onlyShow
? Container(
height: size ?? 22,
width: size ?? 22,
decoration: boxDecoration,
child: Icon(
Icons.check,
size: size != null ? (size! / 2) : 11,
color: Colors.white,
),
)
: InkWell(
onTap: () {
if (onChanged != null && !disabled) {
onChanged!(!isChecked);
}
},
child: Container(
height: size ?? 22,
width: size ?? 22,
decoration: boxDecoration,
child: Icon(
Icons.check,
size: size != null ? (size! / 2) : 11,
color: Colors.white,
),
),
));
}
}
@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'
if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_conversation.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart';
import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_conversation_view_model.dart';
import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/avatar.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/az_list_view.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/radio_button.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart';
import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart';
class RecentForwardList extends StatefulWidget {
final bool isMultiSelect;
final Function(List<V2TimConversation> conversationList)? onChanged;
const RecentForwardList({
Key? key,
this.isMultiSelect = true,
this.onChanged,
}) : super(key: key);
@override
State<StatefulWidget> createState() => _RecentForwardListState();
}
class _RecentForwardListState extends TIMUIKitState<RecentForwardList> {
final TUIConversationViewModel _conversationViewModel = serviceLocator<TUIConversationViewModel>();
final List<V2TimConversation> _selectedConversation = [];
List<ISuspensionBeanImpl<V2TimConversation?>> _buildMemberList(List<V2TimConversation?> conversationList) {
final List<ISuspensionBeanImpl<V2TimConversation?>> showList = List.empty(growable: true);
for (var i = 0; i < conversationList.length; i++) {
final item = conversationList[i];
showList.add(ISuspensionBeanImpl(memberInfo: item, tagIndex: "#"));
}
return showList;
}
Widget _buildItem(V2TimConversation conversation) {
final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop;
final faceUrl = conversation.faceUrl ?? "";
final showName = conversation.showName ?? "";
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (widget.isMultiSelect)
Container(
padding: EdgeInsets.only(left: isDesktopScreen ? 8 : 16.0, top: isDesktopScreen ? 10 : 0),
child: CheckBoxButton(
isChecked: _selectedConversation.contains(conversation),
onChanged: (value) {
if (value) {
_selectedConversation.add(conversation);
} else {
_selectedConversation.remove(conversation);
}
setState(() {});
if (widget.onChanged != null) {
widget.onChanged!(_selectedConversation);
}
},
),
),
Expanded(
child: InkWell(
onTap: () {
if (widget.isMultiSelect) {
final isSelected = _selectedConversation.contains(conversation);
if (isSelected) {
_selectedConversation.remove(conversation);
} else {
_selectedConversation.add(conversation);
}
if (widget.onChanged != null) {
widget.onChanged!(_selectedConversation);
}
setState(() {});
} else {
if (widget.onChanged != null) {
widget.onChanged!([conversation]);
}
}
},
child: Container(
padding: const EdgeInsets.only(top: 10, left: 16),
child: Row(
children: [
Container(
height: isDesktopScreen ? 30 : 40,
width: isDesktopScreen ? 30 : 40,
margin: const EdgeInsets.only(right: 12),
child: Avatar(
faceUrl: faceUrl,
showName: showName,
type: conversation.type,
),
),
Expanded(
child: Container(
alignment: Alignment.centerLeft,
padding: EdgeInsets.only(top: 10, bottom: isDesktopScreen ? 12 : 19),
decoration: isDesktopScreen
? null
: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFDBDBDB)))),
child: Text(
showName,
// textAlign: TextAlign.center,
style: TextStyle(color: const Color(0xFF111111), fontSize: isDesktopScreen ? 16 : 18),
),
))
],
),
),
))
],
);
}
@override
void dispose() {
super.dispose();
}
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
final TUITheme theme = value.theme;
if (!widget.isMultiSelect) {
_selectedConversation.clear();
}
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: _conversationViewModel),
],
builder: (context, w) {
final recentConvList = serviceLocator<TUIConversationViewModel>().conversationList;
final showList = _buildMemberList(recentConvList);
final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop;
return AZListViewContainer(
memberList: showList,
isShowIndexBar: false,
susItemBuilder: (context, index) {
return isDesktopScreen
? Container()
: Container(
height: 40,
width: MediaQuery.of(context).size.width,
padding: const EdgeInsets.only(left: 16.0),
color: theme.weakDividerColor,
alignment: Alignment.centerLeft,
child: Text(
TIM_t("最近联系人"),
softWrap: true,
style: TextStyle(
fontSize: 14.0,
color: theme.weakTextColor,
),
),
);
},
itemBuilder: (context, index) {
final conversation = showList[index].memberInfo;
if (conversation != null) {
return _buildItem(conversation);
} else {
return Container();
}
},
);
},
);
}
}
+126
View File
@@ -0,0 +1,126 @@
// ignore_for_file: file_names
import 'package:flutter/material.dart';
class TextSize {
static Size boundingTextSize(String text, TextStyle style,
{int maxLines = 2 ^ 31, double maxWidth = double.infinity}) {
if (text.isEmpty) {
return const Size(0, 0);
}
final TextPainter textPainter = TextPainter(
textDirection: TextDirection.ltr,
text: TextSpan(text: text, style: style),
maxLines: maxLines)
..layout(maxWidth: maxWidth);
return textPainter.size;
}
}
class CustomText extends StatefulWidget {
const CustomText(
this.text, {
Key? key,
required this.width,
this.offset,
this.overflowtext,
this.style,
this.strutStyle,
this.textAlign,
this.textDirection,
this.locale,
this.softWrap,
this.overflow,
this.textScaleFactor,
this.maxLines,
this.semanticsLabel,
this.textWidthBasis,
this.textHeightBehavior,
}) : super(key: key);
final String text;
final double width;
final String? overflowtext;
final int? offset;
final TextStyle? style;
final StrutStyle? strutStyle;
final TextAlign? textAlign;
final TextDirection? textDirection;
final Locale? locale;
final bool? softWrap;
final TextOverflow? overflow;
final double? textScaleFactor;
final int? maxLines;
final String? semanticsLabel;
final TextWidthBasis? textWidthBasis;
final TextHeightBehavior? textHeightBehavior;
@override
State<CustomText> createState() => _ExtendTextState();
}
class _ExtendTextState extends State<CustomText> {
String? text;
countTextSize() {
TextStyle style = widget.style ?? const TextStyle(fontSize: 14);
double textwidth = TextSize.boundingTextSize(widget.text, style).width;
int offset = widget.offset ?? 3;
if (textwidth > widget.width) {
int position = widget.text.lastIndexOf('.');
if(position < 1){
int numberOfCharsToRemove = ((textwidth - widget.width) / (style.fontSize ?? 14)).floor();
String overflowText = widget.overflowtext ?? '...';
text = widget.text.replaceRange(widget.text.length - numberOfCharsToRemove, widget.text.length, '') + overflowText;
}else{
String overflowtext = widget.overflowtext ?? '...';
int overflowtextLength = overflowtext.length;
double singTextSize = textwidth / widget.text.length;
String newtext =
'${widget.text.substring(0, position - offset)}$overflowtext${widget.text.substring(position - offset, widget.text.length)}';
position += overflowtextLength;
int number = ((textwidth - widget.width) / singTextSize).ceil();
do {
int a = position - offset - overflowtextLength - number;
newtext = newtext.substring(0, a < 1 ? 1 : a) +
newtext.substring(
position - offset - overflowtextLength, newtext.length);
position -= number;
number =
((TextSize.boundingTextSize(newtext, style).width - widget.width) /
singTextSize)
.ceil();
if (a < 1 || number < 1) {
break;
}
} while (
TextSize.boundingTextSize(newtext, style).width > widget.width - 20);
text = newtext;
}
}
}
@override
void initState() {
super.initState();
countTextSize();
}
@override
Widget build(BuildContext context) {
return Text(
text ?? widget.text,
style: widget.style,
strutStyle: widget.strutStyle,
textAlign: widget.textAlign,
textDirection: widget.textDirection,
locale: widget.locale,
softWrap: widget.softWrap,
overflow: widget.overflow,
textScaleFactor: widget.textScaleFactor,
maxLines: widget.maxLines,
semanticsLabel: widget.semanticsLabel,
textWidthBasis: widget.textWidthBasis,
textHeightBehavior: widget.textHeightBehavior,
);
}
}
+248
View File
@@ -0,0 +1,248 @@
import 'package:flutter/material.dart';
import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/drag_widget.dart';
import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart';
class TextInputBottomSheet {
static OverlayEntry? entry;
static Widget inputBoxContent(
{required BuildContext context,
required String title,
String? tips,
required Function(String text) onSubmitted,
required TUITheme theme,
bool isShowCancel = false,
Offset? initOffset,
String? initText,
required TextEditingController selectionController}) {
final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop;
selectionController.text = initText ?? "";
return SingleChildScrollView(
child: Container(
padding: EdgeInsets.only(
top: 16,
left: 16,
right: 16,
bottom:
isDesktopScreen ? 16 : MediaQuery.of(context).viewInsets.bottom + 30,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(title,
style:
const TextStyle(fontWeight: FontWeight.w500, fontSize: 16)),
),
Divider(height: 2, color: theme.weakDividerColor),
TextField(
onSubmitted: (text) {
onSubmitted(text);
if (entry != null) {
entry?.remove();
entry = null;
} else {
Navigator.pop(context);
}
},
autofocus: true,
controller: selectionController,
),
if(tips != null) Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(vertical: 5),
height: 40,
child: Text(
tips,
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
)
],
),
if (isDesktopScreen)
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (isShowCancel)
Container(
margin: const EdgeInsets.only(right: 20),
child: SizedBox(
width: 84,
child: ElevatedButton(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
theme.wideBackgroundColor),
shape: MaterialStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5))),
),
onPressed: () {
if (entry != null) {
entry?.remove();
entry = null;
} else {
Navigator.pop(context);
}
},
child: Text(
TIM_t("取消"),
style: TextStyle(color: theme.darkTextColor),
)),
)),
SizedBox(
width: 84,
child: ElevatedButton(
style: ButtonStyle(
shape: MaterialStateProperty.all(RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5))),
),
onPressed: () {
String text = selectionController.text;
onSubmitted(text);
if (entry != null) {
entry?.remove();
entry = null;
} else {
Navigator.pop(context);
}
},
child: Text(TIM_t("保存"))),
),
],
),
if (!isDesktopScreen)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isShowCancel)
Expanded(
child: Container(
margin: const EdgeInsets.only(right: 20),
child: ElevatedButton(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
theme.wideBackgroundColor),
shape: MaterialStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5))),
),
onPressed: () {
if (entry != null) {
entry?.remove();
entry = null;
} else {
Navigator.pop(context);
}
},
child: Text(
TIM_t("取消"),
style: TextStyle(color: theme.darkTextColor),
)),
)),
Expanded(
child: SizedBox(
child: ElevatedButton(
style: ButtonStyle(
shape: MaterialStateProperty.all(RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5))),
),
onPressed: () {
String text = selectionController.text;
onSubmitted(text);
if (entry != null) {
entry?.remove();
entry = null;
} else {
Navigator.pop(context);
}
},
child: Text(TIM_t("确定"))),
)),
],
),
],
),
));
}
static showTextInputBottomSheet({
required BuildContext context,
required String title,
String? tips,
required Function(String text) onSubmitted,
required TUITheme theme,
Offset? initOffset,
String? initText,
}) {
TextEditingController _selectionController = TextEditingController();
final isDesktopScreen = TUIKitScreenUtils.getFormFactor(context) == DeviceType.Desktop;
if (isDesktopScreen) {
if (entry != null) {
return;
}
entry = OverlayEntry(builder: (BuildContext context) {
return TUIKitDragArea(
closeFun: (){
if(entry != null){
entry?.remove();
entry = null;
}
},
initOffset: initOffset ??
Offset(MediaQuery.of(context).size.height * 0.5 + 20,
MediaQuery.of(context).size.height * 0.5 - 100),
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
color: theme.wideBackgroundColor,
border: Border.all(
width: 2,
color: theme.weakBackgroundColor ?? const Color(0xFFbebebe),
),
boxShadow: const [
BoxShadow(
color: Color(0xFFbebebe),
offset: Offset(5, 5),
blurRadius: 10,
spreadRadius: 1,
),
],
),
child: SizedBox(
width: 350,
child: inputBoxContent(
context: context,
isShowCancel: true,
title: title,
tips: tips,
onSubmitted: onSubmitted,
theme: theme,
initText: initText,
selectionController: _selectionController),
),
));
});
Overlay.of(context).insert(entry!);
} else {
showModalBottomSheet(
isScrollControlled: true, // !important
context: context,
builder: (BuildContext context) {
return inputBoxContent(
context: context,
title: title,
tips: tips,
initText: initText,
onSubmitted: onSubmitted,
theme: theme,
selectionController: _selectionController);
});
}
}
}
@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_full_info.dart'
if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_full_info.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_group_member_search_param.dart'
if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_group_member_search_param.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart';
import 'package:tencent_cloud_chat_uikit/business_logic/separate_models/tui_group_profile_model.dart';
import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/screen_utils.dart';
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitGroupProfile/widgets/tim_ui_group_member_search.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/group_member_list.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart';
import 'package:tencent_cloud_chat_uikit/data_services/core/core_services_implements.dart';
import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart';
GlobalKey<_SelectNewGroupOwner> selectNewGroupOwnerKey = GlobalKey();
class SelectNewGroupOwner extends StatefulWidget {
final String? groupID;
final TUIGroupProfileModel model;
final ValueChanged<List<V2TimGroupMemberFullInfo>>? onSelectedMember;
const SelectNewGroupOwner({
this.groupID,
Key? key,
required this.model,
this.onSelectedMember,
}) : super(key: key);
@override
State<StatefulWidget> createState() => _SelectNewGroupOwner();
}
class _SelectNewGroupOwner extends TIMUIKitState<SelectNewGroupOwner> {
final CoreServicesImpl _coreServicesImpl = serviceLocator<CoreServicesImpl>();
List<V2TimGroupMemberFullInfo> selectedMember = [];
List<V2TimGroupMemberFullInfo?>? searchMemberList;
String? searchText;
@override
void dispose() {
super.dispose();
}
bool isSearchTextExist(String? searchText) {
return searchText != null && searchText != "";
}
handleSearchGroupMembers(String searchText, context) async {
searchText = searchText;
List<V2TimGroupMemberFullInfo?> currentGroupMember =
widget.model.groupMemberList.where((element) => element?.userID != _coreServicesImpl.loginInfo.userID).toList();
final res = await widget.model.searchGroupMember(V2TimGroupMemberSearchParam(
keywordList: [searchText],
groupIDList: [widget.model.groupInfo!.groupID],
isSearchMemberNameCard: true,
isSearchMemberUserID: true,
isSearchMemberNickName: true,
isSearchMemberRemark: true,
));
if (res.code == 0) {
List<V2TimGroupMemberFullInfo?> list = [];
final searchResult = res.data!.groupMemberSearchResultItems!;
searchResult.forEach((key, value) {
if (value is List) {
for (V2TimGroupMemberFullInfo item in value) {
list.add(item);
}
}
});
currentGroupMember = list;
} else {
currentGroupMember = [];
}
setState(() {
searchMemberList = isSearchTextExist(searchText) ? currentGroupMember : null;
});
}
onSubmit() {
if (widget.onSelectedMember != null) {
widget.onSelectedMember!(selectedMember);
}
}
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
final TUITheme theme = value.theme;
Widget memberBody() {
return GroupProfileMemberList(
customTopArea: PlatformUtils().isWeb
? null
: GroupMemberSearchTextField(
onTextChange: (text) => handleSearchGroupMembers(text, context),
),
memberList: (searchMemberList ?? widget.model.groupMemberList)
.where((element) => element?.userID != _coreServicesImpl.loginInfo.userID)
.toList(),
canSlideDelete: false,
canSelectMember: true,
maxSelectNum: 1,
onSelectedMemberChange: (member) {
selectedMember = member;
setState(() {});
},
touchBottomCallBack: () {},
);
}
return TUIKitScreenUtils.getDeviceWidget(
context: context,
defaultWidget: Scaffold(
appBar: AppBar(
shadowColor: theme.weakBackgroundColor,
iconTheme: IconThemeData(
color: theme.appbarTextColor,
),
backgroundColor: theme.appbarBgColor ?? theme.primaryColor,
leading: TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(
TIM_t("取消"),
style: TextStyle(
color: theme.appbarTextColor,
fontSize: 16,
),
),
),
actions: [
TextButton(
onPressed: () {
if (selectedMember.isNotEmpty) {
Navigator.pop(context, selectedMember);
}
},
child: Text(
TIM_t("完成"),
style: TextStyle(
color: theme.appbarTextColor,
fontSize: 16,
),
),
)
],
centerTitle: true,
leadingWidth: 100,
title: Text(
"转让群主",
style: TextStyle(
color: theme.appbarTextColor,
fontSize: 16,
),
),
),
body: memberBody()),
desktopWidget: memberBody());
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart';
import 'package:tencent_cloud_chat_uikit/theme/color.dart';
import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart';
class UnreadMessage extends TIMUIKitStatelessWidget {
final int unreadCount;
final double? width;
final double? height;
UnreadMessage(
{Key? key,
required this.unreadCount,
this.width = 22.0,
this.height = 22.0})
: super(key: key);
String generateUnreadText() =>
unreadCount > 99 ? '99+' : unreadCount.toString();
double generateFontSize(String text) => text.length * -2 + 13;
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
final TUITheme theme = value.theme;
final unreadText = generateUnreadText();
final fontSize = generateFontSize(unreadText);
return Container(
width: width,
height: height,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.conversationItemUnreadCountBgColor ??
CommonColor.cautionColor,
),
child: unreadText != "0"
? Center(
child: Text(
unreadText,
style: TextStyle(
color: theme.conversationItemUnreadCountTextColor,
fontSize: fontSize,
),
),
)
: null,
);
}
}
+437
View File
@@ -0,0 +1,437 @@
// ignore_for_file: implementation_imports, unused_element
import 'dart:async';
import 'package:chewie/chewie.dart';
import 'package:chewie/src/animated_play_pause.dart';
import 'package:chewie/src/helpers/utils.dart';
import 'package:chewie/src/material/material_progress_bar.dart';
import 'package:flutter/material.dart';
import 'package:loading_animation_widget/loading_animation_widget.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_statelesswidget.dart';
import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart';
import 'package:video_player/video_player.dart';
import 'center_play_button.dart';
class VideoCustomControls extends StatefulWidget {
const VideoCustomControls({required this.downloadFn, Key? key}) : super(key: key);
final Future<void> Function() downloadFn;
@override
State<StatefulWidget> createState() {
return _VideoCustomControlsState();
}
}
class _VideoCustomControlsState extends TIMUIKitState<VideoCustomControls> with SingleTickerProviderStateMixin {
late VideoPlayerValue _latestValue;
bool _hideStuff = true;
Timer? _hideTimer;
Timer? _initTimer;
Timer? _showAfterExpandCollapseTimer;
bool _dragging = false;
bool _displayTapped = false;
bool isLoading = false;
final barHeight = 48.0;
final marginSize = 5.0;
late VideoPlayerController controller;
ChewieController? _chewieController;
// We know that _chewieController is set in didChangeDependencies
ChewieController get chewieController => _chewieController!;
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
if (_latestValue.hasError) {
return Container(
color: Colors.transparent,
child: chewieController.errorBuilder?.call(
context,
chewieController.videoPlayerController.value.errorDescription!,
) ??
const Center(
child: Icon(
Icons.error,
color: Colors.white,
size: 42,
),
),
);
}
return MouseRegion(
onHover: (_) {
_cancelAndRestartTimer();
},
child: GestureDetector(
onTap: () => _cancelAndRestartTimer(),
child: AbsorbPointer(
absorbing: _hideStuff,
child: Stack(
alignment: Alignment.center,
children: <Widget>[
if (_latestValue.isBuffering) const Center(child: CircularProgressIndicator(color: Colors.white)) else _buildHitArea(),
Positioned(
bottom: 0,
width: MediaQuery.of(context).size.width,
child: Column(children: [_buildVideoControlBar(context), _buildBottomBar()]),
),
if (isLoading)
Container(
child: LoadingAnimationWidget.staggeredDotsWave(
size: 35,
color: Colors.white,
),
padding: const EdgeInsets.all(30),
decoration: const BoxDecoration(
color: Color(0xB22b2b2b),
borderRadius: BorderRadius.all(Radius.circular(20)),
),
),
],
),
),
),
);
}
@override
void dispose() {
_dispose();
super.dispose();
}
void _dispose() {
controller.removeListener(_updateState);
_hideTimer?.cancel();
_initTimer?.cancel();
_showAfterExpandCollapseTimer?.cancel();
}
@override
void didChangeDependencies() {
final _oldController = _chewieController;
_chewieController = ChewieController.of(context);
controller = chewieController.videoPlayerController;
if (_oldController != chewieController) {
_dispose();
_initialize();
}
super.didChangeDependencies();
}
Widget _buildBottomBar() {
return Material(
color: Colors.transparent,
child: Container(
height: barHeight,
margin: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Row(
children: <Widget>[
SizedBox(
width: 48,
height: 48,
child: IconButton(
icon: Image.asset(
'images/close.png',
package: 'tencent_cloud_chat_uikit',
),
iconSize: 30,
onPressed: () {
if (_latestValue.isPlaying) {
_playPause();
}
Navigator.of(context).pop();
},
),
),
Expanded(child: Container()),
SizedBox(
width: 48,
height: 48,
child: IconButton(
icon: Image.asset(
'images/download.png',
package: 'tencent_cloud_chat_uikit',
),
iconSize: 30,
onPressed: () async {
setState(() {
isLoading = true;
});
await widget.downloadFn();
Future.delayed(
const Duration(milliseconds: 200),
() {
setState(
() {
isLoading = false;
},
);
},
);
},
),
)
],
),
),
);
}
AnimatedOpacity _buildVideoControlBar(
BuildContext context,
) {
const iconColor = Colors.white;
return AnimatedOpacity(
opacity: _hideStuff ? 0.0 : 1.0,
duration: const Duration(milliseconds: 300),
child: SizedBox(
height: barHeight,
child: Row(
children: <Widget>[
_buildPlayPause(controller, iconColor),
if (chewieController.isLive) const Expanded(child: Text('LIVE')) else _buildPositionStart(iconColor),
if (chewieController.isLive) const SizedBox() else _buildProgressBar(),
if (!chewieController.isLive) _buildPositionEnd(iconColor),
],
),
),
);
}
Widget _buildHitArea() {
// final bool isFinished = _latestValue.position >= _latestValue.duration;
return GestureDetector(
onTap: () {
if (_latestValue.isPlaying) {
if (_displayTapped) {
setState(() {
_hideStuff = true;
});
} else {
_cancelAndRestartTimer();
}
} else {
_playPause();
setState(() {
_hideStuff = true;
});
}
},
child: CenterPlayButton(
isPlaying: controller.value.isPlaying,
show: !_latestValue.isPlaying && !_dragging,
onPressed: _playPause,
));
}
GestureDetector _buildPlayPause(VideoPlayerController controller, Color color) {
return GestureDetector(
onTap: _playPause,
child: Container(
height: barHeight,
color: Colors.transparent,
margin: const EdgeInsets.only(left: 8.0, right: 4.0),
padding: const EdgeInsets.only(
left: 12.0,
right: 12.0,
),
child: AnimatedPlayPause(
playing: controller.value.isPlaying,
color: color,
),
),
);
}
Widget _buildPositionStart(Color? iconColor) {
final position = _latestValue.position;
return Padding(
padding: const EdgeInsets.only(right: 24.0),
child: Text(
formatDuration(position),
style: TextStyle(fontSize: 14.0, color: iconColor),
),
);
}
Widget _buildPositionEnd(Color? iconColor) {
final duration = _latestValue.duration;
return Padding(
padding: const EdgeInsets.only(right: 24.0),
child: Text(
formatDuration(duration),
style: TextStyle(fontSize: 14.0, color: iconColor),
),
);
}
void _cancelAndRestartTimer() {
_hideTimer?.cancel();
_startHideTimer();
setState(() {
_hideStuff = false;
_displayTapped = true;
});
}
Future<void> _initialize() async {
controller.addListener(_updateState);
_updateState();
if (controller.value.isPlaying || chewieController.autoPlay) {
_startHideTimer();
}
if (chewieController.showControlsOnInitialize) {
_initTimer = Timer(const Duration(milliseconds: 200), () {
setState(() {
_hideStuff = false;
});
});
}
}
void _onExpandCollapse() {
setState(() {
_hideStuff = true;
chewieController.toggleFullScreen();
_showAfterExpandCollapseTimer = Timer(const Duration(milliseconds: 300), () {
setState(() {
_cancelAndRestartTimer();
});
});
});
}
void _playPause() {
final isFinished = _latestValue.position >= _latestValue.duration;
setState(() {
if (controller.value.isPlaying) {
_hideStuff = false;
_hideTimer?.cancel();
controller.pause();
} else {
_cancelAndRestartTimer();
if (!controller.value.isInitialized) {
controller.initialize().then((_) {
controller.play();
});
} else {
if (isFinished) {
controller.seekTo(const Duration());
}
Timer(const Duration(milliseconds: 100), () => controller.play());
}
}
});
}
void _startHideTimer() {
_hideTimer = Timer(const Duration(seconds: 3), () {
setState(() {
_hideStuff = true;
});
});
}
void _updateState() {
if (!mounted) return;
setState(() {
_latestValue = controller.value;
});
}
Widget _buildProgressBar() {
return Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 20.0),
child: MaterialVideoProgressBar(
controller,
onDragStart: () {
setState(() {
_dragging = true;
});
_hideTimer?.cancel();
},
onDragEnd: () {
setState(() {
_dragging = false;
});
_startHideTimer();
},
colors: chewieController.materialProgressColors ?? ChewieProgressColors(playedColor: Colors.white, handleColor: Colors.white, bufferedColor: Colors.white38, backgroundColor: Colors.white24),
),
),
);
}
}
class _PlaybackSpeedDialog extends TIMUIKitStatelessWidget {
_PlaybackSpeedDialog({
Key? key,
required List<double> speeds,
required double selected,
}) : _speeds = speeds,
_selected = selected,
super(key: key);
final List<double> _speeds;
final double _selected;
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
final TUITheme theme = value.theme;
final Color selectedColor = theme.primaryColor ?? Theme.of(context).primaryColor;
return ListView.builder(
shrinkWrap: true,
physics: const ScrollPhysics(),
itemBuilder: (context, index) {
final _speed = _speeds[index];
return ListTile(
dense: true,
title: Row(
children: [
if (_speed == _selected)
Icon(
Icons.check,
size: 20.0,
color: selectedColor,
)
else
Container(width: 20.0),
const SizedBox(width: 16.0),
Text(_speed.toString()),
],
),
selected: _speed == _selected,
onTap: () {
Navigator.of(context).pop(_speed);
},
);
},
itemCount: _speeds.length,
);
}
}
+302
View File
@@ -0,0 +1,302 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart';
import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart';
import 'package:tencent_cloud_chat_sdk/enum/message_status.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'
if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_message.dart';
import 'package:tencent_cloud_chat_sdk/models/v2_tim_video_elem.dart'
if (dart.library.html) 'package:tencent_cloud_chat_sdk/web/compatible_models/v2_tim_video_elem.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_callback.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_base.dart';
import 'package:tencent_cloud_chat_uikit/base_widgets/tim_ui_kit_state.dart';
import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_chat_global_model.dart';
import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart';
import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/permission.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart';
import 'package:tencent_cloud_chat_uikit/ui/views/TIMUIKitChat/TIMUIKITMessageItem/tim_uikit_chat_videoplayer.dart';
import 'package:universal_html/html.dart' as html;
class VideoScreen extends StatefulWidget {
const VideoScreen({required this.message, required this.heroTag, required this.videoElement, Key? key})
: super(key: key);
final V2TimMessage message;
final dynamic heroTag;
final V2TimVideoElem videoElement;
@override
State<StatefulWidget> createState() => _VideoScreenState();
}
class _VideoScreenState extends TIMUIKitState<VideoScreen> {
GlobalKey<ExtendedImageSlidePageState> slidePagekey = GlobalKey<ExtendedImageSlidePageState>();
final TUIChatGlobalModel model = serviceLocator<TUIChatGlobalModel>();
@override
initState() {
super.initState();
// 允许横屏
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
}
//保存网络视频到本地
Future<void> _saveNetworkVideo(
context,
String videoUrl, {
bool isAsset = true,
}) async {
if (PlatformUtils().isWeb) {
RegExp exp = RegExp(r"((\.){1}[^?]{2,4})");
String? suffix = exp.allMatches(videoUrl).last.group(0);
var xhr = html.HttpRequest();
xhr.open('get', videoUrl);
xhr.responseType = 'arraybuffer';
xhr.onLoad.listen((event) {
final a = html.AnchorElement(href: html.Url.createObjectUrl(html.Blob([xhr.response])));
a.download = '${md5.convert(utf8.encode(videoUrl)).toString()}$suffix';
a.click();
a.remove();
});
xhr.send();
return;
}
if (PlatformUtils().isMobile) {
if (PlatformUtils().isIOS) {
if (!await Permissions.checkPermission(
context,
Permission.photosAddOnly.value,
)) {
return;
}
} else {
final DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
if ((androidInfo.version.sdkInt) >= 33) {
final videos = await Permissions.checkPermission(
context,
Permission.videos.value,
);
if (!videos) {
return;
}
} else {
final storage = await Permissions.checkPermission(
context,
Permission.storage.value,
);
if (!storage) {
return;
}
}
}
}
String savePath = videoUrl;
if (!isAsset) {
if (widget.message.msgID == null || widget.message.msgID!.isEmpty) {
return;
}
if (model.getMessageProgress(widget.message.msgID) == 100) {
String savePath;
if (widget.message.videoElem!.localVideoUrl != null && widget.message.videoElem!.localVideoUrl != '') {
savePath = widget.message.videoElem!.localVideoUrl!;
} else {
savePath = model.getFileMessageLocation(widget.message.msgID);
}
File f = File(savePath);
if (f.existsSync()) {
var result = await ImageGallerySaverPlus.saveFile(savePath);
if (PlatformUtils().isIOS) {
if (result['isSuccess']) {
onTIMCallback(
TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频保存成功"), infoCode: 6660402));
} else {
onTIMCallback(
TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频保存失败"), infoCode: 6660403));
}
} else {
if (result != null) {
onTIMCallback(
TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频保存成功"), infoCode: 6660402));
} else {
onTIMCallback(
TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频保存失败"), infoCode: 6660403));
}
}
}
} else {
onTIMCallback(TIMCallback(
type: TIMCallbackType.INFO, infoRecommendText: TIM_t("the message is downloading"), infoCode: -1));
}
return;
}
var result = await ImageGallerySaverPlus.saveFile(savePath);
if (PlatformUtils().isIOS) {
if (result['isSuccess']) {
onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频保存成功"), infoCode: 6660402));
} else {
onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频保存失败"), infoCode: 6660403));
}
} else {
if (result != null) {
onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频保存成功"), infoCode: 6660402));
} else {
onTIMCallback(TIMCallback(type: TIMCallbackType.INFO, infoRecommendText: TIM_t("视频保存失败"), infoCode: 6660403));
}
}
return;
}
Future<void> _saveVideo() async {
if (PlatformUtils().isWeb) {
return await _saveNetworkVideo(
context,
widget.videoElement.videoPath!,
isAsset: true,
);
}
if (widget.videoElement.videoPath != '' &&
widget.videoElement.videoPath != null &&
File(widget.videoElement.videoPath!).existsSync()) {
File f = File(widget.videoElement.videoPath!);
if (f.existsSync()) {
return await _saveNetworkVideo(
context,
widget.videoElement.videoPath!,
isAsset: true,
);
}
}
if (widget.videoElement.localVideoUrl != '' && widget.videoElement.localVideoUrl != null) {
File f = File(widget.videoElement.localVideoUrl!);
if (f.existsSync()) {
return await _saveNetworkVideo(
context,
widget.videoElement.localVideoUrl!,
isAsset: true,
);
}
}
return await _saveNetworkVideo(
context,
widget.videoElement.videoUrl!,
isAsset: false,
);
}
double getVideoHeight() {
double height = widget.videoElement.snapshotHeight!.toDouble();
double width = widget.videoElement.snapshotWidth!.toDouble();
// 横图
if (width > height) {
return height * 1.3;
}
return height;
}
double getVideoWidth() {
double height = widget.videoElement.snapshotHeight!.toDouble();
double width = widget.videoElement.snapshotWidth!.toDouble();
// 横图
if (width > height) {
return width * 1.3;
}
return width;
}
@override
void dispose() {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
super.dispose();
}
@override
Widget tuiBuild(BuildContext context, TUIKitBuildValue value) {
return OrientationBuilder(builder: ((context, orientation) {
return Material(
color: Colors.transparent,
child: Stack(
children: [
Container(
color: Colors.transparent,
constraints: BoxConstraints.expand(
height: MediaQuery.of(context).size.height,
),
child: ExtendedImageSlidePage(
key: slidePagekey,
slidePageBackgroundHandler: (Offset offset, Size size) {
if (orientation == Orientation.landscape) {
return Colors.black;
}
double opacity = 0.0;
opacity = offset.distance / (Offset(size.width, size.height).distance / 2.0);
return Colors.black.withOpacity(min(1.0, max(1.0 - opacity, 0.0)));
},
slideType: SlideType.onlyImage,
slideEndHandler: (
Offset offset, {
ExtendedImageSlidePageState? state,
ScaleEndDetails? details,
}) {
final vy = details?.velocity.pixelsPerSecond.dy ?? 0;
final oy = offset.dy;
if (vy > 300 || oy > 100) {
return true;
}
return null;
},
child: TIMUIKitVideoPlayer(
message: widget.message,
controller: true,
isSending: widget.message.status == MessageStatus.V2TIM_MSG_STATUS_SENDING,
),
),
),
Positioned(
bottom: 20,
left: 20,
child: IconButton(
icon: Image.asset(
'images/close.png',
package: 'tencent_cloud_chat_uikit',
),
onPressed: () {
Navigator.of(context).pop();
},
),
),
Positioned(
bottom: 20,
right: 20,
child: IconButton(
icon: Image.asset(
'images/download.png',
package: 'tencent_cloud_chat_uikit',
),
onPressed: () async {
await _saveVideo();
},
),
),
],
),
);
}));
}
}
+399
View File
@@ -0,0 +1,399 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'package:tencent_chat_i18n_tool/tencent_chat_i18n_tool.dart';
import 'package:tencent_cloud_chat_uikit/business_logic/view_models/tui_self_info_view_model.dart';
import 'package:tencent_cloud_chat_uikit/data_services/core/tim_uikit_wide_modal_operation_key.dart';
import 'package:tencent_cloud_chat_uikit/data_services/services_locatar.dart';
import 'package:tencent_cloud_chat_uikit/tencent_cloud_chat_uikit.dart';
import 'package:tencent_cloud_chat_uikit/ui/utils/platform.dart';
import 'package:tencent_cloud_chat_uikit/ui/widgets/drag_widget.dart';
import 'package:tencent_cloud_chat_uikit/theme/tui_theme.dart';
import 'package:tencent_cloud_chat_uikit/theme/color.dart';
import 'package:video_player/video_player.dart';
typedef BuildContentFunction = Widget Function(BuildContext context);
class TUIKitWidePopup {
static OverlayEntry? entry;
static bool isShow = false;
static showSecondaryConfirmDialog({
required TUIKitWideModalOperationKey operationKey,
required BuildContext context,
required String text,
required TUITheme theme,
VoidCallback? onConfirm,
VoidCallback? onCancel,
}) {
return TUIKitWidePopup.showPopupWindow(
operationKey: operationKey,
context: context,
isDarkBackground: false,
onCancel: onCancel,
onConfirm: onConfirm,
width: 350,
height: 120,
child: (onClose) => Container(
padding: const EdgeInsets.only(left: 16, right: 16),
child: Row(
children: [
Icon(Icons.info, color: theme.primaryColor),
const SizedBox(
width: 8,
),
Expanded(child: Text(text))
],
),
));
}
static showPopupWindow({
/// You could determine this field as `TUIKitWideModalOperationKey.custom` for your own business needs.
required TUIKitWideModalOperationKey operationKey,
required BuildContext context,
required Widget Function(VoidCallback closeFunc) child,
TUITheme? theme,
double? width,
double? height,
Offset? offset,
String? initText,
BorderRadius? borderRadius,
bool isDarkBackground = true,
String? title,
VoidCallback? onSubmit,
Widget? submitWidget,
VoidCallback? onConfirm,
VoidCallback? onCancel,
}) async {
if (isShow) {
return;
}
isShow = true;
final TUISelfInfoViewModel selfInfoViewModel = serviceLocator<TUISelfInfoViewModel>();
if (selfInfoViewModel.globalConfig?.showDesktopModalFunc != null) {
final res = await selfInfoViewModel.globalConfig!.showDesktopModalFunc!(operationKey, context, child, theme, width, height, offset, initText, borderRadius, isDarkBackground, title, onSubmit, submitWidget, onConfirm, onCancel);
if (res == true) {
return;
}
}
final isUseMaterialAlert = (offset == null);
// ignore: prefer_function_declarations_over_variables
final BuildContentFunction buildContent = (BuildContext contentContext) => Container(
key:UniqueKey(),
width: width,
height: height,
decoration: BoxDecoration(
borderRadius: borderRadius ?? const BorderRadius.all(Radius.circular(16)),
color: theme?.wideBackgroundColor ?? const Color(0xFFffffff),
border: isDarkBackground
? Border.all(
width: 2,
color: theme?.weakBackgroundColor ?? const Color(0xFFbebebe),
)
: null,
boxShadow: (isDarkBackground || isUseMaterialAlert)
? null
: const [
BoxShadow(
color: Color(0xFFbebebe),
offset: Offset(3, 3),
blurRadius: 10,
spreadRadius: 1,
),
],
),
child: Column(
children: [
if (title != null)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: hexToColor("f5f6f7"),
borderRadius: const BorderRadius.only(topLeft: Radius.circular(16), topRight: Radius.circular(16)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: [
Text(
title,
style: TextStyle(fontSize: 18, color: theme?.darkTextColor ?? const Color(0xFF444444)),
),
InkWell(
onTap: () {
if (onSubmit != null) {
onSubmit();
}
isShow = false;
if (offset == null) {
if (contentContext.mounted) {
Navigator.pop(contentContext);
}
} else {
entry?.remove();
entry = null;
}
},
child: onSubmit != null ? (submitWidget ?? const Icon(Icons.check)) : const Icon(Icons.close),
)
],
),
),
if (title != null)
SizedBox(
height: 1,
child: Container(
color: theme?.weakDividerColor ?? const Color(0xFFE5E6E9),
),
),
if (height != null && width != null)
Expanded(child: child(() {
isShow = false;
if (isUseMaterialAlert) {
Navigator.pop(contentContext);
} else {
entry?.remove();
entry = null;
}
})),
if (height == null || width == null)
child(() {
isShow = false;
if (isUseMaterialAlert) {
Navigator.pop(contentContext);
} else {
entry?.remove();
entry = null;
}
}),
if (onCancel != null || onConfirm != null)
Container(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (onCancel != null)
Container(
margin: const EdgeInsets.only(right: 16),
child: OutlinedButton(
onPressed: () {
isShow = false;
if (isUseMaterialAlert) {
Navigator.pop(contentContext);
} else {
entry?.remove();
entry = null;
}
onCancel();
},
child: Text(
TIM_t("取消"),
style: TextStyle(color: theme?.weakTextColor ?? Colors.black),
)),
),
if (onConfirm != null)
Container(
margin: const EdgeInsets.only(right: 16),
child: ElevatedButton(
onPressed: () {
isShow = false;
if (isUseMaterialAlert) {
Navigator.pop(contentContext);
} else {
entry?.remove();
entry = null;
}
onConfirm();
},
child: Text(
TIM_t("确定"),
style: TextStyle(color: theme?.primaryColor),
)),
),
],
),
)
],
),
);
if (isUseMaterialAlert) {
return showDialog(
barrierDismissible: true,
context: context,
builder: (dialogContext) {
return WillPopScope(
child: AlertDialog(
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
backgroundColor: Colors.transparent,
titlePadding: const EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 0.0),
contentPadding: const EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 0.0),
content: buildContent(dialogContext),
),
onWillPop: () {
isShow = false;
return Future.value(true);
});
});
}
if (entry != null) {
return;
}
entry = OverlayEntry(builder: (BuildContext overlayContext) {
return Material(
color: Colors.transparent,
child: TUIKitDragArea(
backgroundColor: isDarkBackground ? const Color(0x7F000000) : null,
closeFun: () {
isShow = false;
if (entry != null) {
entry?.remove();
entry = null;
}
},
initOffset: offset,
child: buildContent(overlayContext)),
);
});
Overlay.of(context).insert(entry!);
}
static void showMedia({
String? mediaLocalPath,
String? mediaURL,
required BuildContext context,
required VoidCallback onClickOrigin,
double? aspectRatio,
}) async {
assert((mediaLocalPath != null) || (mediaURL != null), "At least one of mediaLocalPath or mediaURL must be provided.");
String _removeQueryString(String urlString) {
Uri uri = Uri.parse(urlString);
Uri cleanUri = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.port,
path: uri.path,
);
return cleanUri.toString();
}
final String mediaPath = mediaLocalPath ?? mediaURL ?? "";
final isLocalResource = mediaLocalPath != null;
String fileExtension = p.extension(isLocalResource ? mediaPath : _removeQueryString(mediaPath));
bool isVideo = ['.mp4', '.avi', '.mov', '.flv', '.wmv'].contains(fileExtension);
VideoPlayerController? videoController;
ChewieController? chewieController;
Widget mediaWidget;
double? aspectRatioFinal = aspectRatio;
if (isVideo) {
if (isLocalResource) {
videoController = VideoPlayerController.file(File(mediaPath));
} else {
videoController = VideoPlayerController.networkUrl(Uri.parse(mediaPath));
}
await videoController.initialize();
aspectRatioFinal = videoController.value.aspectRatio;
chewieController = ChewieController(
allowFullScreen: false,
videoPlayerController: videoController,
aspectRatio: aspectRatioFinal,
autoPlay: true,
looping: false,
autoInitialize: true,
);
mediaWidget = Chewie(controller: chewieController);
} else {
mediaWidget = isLocalResource ? Image.file(File(mediaPath), fit: BoxFit.contain) : Image.network(mediaPath, fit: BoxFit.contain);
}
showDialog(
barrierDismissible: true,
context: context,
builder: (context) {
return WillPopScope(
child: AlertDialog(
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
backgroundColor: Colors.transparent,
titlePadding: const EdgeInsets.all(0),
contentPadding: const EdgeInsets.all(0),
content: GestureDetector(
onTap: () {
Navigator.pop(context);
},
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.85,
maxHeight: MediaQuery.of(context).size.height * 0.82,
),
child: aspectRatioFinal != null ? AspectRatio(aspectRatio: aspectRatioFinal, child: mediaWidget) : mediaWidget,
),
const SizedBox(height: 10),
InkWell(
onTap: onClickOrigin,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Container(
margin: const EdgeInsets.only(top: 0),
child: Icon(
Icons.open_in_new,
size: 14,
color: Colors.grey.shade200,
),
),
const SizedBox(
width: 8,
),
// Custom Text Widget with designer baseline
Text(
TIM_t("在新窗口中打开"),
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade200,
),
),
],
),
),
],
),
),
),
),
onWillPop: () {
if (isVideo) videoController?.dispose();
return Future.value(true);
},
);
});
}
}