init
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
))
|
||||
]
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}),
|
||||
),
|
||||
))
|
||||
],
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
])),
|
||||
);
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user