feat(mobile-next): 导入官方 openim-flutter-demo 固定基线 3.8.3-patch.3 并完成双构建

- 上游: github.com/OpenIMSDK/openim-flutter-demo tag 3.8.3-patch.3
  commit b3dfdb1e8aaeaaf6f0793e10cadd20d5c184a31f
- 并行目录 mobile-next/ 原样导入(含 LICENSE),不覆盖现网 mobile/
- 锁定 Flutter 3.24.5 / Dart 3.5.4 / JDK 17 / Gradle 7.6.3 / AGP 7.3.1
- 实测 Android debug 与 release 构建均成功(见 docs/mobile-next-baseline-import.md)
- 本提交可单独回退:git revert 1db1229(重写前)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
编码工程师
2026-08-19 15:24:33 +08:00
co-authored by multica-agent
parent fa8584a73f
commit 8046ec7483
767 changed files with 57658 additions and 0 deletions
@@ -0,0 +1,271 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:openim_common/openim_common.dart';
import 'package:uuid/uuid.dart';
typedef CustomAvatarBuilder = Widget? Function();
class AvatarView extends StatelessWidget {
const AvatarView({
Key? key,
this.width,
this.height,
this.onTap,
this.url,
this.file,
this.builder,
this.text,
this.textStyle,
this.onLongPress,
this.isCircle = false,
this.borderRadius,
this.enabledPreview = false,
this.lowMemory = false,
this.nineGridUrl = const [],
this.isGroup = false,
this.showDefaultAvatar = true,
}) : super(key: key);
final double? width;
final double? height;
final Function()? onTap;
final Function()? onLongPress;
final String? url;
final File? file;
final CustomAvatarBuilder? builder;
final bool isCircle;
final BorderRadius? borderRadius;
final bool enabledPreview;
final String? text;
final TextStyle? textStyle;
final bool lowMemory;
final List<String> nineGridUrl;
final bool isGroup;
final bool showDefaultAvatar;
double get _avatarSize => min(width ?? 44.w, height ?? 44.h);
TextStyle get _textStyle => textStyle ?? Styles.ts_FFFFFF_16sp;
Color get _textAvatarBgColor => Styles.c_0089FF;
String? get _showName {
if (isGroup) return null;
if (text != null && text!.trim().isNotEmpty) {
return text!.substring(0, 1);
}
return null;
}
bool get isUrlValid => IMUtils.isUrlValid(url);
@override
Widget build(BuildContext context) {
var tag = const Uuid().v4();
var child = GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onTap ??
((enabledPreview && isUrlValid)
? () => IMUtils.previewUrlPicture([MediaSource(thumbnail: url!, url: url)])
: null),
onLongPress: onLongPress,
child: builder?.call() ?? (nineGridUrl.isNotEmpty ? _nineGridAvatar() : _normalAvatar()),
);
return Hero(
tag: tag,
child: isCircle
? ClipOval(child: child)
: ClipRRect(
borderRadius: borderRadius ?? BorderRadius.circular(6.r),
child: child,
),
);
}
Widget _normalAvatar() => !isUrlValid ? _textAvatar() : _networkImageAvatar();
Widget _textAvatar() => Container(
width: _avatarSize,
height: _avatarSize,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.rectangle,
color: _textAvatarBgColor,
),
child: null == _showName
? (showDefaultAvatar
? FaIcon(
isGroup ? FontAwesomeIcons.userGroup : FontAwesomeIcons.solidUser,
color: Colors.white,
size: _avatarSize / 2,
)
: null)
: Text(_showName!, style: _textStyle),
);
Widget _networkImageAvatar() => file != null
? ImageUtil.fileImage(file: file!)
: ImageUtil.networkImage(
url: url!,
width: _avatarSize,
height: _avatarSize,
fit: BoxFit.cover,
lowMemory: lowMemory,
loadProgress: false,
errorWidget: _textAvatar(),
);
Widget _nineGridAvatar() => Container(
width: _avatarSize,
height: _avatarSize,
color: Colors.grey[300],
padding: const EdgeInsets.all(2.0),
alignment: Alignment.center,
child: _nineGridColumn(),
);
Widget _nineGridColumn() {
double width = 0.0;
double margin = 2.0;
int row1Length = 0;
int row2Length = 0;
int row3Length = 0;
var list = <Widget>[];
switch (nineGridUrl.length) {
case 1:
width = _avatarSize;
row1Length = 1;
break;
case 2:
width = _avatarSize / 2;
row1Length = 2;
break;
case 3:
width = _avatarSize / 2;
row1Length = 1;
row2Length = 2;
break;
case 4:
width = _avatarSize / 2;
row1Length = 2;
row2Length = 2;
break;
case 5:
width = _avatarSize / 3;
row1Length = 2;
row2Length = 3;
break;
case 6:
width = _avatarSize / 3;
row1Length = 3;
row2Length = 3;
break;
case 7:
width = _avatarSize / 3;
row1Length = 1;
row2Length = 3;
row3Length = 3;
break;
case 8:
width = _avatarSize / 3;
row1Length = 2;
row2Length = 3;
row3Length = 3;
break;
case 9:
width = _avatarSize / 3;
row1Length = 3;
row2Length = 3;
row3Length = 3;
break;
}
if (row1Length > 0) {
list.add(_nineGridRow(
length: row1Length,
start: 0,
size: width,
margin: margin,
));
}
if (row2Length > 0) {
list.add(_nineGridRow(
length: row2Length,
start: row1Length,
size: width,
margin: margin,
));
}
if (row3Length > 0) {
list.add(_nineGridRow(
length: row3Length,
start: row1Length + row2Length,
size: width,
margin: margin,
));
}
return Column(
children: list,
);
}
Widget _nineGridRow({
required int length,
required int start,
required double size,
required double margin,
}) {
Widget nineGridImage(String? url, double size) => _normalAvatar();
Widget nineGridLine({
double? width,
double? height,
}) =>
Container(height: height, width: width, color: Colors.white);
var list = <Widget>[];
for (var i = 0; i < length; i++) {
start += i;
list.add(nineGridImage(nineGridUrl.elementAt(start), size));
if (i != length - 1) {
list.add(nineGridLine(width: margin, height: size));
}
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: list,
);
}
}
class RedDotView extends StatelessWidget {
const RedDotView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) => Container(
width: 8,
height: 8,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Styles.c_FF381F,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: const Color(0x26C61B4A),
offset: Offset(1.15.w, 1.15.h),
blurRadius: 57.58.r,
),
BoxShadow(
color: const Color(0x1AC61B4A),
offset: Offset(2.3.w, 2.3.h),
blurRadius: 11.52.r,
),
BoxShadow(
color: const Color(0x0DC61B4A),
offset: Offset(4.61.w, 4.61.h),
blurRadius: 17.28.r,
),
],
),
);
}
@@ -0,0 +1,62 @@
import 'package:azlistview/azlistview.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class WrapAzListView<T extends ISuspensionBean> extends StatelessWidget {
const WrapAzListView({
Key? key,
required this.data,
required this.itemCount,
required this.itemBuilder,
}) : super(key: key);
final List<T> data;
final int itemCount;
final Widget Function(BuildContext context, T data, int index) itemBuilder;
@override
Widget build(BuildContext context) {
return AzListView(
data: data,
itemCount: itemCount,
itemBuilder: (BuildContext context, int index) {
var model = data[index];
return itemBuilder(context, model, index);
},
susItemBuilder: (BuildContext context, int index) {
var model = data[index];
if ('' == model.getSuspensionTag()) {
return Container();
}
return _buildTagView(model.getSuspensionTag());
},
susItemHeight: 23.h,
indexBarData: SuspensionUtil.getTagIndexList(data),
indexBarOptions: IndexBarOptions(
needRebuild: true,
selectTextStyle: Styles.ts_FFFFFF_12sp,
indexHintWidth: 96,
indexHintHeight: 97,
indexHintDecoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage(ImageRes.indexBarBg, package: 'openim_common'),
fit: BoxFit.contain,
),
),
indexHintAlignment: Alignment.centerRight,
indexHintTextStyle: Styles.ts_0C1C33_20sp_semibold,
indexHintOffset: const Offset(-30, 0),
),
);
}
Widget _buildTagView(String tag) => Container(
height: 23.h,
padding: EdgeInsets.symmetric(horizontal: 16.w),
alignment: Alignment.centerLeft,
width: 1.sw,
color: Styles.c_E8EAEF,
child: tag.toText..style = Styles.ts_8E9AB0_14sp,
);
}
@@ -0,0 +1,137 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class BottomBar extends StatelessWidget {
const BottomBar({
Key? key,
this.index = 0,
required this.items,
}) : super(key: key);
final int index;
final List<BottomBarItem> items;
@override
Widget build(BuildContext context) {
return Container(
height: 56.h,
decoration: BoxDecoration(
color: Styles.c_FFFFFF,
border: BorderDirectional(
top: BorderSide(
color: Styles.c_E8EAEF,
width: 1,
),
),
),
child: Row(
children: List.generate(
items.length,
(index) => _buildItemView(
i: index,
item: items.elementAt(index),
)).toList(),
),
);
}
Widget _buildItemView({required int i, required BottomBarItem item}) => Expanded(
child: GestureDetector(
onDoubleTap: () => item.onDoubleClick?.call(i),
onTapDown: (_) => item.onClick?.call(i),
child: Container(
color: Styles.c_FFFFFF,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Stack(
alignment: Alignment.center,
children: [
(i == index ? item.selectedImgRes.toImage : item.unselectedImgRes.toImage)
..width = item.imgWidth
..height = item.imgHeight,
Positioned(
top: 0,
right: 0,
child: Transform.translate(
offset: const Offset(2, -2),
child: UnreadCountView(count: item.count ?? 0),
),
),
],
),
4.verticalSpace,
item.label.toText
..style = i == index
? (item.selectedStyle ?? Styles.ts_0089FF_10sp_semibold)
: (item.unselectedStyle ?? Styles.ts_8E9AB0_10sp_semibold),
],
),
),
),
/*child: InkWell(
onTap: () {
if (item.onClick != null) item.onClick!(i);
},
onDoubleTap: () => item.onDoubleClick?.call(i),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Stack(
alignment: Alignment.center,
children: [
(i == index
? item.selectedImgRes.toImage
: item.unselectedImgRes.toImage)
..width = item.imgWidth
..height = item.imgHeight,
Positioned(
top: 0,
right: 0,
child: Transform.translate(
offset: const Offset(2, -2),
child: UnreadCountView(count: item.count ?? 0),
),
),
],
),
4.verticalSpace,
item.label.toText
..style = i == index
? (item.selectedStyle ?? Styles.ts_0089FF_10sp_semibold)
: (item.unselectedStyle ?? Styles.ts_8E9AB0_10sp_semibold),
],
),
),*/
);
}
class BottomBarItem {
final String selectedImgRes;
final String unselectedImgRes;
final String label;
final TextStyle? selectedStyle;
final TextStyle? unselectedStyle;
final double imgWidth;
final double imgHeight;
final Function(int index)? onClick;
final Function(int index)? onDoubleClick;
final Stream<int>? steam;
final int? count;
BottomBarItem(
{required this.selectedImgRes,
required this.unselectedImgRes,
required this.label,
this.selectedStyle,
this.unselectedStyle,
required this.imgWidth,
required this.imgHeight,
this.onClick,
this.onDoubleClick,
this.steam,
this.count});
}
@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
class BottomSheetView extends StatelessWidget {
const BottomSheetView({
Key? key,
required this.items,
this.itemHeight,
this.textStyle,
this.mainAxisAlignment,
this.isOverlaySheet = false,
this.onCancel,
}) : super(key: key);
final List<SheetItem> items;
final double? itemHeight;
final TextStyle? textStyle;
final MainAxisAlignment? mainAxisAlignment;
final bool isOverlaySheet;
final Function()? onCancel;
@override
Widget build(BuildContext context) {
return SafeArea(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 10.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.r),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: items.map(_parseItem).toList(),
),
),
10.verticalSpace,
_itemBgView(
label: StrRes.cancel,
onTap: isOverlaySheet ? onCancel : () => Get.back(),
borderRadius: BorderRadius.circular(6.r),
alignment: MainAxisAlignment.center,
),
10.verticalSpace,
],
),
),
);
}
Widget _parseItem(SheetItem item) {
BorderRadius? borderRadius;
int length = items.length;
bool isLast = items.indexOf(item) == items.length - 1;
bool isFirst = items.indexOf(item) == 0;
if (length == 1) {
borderRadius = item.borderRadius ?? BorderRadius.circular(6.r);
} else {
borderRadius = item.borderRadius ??
BorderRadius.only(
topLeft: isFirst ? Radius.circular(6.r) : Radius.zero,
topRight: isFirst ? Radius.circular(6.r) : Radius.zero,
bottomLeft: isLast ? Radius.circular(6.r) : Radius.zero,
bottomRight: isLast ? Radius.circular(6.r) : Radius.zero,
);
}
return _itemBgView(
label: item.label,
textStyle: item.textStyle,
icon: item.icon,
alignment: item.alignment,
line: !isLast,
borderRadius: borderRadius,
onTap: () {
if (!isOverlaySheet) Get.back(result: item.result);
item.onTap?.call();
});
}
Widget _itemBgView({
required String label,
String? icon,
Function()? onTap,
BorderRadius? borderRadius,
TextStyle? textStyle,
MainAxisAlignment? alignment,
bool line = false,
}) =>
Ink(
decoration: BoxDecoration(
color: Styles.c_FFFFFF,
borderRadius: borderRadius,
),
child: InkWell(
onTap: onTap,
child: Container(
decoration: line
? BoxDecoration(
border: BorderDirectional(
bottom: BorderSide(color: Styles.c_E8EAEF, width: 0.5),
),
)
: null,
height: itemHeight ?? 56.h,
child: Row(
mainAxisAlignment:
alignment ?? mainAxisAlignment ?? MainAxisAlignment.center,
children: [
if (null != icon) 10.horizontalSpace,
if (null != icon) _image(icon),
if (null != icon) 5.horizontalSpace,
_text(label, textStyle),
],
),
),
),
);
_text(String label, TextStyle? style) =>
label.toText..style = (style ?? textStyle ?? Styles.ts_0C1C33_17sp);
_image(String icon) => icon.toImage
..width = 24.w
..height = 24.h;
}
class SheetItem {
final String label;
final TextStyle? textStyle;
final String? icon;
final Function()? onTap;
final BorderRadius? borderRadius;
final MainAxisAlignment? alignment;
final dynamic result;
SheetItem({
required this.label,
this.textStyle,
this.icon,
this.onTap,
this.borderRadius,
this.alignment,
this.result,
});
}
@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class Button extends StatelessWidget {
const Button({
Key? key,
required this.text,
this.enabled = true,
this.enabledColor,
this.disabledColor,
this.radius,
this.textStyle,
this.disabledTextStyle,
this.onTap,
this.height,
this.margin,
this.padding,
}) : super(key: key);
final Color? enabledColor;
final Color? disabledColor;
final double? radius;
final TextStyle? textStyle;
final TextStyle? disabledTextStyle;
final String text;
final double? height;
final Function()? onTap;
final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? padding;
final bool enabled;
@override
Widget build(BuildContext context) {
return Container(
margin: margin,
child: Material(
type: MaterialType.transparency,
child: Ink(
height: height ?? 44.h,
decoration: BoxDecoration(
color: enabled ? enabledColor ?? Styles.c_0089FF : disabledColor ?? Styles.c_0089FF_opacity50,
borderRadius: BorderRadius.circular(radius ?? 4.r),
),
child: InkWell(
onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(radius ?? 4.r),
child: Container(
alignment: Alignment.center,
padding: padding,
child: Text(
text,
style: textStyle ?? Styles.ts_FFFFFF_17sp_semibold,
maxLines: 1,
),
),
),
),
),
);
}
}
class ImageTextButton extends StatelessWidget {
const ImageTextButton({
Key? key,
required this.icon,
required this.text,
this.textStyle,
this.color,
this.height,
this.onTap,
}) : super(key: key);
final String icon;
final String text;
final TextStyle? textStyle;
final Color? color;
final double? height;
final Function()? onTap;
ImageTextButton.call({super.key, this.onTap})
: icon = ImageRes.audioAndVideoCall,
text = StrRes.audioAndVideoCall,
color = Styles.c_FFFFFF,
textStyle = null,
height = null;
ImageTextButton.message({super.key, this.onTap})
: icon = ImageRes.message,
text = StrRes.sendMessage,
color = Styles.c_0089FF,
textStyle = Styles.ts_FFFFFF_17sp,
height = null;
@override
Widget build(BuildContext context) {
return Material(
child: Ink(
height: height ?? 46.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.r),
color: color,
),
child: InkWell(
onTap: onTap,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
icon.toImage
..width = 20.w
..height = 20.h,
6.horizontalSpace,
text.toText..style = textStyle ?? Styles.ts_0C1C33_17sp,
],
),
),
),
);
}
}
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
enum BubbleType {
send,
receiver,
}
class ChatBubble extends StatelessWidget {
const ChatBubble({
Key? key,
this.margin,
this.constraints,
this.alignment = Alignment.center,
this.backgroundColor,
this.child,
required this.bubbleType,
}) : super(key: key);
final EdgeInsetsGeometry? margin;
final BoxConstraints? constraints;
final AlignmentGeometry? alignment;
final Color? backgroundColor;
final Widget? child;
final BubbleType bubbleType;
bool get isISend => bubbleType == BubbleType.send;
@override
Widget build(BuildContext context) => Container(
constraints: constraints,
margin: margin,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h),
alignment: alignment,
decoration: BoxDecoration(
color:
backgroundColor ?? (isISend ? Styles.c_CCE7FE : Styles.c_F4F5F7),
borderRadius: borderRadius(isISend),
),
child: child,
);
}
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class ChatCallItemView extends StatelessWidget {
const ChatCallItemView({
Key? key,
required this.type,
required this.content,
}) : super(key: key);
final String content;
final String type;
@override
Widget build(BuildContext context) => Row(
children: [
(type == 'audio' ? ImageRes.voiceCallMsg : ImageRes.videoCallMsg).toImage
..width = 18.w
..height = 18.h
..color = (/*isISend ? Styles.c_FFFFFF : */ Styles.c_0C1C33),
8.horizontalSpace,
Text(
content,
style: /*isISend ? Styles.ts_FFFFFF_17sp : */ Styles.ts_0C1C33_17sp,
),
],
);
}
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class ChatCarteView extends StatelessWidget {
const ChatCarteView({
Key? key,
required this.cardElem,
}) : super(key: key);
final CardElem cardElem;
@override
Widget build(BuildContext context) => Container(
width: locationWidth,
height: 91.h,
decoration: BoxDecoration(
color: Styles.c_FFFFFF,
border: Border.all(color: Styles.c_E8EAEF, width: 1),
borderRadius: BorderRadius.circular(6.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
AvatarView(
width: 44.w,
height: 44.h,
url: cardElem.faceURL,
text: cardElem.nickname,
textStyle: Styles.ts_FFFFFF_14sp_medium,
),
10.horizontalSpace,
Flexible(
child: cardElem.nickname!.toText
..style = Styles.ts_0C1C33_17sp
..overflow = TextOverflow.ellipsis,
),
],
),
),
),
Container(color: Styles.c_E8EAEF, height: 1),
Container(
height: 26.h,
padding: EdgeInsets.only(top: 4.h, bottom: 4.h, left: 17.w),
child: StrRes.carte.toText..style = Styles.ts_8E9AB0_12sp,
)
],
),
);
}
@@ -0,0 +1,61 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:openim_common/openim_common.dart';
class ChatCustomEmojiView extends StatelessWidget {
const ChatCustomEmojiView({
Key? key,
this.index,
this.data,
this.heroTag,
required this.isISend,
}) : super(key: key);
final int? index;
final String? data;
final bool isISend;
final String? heroTag;
@override
Widget build(BuildContext context) {
try {
if (data != null) {
var map = json.decode(data!);
var url = map['url'];
var w = map['width'] ?? 1.0;
var h = map['height'] ?? 1.0;
if (w is int) {
w = w.toDouble();
}
if (h is int) {
h = h.toDouble();
}
double trulyWidth;
double trulyHeight;
if (pictureWidth < w) {
trulyWidth = pictureWidth;
trulyHeight = trulyWidth * h / w;
} else {
trulyWidth = w;
trulyHeight = h;
}
final child = ClipRRect(
borderRadius: borderRadius(isISend),
child: ImageUtil.networkImage(
url: url,
width: trulyWidth,
height: trulyHeight,
fit: BoxFit.fitWidth,
),
);
return null != heroTag ? Hero(tag: heroTag!, child: child) : child;
}
} catch (e, s) {
Logger.print('e:$e s:$s');
}
return Container();
}
}
@@ -0,0 +1,38 @@
import 'package:flutter/cupertino.dart';
import 'package:openim_common/openim_common.dart';
class ChatDelayedStatusView extends StatefulWidget {
const ChatDelayedStatusView({
super.key,
required this.isSending,
this.delay = true,
});
final bool isSending;
final bool delay;
@override
State<ChatDelayedStatusView> createState() => _ChatDelayedStatusViewState();
}
class _ChatDelayedStatusViewState extends State<ChatDelayedStatusView> {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: Future.delayed(
Duration(seconds: widget.isSending && widget.delay ? 1 : 0),
() => widget.isSending,
),
builder: (_, AsyncSnapshot<bool> hot) => Visibility(
visible: hot.hasData && hot.data == true,
child: CupertinoActivityIndicator(
color: Styles.c_0089FF,
),
),
);
}
}
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class ChatDisableInputBox extends StatelessWidget {
const ChatDisableInputBox({Key? key, this.type = 0}) : super(key: key);
final int type;
@override
Widget build(BuildContext context) {
return type == 0
? Container(
height: 56.h,
color: Styles.c_F0F2F6,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ImageRes.warn.toImage
..width = 14.w
..height = 14.h,
6.horizontalSpace,
StrRes.notSendMessageNotInGroup.toText..style = Styles.ts_8E9AB0_14sp,
],
),
)
: Container();
}
}
@@ -0,0 +1,177 @@
import 'dart:io';
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart' hide Config;
import 'package:should_rebuild/should_rebuild.dart';
class ChatEmojiView extends StatefulWidget {
const ChatEmojiView({
Key? key,
this.favoriteList = const [],
this.onAddFavorite,
this.onSelectedFavorite,
required this.textEditingController,
this.height,
this.customEmojiLayout,
}) : super(key: key);
final List<String> favoriteList;
final Function()? onAddFavorite;
final Function(int index, String url)? onSelectedFavorite;
final TextEditingController textEditingController;
final double? height;
final Widget? customEmojiLayout;
@override
State<ChatEmojiView> createState() => _ChatEmojiViewState();
}
class _ChatEmojiViewState extends State<ChatEmojiView> {
var _index = 0;
@override
Widget build(BuildContext context) {
return Container(
color: Styles.c_FFFFFF,
child: Column(
children: [
IndexedStack(
index: _index,
children: [
widget.customEmojiLayout ??
ShouldRebuild<EmojiLayout>(
shouldRebuild: (oldWidget, newWidget) => false,
child: EmojiLayout(
controller: widget.textEditingController,
),
),
_buildFavoriteLayout(),
],
),
_buildTabView(),
],
),
);
}
Widget _buildTabView() => Container(
height: 56.h,
decoration: BoxDecoration(
color: Styles.c_FFFFFF,
border: BorderDirectional(
top: BorderSide(
color: Styles.c_E8EAEF,
width: 1.h,
),
),
),
child: Row(
children: [
_buildTabSelectedBgView(selected: _index == 0, index: 0),
_buildTabSelectedBgView(selected: _index == 1, index: 1),
],
),
);
Widget _buildTabSelectedBgView({
bool selected = false,
int index = 0,
}) =>
GestureDetector(
onTap: () {
setState(() {
_index = index;
});
},
child: Container(
width: 62.w,
height: 56.h,
decoration: BoxDecoration(
color: selected ? Styles.c_E8EAEF : null,
),
child: Center(
child: (index == 0 ? ImageRes.emojiTab : ImageRes.favoriteTab).toImage
..width = 28.w
..height = 28.h
..color = (selected ? Styles.c_0089FF : Styles.c_8E9AB0),
),
),
);
Widget _buildFavoriteLayout() => Container(
color: Styles.c_FFFFFF,
height: widget.height ?? 188.h,
child: GridView.builder(
padding: EdgeInsets.fromLTRB(22.w, 12.h, 22.w, 22.h),
itemCount: widget.favoriteList.length + 1,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
childAspectRatio: 1,
mainAxisSpacing: 22.h,
crossAxisSpacing: 22.w,
),
itemBuilder: (BuildContext context, int index) {
if (index == 0) {
return GestureDetector(
onTap: widget.onAddFavorite,
child: ImageRes.addFavorite.toImage
..width = 66.w
..height = 66.h,
);
}
var url = widget.favoriteList.elementAt(index - 1);
return GestureDetector(
onTap: () => widget.onSelectedFavorite?.call(index - 1, url),
child: ImageUtil.networkImage(
url: url,
width: 66.w,
height: 66.h,
fit: BoxFit.cover,
),
);
},
),
);
}
class EmojiLayout extends StatelessWidget {
const EmojiLayout({
Key? key,
required this.controller,
this.height,
}) : super(key: key);
final TextEditingController controller;
final double? height;
@override
Widget build(BuildContext context) {
return Container(
height: height ?? 188.h,
color: Styles.c_FFFFFF,
child: EmojiPicker(
onEmojiSelected: (category, emoji) {
controller
..text += emoji.emoji
..selection = TextSelection.fromPosition(TextPosition(offset: controller.text.length));
},
onBackspacePressed: () {
controller
..text = controller.text.characters.skipLast(1).toString()
..selection = TextSelection.fromPosition(TextPosition(offset: controller.text.length));
},
config: Config(
checkPlatformCompatibility: true,
emojiViewConfig: EmojiViewConfig(
columns: 8,
recentsLimit: 9,
emojiSizeMax: 28 * (Platform.isIOS ? 1.20 : 1.0),
),
skinToneConfig: SkinToneConfig(enabled: false),
categoryViewConfig: CategoryViewConfig(),
bottomActionBarConfig: BottomActionBarConfig(enabled: false),
searchViewConfig: SearchViewConfig(),
)),
);
}
}
@@ -0,0 +1,69 @@
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
import 'package:openim_common/openim_common.dart';
class ChatFacePreview extends StatelessWidget {
const ChatFacePreview({
Key? key,
required this.url,
}) : super(key: key);
final String url;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: TitleBar.back(title: StrRes.emoji),
backgroundColor: Styles.c_FFFFFF,
body: Center(
child: _networkGestureImage(url),
),
);
}
Widget _networkGestureImage(String url) => ExtendedImage.network(
url,
fit: BoxFit.contain,
mode: ExtendedImageMode.gesture,
clearMemoryCacheWhenDispose: true,
clearMemoryCacheIfFailed: true,
handleLoadingProgress: true,
enableSlideOutPage: true,
initGestureConfigHandler: (ExtendedImageState state) {
return GestureConfig(
inPageView: true,
initialScale: 1.0,
maxScale: 5.0,
animationMaxScale: 6.0,
initialAlignment: InitialAlignment.center,
);
},
loadStateChanged: (ExtendedImageState state) {
switch (state.extendedImageLoadState) {
case LoadState.loading:
{
final ImageChunkEvent? loadingProgress = state.loadingProgress;
final double? progress = loadingProgress?.expectedTotalBytes != null
? loadingProgress!.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
: null;
return SizedBox(
width: 15.0,
height: 15.0,
child: Center(
child: CircularProgressIndicator(
color: Styles.c_0089FF,
strokeWidth: 1.5,
value: progress ?? 0,
),
),
);
}
case LoadState.completed:
return null;
case LoadState.failed:
state.imageProvider.evict();
return ImageRes.pictureError.toImage;
}
},
);
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class ChatFileIconView extends StatelessWidget {
const ChatFileIconView({
Key? key,
required this.message,
this.downloadProgressView,
}) : super(key: key);
final Message message;
final Widget? downloadProgressView;
@override
Widget build(BuildContext context) {
final fileName = message.fileElem!.fileName!;
return Stack(
children: [
IMUtils.fileIcon(fileName).toImage
..width = 38.w
..height = 44.h,
if (null != downloadProgressView) downloadProgressView!,
],
);
}
}
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class ChatFileView extends StatelessWidget {
const ChatFileView({
Key? key,
required this.message,
required this.isISend,
this.sendProgressStream,
this.fileDownloadProgressView,
}) : super(key: key);
final Message message;
final Stream<MsgStreamEv<int>>? sendProgressStream;
final bool isISend;
final Widget? fileDownloadProgressView;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 12.w),
width: maxWidth,
height: 64.h,
decoration: BoxDecoration(
color: Styles.c_FFFFFF,
border: Border.all(color: Styles.c_E8EAEF, width: 1),
borderRadius: borderRadius(isISend),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextWithMidEllipsis(
message.fileElem?.fileName ?? '',
style: Styles.ts_0C1C33_17sp,
endPartLength: 8,
),
IMUtils.formatBytes(message.fileElem?.fileSize ?? 0).toText..style = Styles.ts_8E9AB0_14sp,
],
),
),
10.horizontalSpace,
ChatFileIconView(
message: message,
downloadProgressView: fileDownloadProgressView,
),
],
),
],
),
);
}
}
@@ -0,0 +1,43 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:openim_common/openim_common.dart';
import 'package:sprintf/sprintf.dart';
class ChatFriendRelationshipAbnormalHintView extends StatelessWidget {
const ChatFriendRelationshipAbnormalHintView({
Key? key,
this.blockedByFriend = false,
this.deletedByFriend = false,
required this.name,
this.onTap,
}) : super(key: key);
final bool blockedByFriend;
final bool deletedByFriend;
final String name;
final Function()? onTap;
@override
Widget build(BuildContext context) {
if (blockedByFriend) {
return StrRes.blockedByFriendHint.toText..style = Styles.ts_8E9AB0_12sp;
} else if (deletedByFriend) {
return Container(
constraints: BoxConstraints(maxWidth: maxWidth),
child: RichText(
text: TextSpan(
text: sprintf(StrRes.deletedByFriendHint, [name]),
style: Styles.ts_8E9AB0_12sp,
children: [
TextSpan(
text: StrRes.sendFriendVerification,
style: Styles.ts_0089FF_12sp,
recognizer: TapGestureRecognizer()..onTap = onTap,
),
],
),
),
);
}
return Container();
}
}
@@ -0,0 +1,474 @@
import 'dart:convert';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:openim_common/openim_common.dart';
import 'package:sprintf/sprintf.dart';
class ChatHintTextView extends StatelessWidget {
const ChatHintTextView({
super.key,
required this.message,
required this.onTapUserProfile,
});
final Message message;
final ValueChanged<({String userID, String name, String? faceURL, String? groupID})> onTapUserProfile;
@override
Widget build(BuildContext context) {
try {
final groupID = message.groupID;
final elem = message.notificationElem!;
final map = json.decode(elem.detail!);
switch (message.contentType) {
case MessageType.groupCreatedNotification:
{
final ntf = GroupNotification.fromJson(map);
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: IMUtils.getGroupMemberShowName(ntf.opUser!),
style: Styles.ts_0089FF_12sp,
recognizer: TapGestureRecognizer()
..onTap = () => onTapUserProfile((
userID: ntf.opUser!.userID!,
name: ntf.opUser!.nickname!,
faceURL: ntf.opUser!.faceURL,
groupID: groupID
)),
children: [
TextSpan(
text: sprintf(StrRes.createGroupNtf, ['']),
style: Styles.ts_8E9AB0_12sp,
),
],
),
);
}
case MessageType.groupInfoSetNotification:
{
final ntf = GroupNotification.fromJson(map);
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: IMUtils.getGroupMemberShowName(ntf.opUser!),
style: Styles.ts_0089FF_12sp,
recognizer: TapGestureRecognizer()
..onTap = () => onTapUserProfile((
userID: ntf.opUser!.userID!,
name: ntf.opUser!.nickname!,
faceURL: ntf.opUser!.faceURL,
groupID: groupID
)),
children: [
TextSpan(
text: sprintf(StrRes.editGroupInfoNtf, ['']),
style: Styles.ts_8E9AB0_12sp,
),
],
),
);
}
case MessageType.memberQuitNotification:
{
final ntf = QuitGroupNotification.fromJson(map);
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: IMUtils.getGroupMemberShowName(ntf.quitUser!),
style: Styles.ts_0089FF_12sp,
recognizer: TapGestureRecognizer()
..onTap = () => onTapUserProfile((
userID: ntf.quitUser!.userID!,
name: ntf.quitUser!.nickname!,
faceURL: ntf.quitUser!.faceURL,
groupID: groupID
)),
children: [
TextSpan(
text: sprintf(StrRes.quitGroupNtf, ['']),
style: Styles.ts_8E9AB0_12sp,
),
],
),
);
}
case MessageType.memberInvitedNotification:
{
final aMap = <String, String>{};
final bMap = <String, String>{};
final infoMap = <String, GroupMembersInfo>{};
final ntf = InvitedJoinGroupNotification.fromJson(map);
aMap[ntf.opUser!.userID!] = IMUtils.getGroupMemberShowName(ntf.opUser!);
infoMap[ntf.opUser!.userID!] = ntf.opUser!;
for (var user in ntf.invitedUserList!) {
bMap[user.userID!] = IMUtils.getGroupMemberShowName(user);
infoMap[user.userID!] = user;
}
final a = ntf.opUser!.userID!;
final b = bMap.keys.join('');
String pattern = '(${[a, ...bMap.keys].join('|')})';
final text = sprintf(StrRes.invitedJoinGroupNtf, [a, b]);
final List<InlineSpan> children = <InlineSpan>[];
text.splitMapJoin(
RegExp(pattern),
onMatch: (match) {
final text = match[0]!;
final value = aMap[text] ?? bMap[text] ?? '';
final info = infoMap[text];
children.add(TextSpan(
text: value,
style: Styles.ts_0089FF_12sp,
recognizer: TapGestureRecognizer()
..onTap = () => onTapUserProfile((
userID: info?.userID ?? '',
name: info?.nickname ?? '',
faceURL: info?.faceURL,
groupID: groupID
)),
));
return '';
},
onNonMatch: (text) {
children.add(TextSpan(text: text, style: Styles.ts_8E9AB0_12sp));
return '';
},
);
return RichText(
text: TextSpan(children: children),
textAlign: TextAlign.center,
);
}
case MessageType.memberKickedNotification:
{
final aMap = <String, String>{};
final bMap = <String, String>{};
final infoMap = <String, GroupMembersInfo>{};
final ntf = KickedGroupMemeberNotification.fromJson(map);
aMap[ntf.opUser!.userID!] = IMUtils.getGroupMemberShowName(ntf.opUser!);
infoMap[ntf.opUser!.userID!] = ntf.opUser!;
for (var user in ntf.kickedUserList!) {
bMap[user.userID!] = IMUtils.getGroupMemberShowName(user);
infoMap[user.userID!] = user;
}
final a = ntf.opUser!.userID!;
final b = bMap.keys.join('');
String pattern = '(${[a, ...bMap.keys].join('|')})';
final text = sprintf(StrRes.kickedGroupNtf, [b, a]);
final List<InlineSpan> children = <InlineSpan>[];
text.splitMapJoin(
RegExp(pattern),
onMatch: (match) {
final text = match[0]!;
final value = aMap[text] ?? bMap[text] ?? '';
final info = infoMap[text];
children.add(TextSpan(
text: value,
style: Styles.ts_0089FF_12sp,
recognizer: TapGestureRecognizer()
..onTap = () => onTapUserProfile((
userID: info?.userID ?? '',
name: info?.nickname ?? '',
faceURL: info?.faceURL,
groupID: groupID
)),
));
return '';
},
onNonMatch: (text) {
children.add(TextSpan(text: text, style: Styles.ts_8E9AB0_12sp));
return '';
},
);
return RichText(
text: TextSpan(children: children),
textAlign: TextAlign.center,
);
}
case MessageType.memberEnterNotification:
{
final ntf = EnterGroupNotification.fromJson(map);
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: IMUtils.getGroupMemberShowName(ntf.entrantUser!),
style: Styles.ts_0089FF_12sp,
recognizer: TapGestureRecognizer()
..onTap = () => onTapUserProfile((
userID: ntf.entrantUser!.userID!,
name: ntf.entrantUser!.nickname ?? '',
faceURL: ntf.entrantUser!.faceURL,
groupID: groupID,
)),
children: [
TextSpan(
text: sprintf(StrRes.joinGroupNtf, ['']),
style: Styles.ts_8E9AB0_12sp,
),
],
),
);
}
case MessageType.dismissGroupNotification:
{
final ntf = GroupNotification.fromJson(map);
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: IMUtils.getGroupMemberShowName(ntf.opUser!),
style: Styles.ts_0089FF_12sp,
recognizer: TapGestureRecognizer()
..onTap = () => onTapUserProfile((
userID: ntf.opUser!.userID!,
name: ntf.opUser!.nickname ?? '',
faceURL: ntf.opUser!.faceURL,
groupID: groupID,
)),
children: [
TextSpan(
text: sprintf(StrRes.dismissGroupNtf, ['']),
style: Styles.ts_8E9AB0_12sp,
),
],
),
);
}
case MessageType.groupOwnerTransferredNotification:
{
final ntf = GroupRightsTransferNoticication.fromJson(map);
final a = ntf.opUser!.userID;
final b = ntf.newGroupOwner!.userID;
final text = sprintf(StrRes.transferredGroupNtf, [a, b]);
final List<InlineSpan> children = <InlineSpan>[];
text.splitMapJoin(
RegExp('($a|$b)'),
onMatch: (match) {
final text = match[0]!;
final info = text == ntf.opUser!.userID ? ntf.opUser! : ntf.newGroupOwner!;
children.add(TextSpan(
text: IMUtils.getGroupMemberShowName(info),
style: Styles.ts_0089FF_12sp,
recognizer: TapGestureRecognizer()
..onTap = () => onTapUserProfile((
userID: info.userID!,
name: info.nickname ?? '',
faceURL: info.faceURL,
groupID: groupID,
)),
));
return '';
},
onNonMatch: (text) {
children.add(TextSpan(text: text, style: Styles.ts_8E9AB0_12sp));
return '';
},
);
return RichText(
text: TextSpan(children: children),
textAlign: TextAlign.center,
);
}
case MessageType.groupMemberMutedNotification:
{
final ntf = MuteMemberNotification.fromJson(map);
final a = ntf.opUser!.userID;
final b = ntf.mutedUser!.userID;
final c = IMUtils.mutedTime(ntf.mutedSeconds!);
final text = sprintf(StrRes.muteMemberNtf, [b, a, c]);
final List<InlineSpan> children = <InlineSpan>[];
text.splitMapJoin(
RegExp('($a|$b)'),
onMatch: (match) {
final text = match[0]!;
final info = text == ntf.opUser!.userID ? ntf.opUser! : ntf.mutedUser!;
children.add(TextSpan(
text: IMUtils.getGroupMemberShowName(info),
style: Styles.ts_0089FF_12sp,
recognizer: TapGestureRecognizer()
..onTap = () => onTapUserProfile((
userID: info.userID!,
name: info.nickname ?? '',
faceURL: info.faceURL,
groupID: groupID,
)),
));
return '';
},
onNonMatch: (text) {
children.add(TextSpan(text: text, style: Styles.ts_8E9AB0_12sp));
return '';
},
);
return RichText(
text: TextSpan(children: children),
textAlign: TextAlign.center,
);
}
case MessageType.groupMemberCancelMutedNotification:
{
final ntf = MuteMemberNotification.fromJson(map);
final a = ntf.opUser!.userID;
final b = ntf.mutedUser!.userID;
final text = sprintf(StrRes.muteCancelMemberNtf, [b, a]);
final List<InlineSpan> children = <InlineSpan>[];
text.splitMapJoin(
RegExp('($a|$b)'),
onMatch: (match) {
final text = match[0]!;
final info = text == ntf.opUser!.userID ? ntf.opUser! : ntf.mutedUser!;
children.add(TextSpan(
text: IMUtils.getGroupMemberShowName(info),
style: Styles.ts_0089FF_12sp,
recognizer: TapGestureRecognizer()
..onTap = () => onTapUserProfile((
userID: info.userID!,
name: info.nickname ?? '',
faceURL: info.faceURL,
groupID: groupID,
)),
));
return '';
},
onNonMatch: (text) {
children.add(TextSpan(text: text, style: Styles.ts_8E9AB0_12sp));
return '';
},
);
return RichText(
text: TextSpan(children: children),
textAlign: TextAlign.center,
);
}
case MessageType.groupMutedNotification:
{
final ntf = MuteMemberNotification.fromJson(map);
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: IMUtils.getGroupMemberShowName(ntf.opUser!),
style: Styles.ts_0089FF_12sp,
recognizer: TapGestureRecognizer()
..onTap = () => onTapUserProfile((
userID: ntf.opUser!.userID!,
name: ntf.opUser!.nickname ?? '',
faceURL: ntf.opUser!.faceURL,
groupID: groupID,
)),
children: [
TextSpan(
text: sprintf(StrRes.muteGroupNtf, ['']),
style: Styles.ts_8E9AB0_12sp,
),
],
),
);
}
case MessageType.groupCancelMutedNotification:
{
final ntf = MuteMemberNotification.fromJson(map);
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: IMUtils.getGroupMemberShowName(ntf.opUser!),
style: Styles.ts_0089FF_12sp,
recognizer: TapGestureRecognizer()
..onTap = () => onTapUserProfile((
userID: ntf.opUser!.userID!,
name: ntf.opUser!.nickname ?? '',
faceURL: ntf.opUser!.faceURL,
groupID: groupID,
)),
children: [
TextSpan(
text: sprintf(StrRes.muteCancelGroupNtf, ['']),
style: Styles.ts_8E9AB0_12sp,
),
],
),
);
}
case MessageType.friendApplicationApprovedNotification:
{
return StrRes.friendAddedNtf.toText..style = Styles.ts_8E9AB0_12sp;
}
case MessageType.burnAfterReadingNotification:
{
final ntf = BurnAfterReadingNotification.fromJson(map);
return (ntf.isPrivate == true ? StrRes.openPrivateChatNtf : StrRes.closePrivateChatNtf).toText
..style = Styles.ts_8E9AB0_12sp;
}
case MessageType.groupMemberInfoChangedNotification:
final ntf = GroupMemberInfoChangedNotification.fromJson(map);
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: IMUtils.getGroupMemberShowName(ntf.opUser!),
style: Styles.ts_0089FF_12sp,
recognizer: TapGestureRecognizer()
..onTap = () => onTapUserProfile((
userID: ntf.opUser!.userID!,
name: ntf.opUser!.nickname ?? '',
faceURL: ntf.opUser!.faceURL,
groupID: groupID,
)),
children: [
TextSpan(
text: sprintf(StrRes.memberInfoChangedNtf, ['']),
style: Styles.ts_8E9AB0_12sp,
),
],
),
);
case MessageType.groupInfoSetNameNotification:
final ntf = GroupNotification.fromJson(map);
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: IMUtils.getGroupMemberShowName(ntf.opUser!),
style: Styles.ts_0089FF_12sp,
recognizer: TapGestureRecognizer()
..onTap = () => onTapUserProfile((
userID: ntf.opUser!.userID!,
name: ntf.opUser!.nickname ?? '',
faceURL: ntf.opUser!.faceURL,
groupID: groupID,
)),
children: [
TextSpan(
text: sprintf(StrRes.whoModifyGroupName, ['', ntf.group?.groupName ?? ""]),
style: Styles.ts_8E9AB0_12sp,
),
],
),
);
}
return const SizedBox();
} catch (e) {
return const SizedBox();
}
}
}
@@ -0,0 +1,54 @@
import 'dart:io';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
class ThumbnailViewer extends StatefulWidget {
final String? thumbnailUrl;
final String? imageUrl;
final File? thumbnailFile;
final File? imageFile;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
ThumbnailViewer({this.thumbnailUrl, this.imageUrl, this.thumbnailFile, this.imageFile, this.onTap, this.onLongPress});
@override
_ThumbnailViewerState createState() => _ThumbnailViewerState();
}
class _ThumbnailViewerState extends State<ThumbnailViewer> {
bool showThumbnail = true;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: widget.onTap,
onLongPress: widget.onLongPress,
child: Center(
child: showThumbnail
? (widget.thumbnailFile != null
? ExtendedImage.file(
widget.thumbnailFile!,
)
: ExtendedImage.network(
widget.thumbnailUrl!,
fit: BoxFit.cover,
loadStateChanged: (state) {
if (state.extendedImageLoadState == LoadState.completed) {
setState(() {
showThumbnail = false;
});
}
return null;
},
))
: (widget.imageFile != null
? ExtendedImage.file(widget.imageFile!)
: ExtendedImage.network(
widget.imageUrl!,
)),
),
);
}
}
@@ -0,0 +1,378 @@
import 'package:animate_do/animate_do.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
double kInputBoxMinHeight = 56.h;
class ChatInputBox extends StatefulWidget {
const ChatInputBox({
Key? key,
required this.toolbox,
required this.voiceRecordBar,
required this.emojiView,
this.controller,
this.focusNode,
this.style,
this.atStyle,
this.enabled = true,
this.isNotInGroup = false,
this.hintText,
this.forceCloseToolboxSub,
this.quoteContent,
this.onClearQuote,
this.onSend,
this.directionalText,
this.onCloseDirectional,
}) : super(key: key);
final FocusNode? focusNode;
final TextEditingController? controller;
final TextStyle? style;
final TextStyle? atStyle;
final bool enabled;
final bool isNotInGroup;
final String? hintText;
final Widget toolbox;
final Widget voiceRecordBar;
final Widget emojiView;
final Stream? forceCloseToolboxSub;
final String? quoteContent;
final Function()? onClearQuote;
final ValueChanged<String>? onSend;
final TextSpan? directionalText;
final VoidCallback? onCloseDirectional;
@override
State<ChatInputBox> createState() => _ChatInputBoxState();
}
class _ChatInputBoxState extends State<ChatInputBox> /*with TickerProviderStateMixin */ {
bool _toolsVisible = false;
bool _emojiVisible = false;
bool _leftKeyboardButton = false;
bool _rightKeyboardButton = false;
bool _sendButtonVisible = false;
bool get _showQuoteView => IMUtils.isNotNullEmptyStr(widget.quoteContent);
double get _opacity => (widget.enabled ? 1 : .4);
bool get _showDirectionalView => widget.directionalText != null;
@override
void initState() {
widget.focusNode?.addListener(() {
if (widget.focusNode!.hasFocus) {
setState(() {
_toolsVisible = false;
_emojiVisible = false;
_leftKeyboardButton = false;
_rightKeyboardButton = false;
});
}
});
widget.forceCloseToolboxSub?.listen((value) {
if (!mounted) return;
setState(() {
_toolsVisible = false;
_emojiVisible = false;
_rightKeyboardButton = false;
});
});
widget.controller?.addListener(() {
setState(() {
_sendButtonVisible = widget.controller!.text.isNotEmpty;
});
});
super.initState();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!widget.enabled) widget.controller?.clear();
return widget.isNotInGroup
? const ChatDisableInputBox()
: Column(
children: [
Container(
constraints: BoxConstraints(minHeight: kInputBoxMinHeight),
color: Styles.c_F0F2F6,
child: Row(
children: [
12.horizontalSpace,
(_leftKeyboardButton
? (ImageRes.openKeyboard.toImage..onTap = onTapLeftKeyboard)
: (ImageRes.openVoice.toImage..onTap = onTapSpeak))
..width = 32.w
..height = 32.h
..opacity = _opacity,
12.horizontalSpace,
Expanded(
child: Stack(
children: [
Offstage(
offstage: _leftKeyboardButton,
child: _textFiled,
),
Offstage(
offstage: !_leftKeyboardButton,
child: widget.voiceRecordBar,
),
],
),
),
12.horizontalSpace,
(_rightKeyboardButton
? (ImageRes.openKeyboard.toImage..onTap = onTapRightKeyboard)
: (ImageRes.openEmoji.toImage..onTap = onTapEmoji))
..width = 32.w
..height = 32.h
..opacity = _opacity,
12.horizontalSpace,
(_sendButtonVisible ? ImageRes.sendMessage : ImageRes.openToolbox).toImage
..width = 32.w
..height = 32.h
..opacity = _opacity
..onTap = _sendButtonVisible ? send : toggleToolbox,
12.horizontalSpace,
],
),
),
if (_showQuoteView)
_QuoteView(
content: widget.quoteContent!,
onClearQuote: widget.onClearQuote,
),
if (_showDirectionalView)
_SubView(
textSpan: widget.directionalText,
onClose: () {
widget.onCloseDirectional?.call();
},
),
Visibility(
visible: _toolsVisible,
child: FadeInUp(
duration: const Duration(milliseconds: 200),
child: widget.toolbox,
),
),
Visibility(
visible: _emojiVisible,
child: FadeInUp(
duration: const Duration(milliseconds: 200),
child: widget.emojiView,
),
),
],
);
}
Widget get _textFiled => Container(
margin: EdgeInsets.only(top: 10.h, bottom: _showQuoteView ? 4.h : 10.h),
decoration: BoxDecoration(
color: Styles.c_FFFFFF,
borderRadius: BorderRadius.circular(4.r),
),
child: ChatTextField(
controller: widget.controller,
focusNode: widget.focusNode,
style: widget.style ?? Styles.ts_0C1C33_17sp,
atStyle: widget.atStyle ?? Styles.ts_0089FF_17sp,
enabled: widget.enabled,
hintText: widget.hintText,
textAlign: widget.enabled ? TextAlign.start : TextAlign.center,
),
);
void send() {
if (!widget.enabled) return;
if (!_emojiVisible) focus();
if (null != widget.onSend && null != widget.controller) {
widget.onSend!(widget.controller!.text.toString().trim());
}
}
void toggleToolbox() {
if (!widget.enabled) return;
setState(() {
_toolsVisible = !_toolsVisible;
_emojiVisible = false;
_leftKeyboardButton = false;
_rightKeyboardButton = false;
if (_toolsVisible) {
unfocus();
} else {
focus();
}
});
}
void onTapSpeak() {
if (!widget.enabled) return;
Permissions.microphone(() => setState(() {
_leftKeyboardButton = true;
_rightKeyboardButton = false;
_toolsVisible = false;
_emojiVisible = false;
unfocus();
}));
}
void onTapLeftKeyboard() {
if (!widget.enabled) return;
setState(() {
_leftKeyboardButton = false;
_toolsVisible = false;
_emojiVisible = false;
focus();
});
}
void onTapRightKeyboard() {
if (!widget.enabled) return;
setState(() {
_rightKeyboardButton = false;
_toolsVisible = false;
_emojiVisible = false;
focus();
});
}
void onTapEmoji() {
if (!widget.enabled) return;
setState(() {
_rightKeyboardButton = true;
_leftKeyboardButton = false;
_emojiVisible = true;
_toolsVisible = false;
unfocus();
});
}
focus() => FocusScope.of(context).requestFocus(widget.focusNode);
unfocus() => FocusScope.of(context).requestFocus(FocusNode());
}
class _QuoteView extends StatelessWidget {
const _QuoteView({
Key? key,
this.onClearQuote,
required this.content,
}) : super(key: key);
final Function()? onClearQuote;
final String content;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(bottom: 10.h, left: 56.w, right: 100.w),
color: Styles.c_F0F2F6,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onClearQuote,
child: Container(
padding: EdgeInsets.symmetric(vertical: 1.h, horizontal: 4.w),
decoration: BoxDecoration(
color: Styles.c_FFFFFF,
borderRadius: BorderRadius.circular(4.r),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
content,
style: Styles.ts_8E9AB0_14sp,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
ImageRes.delQuote.toImage
..width = 14.w
..height = 14.h,
],
),
),
),
);
}
}
class _SubView extends StatelessWidget {
const _SubView({
this.onClose,
this.title,
this.content,
this.textSpan,
}) : assert(content != null || textSpan != null, 'Either content or textSpan must be provided.');
final VoidCallback? onClose;
final String? title;
final String? content;
final InlineSpan? textSpan;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(bottom: 10.h, left: 56.w, right: 100.w),
color: Styles.c_F0F2F6,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onClose,
child: Container(
padding: EdgeInsets.symmetric(vertical: 1.h, horizontal: 4.w),
decoration: BoxDecoration(
color: Styles.c_FFFFFF,
borderRadius: BorderRadius.circular(4.r),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Row(
children: [
if (title != null)
Text(
title!,
style: Styles.ts_8E9AB0_14sp,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
if (content != null)
Text(
title!,
style: Styles.ts_8E9AB0_14sp,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
if (textSpan != null)
Expanded(
child: RichText(
text: textSpan!,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
ImageRes.delQuote.toImage
..width = 14.w
..height = 14.h,
],
),
),
),
);
}
}
@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class ChatItemContainer extends StatelessWidget {
const ChatItemContainer({
super.key,
required this.id,
this.leftFaceUrl,
this.rightFaceUrl,
this.leftNickname,
this.rightNickname,
this.timelineStr,
this.timeStr,
required this.isBubbleBg,
required this.isISend,
required this.hasRead,
required this.isSending,
required this.isSendFailed,
this.ignorePointer = false,
this.showLeftNickname = true,
this.showRightNickname = false,
required this.readingDuration,
this.menus,
required this.child,
this.popupMenuController,
this.sendStatusStream,
this.onTapLeftAvatar,
this.onTapRightAvatar,
this.onLongPressLeftAvatar,
this.onLongPressRightAvatar,
this.onFailedToResend,
});
final String id;
final String? leftFaceUrl;
final String? rightFaceUrl;
final String? leftNickname;
final String? rightNickname;
final String? timelineStr;
final String? timeStr;
final bool isBubbleBg;
final bool isISend;
final bool hasRead;
final bool isSending;
final bool isSendFailed;
final bool ignorePointer;
final bool showLeftNickname;
final bool showRightNickname;
final int readingDuration;
final List<MenuInfo>? menus;
final Widget child;
final CustomPopupMenuController? popupMenuController;
final Stream<MsgStreamEv<bool>>? sendStatusStream;
final Function()? onTapLeftAvatar;
final Function()? onTapRightAvatar;
final Function()? onLongPressLeftAvatar;
final Function()? onLongPressRightAvatar;
final Function()? onFailedToResend;
@override
Widget build(BuildContext context) {
return IgnorePointer(
ignoring: ignorePointer,
child: Column(
children: [
if (null != timelineStr)
ChatTimelineView(
timeStr: timelineStr!,
margin: EdgeInsets.only(bottom: 20.h),
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: isISend ? _buildRightView() : _buildLeftView()),
],
),
],
),
);
}
Widget _buildChildView(BubbleType type) => (null != menus && menus!.isEmpty)
? isBubbleBg
? ChatBubble(bubbleType: type, child: child)
: child
: CopyCustomPopupMenu(
controller: popupMenuController,
menuBuilder: () => ChatLongPressMenu(
popupMenuController: popupMenuController,
menus: menus ?? allMenus,
),
pressType: PressType.longPress,
arrowColor: Styles.c_0C1C33_opacity85,
barrierColor: Colors.transparent,
verticalMargin: 0,
child: isBubbleBg ? ChatBubble(bubbleType: type, child: child) : child,
);
Widget _buildLeftView() => Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
AvatarView(
width: 44.w,
height: 44.h,
textStyle: Styles.ts_FFFFFF_14sp_medium,
url: leftFaceUrl,
text: leftNickname,
onTap: onTapLeftAvatar,
onLongPress: onLongPressLeftAvatar,
),
10.horizontalSpace,
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
ChatNicknameView(
nickname: showLeftNickname ? leftNickname : null,
timeStr: timeStr,
),
4.verticalSpace,
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildChildView(BubbleType.receiver),
],
),
],
),
],
);
Widget _buildRightView() => Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.start,
children: [
ChatNicknameView(
nickname: showRightNickname ? rightNickname : null,
timeStr: timeStr,
),
4.verticalSpace,
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSendFailed)
ChatSendFailedView(
id: id,
isISend: isISend,
onFailedToResend: onFailedToResend,
isFailed: isSendFailed,
stream: sendStatusStream,
),
if (isSending) ChatDelayedStatusView(isSending: isSending),
4.horizontalSpace,
_buildChildView(BubbleType.send),
],
),
],
),
10.horizontalSpace,
AvatarView(
width: 44.w,
height: 44.h,
textStyle: Styles.ts_FFFFFF_14sp_medium,
url: rightFaceUrl,
text: rightNickname,
onTap: onTapRightAvatar,
onLongPress: onLongPressRightAvatar,
),
],
);
}
@@ -0,0 +1,391 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:focus_detector_v2/focus_detector_v2.dart';
import 'package:openim_common/openim_common.dart';
import 'package:rxdart/rxdart.dart';
double maxWidth = 247.w;
double pictureWidth = 120.w;
double videoWidth = 120.w;
double locationWidth = 220.w;
BorderRadius borderRadius(bool isISend) => BorderRadius.only(
topLeft: Radius.circular(isISend ? 6.r : 0),
topRight: Radius.circular(isISend ? 0 : 6.r),
bottomLeft: Radius.circular(6.r),
bottomRight: Radius.circular(6.r),
);
class MsgStreamEv<T> {
final String id;
final T value;
MsgStreamEv({required this.id, required this.value});
@override
String toString() {
return 'MsgStreamEv{msgId: $id, value: $value}';
}
}
class CustomTypeInfo {
final Widget customView;
final bool needBubbleBackground;
final bool needChatItemContainer;
CustomTypeInfo(
this.customView, [
this.needBubbleBackground = true,
this.needChatItemContainer = true,
]);
}
typedef CustomTypeBuilder = CustomTypeInfo? Function(
BuildContext context,
Message message,
);
typedef NotificationTypeBuilder = Widget? Function(
BuildContext context,
Message message,
);
typedef ItemViewBuilder = Widget? Function(
BuildContext context,
Message message,
);
typedef ItemVisibilityChange = void Function(
Message message,
bool visible,
);
class ChatItemView extends StatefulWidget {
const ChatItemView({
Key? key,
this.mediaItemBuilder,
this.itemViewBuilder,
this.customTypeBuilder,
this.notificationTypeBuilder,
this.sendStatusSubject,
this.visibilityChange,
this.timelineStr,
this.leftNickname,
this.leftFaceUrl,
this.rightNickname,
this.rightFaceUrl,
required this.message,
this.textScaleFactor = 1.0,
this.readingDuration = 30,
this.enabledReadStatus = true,
this.showLongPressMenu = true,
this.isPlayingSound = false,
this.ignorePointer = false,
this.enabledAddEmojiMenu = true,
this.enabledCopyMenu = true,
this.enabledDelMenu = true,
this.enabledForwardMenu = true,
this.enabledReplyMenu = true,
this.enabledRevokeMenu = true,
this.showLeftNickname = true,
this.showRightNickname = false,
this.onTapAddEmojiMenu,
this.highlightColor,
this.allAtMap = const {},
this.patterns = const [],
this.onTapLeftAvatar,
this.onTapRightAvatar,
this.onLongPressLeftAvatar,
this.onLongPressRightAvatar,
this.onTapCopyMenu,
this.onTapDelMenu,
this.onTapForwardMenu,
this.onTapRevokeMenu,
this.onVisibleTrulyText,
this.onPopMenuShowChanged,
this.onFailedToResend,
this.closePopMenuSubject,
this.onClickItemView,
this.fileDownloadProgressView,
required this.onTapUserProfile,
}) : super(key: key);
final ItemViewBuilder? mediaItemBuilder;
final ItemViewBuilder? itemViewBuilder;
final CustomTypeBuilder? customTypeBuilder;
final NotificationTypeBuilder? notificationTypeBuilder;
final Subject<MsgStreamEv<bool>>? sendStatusSubject;
final ItemVisibilityChange? visibilityChange;
final String? timelineStr;
final String? leftNickname;
final String? leftFaceUrl;
final String? rightNickname;
final String? rightFaceUrl;
final Message message;
final double textScaleFactor;
final int readingDuration;
final bool enabledReadStatus;
final bool showLongPressMenu;
final bool isPlayingSound;
final bool ignorePointer;
final bool enabledCopyMenu;
final bool enabledDelMenu;
final bool enabledForwardMenu;
final bool enabledReplyMenu;
final bool enabledRevokeMenu;
final bool enabledAddEmojiMenu;
final bool showLeftNickname;
final bool showRightNickname;
final Color? highlightColor;
final Map<String, String> allAtMap;
final List<MatchPattern> patterns;
final Function()? onTapLeftAvatar;
final Function()? onTapRightAvatar;
final Function()? onLongPressLeftAvatar;
final Function()? onLongPressRightAvatar;
final Function()? onTapCopyMenu;
final Function()? onTapDelMenu;
final Function()? onTapForwardMenu;
final Function()? onTapRevokeMenu;
final Function()? onTapAddEmojiMenu;
final Function(String? text)? onVisibleTrulyText;
final Function(bool show)? onPopMenuShowChanged;
final Function()? onClickItemView;
final ValueChanged<({String userID, String name, String? faceURL, String? groupID})> onTapUserProfile;
final Function()? onFailedToResend;
final Subject<bool>? closePopMenuSubject;
final Widget? fileDownloadProgressView;
@override
State<ChatItemView> createState() => _ChatItemViewState();
}
class _ChatItemViewState extends State<ChatItemView> {
final _popupCtrl = CustomPopupMenuController();
Message get _message => widget.message;
bool get _isISend => _message.sendID == OpenIM.iMManager.userID;
late StreamSubscription<bool> _keyboardSubs;
StreamSubscription<bool>? _closeMenuSubs;
@override
void dispose() {
_popupCtrl.dispose();
_keyboardSubs.cancel();
_closeMenuSubs?.cancel();
super.dispose();
}
@override
void initState() {
final keyboardVisibilityCtrl = KeyboardVisibilityController();
_keyboardSubs = keyboardVisibilityCtrl.onChange.listen((bool visible) {
_popupCtrl.hideMenu();
});
_popupCtrl.addListener(() {
widget.onPopMenuShowChanged?.call(_popupCtrl.menuIsShowing);
});
_closeMenuSubs = widget.closePopMenuSubject?.listen((value) {
if (value == true) {
_popupCtrl.hideMenu();
}
});
super.initState();
}
@override
Widget build(BuildContext context) {
return FocusDetector(
child: Container(
color: widget.highlightColor,
margin: EdgeInsets.only(bottom: 20.h),
padding: EdgeInsets.symmetric(horizontal: 10.w),
child: Center(child: _child),
),
onVisibilityLost: () {
widget.visibilityChange?.call(widget.message, false);
},
onVisibilityGained: () {
widget.visibilityChange?.call(widget.message, true);
},
);
}
Widget get _child => widget.itemViewBuilder?.call(context, _message) ?? _buildChildView();
Widget _buildChildView() {
Widget? child;
String? senderNickname;
String? senderFaceURL;
bool isBubbleBg = false;
/* if (_message.isCallType) {
} else if (_message.isMeetingType) {
} else if (_message.isDeletedByFriendType) {
} else if (_message.isBlockedByFriendType) {
} else if (_message.isEmojiType) {
} else if (_message.isTagType) {
}*/
if (_message.isTextType) {
isBubbleBg = true;
child = ChatText(
text: _message.textElem!.content!,
patterns: widget.patterns,
textScaleFactor: widget.textScaleFactor,
onVisibleTrulyText: widget.onVisibleTrulyText,
);
} else if (_message.isPictureType) {
child = widget.mediaItemBuilder?.call(context, _message) ??
ChatPictureView(
isISend: _isISend,
message: _message,
);
} else if (_message.isVoiceType) {
isBubbleBg = true;
final sound = _message.soundElem;
child = ChatVoiceView(
isISend: _isISend,
soundPath: sound?.soundPath,
soundUrl: sound?.sourceUrl,
duration: sound?.duration,
isPlaying: widget.isPlayingSound,
);
} else if (_message.isVideoType) {
child = widget.mediaItemBuilder?.call(context, _message) ??
ChatVideoView(
isISend: _isISend,
message: _message,
);
} else if (_message.isFileType) {
child = ChatFileView(
message: _message,
isISend: _isISend,
fileDownloadProgressView: widget.fileDownloadProgressView,
);
} else if (_message.isLocationType) {
final location = _message.locationElem;
child = ChatLocationView(
description: location!.description!,
latitude: location.latitude!,
longitude: location.longitude!,
);
} else if (_message.isCardType) {
child = ChatCarteView(cardElem: _message.cardElem!);
} else if (_message.isCustomFaceType) {
final face = _message.faceElem;
child = ChatCustomEmojiView(
index: face?.index,
data: face?.data,
isISend: _isISend,
heroTag: _message.clientMsgID,
);
} else if (_message.isCustomType) {
final info = widget.customTypeBuilder?.call(context, _message);
if (null != info) {
isBubbleBg = info.needBubbleBackground;
child = info.customView;
if (!info.needChatItemContainer) {
return child;
}
}
} else if (_message.isRevokeType) {
return child = ChatRevokeView(
message: _message,
);
} else if (_message.isNotificationType) {
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: ChatHintTextView(
message: _message,
onTapUserProfile: widget.onTapUserProfile,
),
);
}
senderNickname ??= widget.leftNickname ?? _message.senderNickname;
senderFaceURL ??= widget.leftFaceUrl ?? _message.senderFaceUrl;
return child = ChatItemContainer(
id: _message.clientMsgID!,
isISend: _isISend,
leftNickname: senderNickname,
leftFaceUrl: senderFaceURL,
rightNickname: widget.rightNickname ?? OpenIM.iMManager.userInfo.nickname,
rightFaceUrl: widget.rightFaceUrl ?? OpenIM.iMManager.userInfo.faceURL,
showLeftNickname: widget.showLeftNickname,
showRightNickname: widget.showRightNickname,
timelineStr: widget.timelineStr,
timeStr: IMUtils.getChatTimeline(_message.sendTime!, 'HH:mm:ss'),
hasRead: _message.isRead!,
isSending: _message.isVideoType ? false : _message.status == MessageStatus.sending,
isSendFailed: _message.status == MessageStatus.failed,
isBubbleBg: child == null ? true : isBubbleBg,
menus: widget.showLongPressMenu ? _menusItem : [],
ignorePointer: widget.ignorePointer,
readingDuration: widget.readingDuration,
sendStatusStream: widget.sendStatusSubject,
onFailedToResend: widget.onFailedToResend,
popupMenuController: _popupCtrl,
onLongPressLeftAvatar: widget.onLongPressLeftAvatar,
onLongPressRightAvatar: widget.onLongPressRightAvatar,
onTapLeftAvatar: widget.onTapLeftAvatar,
onTapRightAvatar: widget.onTapRightAvatar,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: widget.onClickItemView,
child: child ?? ChatText(text: StrRes.unsupportedMessage),
),
);
}
List<MenuInfo> get _menusItem => [
if (widget.enabledCopyMenu)
MenuInfo(
icon: ImageRes.menuCopy,
text: StrRes.menuCopy,
enabled: widget.enabledCopyMenu,
onTap: widget.onTapCopyMenu,
),
if (widget.enabledDelMenu)
MenuInfo(
icon: ImageRes.menuDel,
text: StrRes.menuDel,
enabled: widget.enabledDelMenu,
onTap: widget.onTapDelMenu,
),
if (widget.enabledForwardMenu)
MenuInfo(
icon: ImageRes.menuForward,
text: StrRes.menuForward,
enabled: widget.enabledForwardMenu,
onTap: widget.onTapForwardMenu,
),
if (widget.enabledRevokeMenu)
MenuInfo(
icon: ImageRes.menuRevoke,
text: StrRes.menuRevoke,
enabled: widget.enabledRevokeMenu,
onTap: widget.onTapRevokeMenu,
),
if (widget.enabledAddEmojiMenu)
MenuInfo(
icon: ImageRes.menuAddFace,
text: StrRes.menuAdd,
enabled: widget.enabledAddEmojiMenu,
onTap: widget.onTapAddEmojiMenu,
),
];
}
@@ -0,0 +1,385 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
extension ScrollControllerExt on ScrollController {
Future scrollToBottom(Function()? onScrollStop) async {
WidgetsBinding.instance.addPostFrameCallback((_) async {
while (position.pixels != position.maxScrollExtent) {
jumpTo(position.maxScrollExtent);
await SchedulerBinding.instance.endOfFrame;
}
onScrollStop?.call();
});
}
Future scrollToTop() async {
WidgetsBinding.instance.addPostFrameCallback((_) async {
while (position.pixels != position.minScrollExtent) {
jumpTo(position.minScrollExtent);
await SchedulerBinding.instance.endOfFrame;
}
});
}
}
class CustomChatListViewController<E> extends ChangeNotifier {
final _topList = <E>[];
final _bottomList = <E>[];
List<E> get topList => _topList;
List<E> get bottomList => _bottomList;
List<E> get list => _topList + _bottomList;
int get length => list.length;
bool topHasMore = true;
bool bottomHasMore = true;
CustomChatListViewController(List<E> list) {
_bottomList.addAll(list);
}
void insertToTop(E data) {
_topList.insert(0, data);
}
void insertAllToTop(Iterable<E> iterable) {
_topList.insertAll(0, iterable);
}
void insertToBottom(E data) {
_bottomList.add(data);
}
void insertAllToBottom(Iterable<E> iterable) {
_bottomList.addAll(iterable);
}
void bottomLoadCompleted(bool hasMore) {
bottomHasMore = hasMore;
notifyListeners();
}
void topLoadCompleted(bool hasMore) {
topHasMore = hasMore;
notifyListeners();
}
E elementAt(int position) => list.elementAt(position);
E removeAt(int position) => list.removeAt(position);
bool remove(Object? value) => list.remove(value);
}
typedef CustomChatListViewItemBuilder<T> = Widget Function(
BuildContext context,
int index,
int position,
T data,
);
class CustomChatListView extends StatefulWidget {
const CustomChatListView({
Key? key,
required this.itemBuilder,
required this.controller,
this.scrollController,
this.onScrollToTopLoad,
this.onScrollToBottomLoad,
this.enabledBottomLoad = false,
this.enabledTopLoad = false,
this.indicatorColor,
}) : super(key: key);
final CustomChatListViewItemBuilder itemBuilder;
final CustomChatListViewController controller;
final ScrollController? scrollController;
final Future<bool> Function()? onScrollToTopLoad;
final Future<bool> Function()? onScrollToBottomLoad;
final bool enabledTopLoad;
final bool enabledBottomLoad;
final Color? indicatorColor;
@override
State<CustomChatListView> createState() => _CustomChatListViewState();
}
class _CustomChatListViewState extends State<CustomChatListView> {
final Key centerKey = const ValueKey('second-sliver-list');
bool _bottomHasMore = true;
bool _topHasMore = true;
bool get _isBottom => widget.scrollController!.offset == widget.scrollController!.position.maxScrollExtent;
bool get _isTop => widget.scrollController!.offset == widget.scrollController!.position.minScrollExtent;
@override
void initState() {
widget.controller.addListener(_loadFinished);
widget.scrollController?.addListener(_scrollListener);
super.initState();
}
@override
void dispose() {
widget.controller.removeListener(_loadFinished);
widget.scrollController?.removeListener(_scrollListener);
super.dispose();
}
void _loadFinished() {
setState(() {
_topHasMore = widget.controller.topHasMore;
_bottomHasMore = widget.controller.bottomHasMore;
});
}
void _scrollListener() {
if (widget.enabledBottomLoad && _isBottom && _bottomHasMore) {
_onScrollToBottomLoadMore();
} else if (widget.enabledTopLoad && _isTop && _topHasMore) {
_onScrollToTopLoadMore();
}
}
void _onScrollToBottomLoadMore() {
widget.onScrollToBottomLoad?.call().then((hasMore) {
if (!mounted) return;
setState(() {
_bottomHasMore = hasMore;
});
});
}
void _onScrollToTopLoadMore() {
widget.onScrollToTopLoad?.call().then((hasMore) {
if (!mounted) return;
setState(() {
_topHasMore = hasMore;
});
});
}
Widget _buildLoadMoreView() => Container(
alignment: Alignment.center,
height: 44,
child: CupertinoActivityIndicator(
color: widget.indicatorColor ?? Colors.blueAccent,
),
);
@override
Widget build(BuildContext context) {
return CustomScrollView(
center: centerKey,
controller: widget.scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: <Widget>[
if (_topHasMore && widget.enabledTopLoad) SliverToBoxAdapter(child: _buildLoadMoreView()),
SliverList(
delegate: SliverChildBuilderDelegate(
(_, index) {
return widget.itemBuilder(
context,
index,
widget.controller.topList.length - index - 1,
widget.controller.topList.elementAt(widget.controller.topList.length - 1 - index),
);
},
childCount: widget.controller.topList.length,
),
),
SliverList(
key: centerKey,
delegate: SliverChildBuilderDelegate(
(_, index) {
return widget.itemBuilder(
context,
index,
widget.controller.topList.length + index,
widget.controller.bottomList.elementAt(index),
);
},
childCount: widget.controller.bottomList.length,
),
),
if (_bottomHasMore && widget.enabledBottomLoad) SliverToBoxAdapter(child: _buildLoadMoreView()),
],
);
}
}
class ChatListView extends StatefulWidget {
const ChatListView({
Key? key,
this.physics,
this.onTouch,
this.itemCount,
this.controller,
required this.itemBuilder,
this.enabledScrollTopLoad = false,
this.onScrollToBottomLoad,
this.onScrollToTopLoad,
this.onScrollToBottom,
this.onScrollToTop,
}) : super(key: key);
final ScrollController? controller;
final ScrollPhysics? physics;
final int? itemCount;
final IndexedWidgetBuilder itemBuilder;
final Future<bool> Function()? onScrollToBottomLoad;
final Future<bool> Function()? onScrollToTopLoad;
final Function()? onScrollToBottom;
final Function()? onScrollToTop;
final bool enabledScrollTopLoad;
final Function()? onTouch;
@override
State<ChatListView> createState() => _ChatListViewState();
}
class _ChatListViewState extends State<ChatListView> {
bool _scrollToBottomLoadMore = true;
bool _scrollToTopLoadMore = true;
bool get _isBottom => widget.controller!.offset >= widget.controller!.position.maxScrollExtent;
bool get _isTop => widget.controller!.offset <= 0;
@override
void dispose() {
widget.controller?.removeListener(_scrollListener);
super.dispose();
}
@override
void initState() {
_onScrollToBottomLoadMore();
widget.controller?.addListener(_scrollListener);
super.initState();
}
_scrollListener() {
if (_isBottom) {
Logger.print('-------------ChatListView scroll to bottom');
_onScrollToBottomLoadMore();
} else if (_isTop) {
Logger.print('-------------ChatListView scroll to top');
_onScrollToTopLoadMore();
}
}
void _onScrollToBottomLoadMore() {
widget.onScrollToBottom?.call();
widget.onScrollToBottomLoad?.call().then((hasMore) {
if (!mounted) return;
setState(() {
_scrollToBottomLoadMore = hasMore;
});
});
}
void _onScrollToTopLoadMore() {
widget.onScrollToTop?.call();
if (widget.enabledScrollTopLoad) {
widget.onScrollToTopLoad?.call().then((hasMore) {
if (!mounted) return;
setState(() {
_scrollToTopLoadMore = hasMore;
});
});
}
}
Widget get loadMoreView => Container(
alignment: Alignment.center,
height: 44,
child: CupertinoActivityIndicator(color: Styles.c_0089FF),
);
@override
Widget build(BuildContext context) {
return TouchCloseSoftKeyboard(
onTouch: widget.onTouch,
child: Align(
alignment: Alignment.topCenter,
child: ListView.builder(
reverse: true,
shrinkWrap: true,
physics: widget.physics ?? const ClampingScrollPhysics(),
itemCount: widget.itemCount ?? 0,
padding: EdgeInsets.only(top: 10.h),
controller: widget.controller,
itemBuilder: (context, index) => _wrapLoadMoreItem(index),
),
),
);
}
Widget _wrapLoadMoreItem(int index) {
final child = widget.itemBuilder(context, index);
if (index == widget.itemCount! - 1) {
return _scrollToBottomLoadMore ? Column(children: [loadMoreView, child]) : child;
}
if (index == 0 && widget.enabledScrollTopLoad) {
return _scrollToTopLoadMore ? Column(children: [child, loadMoreView]) : child;
}
return child;
}
}
class PositionRetainedScrollPhysics extends ClampingScrollPhysics {
final bool shouldRetain;
const PositionRetainedScrollPhysics({super.parent, this.shouldRetain = true});
@override
PositionRetainedScrollPhysics applyTo(ScrollPhysics? ancestor) {
return PositionRetainedScrollPhysics(
parent: buildParent(ancestor),
shouldRetain: shouldRetain,
);
}
@override
double adjustPositionForNewDimensions({
required ScrollMetrics oldPosition,
required ScrollMetrics newPosition,
required bool isScrolling,
required double velocity,
}) {
final position = super.adjustPositionForNewDimensions(
oldPosition: oldPosition,
newPosition: newPosition,
isScrolling: isScrolling,
velocity: velocity,
);
final diff = newPosition.maxScrollExtent - oldPosition.maxScrollExtent;
if (oldPosition.pixels > oldPosition.minScrollExtent && diff > 0 && shouldRetain) {
return position + diff;
} else {
return position;
}
}
}
@@ -0,0 +1,66 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class ChatLocationView extends StatelessWidget {
const ChatLocationView({
Key? key,
required this.description,
required this.latitude,
required this.longitude,
}) : super(key: key);
final String description;
final double latitude;
final double longitude;
final _decoder = const JsonDecoder();
@override
Widget build(BuildContext context) {
try {
final map = _decoder.convert(description);
String url = map['url'] ?? '';
String name = map['name'] ?? '';
String addr = map['addr'] ?? '';
return Container(
width: locationWidth,
height: 130.h,
decoration: BoxDecoration(
color: Styles.c_FFFFFF,
border: Border.all(color: Styles.c_E8EAEF, width: 1),
borderRadius: BorderRadius.circular(6.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
4.verticalSpace,
Padding(
padding: EdgeInsets.symmetric(horizontal: 4.w),
child: name.toText
..style = Styles.ts_0C1C33_14sp
..maxLines = 1
..overflow = TextOverflow.ellipsis,
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 4.w),
child: addr.toText
..style = Styles.ts_8E9AB0_12sp
..maxLines = 1
..overflow = TextOverflow.ellipsis,
),
2.verticalSpace,
Expanded(
child: ImageUtil.networkImage(
url: url,
width: locationWidth,
fit: BoxFit.cover,
),
),
],
),
);
} catch (e) {}
return Container();
}
}
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class ChatNicknameView extends StatelessWidget {
const ChatNicknameView({
Key? key,
this.nickname,
this.timeStr,
}) : super(key: key);
final String? nickname;
final String? timeStr;
@override
Widget build(BuildContext context) {
return RichText(
text: TextSpan(
text: '',
style: Styles.ts_8E9AB0_12sp,
children: [
if (null != nickname)
WidgetSpan(
child: Container(
constraints: BoxConstraints(maxWidth: 100.w),
margin: EdgeInsets.only(right: 6.w),
child: nickname!.toText
..style = Styles.ts_8E9AB0_12sp
..maxLines = 1
..overflow = TextOverflow.ellipsis,
),
),
TextSpan(text: timeStr),
],
),
);
}
}
@@ -0,0 +1,142 @@
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
class ChatPicturePreview extends StatelessWidget {
ChatPicturePreview({
Key? key,
this.currentIndex = 0,
this.images = const [],
this.heroTag,
this.onTap,
this.onLongPress,
}) : controller = images.length > 1 ? ExtendedPageController(initialPage: currentIndex, pageSpacing: 50) : null,
super(key: key);
final int currentIndex;
final List<MediaSource> images;
final String? heroTag;
final Function()? onTap;
final Function(String url)? onLongPress;
final ExtendedPageController? controller;
GlobalKey<ExtendedImageSlidePageState> slidePagekey = GlobalKey<ExtendedImageSlidePageState>();
@override
Widget build(BuildContext context) {
return ExtendedImageSlidePage(
key: slidePagekey,
slideAxis: SlideAxis.vertical,
slidePageBackgroundHandler: (offset, pageSize) => defaultSlidePageBackgroundHandler(
color: Colors.black,
offset: offset,
pageSize: pageSize,
),
child: MetaHero(
heroTag: heroTag,
onTap: onTap ?? () => Get.back(),
onLongPress: () {
final index = controller?.page?.round() ?? 0;
onLongPress?.call(images[index].url!);
},
child: _childView,
),
);
}
Widget get _childView {
return images.length == 1 ? _networkGestureImage(images[0]) : _pageView;
}
Widget get _pageView => ExtendedImageGesturePageView.builder(
controller: controller,
onPageChanged: (int index) {},
itemCount: images.length,
itemBuilder: (BuildContext context, int index) {
return _networkGestureImage(images.elementAt(index));
},
);
Widget _networkGestureImage(MediaSource source) => ExtendedImage.network(
source.thumbnail,
fit: BoxFit.contain,
mode: ExtendedImageMode.gesture,
clearMemoryCacheWhenDispose: true,
clearMemoryCacheIfFailed: true,
handleLoadingProgress: true,
enableSlideOutPage: true,
initGestureConfigHandler: (ExtendedImageState state) {
return GestureConfig(
inPageView: true,
initialScale: 1.0,
maxScale: 5.0,
animationMaxScale: 6.0,
initialAlignment: InitialAlignment.center,
);
},
loadStateChanged: (ExtendedImageState state) {
switch (state.extendedImageLoadState) {
case LoadState.loading:
{
if (source.url?.isVideoFileName == true) {
return null;
}
final ImageChunkEvent? loadingProgress = state.loadingProgress;
final double? progress = loadingProgress?.expectedTotalBytes != null
? loadingProgress!.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
: null;
return SizedBox(
width: 15.0,
height: 15.0,
child: Center(
child: CircularProgressIndicator(
color: Styles.c_0089FF,
strokeWidth: 1.5,
value: progress ?? 0,
),
),
);
}
case LoadState.completed:
final url = source.url;
if (url?.isVideoFileName == true) {
return Center(
child: ChatVideoPlayerView(
url: url,
coverUrl: source.thumbnail,
));
}
return Center(
child: ExtendedImage.network(url!),
);
case LoadState.failed:
state.imageProvider.evict();
return ImageRes.pictureError.toImage;
}
},
);
}
class MetaHero extends StatelessWidget {
const MetaHero({
Key? key,
required this.heroTag,
required this.child,
this.onTap,
this.onLongPress,
}) : super(key: key);
final Widget child;
final String? heroTag;
final Function()? onTap;
final Function()? onLongPress;
@override
Widget build(BuildContext context) {
final view = GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onTap,
onLongPress: onLongPress,
child: child,
);
return heroTag == null ? view : Hero(tag: heroTag!, child: view);
}
}
@@ -0,0 +1,134 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
import 'package:path_provider/path_provider.dart';
class ChatPictureView extends StatefulWidget {
const ChatPictureView({
Key? key,
required this.message,
required this.isISend,
}) : super(key: key);
final bool isISend;
final Message message;
@override
State<ChatPictureView> createState() => _ChatPictureViewState();
}
class _ChatPictureViewState extends State<ChatPictureView> {
String? _sourcePath;
String? _sourceUrl;
String? _snapshotUrl;
late double _trulyWidth;
late double _trulyHeight;
Message get _message => widget.message;
Widget? _child;
@override
void initState() {
final picture = _message.pictureElem;
_sourcePath = picture?.sourcePath;
_sourceUrl = picture?.bigPicture?.url;
final snap = picture?.snapshotPicture?.url;
_snapshotUrl = snap?.adjustThumbnailAbsoluteString(960);
var w = picture?.sourcePicture?.width?.toDouble() ?? 1.0;
var h = picture?.sourcePicture?.height?.toDouble() ?? 1.0;
if (pictureWidth > w) {
_trulyWidth = w;
_trulyHeight = h;
} else {
_trulyWidth = pictureWidth;
_trulyHeight = _trulyWidth * h / w;
}
final height = pictureWidth * 1.sh / 1.sw;
if (_trulyHeight > 2 * height) {
_trulyHeight = _trulyWidth;
}
if (Platform.isIOS) {
if (_sourcePath?.contains('/Library/Caches/') == true) {
getApplicationCacheDirectory().then((value) {
final path = _sourcePath!.split('/Library/Caches').last;
_sourcePath = value.path + path;
_createChildView();
});
} else {
_createChildView();
}
} else {
_createChildView();
}
super.initState();
}
Future<bool> _checkingPath() async {
var valid = IMUtils.isNotNullEmptyStr(_sourcePath);
if (!valid) {
return false;
}
if (Platform.isIOS) {
final exist = await File(_sourcePath!).exists();
valid = valid && exist;
} else {
valid = valid && File(_sourcePath!).existsSync();
}
_message.exMap['validPath_$_sourcePath'] = valid;
return valid;
}
bool? get isValidPath => _message.exMap['validPath_$_sourcePath'];
_createChildView() async {
if (widget.isISend && (isValidPath == true || isValidPath == null && await _checkingPath())) {
_child = _buildPathPicture(path: _sourcePath!);
} else if (IMUtils.isNotNullEmptyStr(_snapshotUrl)) {
_child = _buildUrlPicture(url: _snapshotUrl!);
} else if (IMUtils.isNotNullEmptyStr(_sourceUrl)) {
_child = _buildUrlPicture(url: _sourceUrl!);
}
if (null != _child) {
if (!mounted) return;
setState(() {});
}
}
Widget _buildUrlPicture({required String url}) => ImageUtil.networkImage(
url: url,
height: _trulyHeight,
width: _trulyWidth,
fit: BoxFit.fitWidth,
);
Widget _buildPathPicture({required String path}) => Stack(
children: [
ImageUtil.fileImage(
file: File(path),
height: _trulyHeight,
width: _trulyWidth,
fit: BoxFit.fitWidth,
),
],
);
@override
Widget build(BuildContext context) {
final child = ClipRRect(
borderRadius: borderRadius(widget.isISend),
child: SizedBox(width: _trulyWidth, height: _trulyHeight, child: _child),
);
return child;
}
}
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class MenuInfo {
String icon;
String text;
Function()? onTap;
bool enabled;
MenuInfo({
required this.icon,
required this.text,
this.onTap,
this.enabled = true,
});
}
class ChatLongPressMenu extends StatelessWidget {
final CustomPopupMenuController? popupMenuController;
final List<MenuInfo> menus;
const ChatLongPressMenu({
Key? key,
required this.popupMenuController,
required this.menus,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(maxWidth: 256.w, maxHeight: 122.h),
decoration: BoxDecoration(
color: Styles.c_0C1C33_opacity85,
borderRadius: BorderRadius.circular(15.r),
),
child: Container(
padding: EdgeInsets.fromLTRB(15.w, 6.h, 15.w, 3.h),
child: Wrap(
children: menus
.map((e) => _menuItem(
icon: e.icon,
label: e.text,
onTap: e.onTap,
))
.toList(),
),
),
/*child: GridView.count(
padding: EdgeInsets.symmetric(horizontal: 15.w, vertical: 7.h),
crossAxisCount: count,
crossAxisSpacing: 4.w,
mainAxisSpacing: 4.h,
childAspectRatio: 42 / 52,
children: menus
.map((e) => _menuItem(
icon: e.icon,
label: e.text,
onTap: e.onTap,
))
.toList(),
),*/
);
}
Widget _menuItem({
required String icon,
required String label,
Function()? onTap,
}) =>
GestureDetector(
onTap: () {
popupMenuController?.hideMenu();
onTap?.call();
},
behavior: HitTestBehavior.translucent,
child: SizedBox(
width: 42.w,
height: 52.h,
child: _MenuItemView(icon: icon, label: label),
),
/*child: SizedBox(
width: 42.w,
height: 52.h,
child: _MenuItemView(icon: icon, label: label),
),*/
);
}
class _MenuItemView extends StatelessWidget {
const _MenuItemView({
Key? key,
required this.icon,
required this.label,
}) : super(key: key);
final String icon;
final String label;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
icon.toImage
..width = 28.w
..height = 28.h,
label.toText
..style = Styles.ts_FFFFFF_10sp
..maxLines = 1
..overflow = TextOverflow.ellipsis,
],
);
}
}
final allMenus = <MenuInfo>[
MenuInfo(
icon: ImageRes.menuCopy,
text: StrRes.menuCopy,
onTap: () {},
),
MenuInfo(
icon: ImageRes.menuDel,
text: StrRes.menuDel,
onTap: () {},
),
MenuInfo(
icon: ImageRes.menuForward,
text: StrRes.menuForward,
onTap: () {},
),
MenuInfo(
icon: ImageRes.menuReply,
text: StrRes.menuReply,
onTap: () {},
),
MenuInfo(
icon: ImageRes.menuMulti,
text: StrRes.menuMulti,
onTap: () {},
),
MenuInfo(
icon: ImageRes.menuRevoke,
text: StrRes.menuRevoke,
onTap: () {},
),
MenuInfo(
icon: ImageRes.menuAddFace,
text: StrRes.menuAdd,
onTap: () {},
),
];
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class ChatRadio extends StatelessWidget {
const ChatRadio({
Key? key,
required this.checked,
this.onTap,
this.enabled = true,
}) : super(key: key);
final bool checked;
final Function()? onTap;
final bool enabled;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
child:
(checked || !enabled ? ImageRes.radioSel : ImageRes.radioNor).toImage
..width = 20.w
..height = 20.h
..opacity = (enabled ? 1 : .5),
);
}
}
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:openim_common/openim_common.dart';
import 'package:sprintf/sprintf.dart';
class ChatReadTagView extends StatelessWidget {
const ChatReadTagView({
Key? key,
required this.message,
this.onTap,
}) : super(key: key);
final Message message;
final Function()? onTap;
int get _needReadMemberCount {
final hasReadCount = message.attachedInfoElem?.groupHasReadInfo?.hasReadCount ?? 0;
final unreadCount = message.attachedInfoElem?.groupHasReadInfo?.unreadCount ?? 0;
return hasReadCount + unreadCount;
}
int get _unreadCount => message.attachedInfoElem?.groupHasReadInfo?.unreadCount ?? 0;
bool get isRead => message.isRead!;
@override
Widget build(BuildContext context) {
if (message.isSingleChat) {
return (isRead ? StrRes.hasRead : StrRes.unread).toText..style = (isRead ? Styles.ts_8E9AB0_12sp : Styles.ts_0089FF_12sp);
} else {
if (_needReadMemberCount == 0) return const SizedBox();
bool isAllRead = _unreadCount <= 0;
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.translucent,
child: (isAllRead ? StrRes.allRead : sprintf(StrRes.nPersonUnRead, [_unreadCount])).toText
..style = (isAllRead ? Styles.ts_8E9AB0_12sp : Styles.ts_0089FF_12sp),
);
}
}
}
@@ -0,0 +1,97 @@
import 'dart:convert';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:openim_common/openim_common.dart';
import 'package:sprintf/sprintf.dart';
class ChatRevokeView extends StatelessWidget {
const ChatRevokeView({
Key? key,
required this.message,
}) : super(key: key);
final Message message;
bool get _isISend => message.sendID == OpenIM.iMManager.userID;
String get _who => _isISend ? StrRes.you : message.senderNickname ?? '';
@override
Widget build(BuildContext context) {
final bridge = PackageBridge.viewUserProfileBridge;
final groupID = message.groupID;
String? revoker, sender;
final value = <String, String>{};
var map = json.decode(message.notificationElem!.detail!);
var info = RevokedInfo.fromJson(map);
if (info.revokerID == info.sourceMessageSendID) {
revoker = _who;
} else {
if (info.revokerID == OpenIM.iMManager.userID) {
revoker = info.revokerID!;
value[revoker] = StrRes.you;
} else {
revoker = info.revokerID!;
value[revoker] = info.revokerNickname!;
}
if (info.sourceMessageSendID == OpenIM.iMManager.userID) {
sender = info.sourceMessageSendID!;
value[sender] = StrRes.you;
} else {
sender = info.sourceMessageSendID!;
value[sender] = info.sourceMessageSenderNickname!;
}
}
final List<InlineSpan> children = <InlineSpan>[];
if (sender != null) {
final text = sprintf(StrRes.aRevokeBMsg, [revoker, sender]);
text.splitMapJoin(
RegExp('($revoker|$sender)'),
onMatch: (match) {
final matchText = match[0]!;
final nickname = value[matchText];
children.add(TextSpan(
text: nickname,
style: Styles.ts_0089FF_12sp,
recognizer: TapGestureRecognizer()
..onTap = () => bridge?.viewUserProfile(
matchText,
nickname,
null,
groupID,
),
));
return '';
},
onNonMatch: (text) {
children.add(TextSpan(text: text, style: Styles.ts_8E9AB0_12sp));
return '';
},
);
} else {
children
..add(TextSpan(
text: '$revoker ',
style: Styles.ts_0089FF_12sp,
recognizer: TapGestureRecognizer()
..onTap = () => bridge?.viewUserProfile(
info.sourceMessageSendID!,
info.revokerNickname,
null,
groupID,
),
))
..add(TextSpan(
text: StrRes.revokeMsg,
style: Styles.ts_8E9AB0_12sp,
));
}
return RichText(
text: TextSpan(children: children),
textAlign: TextAlign.center,
);
}
}
@@ -0,0 +1,68 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class ChatSendFailedView extends StatefulWidget {
const ChatSendFailedView({
Key? key,
required this.id,
required this.isISend,
this.isFailed = false,
this.stream,
this.onFailedToResend,
}) : super(key: key);
final String id;
final bool isISend;
final Stream<MsgStreamEv<bool>>? stream;
final bool isFailed;
final Function()? onFailedToResend;
@override
State<ChatSendFailedView> createState() => _ChatSendFailedViewState();
}
class _ChatSendFailedViewState extends State<ChatSendFailedView> {
late bool _failed;
StreamSubscription? _statusSubs;
@override
void initState() {
_failed = widget.isFailed;
_statusSubs = widget.stream?.listen((event) {
if (!mounted) return;
if (widget.id == event.id) {
setState(() {
_failed = !event.value;
});
}
});
super.initState();
}
@override
void dispose() {
_statusSubs?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Visibility(
visible: widget.isISend && _failed,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
setState(() {
_failed = false;
});
widget.onFailedToResend?.call();
},
child: ImageRes.failedToResend.toImage
..width = 16.w
..height = 16.h,
),
);
}
}
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:openim_common/openim_common.dart';
class ChatText extends StatelessWidget {
const ChatText({
Key? key,
this.isISend = false,
required this.text,
this.prefixSpan,
this.patterns = const <MatchPattern>[],
this.textAlign = TextAlign.left,
this.overflow = TextOverflow.clip,
this.textStyle,
this.maxLines,
this.textScaleFactor = 1.0,
this.model = TextModel.match,
this.onVisibleTrulyText,
}) : super(key: key);
final bool isISend;
final String text;
final TextStyle? textStyle;
final InlineSpan? prefixSpan;
final TextAlign textAlign;
final TextOverflow overflow;
final int? maxLines;
final double textScaleFactor;
final List<MatchPattern> patterns;
final TextModel model;
final Function(String? text)? onVisibleTrulyText;
@override
Widget build(BuildContext context) => MatchTextView(
text: text,
textStyle: textStyle ??
(isISend ? Styles.ts_FFFFFF_17sp : Styles.ts_0C1C33_17sp),
matchTextStyle: Styles.ts_0089FF_17sp,
prefixSpan: prefixSpan,
textAlign: textAlign,
overflow: overflow,
textScaleFactor: textScaleFactor,
patterns: patterns,
model: model,
maxLines: maxLines,
onVisibleTrulyText: onVisibleTrulyText,
);
}
@@ -0,0 +1,52 @@
import 'package:extended_text_field/extended_text_field.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class ChatTextField extends StatelessWidget {
final FocusNode? focusNode;
final TextEditingController? controller;
final String? hintText;
final TextStyle? style;
final TextStyle? atStyle;
final bool enabled;
final TextAlign textAlign;
const ChatTextField({
Key? key,
this.focusNode,
this.controller,
this.hintText,
this.style,
this.atStyle,
this.enabled = true,
this.textAlign = TextAlign.start,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ExtendedTextField(
style: style,
focusNode: focusNode,
controller: controller,
keyboardType: TextInputType.multiline,
enabled: enabled,
autofocus: false,
minLines: 1,
maxLines: 4,
textAlign: textAlign,
decoration: InputDecoration(
border: InputBorder.none,
isDense: true,
hintText: hintText,
hintStyle: Styles.ts_8E9AB0_17sp,
contentPadding: EdgeInsets.symmetric(
horizontal: 4.w,
vertical: 8.h,
),
),
);
}
}
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class ChatTimelineView extends StatelessWidget {
const ChatTimelineView({
Key? key,
required this.timeStr,
this.margin,
}) : super(key: key);
final String timeStr;
final EdgeInsetsGeometry? margin;
@override
Widget build(BuildContext context) {
return Container(
margin: margin,
padding: EdgeInsets.symmetric(vertical: 2.h, horizontal: 6.w),
decoration: BoxDecoration(
color: Styles.c_F4F5F7,
borderRadius: BorderRadius.circular(4.r),
),
child: timeStr.toText..style = Styles.ts_8E9AB0_12sp,
);
}
}
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class ChatToolBox extends StatelessWidget {
const ChatToolBox({
super.key,
this.onTapAlbum,
this.onTapCall,
this.onTapCamera,
this.onTapCard,
this.onTapFile,
this.onTapLocation,
this.onTapDirectionalMessage,
});
final Function()? onTapAlbum;
final Function()? onTapCamera;
final Function()? onTapCall;
final Function()? onTapFile;
final Function()? onTapCard;
final Function()? onTapLocation;
final VoidCallback? onTapDirectionalMessage;
@override
Widget build(BuildContext context) {
final items = [
ToolboxItemInfo(
text: StrRes.toolboxAlbum,
icon: ImageRes.toolboxAlbum,
onTap: () => Permissions.photos(onTapAlbum),
),
ToolboxItemInfo(
text: StrRes.toolboxCamera,
icon: ImageRes.toolboxCamera,
onTap: () => Permissions.cameraAndMicrophone(onTapCamera),
),
if (onTapCall != null)
ToolboxItemInfo(
text: StrRes.toolboxCall,
icon: ImageRes.toolboxCall,
onTap: () => Permissions.cameraAndMicrophone(onTapCall),
),
ToolboxItemInfo(
text: StrRes.toolboxFile,
icon: ImageRes.toolboxFile,
onTap: () => Permissions.storage(onTapFile),
),
ToolboxItemInfo(
text: StrRes.toolboxCard,
icon: ImageRes.toolboxCard,
onTap: onTapCard,
),
ToolboxItemInfo(
text: StrRes.toolboxLocation,
icon: ImageRes.toolboxLocation,
onTap: () => Permissions.location(onTapLocation),
),
if (onTapDirectionalMessage != null)
ToolboxItemInfo(
text: StrRes.toolboxDirectionalMessage,
icon: ImageRes.toolboxDirectionalMessage,
onTap: onTapDirectionalMessage,
),
];
return Container(
color: Styles.c_F0F2F6,
height: 224.h,
child: GridView.builder(
itemCount: items.length,
padding: EdgeInsets.only(
left: 16.w,
right: 16.w,
top: 6.h,
bottom: 6.h,
),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
childAspectRatio: 78.w / 105.h,
crossAxisSpacing: 10.w,
mainAxisSpacing: 2.h,
),
itemBuilder: (_, index) {
final item = items.elementAt(index);
return _buildItemView(
icon: item.icon,
text: item.text,
onTap: item.onTap,
);
},
),
);
}
Widget _buildItemView({
required String text,
required String icon,
Function()? onTap,
}) =>
Column(
children: [
icon.toImage
..width = 58.w
..height = 58.h
..onTap = onTap,
10.verticalSpace,
text.toText..style = Styles.ts_0C1C33_12sp,
],
);
}
class ToolboxItemInfo {
String text;
String icon;
Function()? onTap;
ToolboxItemInfo({required this.text, required this.icon, this.onTap});
}
@@ -0,0 +1,218 @@
import 'dart:async';
import 'dart:io';
import 'package:chewie/chewie.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
import 'package:video_player/video_player.dart';
import '../custom_cupertino_controls.dart';
abstract class VideoControllerService {
Future<VideoPlayerController> getVideo(String videoUrl);
Future<File?> getCacheFile(String videoUrl);
}
class CachedVideoControllerService extends VideoControllerService {
final BaseCacheManager _cacheManager;
CachedVideoControllerService(this._cacheManager);
@override
Future<VideoPlayerController> getVideo(String videoUrl) async {
final file = await getCacheFile(videoUrl);
if (file == null) {
Logger.print('[VideoControllerService]: No video in cache');
Logger.print('[VideoControllerService]: Saving video to cache');
unawaited(_cacheManager.downloadFile(videoUrl));
return VideoPlayerController.networkUrl(Uri.parse(videoUrl),
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
httpHeaders: Platform.isAndroid
? {}
: {
'AVURLAssetOutOfBandMIMETypeKey': 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"',
});
} else {
Logger.print('[VideoControllerService]: Loading video from cache');
return VideoPlayerController.file(
file,
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
);
}
}
@override
Future<File?> getCacheFile(String videoUrl) async {
final fileInfo = await _cacheManager.getFileFromCache(videoUrl);
return fileInfo?.file;
}
}
class ChatVideoPlayerView extends StatefulWidget {
const ChatVideoPlayerView({
super.key,
this.path,
this.url,
this.coverUrl,
this.file,
this.heroTag,
this.onDownload,
this.autoPlay = true,
this.muted = false,
});
final String? path;
final String? url;
final File? file;
final String? coverUrl;
final String? heroTag;
final bool autoPlay;
final bool muted;
final Function(String? url, File? file)? onDownload;
@override
State<ChatVideoPlayerView> createState() => _ChatVideoPlayerViewState();
}
class _ChatVideoPlayerViewState extends State<ChatVideoPlayerView> with SingleTickerProviderStateMixin {
late VideoPlayerController _videoPlayerController;
ChewieController? _chewieController;
final _cachedVideoControllerService = CachedVideoControllerService(DefaultCacheManager());
@override
void initState() {
initializePlayer();
super.initState();
}
@override
void dispose() {
Logger.print('[ChatVideoPlayerView]: dispose');
() async {
await _chewieController?.pause();
await _videoPlayerController.pause();
await _videoPlayerController.dispose();
_chewieController?.dispose();
}();
super.dispose();
}
Future<void> initializePlayer() async {
var file = widget.file;
if (file == null) {
bool existFile = false;
if (IMUtils.isNotNullEmptyStr(_path) && (await Permissions.checkStorage())) {
file = File(_path!);
existFile = await file.exists();
if (!existFile) {
file = null;
}
}
}
if (null != file && file.existsSync()) {
_videoPlayerController = VideoPlayerController.file(
file,
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
);
} else {
_videoPlayerController = await _cachedVideoControllerService.getVideo(_url!);
}
await _videoPlayerController.initialize();
if (widget.muted) {
_videoPlayerController.setVolume(0);
}
_createChewieController();
setState(() {});
}
void _createChewieController() {
_chewieController = ChewieController(
videoPlayerController: _videoPlayerController,
autoPlay: widget.autoPlay,
looping: false,
allowFullScreen: false,
allowPlaybackSpeedChanging: false,
showControlsOnInitialize: true,
customControls: CustomCupertinoControls(backgroundColor: Colors.black.withOpacity(0.7), iconColor: Colors.white),
optionsTranslation: OptionsTranslation(
playbackSpeedButtonText: StrRes.playSpeed,
cancelButtonText: StrRes.cancel,
),
additionalOptions: (context) => [
OptionItem(
onTap: () async {
final file = await _cachedVideoControllerService.getCacheFile(widget.url!);
widget.onDownload?.call(widget.url, file);
Get.back();
},
iconData: Icons.download_outlined,
title: StrRes.download,
),
],
errorBuilder: (context, errorMessage) {
return Center(
child: Text(
errorMessage,
style: TextStyle(color: Colors.white),
),
);
},
);
}
Future<void> toggleVideo() async {
await _videoPlayerController.pause();
await initializePlayer();
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Stack(
children: [
if (_chewieController != null && _chewieController!.videoPlayerController.value.isInitialized)
Chewie(controller: _chewieController!)
else
_buildCoverView(context),
],
),
);
}
Widget _buildCoverView(BuildContext context) => null != widget.coverUrl
? Stack(
alignment: Alignment.center,
children: [
Center(
child: ImageUtil.networkImage(
url: widget.coverUrl!,
loadProgress: false,
height: MediaQuery.of(context).size.height,
width: MediaQuery.of(context).size.width,
fit: BoxFit.fitWidth),
),
const Center(
child: CupertinoActivityIndicator(
color: Colors.white,
radius: 15,
),
),
],
)
: Container();
String? get _path => widget.path;
String? get _url => widget.url;
}
@@ -0,0 +1,121 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
import 'package:path_provider/path_provider.dart';
class ChatVideoView extends StatefulWidget {
const ChatVideoView({
Key? key,
required this.message,
required this.isISend,
}) : super(key: key);
final bool isISend;
final Message message;
@override
State<ChatVideoView> createState() => _ChatVideoViewState();
}
class _ChatVideoViewState extends State<ChatVideoView> {
late double _trulyWidth;
late double _trulyHeight;
String? _snapshotUrl;
String? _snapshotPath;
Widget? _child;
Message get _message => widget.message;
@override
void initState() {
final video = _message.videoElem;
_snapshotUrl = video?.snapshotUrl?.adjustThumbnailAbsoluteString(960);
_snapshotPath = video?.snapshotPath;
var w = video?.snapshotWidth?.toDouble() ?? 1.0;
var h = video?.snapshotHeight?.toDouble() ?? 1.0;
_trulyWidth = pictureWidth;
_trulyHeight = _trulyWidth * h / w;
if (Platform.isIOS) {
if (_snapshotPath?.contains('/Library/Caches/') == true) {
getApplicationCacheDirectory().then((value) {
final path = _snapshotPath!.split('/Library/Caches').last;
_snapshotPath = value.path + path;
_createThumbView();
});
} else {
_createThumbView();
}
} else {
_createThumbView();
}
super.initState();
}
Future<bool> _checkingPath() async {
var valid = IMUtils.isNotNullEmptyStr(_snapshotPath);
if (!valid) {
return false;
}
if (Platform.isIOS) {
final exist = File(_snapshotPath!).existsSync();
valid = valid && exist;
} else {
valid = valid && File(_snapshotPath!).existsSync();
}
_message.exMap['validPath_$_snapshotPath'] = valid;
return valid;
}
bool? get isValidPath => _message.exMap['validPath_$_snapshotPath'];
_createThumbView() async {
if (widget.isISend && (isValidPath == true || isValidPath == null && await _checkingPath())) {
_child = ImageUtil.fileImage(
file: File(_snapshotPath!),
height: _trulyHeight,
width: _trulyWidth,
fit: BoxFit.fitWidth,
);
} else if (IMUtils.isNotNullEmptyStr(_snapshotUrl)) {
_child = ImageUtil.networkImage(
url: _snapshotUrl!,
width: _trulyWidth,
height: _trulyHeight,
fit: BoxFit.fitWidth,
);
}
if (null != _child) {
if (!mounted) return;
setState(() {});
}
}
@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: borderRadius(widget.isISend),
child: SizedBox(
width: _trulyWidth,
height: _trulyHeight,
child: Stack(
alignment: Alignment.center,
children: [
if (null != _child) _child!,
ImageRes.videoPause.toImage
..width = 40.w
..height = 40.h,
if (null != _message.videoElem?.duration)
Positioned(
bottom: 2.h,
right: 3.w,
child: IMUtils.seconds2HMS(_message.videoElem!.duration!).toText..style = Styles.ts_FFFFFF_12sp,
),
],
),
),
);
}
@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class ChatVoiceReadStatusView extends StatelessWidget {
const ChatVoiceReadStatusView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) => Container(
width: 6.w,
height: 6.w,
decoration: BoxDecoration(
color: Styles.c_FF381F,
shape: BoxShape.circle,
),
);
}
@@ -0,0 +1,119 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
double kVoiceRecordBarHeight = 36.h;
enum RecordBarStatus {
holdTalk,
releaseToSend,
liftFingerToCancelSend,
}
class ChatVoiceRecordBar extends StatefulWidget {
const ChatVoiceRecordBar({
Key? key,
required this.onLongPressStart,
required this.onLongPressEnd,
required this.onLongPressMoveUpdate,
this.speakBarColor,
this.speakTextStyle,
this.interruptListener,
this.onChangedBarStatus,
}) : super(key: key);
final Function(LongPressStartDetails details) onLongPressStart;
final Function(LongPressEndDetails details) onLongPressEnd;
final Function(LongPressMoveUpdateDetails details) onLongPressMoveUpdate;
final Color? speakBarColor;
final TextStyle? speakTextStyle;
final Stream<bool>? interruptListener;
final Function(RecordBarStatus status)? onChangedBarStatus;
@override
State<ChatVoiceRecordBar> createState() => _ChatVoiceRecordBarState();
}
class _ChatVoiceRecordBarState extends State<ChatVoiceRecordBar> {
bool _pressing = false;
bool _canCancel = false;
final double _offset = 40.h;
StreamSubscription? _sub;
@override
void initState() {
_sub = widget.interruptListener?.listen((interrupt) {
if (!mounted) return;
setState(() {
_pressing = false;
});
});
super.initState();
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTapDown: (details) {
setState(() {
_pressing = true;
});
},
onTapUp: (details) {
setState(() {
_pressing = false;
});
},
onTapCancel: () {
setState(() {
_pressing = false;
});
},
onLongPressStart: (details) {
HapticFeedback.heavyImpact();
widget.onLongPressStart(details);
setState(() {
_pressing = true;
});
},
onLongPressEnd: (details) {
widget.onLongPressEnd(details);
setState(() {
_pressing = false;
_canCancel = false;
});
},
onLongPressMoveUpdate: (details) {
widget.onLongPressMoveUpdate(details);
Offset global = details.globalPosition;
setState(() {
_canCancel = global.dy < (1.sh - kInputBoxMinHeight - _offset);
widget.onChangedBarStatus
?.call(_canCancel ? RecordBarStatus.liftFingerToCancelSend : RecordBarStatus.releaseToSend);
});
},
child: Container(
height: kVoiceRecordBarHeight,
alignment: Alignment.center,
decoration: BoxDecoration(
color: widget.speakBarColor ?? (_pressing ? Styles.c_8E9AB0_opacity30 : Styles.c_FFFFFF),
borderRadius: BorderRadius.circular(4.r),
),
child: Text(
_pressing ? (_canCancel ? StrRes.liftFingerToCancelSend : StrRes.releaseToSend) : StrRes.holdTalk,
style: widget.speakTextStyle ?? Styles.ts_0C1C33_14sp_medium,
),
),
);
}
}
@@ -0,0 +1,139 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:lottie/lottie.dart';
import 'package:openim_common/openim_common.dart';
import 'package:rxdart/rxdart.dart';
typedef SpeakViewChildBuilder = Widget Function(ChatVoiceRecordBar recordBar);
class ChatVoiceRecordLayout extends StatefulWidget {
const ChatVoiceRecordLayout({
Key? key,
required this.builder,
this.locale,
this.onCompleted,
this.speakTextStyle,
this.speakBarColor,
this.maxRecordSec = 60,
}) : super(key: key);
final SpeakViewChildBuilder builder;
final Locale? locale;
final Function(int sec, String path)? onCompleted;
final Color? speakBarColor;
final TextStyle? speakTextStyle;
final int maxRecordSec;
@override
State<ChatVoiceRecordLayout> createState() => _ChatVoiceRecordLayoutState();
}
class _ChatVoiceRecordLayoutState extends State<ChatVoiceRecordLayout> {
final _interruptSub = PublishSubject<bool>();
bool _showVoiceRecordView = false;
RecordBarStatus _status = RecordBarStatus.holdTalk;
bool _isCancelSend = false;
void _completed(int sec, String path) {
if (_isCancelSend) {
File(path).delete();
_status = RecordBarStatus.holdTalk;
_isCancelSend = false;
} else {
if (sec == 0) {
File(path).delete();
IMViews.showToast(StrRes.talkTooShort);
} else {
widget.onCompleted?.call(sec, path);
}
}
}
@override
void initState() {
super.initState();
}
@override
void dispose() {
_interruptSub.close();
super.dispose();
}
ChatVoiceRecordBar get _createSpeakBar => ChatVoiceRecordBar(
speakBarColor: widget.speakBarColor,
speakTextStyle: widget.speakTextStyle,
interruptListener: _interruptSub.stream,
onChangedBarStatus: (status) {
if (status != _status) {
setState(() {
_isCancelSend = status == RecordBarStatus.liftFingerToCancelSend;
_status = status;
});
}
},
onLongPressMoveUpdate: (details) {},
onLongPressEnd: (details) async {
setState(() {
_showVoiceRecordView = false;
});
},
onLongPressStart: (details) {
setState(() {
_showVoiceRecordView = true;
});
},
);
@override
Widget build(BuildContext context) {
return Stack(
children: [
widget.builder(_createSpeakBar),
Visibility(
visible: _showVoiceRecordView,
child: ChatRecordVoiceView(
onCompleted: _completed,
onInterrupt: () {
setState(() {
_interruptSub.add(true);
_showVoiceRecordView = false;
});
},
builder: (_, sec) => Material(
color: Colors.transparent,
child: Center(
child: Container(
width: 138.w,
height: 124.h,
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 6.h),
decoration: BoxDecoration(
color: _isCancelSend ? Styles.c_FF381F_opacity70 : Styles.c_0C1C33_opacity60,
borderRadius: BorderRadius.circular(6.r),
),
child: Column(
children: [
IMUtils.seconds2HMS(sec).toText..style = Styles.ts_FFFFFF_12sp,
Expanded(child: _lottieAnimWidget),
(_isCancelSend ? StrRes.liftFingerToCancelSend : StrRes.releaseToSendSwipeUpToCancel).toText
..style = Styles.ts_FFFFFF_12sp,
],
),
),
),
),
),
),
],
);
}
Widget get _lottieAnimWidget => Lottie.asset(
'assets/anim/voice_record.json',
fit: BoxFit.contain,
package: 'openim_common',
);
}
@@ -0,0 +1,90 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:openim_common/openim_common.dart';
import 'package:record/record.dart';
class ChatRecordVoiceView extends StatefulWidget {
const ChatRecordVoiceView({
Key? key,
required this.builder,
this.maxRecordSec = 60,
this.onInterrupt,
this.onCompleted,
}) : super(key: key);
final int maxRecordSec;
final Function()? onInterrupt;
final Function(int sec, String path)? onCompleted;
final Widget Function(BuildContext context, int sec) builder;
@override
State<ChatRecordVoiceView> createState() => _ChatRecordVoiceViewState();
}
class _ChatRecordVoiceViewState extends State<ChatRecordVoiceView> {
static const _dir = "voice";
static const _ext = ".m4a";
late String _path;
int _startTimestamp = 0;
final _audioRecorder = AudioRecorder();
Timer? _timer;
int _duration = 0;
static int _now() => DateTime.now().millisecondsSinceEpoch;
@override
void initState() {
(() async {
if (await _audioRecorder.hasPermission()) {
_path = '${await IMUtils.createTempDir(dir: _dir)}/${_now()}$_ext';
await _audioRecorder.start(RecordConfig(), path: _path);
_startTimestamp = _now();
_timer?.cancel();
_timer = null;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) async {
setState(() {
_duration = ((_now() - _startTimestamp) ~/ 1000);
if (_duration >= widget.maxRecordSec) {
widget.onInterrupt?.call();
}
});
});
}
})();
super.initState();
}
@override
void dispose() {
(() async {
_timer?.cancel();
_timer = null;
if (await _audioRecorder.isRecording()) {
await _audioRecorder.stop();
}
widget.onCompleted?.call(_duration, _path);
})();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.builder.call(context, _duration);
}
class _ArrowClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
Path path = Path();
path.moveTo(0, -2);
path.lineTo(size.width, -2);
path.lineTo(size.width / 2, size.height * 2 / 4);
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) {
return true;
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class ChatVoiceView extends StatelessWidget {
final bool isISend;
final String? soundPath;
final String? soundUrl;
final int? duration;
final bool isPlaying;
const ChatVoiceView({
Key? key,
required this.isISend,
this.soundPath,
this.soundUrl,
this.duration,
this.isPlaying = false,
}) : super(key: key);
Widget _buildVoiceAnimView() {
return isISend
? Row(
children: [
'${duration ?? 0}``'.toText..style = Styles.ts_0089FF_17sp,
4.horizontalSpace,
RotatedBox(
quarterTurns: 90,
child: isPlaying
? (ImageRes.voiceBlueAnim.toLottie
..height = 25.h
..width = 25.w
..fit = BoxFit.fitHeight)
: (ImageRes.voiceBlue.toImage
..width = 25.w
..height = 25.h),
),
],
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isPlaying)
ImageRes.voiceBlueAnim.toLottie
..width = 24.w
..height = 24.h
..fit = BoxFit.fitHeight
else
ImageRes.voiceBlue.toImage
..width = 24.w
..height = 24.h,
4.horizontalSpace,
'${duration ?? 0}``'.toText..style = Styles.ts_0089FF_17sp,
],
);
}
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.only(
left: isISend ? _margin : 0,
right: !isISend ? _margin : 0,
),
child: _buildVoiceAnimView(),
);
}
double get _margin {
final maxWidth = 100.w;
const maxDuration = 60;
double diff = (duration ?? 0) * maxWidth / maxDuration;
return diff > maxWidth ? maxWidth : diff;
}
}
@@ -0,0 +1,269 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
import 'package:geolocator/geolocator.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
class ChatWebViewMap extends StatefulWidget {
const ChatWebViewMap({
super.key,
required this.host,
required this.webKey,
required this.webServerKey,
this.mapThumbnailSize = "1200*600",
this.mapBackUrl = "http://callback",
this.latitude,
this.longitude,
});
final String host;
final String webKey;
final String webServerKey;
final String mapThumbnailSize;
final String mapBackUrl;
final double? latitude;
final double? longitude;
@override
State<ChatWebViewMap> createState() => _ChatWebViewMapState();
}
class _ChatWebViewMapState extends State<ChatWebViewMap> {
WebViewController? _controller;
String url = "";
double progress = 0;
double? latitude;
double? longitude;
String? description;
late String locationUrl;
late String thumbnailUrl;
late String previewLocationUrl;
late String webKey;
late String webServerKey;
late String host;
void _configUrl() {
locationUrl = "$host?key=$webKey&serverKey=$webServerKey#/";
previewLocationUrl = "$host?key=$webKey&serverKey=$webServerKey&location=$longitude,$latitude#/";
}
String getStaticMapURL(double longitude, double latitude) {
final url =
'https://restapi.amap.com/v3/staticmap?location=$longitude,$latitude&zoom=13&size=200*200&markers=mid,,A:$longitude,$latitude&key=$webServerKey';
return url;
}
bool get isPreview => widget.longitude != null && widget.latitude != null;
@override
void initState() {
super.initState();
host = widget.host;
webKey = widget.webKey;
webServerKey = widget.webServerKey;
longitude = widget.longitude;
latitude = widget.latitude;
_determinePosition().then((position) {
longitude = position.longitude;
latitude = position.latitude;
_configUrl();
late final PlatformWebViewControllerCreationParams params;
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
params = WebKitWebViewControllerCreationParams(
allowsInlineMediaPlayback: true,
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
);
} else {
params = const PlatformWebViewControllerCreationParams();
}
final WebViewController controller = WebViewController.fromPlatformCreationParams(params);
controller
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onProgress: (int progress) {
debugPrint('WebView is loading (progress : $progress%)');
setState(() {
this.progress = progress / 100;
});
},
onPageStarted: (String url) {
debugPrint('Page started loading: $url');
},
onPageFinished: (String url) {
debugPrint('Page finished loading: $url');
},
onWebResourceError: (WebResourceError error) {
debugPrint(
'Page resource error: code: ${error.errorCode} description: ${error.description} errorType: ${error.errorType} isForMainFrame: ${error.isForMainFrame}');
},
onNavigationRequest: (NavigationRequest request) {
if (request.url.startsWith('https://www.youtube.com/')) {
debugPrint('blocking navigation to ${request.url}');
return NavigationDecision.prevent;
}
debugPrint('allowing navigation to ${request.url}');
return NavigationDecision.navigate;
},
onHttpError: (HttpResponseError error) {
debugPrint('Error occurred on page: ${error.response?.statusCode}');
},
onUrlChange: (UrlChange change) {
debugPrint('url change to ${change.url}');
},
onHttpAuthRequest: (HttpAuthRequest request) {},
),
)
..addJavaScriptChannel(
'getLocaltion',
onMessageReceived: (JavaScriptMessage message) {
final value = message.message;
final params = jsonDecode(value);
final locationStr = params['location'] as String;
final locations = locationStr.split(',');
longitude = double.parse(locations[0]);
latitude = double.parse(locations[1]);
final address = params['address'];
final url = getStaticMapURL(longitude!, latitude!);
final result = {
'longitude': longitude,
'latitude': latitude,
'url': url,
'addr': address,
'name': '',
};
description = jsonEncode(result);
Logger.print('$result');
_confirm();
},
)
..loadRequest(Uri.parse(previewLocationUrl));
print('previewLocationUrl: $previewLocationUrl');
if (!Platform.isMacOS) {
controller.setBackgroundColor(const Color(0x80000000));
}
if (controller.platform is AndroidWebViewController) {
AndroidWebViewController.enableDebugging(true);
(controller.platform as AndroidWebViewController).setMediaPlaybackRequiresUserGesture(false);
}
setState(() {
_controller = controller;
});
});
}
Future<Position> _determinePosition() async {
bool serviceEnabled;
LocationPermission permission;
serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
return Future.error('Location services are disabled.');
}
permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
return Future.error('Location permissions are denied');
}
}
if (permission == LocationPermission.deniedForever) {
return Future.error('Location permissions are permanently denied, we cannot request permissions.');
}
return await Geolocator.getCurrentPosition();
}
@override
void dispose() {
super.dispose();
}
void _confirm() async {
if (null == latitude || null == longitude) {
await showDialog(
context: context,
builder: (_) => AlertDialog(
title: StrRes.plsSelectLocation.toText..style = Styles.ts_0C1C33_17sp_semibold,
actions: [
GestureDetector(
onTap: () => Navigator.pop(context),
behavior: HitTestBehavior.translucent,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
child: StrRes.determine.toText..style = Styles.ts_0089FF_17sp_semibold,
),
),
],
),
);
return;
}
Navigator.pop(context, {
'latitude': latitude,
'longitude': longitude,
'description': description,
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: TitleBar.back(
onTap: () async {
Get.back();
},
title: StrRes.location,
),
body: SafeArea(
child: Stack(
children: [
_controller == null
? const Align(
child: CupertinoActivityIndicator(),
)
: WebViewWidget(controller: _controller!),
progress < 1.0
? LinearProgressIndicator(
value: progress,
color: Colors.blue,
)
: const SizedBox(),
],
),
),
);
}
}
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
import 'package:sprintf/sprintf.dart';
class NewMessageIndicator extends StatelessWidget {
const NewMessageIndicator({
Key? key,
this.newMessageCount = 0,
this.onTap,
}) : super(key: key);
final int newMessageCount;
final Function()? onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 13.w, vertical: 7.h),
constraints: BoxConstraints(minHeight: 31.h),
decoration: BoxDecoration(
color: Styles.c_FFFFFF,
borderRadius: BorderRadius.circular(16.r),
border: Border.all(color: Styles.c_E8EAEF, width: 1),
boxShadow: [
BoxShadow(
offset: Offset(0, 6.h),
blurRadius: 16.r,
spreadRadius: 1.r,
color: Styles.c_8E9AB0_opacity16,
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ImageRes.scrollDown.toImage
..width = 16.w
..height = 16.h,
4.horizontalSpace,
sprintf(StrRes.nMessage, [newMessageCount]).toText
..style = Styles.ts_0089FF_12sp,
],
),
),
),
);
}
}
@@ -0,0 +1,120 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class WaterMarkBgView extends StatelessWidget {
const WaterMarkBgView({
Key? key,
this.path,
this.text = '',
this.newMessageCount = 0,
this.textStyle,
this.backgroundColor,
required this.child,
this.topView,
this.bottomView,
this.floatView,
this.onSeeNewMessage,
}) : super(key: key);
final String? path;
final String text;
final int newMessageCount;
final TextStyle? textStyle;
final Color? backgroundColor;
final Widget child;
final Widget? topView;
final Widget? bottomView;
final Widget? floatView;
final Function()? onSeeNewMessage;
@override
Widget build(BuildContext context) {
return Container(
color: backgroundColor,
child: Stack(
alignment: Alignment.center,
fit: StackFit.expand,
children: [
if (path?.isNotEmpty == true) Image.file(File(path!), fit: BoxFit.cover),
if (text.isNotEmpty) _buildWaterMarkTextView(context: context),
Column(
children: [
if (null != topView) topView!,
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
child,
if (newMessageCount > 0)
Positioned(
bottom: 10.h,
child: NewMessageIndicator(
newMessageCount: newMessageCount,
onTap: onSeeNewMessage,
),
),
],
),
),
if (null != bottomView) bottomView!
],
),
if (null != floatView) floatView!,
],
),
);
}
Widget _buildWaterMarkTextView({required BuildContext context}) {
var style = textStyle ??
TextStyle(
color: Color(0x707070).withOpacity(0.25),
fontSize: 16.sp,
);
double screenW = MediaQuery.of(context).size.width;
double screenH = MediaQuery.of(context).size.height;
var size = _textSize(text, style);
double itemW = size.width;
double itemH = size.height;
int rowCount = (screenW / itemW).round() + 1;
int columnCount = (screenH / itemH).round() + 1;
double maxW = screenW * 1.5;
double maxH = screenH * 1.5;
List<Widget> children = List.filled(
columnCount * rowCount,
Transform.rotate(
angle: -15 * pi / 180,
child: Text(
text,
style: style,
textAlign: TextAlign.center,
maxLines: 1,
)),
);
return ClipRect(
child: OverflowBox(
maxWidth: maxW,
maxHeight: maxH,
alignment: Alignment.center,
child: Wrap(
alignment: WrapAlignment.start,
spacing: 40.w,
runSpacing: 70.h,
children: children,
)),
);
}
Size _textSize(String text, TextStyle style) {
final TextPainter textPainter =
TextPainter(text: TextSpan(text: text, style: style), maxLines: 1, textDirection: TextDirection.ltr)
..layout(minWidth: 0, maxWidth: double.infinity);
return textPainter.size;
}
}
@@ -0,0 +1,828 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:chewie/src/animated_play_pause.dart';
import 'package:chewie/src/center_play_button.dart';
import 'package:chewie/src/chewie_player.dart';
import 'package:chewie/src/chewie_progress_colors.dart';
import 'package:chewie/src/cupertino/cupertino_progress_bar.dart';
import 'package:chewie/src/cupertino/widgets/cupertino_options_dialog.dart';
import 'package:chewie/src/helpers/utils.dart';
import 'package:chewie/src/models/option_item.dart';
import 'package:chewie/src/models/subtitle_model.dart';
import 'package:chewie/src/notifiers/index.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
class CustomCupertinoControls extends StatefulWidget {
const CustomCupertinoControls({
required this.backgroundColor,
required this.iconColor,
this.showPlayButton = true,
Key? key,
}) : super(key: key);
final Color backgroundColor;
final Color iconColor;
final bool showPlayButton;
@override
State<StatefulWidget> createState() {
return _CustomCupertinoControlsState();
}
}
class _CustomCupertinoControlsState extends State<CustomCupertinoControls> with SingleTickerProviderStateMixin {
late PlayerNotifier notifier;
late VideoPlayerValue _latestValue;
double? _latestVolume;
Timer? _hideTimer;
final marginSize = 5.0;
Timer? _expandCollapseTimer;
Timer? _initTimer;
bool _dragging = false;
Duration? _subtitlesPosition;
bool _subtitleOn = false;
Timer? _bufferingDisplayTimer;
bool _displayBufferingIndicator = false;
late VideoPlayerController controller;
ChewieController get chewieController => _chewieController!;
ChewieController? _chewieController;
@override
void initState() {
super.initState();
notifier = Provider.of<PlayerNotifier>(context, listen: false);
}
@override
Widget build(BuildContext context) {
if (_latestValue.hasError) {
return chewieController.errorBuilder != null
? chewieController.errorBuilder!(
context,
chewieController.videoPlayerController.value.errorDescription!,
)
: const Center(
child: Icon(
CupertinoIcons.exclamationmark_circle,
color: Colors.white,
size: 42,
),
);
}
final backgroundColor = widget.backgroundColor;
final iconColor = widget.iconColor;
final orientation = MediaQuery.of(context).orientation;
final barHeight = orientation == Orientation.portrait ? 30.0 : 47.0;
final buttonPadding = orientation == Orientation.portrait ? 16.0 : 24.0;
return MouseRegion(
onHover: (_) => _cancelAndRestartTimer(),
child: GestureDetector(
onTap: () => _cancelAndRestartTimer(),
child: AbsorbPointer(
absorbing: notifier.hideStuff,
child: Stack(
children: [
if (_displayBufferingIndicator)
const Center(
child: CupertinoActivityIndicator(
color: Colors.white,
radius: 15,
),
)
else
_buildHitArea(),
Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
_buildTopBar(
backgroundColor,
iconColor,
barHeight,
buttonPadding,
),
const Spacer(),
if (_subtitleOn)
Transform.translate(
offset: Offset(
0.0,
notifier.hideStuff ? barHeight * 0.8 : 0.0,
),
child: _buildSubtitles(chewieController.subtitle!),
),
_buildBottomBar(backgroundColor, iconColor, barHeight),
],
),
],
),
),
),
);
}
@override
void dispose() {
_dispose();
super.dispose();
}
void _dispose() {
controller.removeListener(_updateState);
_hideTimer?.cancel();
_expandCollapseTimer?.cancel();
_initTimer?.cancel();
}
@override
void didChangeDependencies() {
final oldController = _chewieController;
_chewieController = ChewieController.of(context);
controller = chewieController.videoPlayerController;
if (oldController != chewieController) {
_dispose();
_initialize();
}
super.didChangeDependencies();
}
GestureDetector _buildOptionsButton(
Color iconColor,
double barHeight,
) {
final options = <OptionItem>[];
if (chewieController.additionalOptions != null && chewieController.additionalOptions!(context).isNotEmpty) {
options.addAll(chewieController.additionalOptions!(context));
}
return GestureDetector(
onTap: () async {
_hideTimer?.cancel();
if (chewieController.optionsBuilder != null) {
await chewieController.optionsBuilder!(context, options);
} else {
await showCupertinoModalPopup<OptionItem>(
context: context,
semanticsDismissible: true,
useRootNavigator: chewieController.useRootNavigator,
builder: (context) => CupertinoOptionsDialog(
options: options,
cancelButtonText: chewieController.optionsTranslation?.cancelButtonText,
),
);
if (_latestValue.isPlaying) {
_startHideTimer();
}
}
},
child: Container(
height: barHeight,
color: Colors.transparent,
padding: const EdgeInsets.only(left: 4.0, right: 8.0),
margin: const EdgeInsets.only(right: 6.0),
child: Icon(
Icons.more_vert,
color: iconColor,
size: 18,
),
),
);
}
Widget _buildSubtitles(Subtitles subtitles) {
if (!_subtitleOn) {
return const SizedBox();
}
if (_subtitlesPosition == null) {
return const SizedBox();
}
final currentSubtitle = subtitles.getByPosition(_subtitlesPosition!);
if (currentSubtitle.isEmpty) {
return const SizedBox();
}
if (chewieController.subtitleBuilder != null) {
return chewieController.subtitleBuilder!(
context,
currentSubtitle.first!.text,
);
}
return Padding(
padding: EdgeInsets.only(left: marginSize, right: marginSize),
child: Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: const Color(0x96000000),
borderRadius: BorderRadius.circular(10.0),
),
child: Text(
currentSubtitle.first!.text.toString(),
style: const TextStyle(
fontSize: 18,
),
textAlign: TextAlign.center,
),
),
);
}
Widget _buildBottomBar(
Color backgroundColor,
Color iconColor,
double barHeight,
) {
return SafeArea(
bottom: chewieController.isFullScreen,
minimum: chewieController.controlsSafeAreaMinimum,
child: AnimatedOpacity(
opacity: notifier.hideStuff ? 0.0 : 1.0,
duration: const Duration(milliseconds: 300),
child: Container(
color: Colors.transparent,
alignment: Alignment.bottomCenter,
margin: EdgeInsets.all(marginSize),
child: ClipRRect(
borderRadius: BorderRadius.circular(10.0),
child: BackdropFilter(
filter: ui.ImageFilter.blur(
sigmaX: 10.0,
sigmaY: 10.0,
),
child: Container(
height: barHeight,
color: backgroundColor,
child: chewieController.isLive
? Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
_buildPlayPause(controller, iconColor, barHeight),
_buildLive(iconColor),
],
)
: Row(
children: <Widget>[
_buildPlayPause(controller, iconColor, barHeight),
_buildPosition(iconColor),
_buildProgressBar(),
_buildRemaining(iconColor),
_buildSubtitleToggle(iconColor, barHeight),
if (chewieController.allowPlaybackSpeedChanging)
_buildSpeedButton(controller, iconColor, barHeight),
if (chewieController.additionalOptions != null &&
chewieController.additionalOptions!(context).isNotEmpty)
_buildOptionsButton(iconColor, barHeight),
],
),
),
),
),
),
),
);
}
Widget _buildLive(Color iconColor) {
return Padding(
padding: const EdgeInsets.only(right: 12.0),
child: Text(
'LIVE',
style: TextStyle(color: iconColor, fontSize: 12.0),
),
);
}
GestureDetector _buildExpandButton(
Color backgroundColor,
Color iconColor,
double barHeight,
double buttonPadding,
) {
return GestureDetector(
onTap: _onExpandCollapse,
child: AnimatedOpacity(
opacity: notifier.hideStuff ? 0.0 : 1.0,
duration: const Duration(milliseconds: 300),
child: ClipRRect(
borderRadius: BorderRadius.circular(10.0),
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 10.0),
child: Container(
height: barHeight,
padding: EdgeInsets.only(
left: buttonPadding,
right: buttonPadding,
),
color: backgroundColor,
child: Center(
child: Icon(
chewieController.isFullScreen
? CupertinoIcons.arrow_down_right_arrow_up_left
: CupertinoIcons.arrow_up_left_arrow_down_right,
color: iconColor,
size: 16,
),
),
),
),
),
),
);
}
Widget _buildHitArea() {
final bool isFinished = _latestValue.position >= _latestValue.duration;
final bool showPlayButton = widget.showPlayButton && !_latestValue.isPlaying && !_dragging;
return GestureDetector(
onTap: _latestValue.isPlaying
? _cancelAndRestartTimer
: () {
_hideTimer?.cancel();
setState(() {
notifier.hideStuff = false;
});
},
child: CenterPlayButton(
backgroundColor: widget.backgroundColor,
iconColor: widget.iconColor,
isFinished: isFinished,
isPlaying: controller.value.isPlaying,
show: showPlayButton,
onPressed: _playPause,
),
);
}
GestureDetector _buildMuteButton(
VideoPlayerController controller,
Color backgroundColor,
Color iconColor,
double barHeight,
double buttonPadding,
) {
return GestureDetector(
onTap: () {
_cancelAndRestartTimer();
if (_latestValue.volume == 0) {
controller.setVolume(_latestVolume ?? 0.5);
} else {
_latestVolume = controller.value.volume;
controller.setVolume(0.0);
}
},
child: AnimatedOpacity(
opacity: notifier.hideStuff ? 0.0 : 1.0,
duration: const Duration(milliseconds: 300),
child: ClipRRect(
borderRadius: BorderRadius.circular(10.0),
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 10.0),
child: ColoredBox(
color: backgroundColor,
child: Container(
height: barHeight,
padding: EdgeInsets.only(
left: buttonPadding,
right: buttonPadding,
),
child: Icon(
_latestValue.volume > 0 ? Icons.volume_up : Icons.volume_off,
color: iconColor,
size: 16,
),
),
),
),
),
),
);
}
GestureDetector _buildPlayPause(
VideoPlayerController controller,
Color iconColor,
double barHeight,
) {
return GestureDetector(
onTap: _playPause,
child: Container(
height: barHeight,
color: Colors.transparent,
padding: const EdgeInsets.only(
left: 6.0,
right: 6.0,
),
child: AnimatedPlayPause(
color: widget.iconColor,
playing: controller.value.isPlaying,
),
),
);
}
Widget _buildPosition(Color iconColor) {
final position = _latestValue.position;
return Padding(
padding: const EdgeInsets.only(right: 12.0),
child: Text(
formatDuration(position),
style: TextStyle(
color: iconColor,
fontSize: 12.0,
),
),
);
}
Widget _buildRemaining(Color iconColor) {
final position = _latestValue.duration - _latestValue.position;
return Padding(
padding: const EdgeInsets.only(right: 12.0),
child: Text(
'-${formatDuration(position)}',
style: TextStyle(color: iconColor, fontSize: 12.0),
),
);
}
Widget _buildSubtitleToggle(Color iconColor, double barHeight) {
if (chewieController.subtitle?.isEmpty ?? true) {
return const SizedBox();
}
return GestureDetector(
onTap: _subtitleToggle,
child: Container(
height: barHeight,
color: Colors.transparent,
margin: const EdgeInsets.only(right: 10.0),
padding: const EdgeInsets.only(
left: 6.0,
right: 6.0,
),
child: Icon(
Icons.subtitles,
color: _subtitleOn ? iconColor : Colors.grey[700],
size: 16.0,
),
),
);
}
void _subtitleToggle() {
setState(() {
_subtitleOn = !_subtitleOn;
});
}
GestureDetector _buildSkipBack(Color iconColor, double barHeight) {
return GestureDetector(
onTap: _skipBack,
child: Container(
height: barHeight,
color: Colors.transparent,
margin: const EdgeInsets.only(left: 10.0),
padding: const EdgeInsets.only(
left: 6.0,
right: 6.0,
),
child: Icon(
CupertinoIcons.gobackward_15,
color: iconColor,
size: 18.0,
),
),
);
}
GestureDetector _buildSkipForward(Color iconColor, double barHeight) {
return GestureDetector(
onTap: _skipForward,
child: Container(
height: barHeight,
color: Colors.transparent,
padding: const EdgeInsets.only(
left: 6.0,
right: 8.0,
),
margin: const EdgeInsets.only(
right: 8.0,
),
child: Icon(
CupertinoIcons.goforward_15,
color: iconColor,
size: 18.0,
),
),
);
}
GestureDetector _buildSpeedButton(
VideoPlayerController controller,
Color iconColor,
double barHeight,
) {
return GestureDetector(
onTap: () async {
_hideTimer?.cancel();
final chosenSpeed = await showCupertinoModalPopup<double>(
context: context,
semanticsDismissible: true,
useRootNavigator: chewieController.useRootNavigator,
builder: (context) => _PlaybackSpeedDialog(
speeds: chewieController.playbackSpeeds,
selected: _latestValue.playbackSpeed,
),
);
if (chosenSpeed != null) {
controller.setPlaybackSpeed(chosenSpeed);
}
if (_latestValue.isPlaying) {
_startHideTimer();
}
},
child: Container(
height: barHeight,
color: Colors.transparent,
padding: const EdgeInsets.only(
left: 6.0,
right: 8.0,
),
margin: const EdgeInsets.only(
right: 8.0,
),
child: Transform(
alignment: Alignment.center,
transform: Matrix4.skewY(0.0)
..rotateX(math.pi)
..rotateZ(math.pi * 0.8),
child: Icon(
Icons.speed,
color: iconColor,
size: 18.0,
),
),
),
);
}
Widget _buildTopBar(
Color backgroundColor,
Color iconColor,
double barHeight,
double buttonPadding,
) {
return Container(
height: barHeight,
margin: EdgeInsets.only(
top: marginSize,
right: marginSize,
left: marginSize,
),
child: Row(
children: <Widget>[
if (chewieController.allowFullScreen)
_buildExpandButton(
backgroundColor,
iconColor,
barHeight,
buttonPadding,
),
],
),
);
}
void _cancelAndRestartTimer() {
_hideTimer?.cancel();
setState(() {
notifier.hideStuff = false;
_startHideTimer();
});
}
Future<void> _initialize() async {
_subtitleOn = chewieController.subtitle?.isNotEmpty ?? false;
controller.addListener(_updateState);
_updateState();
if (controller.value.isPlaying || chewieController.autoPlay) {
_startHideTimer();
}
if (chewieController.showControlsOnInitialize) {
_initTimer = Timer(const Duration(milliseconds: 200), () {
setState(() {
notifier.hideStuff = false;
});
});
}
}
void _onExpandCollapse() {
setState(() {
notifier.hideStuff = true;
chewieController.toggleFullScreen();
_expandCollapseTimer = Timer(const Duration(milliseconds: 300), () {
setState(() {
_cancelAndRestartTimer();
});
});
});
}
Widget _buildProgressBar() {
return Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 12.0),
child: CupertinoVideoProgressBar(
controller,
onDragStart: () {
setState(() {
_dragging = true;
});
_hideTimer?.cancel();
},
onDragUpdate: () {
_hideTimer?.cancel();
},
onDragEnd: () {
setState(() {
_dragging = false;
});
_startHideTimer();
},
colors: chewieController.cupertinoProgressColors ??
ChewieProgressColors(
playedColor: const Color.fromARGB(
120,
255,
255,
255,
),
handleColor: const Color.fromARGB(
255,
255,
255,
255,
),
bufferedColor: const Color.fromARGB(
60,
255,
255,
255,
),
backgroundColor: const Color.fromARGB(
20,
255,
255,
255,
),
),
),
),
);
}
void _playPause() {
final isFinished = _latestValue.position >= _latestValue.duration;
setState(() {
if (controller.value.isPlaying) {
notifier.hideStuff = false;
_hideTimer?.cancel();
controller.pause();
} else {
_cancelAndRestartTimer();
if (!controller.value.isInitialized) {
controller.initialize().then((_) {
controller.play();
});
} else {
if (isFinished) {
controller.seekTo(Duration.zero);
}
controller.play();
}
}
});
}
void _skipBack() {
_cancelAndRestartTimer();
final beginning = Duration.zero.inMilliseconds;
final skip = (_latestValue.position - const Duration(seconds: 15)).inMilliseconds;
controller.seekTo(Duration(milliseconds: math.max(skip, beginning)));
}
void _skipForward() {
_cancelAndRestartTimer();
final end = _latestValue.duration.inMilliseconds;
final skip = (_latestValue.position + const Duration(seconds: 15)).inMilliseconds;
controller.seekTo(Duration(milliseconds: math.min(skip, end)));
}
void _startHideTimer() {
final hideControlsTimer = chewieController.hideControlsTimer.isNegative
? ChewieController.defaultHideControlsTimer
: chewieController.hideControlsTimer;
_hideTimer = Timer(hideControlsTimer, () {
setState(() {
notifier.hideStuff = true;
});
});
}
void _bufferingTimerTimeout() {
_displayBufferingIndicator = true;
if (mounted) {
setState(() {});
}
}
void _updateState() {
if (!mounted) return;
if (chewieController.progressIndicatorDelay != null) {
if (controller.value.isBuffering) {
_bufferingDisplayTimer ??= Timer(
chewieController.progressIndicatorDelay!,
_bufferingTimerTimeout,
);
} else {
_bufferingDisplayTimer?.cancel();
_bufferingDisplayTimer = null;
_displayBufferingIndicator = false;
}
} else {
_displayBufferingIndicator = controller.value.isBuffering;
}
setState(() {
_latestValue = controller.value;
_subtitlesPosition = controller.value.position;
});
}
}
class _PlaybackSpeedDialog extends StatelessWidget {
const _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 build(BuildContext context) {
final selectedColor = CupertinoTheme.of(context).primaryColor;
return CupertinoActionSheet(
actions: _speeds
.map(
(e) => CupertinoActionSheetAction(
onPressed: () {
Navigator.of(context).pop(e);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (e == _selected) Icon(Icons.check, size: 20.0, color: selectedColor),
Text(e.toString()),
],
),
),
)
.toList(),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,456 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart';
enum PressType {
longPress,
singleClick,
}
enum PreferredPosition {
top,
bottom,
}
class CustomPopupMenuController extends ChangeNotifier {
bool menuIsShowing = false;
void showMenu() {
menuIsShowing = true;
notifyListeners();
}
void hideMenu() {
menuIsShowing = false;
notifyListeners();
}
void toggleMenu() {
menuIsShowing = !menuIsShowing;
notifyListeners();
}
}
Rect _menuRect = Rect.zero;
class CopyCustomPopupMenu extends StatefulWidget {
const CopyCustomPopupMenu({
Key? key,
required this.child,
required this.menuBuilder,
required this.pressType,
this.controller,
this.arrowColor = const Color(0xFF4C4C4C),
this.showArrow = true,
this.barrierColor = Colors.black12,
this.arrowSize = 10.0,
this.horizontalMargin = 10.0,
this.verticalMargin = 10.0,
this.position,
this.menuOnChange,
this.enablePassEvent = true,
}) : super(key: key);
final Widget child;
final PressType pressType;
final bool showArrow;
final Color arrowColor;
final Color barrierColor;
final double horizontalMargin;
final double verticalMargin;
final double arrowSize;
final CustomPopupMenuController? controller;
final Widget Function() menuBuilder;
final PreferredPosition? position;
final void Function(bool)? menuOnChange;
final bool enablePassEvent;
@override
State<CopyCustomPopupMenu> createState() => _CustomPopupMenuState();
}
class _CustomPopupMenuState extends State<CopyCustomPopupMenu> {
RenderBox? _childBox;
RenderBox? _parentBox;
OverlayEntry? _overlayEntry;
CustomPopupMenuController? _controller;
bool _canResponse = true;
TapDownDetails? _tapDownDetails;
_showMenu() {
Widget arrow = ClipPath(
clipper: _ArrowClipper(),
child: Container(
width: widget.arrowSize,
height: widget.arrowSize,
color: widget.arrowColor,
),
);
final viewInsets = EdgeInsets.fromWindowPadding(
WidgetsBinding.instance.window.viewInsets,
WidgetsBinding.instance.window.devicePixelRatio,
);
var keyboardHeight = viewInsets.bottom;
_overlayEntry = OverlayEntry(
builder: (context) {
Widget menu = Center(
child: Container(
constraints: BoxConstraints(
maxWidth: _parentBox!.size.width - 2 * widget.horizontalMargin,
minWidth: 0,
),
child: CustomMultiChildLayout(
delegate: _MenuLayoutDelegate(
anchorSize: _childBox!.size,
anchorOffset: _childBox!.localToGlobal(
Offset(-widget.horizontalMargin, 0),
),
keyboardHeight: keyboardHeight,
verticalMargin: widget.verticalMargin,
position: widget.position,
onTapDownOffset: _tapDownDetails?.globalPosition,
),
children: <Widget>[
if (widget.showArrow)
LayoutId(
id: _MenuLayoutId.arrow,
child: arrow,
),
if (widget.showArrow)
LayoutId(
id: _MenuLayoutId.downArrow,
child: Transform.rotate(
angle: math.pi,
child: arrow,
),
),
LayoutId(
id: _MenuLayoutId.content,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Material(
color: Colors.transparent,
child: widget.menuBuilder(),
),
],
),
),
],
),
),
);
return Listener(
behavior: widget.enablePassEvent ? HitTestBehavior.translucent : HitTestBehavior.opaque,
onPointerDown: (PointerDownEvent event) {
Offset offset = event.localPosition;
if (_menuRect.contains(Offset(offset.dx - widget.horizontalMargin, offset.dy))) {
return;
}
_controller?.hideMenu();
_canResponse = false;
Future.delayed(const Duration(milliseconds: 300)).then((_) => _canResponse = true);
},
child: widget.barrierColor == Colors.transparent
? menu
: Container(
color: widget.barrierColor,
child: menu,
),
);
},
);
if (_overlayEntry != null) {
Overlay.of(context).insert(_overlayEntry!);
}
}
_hideMenu() {
if (_overlayEntry != null) {
_overlayEntry?.remove();
_overlayEntry = null;
}
}
_updateView() {
bool menuIsShowing = _controller?.menuIsShowing ?? false;
widget.menuOnChange?.call(menuIsShowing);
if (menuIsShowing) {
_showMenu();
} else {
_hideMenu();
}
}
@override
void initState() {
super.initState();
_controller = widget.controller;
_controller ??= CustomPopupMenuController();
_controller?.addListener(_updateView);
WidgetsBinding.instance.addPostFrameCallback((call) {
if (mounted) {
_childBox = context.findRenderObject() as RenderBox?;
_parentBox = Overlay.of(context).context.findRenderObject() as RenderBox?;
}
});
}
@override
void dispose() {
_hideMenu();
_controller?.removeListener(_updateView);
super.dispose();
}
@override
Widget build(BuildContext context) {
var child = Material(
color: Colors.transparent,
child: InkWell(
hoverColor: Colors.transparent,
focusColor: Colors.transparent,
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
child: widget.child,
onTap: () {
if (widget.pressType == PressType.singleClick && _canResponse) {
_controller?.showMenu();
}
},
onLongPress: () {
if (widget.pressType == PressType.longPress && _canResponse) {
_controller?.showMenu();
}
},
onTapDown: (details) {
_tapDownDetails = details;
},
),
);
if (Platform.isIOS) {
return child;
} else {
bool menuIsShowing = _controller?.menuIsShowing ?? false;
return WillPopScope(
onWillPop: menuIsShowing
? () {
_hideMenu();
return Future.value(false);
}
: null,
child: child,
);
}
}
}
enum _MenuLayoutId {
arrow,
downArrow,
content,
}
enum _MenuPosition {
bottomLeft,
bottomCenter,
bottomRight,
topLeft,
topCenter,
topRight,
}
class _MenuLayoutDelegate extends MultiChildLayoutDelegate {
_MenuLayoutDelegate({
required this.anchorSize,
required this.anchorOffset,
required this.verticalMargin,
required this.keyboardHeight,
this.position,
this.onTapDownOffset,
});
final Size anchorSize;
final Offset anchorOffset;
final double verticalMargin;
final PreferredPosition? position;
final double keyboardHeight;
final Offset? onTapDownOffset;
@override
void performLayout(Size size) {
Size contentSize = Size.zero;
Size arrowSize = Size.zero;
Offset contentOffset = const Offset(0, 0);
Offset arrowOffset = const Offset(0, 0);
double anchorCenterX = anchorOffset.dx + anchorSize.width / 2;
double anchorTopY = anchorOffset.dy;
double anchorBottomY = anchorTopY + anchorSize.height;
double? touchY = onTapDownOffset?.dy;
_MenuPosition menuPosition = _MenuPosition.bottomCenter;
if (hasChild(_MenuLayoutId.content)) {
contentSize = layoutChild(
_MenuLayoutId.content,
BoxConstraints.loose(size),
);
}
if (hasChild(_MenuLayoutId.arrow)) {
arrowSize = layoutChild(
_MenuLayoutId.arrow,
BoxConstraints.loose(size),
);
}
if (hasChild(_MenuLayoutId.downArrow)) {
layoutChild(
_MenuLayoutId.downArrow,
BoxConstraints.loose(size),
);
}
bool isTop = false;
if (position == null) {
isTop = anchorBottomY > (size.height - keyboardHeight) / 2;
} else {
isTop = position == PreferredPosition.top;
}
double minTopMargin = contentSize.height + arrowSize.height;
if (null != touchY && anchorTopY < minTopMargin) {
if (touchY < minTopMargin) {
isTop = false;
if (anchorSize.height > size.height * 0.8) {
anchorBottomY = touchY;
}
} else {
anchorTopY = touchY;
}
}
if (anchorCenterX - contentSize.width / 2 < 0) {
menuPosition = isTop ? _MenuPosition.topLeft : _MenuPosition.bottomLeft;
} else if (anchorCenterX + contentSize.width / 2 > size.width) {
menuPosition = isTop ? _MenuPosition.topRight : _MenuPosition.bottomRight;
} else {
menuPosition = isTop ? _MenuPosition.topCenter : _MenuPosition.bottomCenter;
}
switch (menuPosition) {
case _MenuPosition.bottomCenter:
arrowOffset = Offset(
anchorCenterX - arrowSize.width / 2,
anchorBottomY + verticalMargin,
);
contentOffset = Offset(
anchorCenterX - contentSize.width / 2,
anchorBottomY + verticalMargin + arrowSize.height,
);
break;
case _MenuPosition.bottomLeft:
arrowOffset = Offset(
anchorCenterX - arrowSize.width / 2,
anchorBottomY + verticalMargin,
);
contentOffset = Offset(
0,
anchorBottomY + verticalMargin + arrowSize.height,
);
break;
case _MenuPosition.bottomRight:
arrowOffset = Offset(
anchorCenterX - arrowSize.width / 2,
anchorBottomY + verticalMargin,
);
contentOffset = Offset(
size.width - contentSize.width,
anchorBottomY + verticalMargin + arrowSize.height,
);
break;
case _MenuPosition.topCenter:
arrowOffset = Offset(
anchorCenterX - arrowSize.width / 2,
anchorTopY - verticalMargin - arrowSize.height,
);
contentOffset = Offset(
anchorCenterX - contentSize.width / 2,
anchorTopY - verticalMargin - arrowSize.height - contentSize.height,
);
break;
case _MenuPosition.topLeft:
arrowOffset = Offset(
anchorCenterX - arrowSize.width / 2,
anchorTopY - verticalMargin - arrowSize.height,
);
contentOffset = Offset(
0,
anchorTopY - verticalMargin - arrowSize.height - contentSize.height,
);
break;
case _MenuPosition.topRight:
arrowOffset = Offset(
anchorCenterX - arrowSize.width / 2,
anchorTopY - verticalMargin - arrowSize.height,
);
contentOffset = Offset(
size.width - contentSize.width,
anchorTopY - verticalMargin - arrowSize.height - contentSize.height,
);
break;
}
if (hasChild(_MenuLayoutId.content)) {
positionChild(_MenuLayoutId.content, contentOffset);
}
_menuRect = Rect.fromLTWH(
contentOffset.dx,
contentOffset.dy,
contentSize.width,
contentSize.height,
);
bool isBottom = false;
if (_MenuPosition.values.indexOf(menuPosition) < 3) {
isBottom = true;
}
if (hasChild(_MenuLayoutId.arrow)) {
positionChild(
_MenuLayoutId.arrow,
isBottom ? Offset(arrowOffset.dx, arrowOffset.dy + 0.1) : const Offset(-100, 0),
);
}
if (hasChild(_MenuLayoutId.downArrow)) {
positionChild(
_MenuLayoutId.downArrow,
!isBottom ? Offset(arrowOffset.dx, arrowOffset.dy - 0.1) : const Offset(-100, 0),
);
}
}
@override
bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) => false;
}
class _ArrowClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
Path path = Path();
path.moveTo(0, size.height);
path.lineTo(size.width / 2, size.height / 2);
path.lineTo(size.width, size.height);
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) {
return true;
}
}
@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
enum DialogType {
confirm,
}
class CustomDialog extends StatelessWidget {
const CustomDialog({
Key? key,
this.title,
this.url,
this.content,
this.rightText,
this.leftText,
this.onTapLeft,
this.onTapRight,
}) : super(key: key);
final String? title;
final String? url;
final String? content;
final String? rightText;
final String? leftText;
final Function()? onTapLeft;
final Function()? onTapRight;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(8.r),
child: Container(
width: 280.w,
color: Styles.c_FFFFFF,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.symmetric(
horizontal: 20.w,
vertical: 20.h,
),
child: Text(
title ?? '',
style: Styles.ts_0C1C33_17sp,
),
),
Divider(
color: Styles.c_E8EAEF,
height: 0.5.h,
),
Row(
children: [
_button(
bgColor: Styles.c_FFFFFF,
text: leftText ?? StrRes.cancel,
textStyle: Styles.ts_0C1C33_17sp,
onTap: onTapLeft ?? () => Get.back(result: false),
),
Container(
color: Styles.c_E8EAEF,
width: 0.5.w,
height: 48.h,
),
_button(
bgColor: Styles.c_FFFFFF,
text: rightText ?? StrRes.determine,
textStyle: Styles.ts_0089FF_17sp,
onTap: onTapRight ?? () => Get.back(result: true),
),
],
)
],
),
),
),
),
);
}
Widget _button({
required Color bgColor,
required String text,
required TextStyle textStyle,
Function()? onTap,
}) =>
Expanded(
child: GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: bgColor,
),
height: 48.h,
alignment: Alignment.center,
child: Text(
text,
style: textStyle,
),
),
),
);
}
class ForwardHintDialog extends StatelessWidget {
const ForwardHintDialog({
Key? key,
required this.title,
this.checkedList = const [],
}) : super(key: key);
final String title;
final List<dynamic> checkedList;
@override
Widget build(BuildContext context) {
final list = IMUtils.convertCheckedListToForwardObj(checkedList);
return Scaffold(
backgroundColor: Colors.transparent,
body: Center(
child: SingleChildScrollView(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 16.h),
margin: EdgeInsets.symmetric(horizontal: 36.w),
decoration: BoxDecoration(
color: Styles.c_FFFFFF,
borderRadius: BorderRadius.circular(8.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
(list.length == 1 ? StrRes.sentTo : StrRes.sentSeparatelyTo).toText
..style = Styles.ts_0C1C33_17sp_medium,
5.verticalSpace,
list.length == 1
? Row(
children: [
AvatarView(
url: list.first['faceURL'],
text: list.first['nickname'],
),
10.horizontalSpace,
Expanded(
child: (list.first['nickname'] ?? '').toText
..style = Styles.ts_0C1C33_17sp
..maxLines = 1
..overflow = TextOverflow.ellipsis,
),
],
)
: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 120.h),
child: GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
crossAxisSpacing: 10.w,
mainAxisSpacing: 0,
childAspectRatio: 50.w / 65.h,
),
itemCount: list.length,
shrinkWrap: true,
itemBuilder: (_, index) => Column(
children: [
AvatarView(
url: list.elementAt(index)['faceURL'],
text: list.elementAt(index)['nickname'],
),
10.horizontalSpace,
(list.elementAt(index)['nickname'] ?? '').toText
..style = Styles.ts_8E9AB0_10sp
..maxLines = 1
..overflow = TextOverflow.ellipsis,
],
),
),
),
5.verticalSpace,
title.toText
..style = Styles.ts_8E9AB0_14sp
..maxLines = 1
..overflow = TextOverflow.ellipsis,
16.verticalSpace,
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
StrRes.cancel.toText
..style = Styles.ts_0C1C33_17sp
..onTap = () => Get.back(),
26.horizontalSpace,
StrRes.determine.toText
..style = Styles.ts_0089FF_17sp
..onTap = () => Get.back(result: true),
],
)
],
),
),
),
),
);
}
}
@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class ExpandedText extends StatefulWidget {
const ExpandedText({
Key? key,
required this.text,
this.textStyle,
this.maxLines = 4,
}) : super(key: key);
final String text;
final TextStyle? textStyle;
final int maxLines;
@override
State<ExpandedText> createState() => _ExpandedTextState();
}
class _ExpandedTextState extends State<ExpandedText> {
bool _isExpand = false;
TextStyle get _textStyle => widget.textStyle ?? Styles.ts_0C1C33_17sp;
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (_, cons) {
final tp = IMUtils.getTextPainter(
widget.text,
_textStyle,
maxLines: widget.maxLines,
maxWidth: cons.maxWidth,
);
return tp.didExceedMaxLines
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_isExpand
? Text(widget.text, style: _textStyle)
: Text(
widget.text,
style: _textStyle,
maxLines: widget.maxLines,
overflow: TextOverflow.ellipsis,
),
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
setState(() {
_isExpand = !_isExpand;
});
},
child: Padding(
padding: EdgeInsets.only(top: 2.h),
child: (_isExpand ? StrRes.rollUp : StrRes.fullText).toText
..style = Styles.ts_0089FF_17sp,
),
),
],
)
: Text(widget.text, style: _textStyle);
});
}
}
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
import 'package:syncfusion_flutter_core/theme.dart';
import 'package:syncfusion_flutter_sliders/sliders.dart';
class FontSizeSlider extends StatelessWidget {
const FontSizeSlider({
Key? key,
required this.value,
this.onChanged,
}) : super(key: key);
final double value;
final Function(dynamic value)? onChanged;
@override
Widget build(BuildContext context) {
return Container(
color: Styles.c_FFFFFF,
padding: EdgeInsets.symmetric(
horizontal: 20.w,
vertical: 20.h,
),
child: Column(
children: [
_buildIndicatorLabel(),
SfSliderTheme(
data: SfSliderThemeData(
activeTrackHeight: 1,
inactiveTrackHeight: 1,
activeTrackColor: Styles.c_8E9AB0_opacity30,
inactiveTrackColor: Styles.c_8E9AB0_opacity30,
activeTickColor: Styles.c_8E9AB0_opacity30,
inactiveTickColor: Styles.c_8E9AB0_opacity30,
activeMinorTickColor: Styles.c_8E9AB0_opacity30,
inactiveMinorTickColor: Styles.c_8E9AB0_opacity30,
thumbColor: Styles.c_FFFFFF,
tickOffset: Offset(0, -10.h),
),
child: SfSlider(
min: 0,
max: 2,
value: value,
interval: 1,
showTicks: true,
showLabels: false,
labelFormatterCallback: (actualValue, formattedText) {
return '';
},
minorTicksPerInterval: 1,
labelPlacement: LabelPlacement.onTicks,
edgeLabelPlacement: EdgeLabelPlacement.inside,
onChanged: onChanged,
),
),
],
),
);
}
Widget _buildIndicatorLabel() => Stack(
children: [
StrRes.little.toText
..style = Styles.ts_0C1C33_12sp
..onTap = () => onChanged?.call(.0),
Align(
alignment: Alignment.center,
child: StrRes.standard.toText
..style = Styles.ts_0C1C33_17sp
..onTap = () => onChanged?.call(1.0),
),
Align(
alignment: Alignment.centerRight,
child: StrRes.big.toText
..style = Styles.ts_0C1C33_20sp
..onTap = () => onChanged?.call(2.0),
),
],
);
}
@@ -0,0 +1,346 @@
library gesture_x_detector;
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import 'package:vector_math/vector_math.dart' as vector;
class XGestureDetector extends StatefulWidget {
const XGestureDetector(
{super.key,
required this.child,
this.onTap,
this.onMoveUpdate,
this.onMoveEnd,
this.onMoveStart,
this.onScaleStart,
this.onScaleUpdate,
this.onScaleEnd,
this.onDoubleTap,
this.onPointerDown,
this.onScrollEvent,
this.bypassMoveEventAfterLongPress = true,
this.bypassTapEventOnDoubleTap = false,
this.doubleTapTimeConsider = 250,
this.longPressTimeConsider = 350,
this.onLongPress,
this.onLongPressMove,
this.onLongPressEnd,
this.behavior = HitTestBehavior.deferToChild,
this.longPressMaximumRangeAllowed = 25});
final Widget child;
final bool bypassTapEventOnDoubleTap;
final bool bypassMoveEventAfterLongPress;
final int doubleTapTimeConsider;
final TapEventListener? onTap;
final MoveEventListener? onMoveStart;
final MoveEventListener? onMoveUpdate;
final MoveEventListener? onMoveEnd;
final void Function(Offset initialFocusPoint)? onScaleStart;
final ScaleEventListener? onScaleUpdate;
final void Function()? onScaleEnd;
final TapEventListener? onDoubleTap;
final TapEventListener? onLongPress;
final MoveEventListener? onLongPressMove;
final Function()? onLongPressEnd;
final Function(ScrollEvent event)? onScrollEvent;
final int longPressTimeConsider;
final HitTestBehavior behavior;
final int longPressMaximumRangeAllowed;
final Function(PointerDownEvent event)? onPointerDown;
@override
State<XGestureDetector> createState() => _XGestureDetectorState();
}
enum _GestureState { PointerDown, MoveStart, ScaleStart, Scalling, LongPress, Unknown }
class _XGestureDetectorState extends State<XGestureDetector> {
List<_Touch> touches = [];
double initialScaleDistance = 1.0;
_GestureState state = _GestureState.Unknown;
Timer? doubleTapTimer;
Timer? longPressTimer;
Offset lastTouchUpPos = const Offset(0, 0);
@override
Widget build(BuildContext context) {
return Listener(
behavior: widget.behavior,
onPointerDown: onPointerDown,
onPointerUp: onPointerUp,
onPointerMove: onPointerMove,
onPointerCancel: onPointerUp,
onPointerSignal: onPointerSignal,
child: widget.child,
);
}
void onPointerSignal(PointerSignalEvent event) {
if (event is PointerScrollEvent) {
widget.onScrollEvent?.call(ScrollEvent(event.pointer, event.localPosition, event.position, event.scrollDelta));
}
}
void onPointerDown(PointerDownEvent event) {
widget.onPointerDown?.call(event);
touches.add(_Touch(event.pointer, event.localPosition));
if (touchCount == 1) {
state = _GestureState.PointerDown;
startLongPressTimer(TapEvent.from(event));
} else if (touchCount == 2) {
state = _GestureState.ScaleStart;
} else {
state = _GestureState.Unknown;
}
}
void initScaleAndRotate() {
initialScaleDistance = (touches[0].currentOffset - touches[1].currentOffset).distance;
}
void onPointerMove(PointerMoveEvent event) {
final touch = touches.firstWhere((touch) => touch.id == event.pointer);
touch.currentOffset = event.localPosition;
cleanupDoubleTimer();
switch (state) {
case _GestureState.LongPress:
if (widget.bypassMoveEventAfterLongPress) {
widget.onLongPressMove?.call(MoveEvent(event.localPosition, event.position, event.pointer,
delta: event.delta, localDelta: event.localDelta));
} else {
switch2MoveStartState(touch, event);
}
break;
case _GestureState.PointerDown:
switch2MoveStartState(touch, event);
break;
case _GestureState.MoveStart:
widget.onMoveUpdate?.call(MoveEvent(event.localPosition, event.position, event.pointer,
delta: event.delta, localDelta: event.localDelta));
break;
case _GestureState.ScaleStart:
touch.startOffset = touch.currentOffset;
state = _GestureState.Scalling;
initScaleAndRotate();
if (widget.onScaleStart != null) {
final centerOffset = (touches[0].currentOffset + touches[1].currentOffset) / 2;
widget.onScaleStart!(centerOffset);
}
break;
case _GestureState.Scalling:
if (widget.onScaleUpdate != null) {
var rotation = angleBetweenLines(touches[0], touches[1]);
final newDistance = (touches[0].currentOffset - touches[1].currentOffset).distance;
final centerOffset = (touches[0].currentOffset + touches[1].currentOffset) / 2;
widget.onScaleUpdate!(ScaleEvent(centerOffset, newDistance / initialScaleDistance, rotation));
}
break;
default:
touch.startOffset = touch.currentOffset;
break;
}
}
void switch2MoveStartState(_Touch touch, PointerMoveEvent event) {
state = _GestureState.MoveStart;
touch.startOffset = event.localPosition;
widget.onMoveStart?.call(MoveEvent(event.localPosition, event.localPosition, event.pointer));
}
double angleBetweenLines(_Touch f, _Touch s) {
double angle1 = math.atan2(f.startOffset.dy - s.startOffset.dy, f.startOffset.dx - s.startOffset.dx);
double angle2 = math.atan2(f.currentOffset.dy - s.currentOffset.dy, f.currentOffset.dx - s.currentOffset.dx);
double angle = vector.degrees(angle1 - angle2) % 360;
if (angle < -180.0) angle += 360.0;
if (angle > 180.0) angle -= 360.0;
return vector.radians(angle);
}
void onPointerUp(PointerEvent event) {
touches.removeWhere((touch) => touch.id == event.pointer);
if (state == _GestureState.PointerDown) {
if (!widget.bypassTapEventOnDoubleTap || widget.onDoubleTap == null) {
callOnTap(TapEvent.from(event));
}
if (widget.onDoubleTap != null) {
final tapEvent = TapEvent.from(event);
if (doubleTapTimer == null) {
startDoubleTapTimer(tapEvent);
} else {
cleanupTimer();
if ((event.localPosition - lastTouchUpPos).distanceSquared < 200) {
widget.onDoubleTap!(tapEvent);
} else {
startDoubleTapTimer(tapEvent);
}
}
}
} else if (state == _GestureState.ScaleStart || state == _GestureState.Scalling) {
state = _GestureState.Unknown;
widget.onScaleEnd?.call();
} else if (state == _GestureState.MoveStart) {
state = _GestureState.Unknown;
widget.onMoveEnd?.call(MoveEvent(event.localPosition, event.position, event.pointer));
} else if (state == _GestureState.LongPress) {
widget.onLongPressEnd?.call();
state = _GestureState.Unknown;
} else if (state == _GestureState.Unknown && touchCount == 2) {
state = _GestureState.ScaleStart;
} else {
state = _GestureState.Unknown;
}
lastTouchUpPos = event.localPosition;
}
void startLongPressTimer(TapEvent event) {
if (widget.onLongPress != null) {
if (longPressTimer != null) {
longPressTimer!.cancel();
longPressTimer = null;
}
longPressTimer = Timer(Duration(milliseconds: widget.longPressTimeConsider), () {
if (touchCount == 1 && touches[0].id == event.pointer && inLongPressRange(touches[0])) {
state = _GestureState.LongPress;
widget.onLongPress!(event);
cleanupTimer();
}
});
}
}
bool inLongPressRange(_Touch touch) {
return (touch.currentOffset - touch.startOffset).distanceSquared < widget.longPressMaximumRangeAllowed;
}
void startDoubleTapTimer(TapEvent event) {
doubleTapTimer = Timer(Duration(milliseconds: widget.doubleTapTimeConsider), () {
state = _GestureState.Unknown;
cleanupTimer();
if (widget.bypassTapEventOnDoubleTap) {
callOnTap(event);
}
});
}
void cleanupTimer() {
cleanupDoubleTimer();
if (longPressTimer != null) {
longPressTimer!.cancel();
longPressTimer = null;
}
}
void cleanupDoubleTimer() {
if (doubleTapTimer != null) {
doubleTapTimer!.cancel();
doubleTapTimer = null;
}
}
void callOnTap(TapEvent event) {
if (widget.onTap != null) {
widget.onTap!(event);
}
}
get touchCount => touches.length;
}
class _Touch {
int id;
Offset startOffset;
late Offset currentOffset;
_Touch(this.id, this.startOffset) {
this.currentOffset = startOffset;
}
}
@immutable
class MoveEvent extends TapEvent {
final Offset localDelta;
final Offset delta;
const MoveEvent(
Offset localPos,
Offset position,
int pointer, {
this.localDelta = const Offset(0, 0),
this.delta = const Offset(0, 0),
}) : super(localPos, position, pointer);
}
@immutable
class TapEvent {
final int pointer;
final Offset localPos;
final Offset position;
const TapEvent(this.localPos, this.position, this.pointer);
static from(PointerEvent event) {
return TapEvent(event.localPosition, event.position, event.pointer);
}
}
@immutable
class ScaleEvent {
final Offset focalPoint;
final double scale;
final double rotationAngle;
const ScaleEvent(this.focalPoint, this.scale, this.rotationAngle);
}
@immutable
class ScrollEvent {
final int pointer;
final Offset localPos;
final Offset position;
final Offset scrollDelta;
const ScrollEvent(this.pointer, this.localPos, this.position, this.scrollDelta);
}
typedef ScaleEventListener = void Function(ScaleEvent event);
typedef TapEventListener = void Function(TapEvent event);
typedef MoveEventListener = void Function(MoveEvent event);
@@ -0,0 +1,311 @@
/*
* Copyright (c) 2015-2019 StoneHui
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
library gesture_zoom_box;
import 'dart:math';
import 'package:flutter/material.dart';
typedef DoubleCallback = void Function(double v);
class GestureZoomBox extends StatefulWidget {
final double maxScale;
final double doubleTapScale;
final Widget child;
final VoidCallback? onPressed;
final DoubleCallback? onScaleListener;
final Duration duration;
const GestureZoomBox(
{Key? key,
this.maxScale = 5.0,
this.doubleTapScale = 2.0,
required this.child,
this.onPressed,
this.duration = const Duration(milliseconds: 200),
this.onScaleListener})
: assert(maxScale >= 1.0),
assert(doubleTapScale >= 1.0 && doubleTapScale <= maxScale),
super(key: key);
@override
State<StatefulWidget> createState() {
return _GestureZoomBoxState();
}
}
class _GestureZoomBoxState extends State<GestureZoomBox> with TickerProviderStateMixin {
AnimationController? _scaleAnimController;
AnimationController? _offsetAnimController;
ScaleUpdateDetails? _firstScaleUpdateDetails;
ScaleUpdateDetails? _latestScaleUpdateDetails;
double _scale = 1.0;
Offset _offset = Offset.zero;
Offset? _doubleTapPosition;
bool _isScaling = false;
bool _isDragging = false;
double _maxDragOver = 100;
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..translate(_offset.dx, _offset.dy)
..scale(_scale, _scale),
child: Listener(
onPointerUp: _onPointerUp,
child: GestureDetector(
onTap: widget.onPressed,
onDoubleTap: _onDoubleTap,
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
onScaleEnd: _onScaleEnd,
child: widget.child,
),
),
);
}
@override
void dispose() {
_scaleAnimController?.dispose();
_offsetAnimController?.dispose();
super.dispose();
}
_onPointerUp(PointerUpEvent event) {
_doubleTapPosition = event.localPosition;
}
_onDoubleTap() {
double targetScale = _scale == 1.0 ? widget.doubleTapScale : 1.0;
_animationScale(targetScale);
if (targetScale == 1.0) {
_animationOffset(Offset.zero);
}
}
_onScaleStart(ScaleStartDetails details) {
_scaleAnimController?.stop();
_offsetAnimController?.stop();
_isScaling = false;
_isDragging = false;
_firstScaleUpdateDetails = null;
_latestScaleUpdateDetails = null;
}
_onScaleUpdate(ScaleUpdateDetails details) {
if (_firstScaleUpdateDetails == null) {
_firstScaleUpdateDetails = details;
return;
}
setState(() {
if (details.scale != 1.0) {
_scaling(details);
} else {
_dragging(details);
}
});
}
_scaling(ScaleUpdateDetails details) {
if (_isDragging) {
return;
}
final latestScaleUpdateDetails = _latestScaleUpdateDetails, size = context.size;
_isScaling = true;
if (latestScaleUpdateDetails == null || size == null) {
_latestScaleUpdateDetails = details;
return;
}
double scaleIncrement = details.scale - latestScaleUpdateDetails.scale;
if (details.scale < 1.0 && _scale > 1.0) {
scaleIncrement *= _scale;
}
if (_scale < 1.0 && scaleIncrement < 0) {
scaleIncrement *= (_scale - 0.5);
} else if (_scale > widget.maxScale && scaleIncrement > 0) {
scaleIncrement *= (2.0 - (_scale - widget.maxScale));
}
_scale = max(_scale + scaleIncrement, 0.0);
double scaleOffsetX = size.width * (_scale - 1.0) / 2;
double scaleOffsetY = size.height * (_scale - 1.0) / 2;
double scalePointDX = (details.localFocalPoint.dx + scaleOffsetX - _offset.dx) / _scale;
double scalePointDY = (details.localFocalPoint.dy + scaleOffsetY - _offset.dy) / _scale;
_offset += Offset(
(size.width / 2 - scalePointDX) * scaleIncrement,
(size.height / 2 - scalePointDY) * scaleIncrement,
);
_latestScaleUpdateDetails = details;
}
_dragging(ScaleUpdateDetails details) {
if (_isScaling) {
return;
}
final latestScaleUpdateDetails = _latestScaleUpdateDetails, size = context.size;
_isDragging = true;
if (latestScaleUpdateDetails == null || size == null) {
_latestScaleUpdateDetails = details;
return;
}
double offsetXIncrement = (details.localFocalPoint.dx - latestScaleUpdateDetails.localFocalPoint.dx) * _scale;
double offsetYIncrement = (details.localFocalPoint.dy - latestScaleUpdateDetails.localFocalPoint.dy) * _scale;
double scaleOffsetX = (size.width * _scale - MediaQuery.of(context).size.width) / 2;
if (scaleOffsetX <= 0) {
offsetXIncrement = 0;
} else if (_offset.dx > scaleOffsetX) {
offsetXIncrement *= (_maxDragOver - (_offset.dx - scaleOffsetX)) / _maxDragOver;
} else if (_offset.dx < -scaleOffsetX) {
offsetXIncrement *= (_maxDragOver - (-scaleOffsetX - _offset.dx)) / _maxDragOver;
}
double scaleOffsetY = (size.height * _scale - MediaQuery.of(context).size.height) / 2;
if (scaleOffsetY <= 0) {
offsetYIncrement = 0;
} else if (_offset.dy > scaleOffsetY) {
offsetYIncrement *= (_maxDragOver - (_offset.dy - scaleOffsetY)) / _maxDragOver;
} else if (_offset.dy < -scaleOffsetY) {
offsetYIncrement *= (_maxDragOver - (-scaleOffsetY - _offset.dy)) / _maxDragOver;
}
_offset += Offset(offsetXIncrement, offsetYIncrement);
_latestScaleUpdateDetails = details;
}
_onScaleEnd(ScaleEndDetails details) {
final size = context.size;
if (size == null) {
return;
}
widget.onScaleListener?.call(_scale);
if (_scale < 1.0) {
_animationScale(1.0);
} else if (_scale > widget.maxScale) {
_animationScale(widget.maxScale);
}
if (_scale <= 1.0) {
_animationOffset(Offset.zero);
} else if (_isDragging) {
double realScale = _scale > widget.maxScale ? widget.maxScale : _scale;
double targetOffsetX = _offset.dx, targetOffsetY = _offset.dy;
double scaleOffsetX = (size.width * realScale - MediaQuery.of(context).size.width) / 2;
if (scaleOffsetX <= 0) {
targetOffsetX = 0;
} else if (_offset.dx > scaleOffsetX) {
targetOffsetX = scaleOffsetX;
} else if (_offset.dx < -scaleOffsetX) {
targetOffsetX = -scaleOffsetX;
}
double scaleOffsetY = (size.height * realScale - MediaQuery.of(context).size.height) / 2;
if (scaleOffsetY < 0) {
targetOffsetY = 0;
} else if (_offset.dy > scaleOffsetY) {
targetOffsetY = scaleOffsetY;
} else if (_offset.dy < -scaleOffsetY) {
targetOffsetY = -scaleOffsetY;
}
if (_offset.dx != targetOffsetX || _offset.dy != targetOffsetY) {
_animationOffset(Offset(targetOffsetX, targetOffsetY));
} else {
double duration = (widget.duration.inSeconds + widget.duration.inMilliseconds / 1000);
Offset targetOffset = _offset + details.velocity.pixelsPerSecond * duration;
targetOffsetX = targetOffset.dx;
if (targetOffsetX > scaleOffsetX) {
targetOffsetX = scaleOffsetX;
} else if (targetOffsetX < -scaleOffsetX) {
targetOffsetX = -scaleOffsetX;
}
targetOffsetY = targetOffset.dy;
if (targetOffsetY > scaleOffsetY) {
targetOffsetY = scaleOffsetY;
} else if (targetOffsetY < -scaleOffsetY) {
targetOffsetY = -scaleOffsetY;
}
_animationOffset(Offset(targetOffsetX, targetOffsetY));
}
}
_isScaling = false;
_isDragging = false;
_latestScaleUpdateDetails = null;
}
_animationScale(double targetScale) {
_scaleAnimController?.dispose();
final scaleAnimController = _scaleAnimController = AnimationController(vsync: this, duration: widget.duration);
Animation anim = Tween<double>(begin: _scale, end: targetScale).animate(scaleAnimController);
anim.addListener(() {
setState(() {
_scaling(ScaleUpdateDetails(
focalPoint: _doubleTapPosition!,
localFocalPoint: _doubleTapPosition!,
scale: anim.value,
horizontalScale: anim.value,
verticalScale: anim.value,
));
});
});
anim.addStatusListener((status) {
if (status == AnimationStatus.completed) {
_onScaleEnd(ScaleEndDetails());
}
});
scaleAnimController.forward();
}
_animationOffset(Offset targetOffset) {
_offsetAnimController?.dispose();
final offsetAnimController = _offsetAnimController = AnimationController(vsync: this, duration: widget.duration);
Animation anim = offsetAnimController.drive(Tween<Offset>(begin: _offset, end: targetOffset));
anim.addListener(() {
setState(() {
_offset = anim.value;
});
});
offsetAnimController.fling();
}
}
@@ -0,0 +1,130 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:openim_common/openim_common.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
class H5Container extends StatefulWidget {
const H5Container({super.key, required this.url, this.title});
final String url;
final String? title;
@override
State<H5Container> createState() => _H5ContainerState();
}
class _H5ContainerState extends State<H5Container> {
late final WebViewController _controller;
double progress = 0;
@override
void initState() {
super.initState();
Logger.print('H5Container: ${widget.url}');
late final PlatformWebViewControllerCreationParams params;
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
params = WebKitWebViewControllerCreationParams(
allowsInlineMediaPlayback: true,
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
);
} else {
params = const PlatformWebViewControllerCreationParams();
}
final WebViewController controller = WebViewController.fromPlatformCreationParams(params);
controller
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onProgress: (int progress) {
debugPrint('WebView is loading (progress : $progress%)');
setState(() {
this.progress = progress / 100;
});
},
onPageStarted: (String url) {
debugPrint('Page started loading: $url');
},
onPageFinished: (String url) {
debugPrint('Page finished loading: $url');
},
onWebResourceError: (WebResourceError error) {
debugPrint('''
Page resource error:
code: ${error.errorCode}
description: ${error.description}
errorType: ${error.errorType}
isForMainFrame: ${error.isForMainFrame}
''');
},
onNavigationRequest: (NavigationRequest request) {
if (request.url.startsWith('https://www.youtube.com/')) {
debugPrint('blocking navigation to ${request.url}');
return NavigationDecision.prevent;
}
debugPrint('allowing navigation to ${request.url}');
return NavigationDecision.navigate;
},
onHttpError: (HttpResponseError error) {
debugPrint('Error occurred on page: ${error.response?.statusCode}');
},
onUrlChange: (UrlChange change) {
debugPrint('url change to ${change.url}');
},
onHttpAuthRequest: (HttpAuthRequest request) {},
),
)
..addJavaScriptChannel(
'Toaster',
onMessageReceived: (JavaScriptMessage message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message.message)),
);
},
)
..loadRequest(Uri.parse('https://openim.io'));
if (!Platform.isMacOS) {
controller.setBackgroundColor(const Color(0x80000000));
}
if (controller.platform is AndroidWebViewController) {
AndroidWebViewController.enableDebugging(true);
(controller.platform as AndroidWebViewController).setMediaPlaybackRequiresUserGesture(false);
}
_controller = controller;
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
Logger.print('H5Container: ${widget.url}');
return Scaffold(
appBar: widget.title != null ? TitleBar.back(title: widget.title) : null,
body: Stack(
children: [
WebViewWidget(controller: _controller),
progress < 1.0
? LinearProgressIndicator(
value: progress,
color: Colors.blue,
)
: const SizedBox(),
],
),
);
}
}
@@ -0,0 +1,427 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
enum InputBoxType {
phone,
account,
password,
verificationCode,
invitationCode,
}
class InputBox extends StatefulWidget {
const InputBox.phone({
super.key,
required this.label,
required this.code,
this.onAreaCode,
this.controller,
this.focusNode,
this.labelStyle,
this.textStyle,
this.codeStyle,
this.hintStyle,
this.formatHintStyle,
this.hintText,
this.formatHintText,
this.margin,
this.inputFormatters,
this.keyBoardType,
}) : obscureText = false,
type = InputBoxType.phone,
arrowColor = null,
clearBtnColor = null,
onSendVerificationCode = null;
InputBox.account({
super.key,
required this.label,
required this.code,
this.onAreaCode,
this.controller,
this.focusNode,
this.labelStyle,
this.textStyle,
this.codeStyle,
this.hintStyle,
this.formatHintStyle,
this.hintText,
this.formatHintText,
this.margin,
this.inputFormatters,
this.keyBoardType,
}) : obscureText = false,
type = InputBoxType.account,
arrowColor = null,
clearBtnColor = null,
onSendVerificationCode = null;
const InputBox.password({
super.key,
required this.label,
this.controller,
this.focusNode,
this.labelStyle,
this.textStyle,
this.hintStyle,
this.formatHintStyle,
this.hintText,
this.formatHintText,
this.margin,
this.inputFormatters,
this.keyBoardType,
}) : obscureText = true,
type = InputBoxType.password,
codeStyle = null,
code = '',
arrowColor = null,
clearBtnColor = null,
onSendVerificationCode = null,
onAreaCode = null;
const InputBox.verificationCode({
super.key,
required this.label,
this.onSendVerificationCode,
this.controller,
this.focusNode,
this.labelStyle,
this.textStyle,
this.hintStyle,
this.formatHintStyle,
this.hintText,
this.formatHintText,
this.margin,
this.inputFormatters,
this.keyBoardType,
}) : obscureText = false,
type = InputBoxType.verificationCode,
code = '',
codeStyle = null,
onAreaCode = null,
arrowColor = null,
clearBtnColor = null;
const InputBox.invitationCode({
super.key,
required this.label,
this.controller,
this.focusNode,
this.labelStyle,
this.textStyle,
this.formatHintStyle,
this.hintStyle,
this.hintText,
this.formatHintText,
this.margin,
this.inputFormatters,
this.keyBoardType,
}) : obscureText = false,
type = InputBoxType.invitationCode,
code = '',
codeStyle = null,
onAreaCode = null,
onSendVerificationCode = null,
arrowColor = null,
clearBtnColor = null;
const InputBox({
Key? key,
required this.label,
this.controller,
this.focusNode,
this.labelStyle,
this.textStyle,
this.hintStyle,
this.codeStyle,
this.formatHintStyle,
this.code = '+86',
this.hintText,
this.formatHintText,
this.arrowColor,
this.clearBtnColor,
this.obscureText = false,
this.type = InputBoxType.account,
this.onAreaCode,
this.onSendVerificationCode,
this.margin,
this.inputFormatters,
this.keyBoardType,
}) : super(key: key);
final TextStyle? labelStyle;
final TextStyle? textStyle;
final TextStyle? hintStyle;
final TextStyle? codeStyle;
final TextStyle? formatHintStyle;
final String code;
final String label;
final String? hintText;
final String? formatHintText;
final Color? arrowColor;
final Color? clearBtnColor;
final bool obscureText;
final TextEditingController? controller;
final FocusNode? focusNode;
final InputBoxType type;
final Function()? onAreaCode;
final Future<bool> Function()? onSendVerificationCode;
final EdgeInsetsGeometry? margin;
final List<TextInputFormatter>? inputFormatters;
final TextInputType? keyBoardType;
@override
State<InputBox> createState() => _InputBoxState();
}
class _InputBoxState extends State<InputBox> {
late bool _obscureText;
bool _showClearBtn = false;
@override
void initState() {
_obscureText = widget.obscureText;
widget.controller?.addListener(_onChanged);
super.initState();
}
void _onChanged() {
setState(() {
_showClearBtn = widget.controller!.text.isNotEmpty;
});
}
void _toggleEye() {
setState(() {
_obscureText = !_obscureText;
});
}
@override
Widget build(BuildContext context) {
return Container(
margin: widget.margin,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.label,
style: widget.labelStyle ?? Styles.ts_8E9AB0_12sp,
),
6.verticalSpace,
Container(
height: 42.h,
padding: EdgeInsets.only(left: 12.w, right: 8.w),
decoration: BoxDecoration(
border: Border.all(color: Styles.c_E8EAEF, width: 1),
borderRadius: BorderRadius.circular(8.r),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (widget.type == InputBoxType.phone || widget.onAreaCode != null) _areaCodeView,
_textField,
_clearBtn,
_eyeBtn,
if (widget.type == InputBoxType.verificationCode)
VerifyCodedButton(
onTapCallback: widget.onSendVerificationCode,
),
],
),
),
if (null != widget.formatHintText)
Padding(
padding: EdgeInsets.only(top: 5.h),
child: widget.formatHintText!.toText..style = (widget.formatHintStyle ?? Styles.ts_8E9AB0_12sp),
),
],
),
);
}
Widget get _textField => Expanded(
child: TextField(
controller: widget.controller,
keyboardType: _textInputType,
textInputAction: TextInputAction.next,
style: widget.textStyle ?? Styles.ts_0C1C33_17sp,
autofocus: false,
obscureText: _obscureText,
focusNode: widget.focusNode,
inputFormatters: [
if (widget.type == InputBoxType.phone || widget.type == InputBoxType.verificationCode)
FilteringTextInputFormatter.allow(RegExp(r'[0-9]')),
if (null != widget.inputFormatters) ...widget.inputFormatters!,
],
decoration: InputDecoration(
hintText: widget.hintText,
hintStyle: widget.hintStyle ?? Styles.ts_8E9AB0_17sp,
isDense: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
),
),
);
Widget get _areaCodeView => GestureDetector(
onTap: widget.onAreaCode,
behavior: HitTestBehavior.translucent,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.code,
style: widget.codeStyle ?? Styles.ts_0C1C33_17sp,
),
8.horizontalSpace,
ImageRes.downArrow.toImage
..width = 8.49.w
..height = 8.49.h,
Container(
width: 1.w,
height: 26.h,
margin: EdgeInsets.symmetric(horizontal: 14.w),
decoration: BoxDecoration(
color: Styles.c_E8EAEF,
borderRadius: BorderRadius.circular(2.r),
),
),
],
),
);
Widget get _clearBtn => Visibility(
visible: _showClearBtn,
child: GestureDetector(
onTap: () {
widget.controller?.clear();
},
behavior: HitTestBehavior.translucent,
child: ImageRes.clearText.toImage
..width = 24.w
..height = 24.h,
),
);
Widget get _eyeBtn => Visibility(
visible: widget.type == InputBoxType.password,
child: GestureDetector(
onTap: _toggleEye,
behavior: HitTestBehavior.translucent,
child: (_obscureText ? ImageRes.eyeClose.toImage : ImageRes.eyeOpen.toImage)
..width = 24.w
..height = 24.h,
),
);
TextInputType? get _textInputType {
if (widget.keyBoardType != null) {
return widget.keyBoardType;
}
TextInputType? keyboardType;
switch (widget.type) {
case InputBoxType.phone:
keyboardType = TextInputType.phone;
break;
case InputBoxType.account:
keyboardType = TextInputType.text;
break;
case InputBoxType.password:
keyboardType = TextInputType.text;
break;
case InputBoxType.verificationCode:
keyboardType = TextInputType.number;
break;
case InputBoxType.invitationCode:
keyboardType = TextInputType.text;
break;
}
return keyboardType;
}
}
class VerifyCodedButton extends StatefulWidget {
final int seconds;
final Future<bool> Function()? onTapCallback;
const VerifyCodedButton({
Key? key,
this.seconds = 60,
required this.onTapCallback,
}) : super(key: key);
@override
State<VerifyCodedButton> createState() => _VerifyCodedButtonState();
}
class _VerifyCodedButtonState extends State<VerifyCodedButton> {
Timer? _timer;
late int _seconds;
bool _firstTime = true;
@override
void dispose() {
_cancel();
super.dispose();
}
@override
void initState() {
super.initState();
_seconds = widget.seconds;
}
void _start() {
_firstTime = false;
_timer = Timer.periodic(1.seconds, (timer) {
if (!mounted) return;
if (_seconds == 0) {
_cancel();
setState(() {});
return;
}
_seconds--;
setState(() {});
});
}
void _cancel() {
if (null != _timer) {
_timer?.cancel();
_timer = null;
}
}
void _reset() {
if (_seconds != widget.seconds) {
_seconds = widget.seconds;
}
_cancel();
setState(() {});
}
void _restart() {
_reset();
_start();
}
bool get _isEnabled => _seconds == 0 || _firstTime;
@override
Widget build(BuildContext context) => (_isEnabled ? StrRes.sendVerificationCode : '${_seconds}S').toText
..style = Styles.ts_0089FF_17sp
..onTap = () {
if (_isEnabled) {
widget.onTapCallback?.call().then((start) {
if (start) _restart();
});
}
};
}
@@ -0,0 +1,105 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
class LoadingView {
static final LoadingView singleton = LoadingView._();
factory LoadingView() => singleton;
LoadingView._();
OverlayState? _overlayState;
OverlayEntry? _overlayEntry;
bool _isVisible = false;
OverlayState? _progressOverlayState;
OverlayEntry? _progressOverlayEntry;
bool isProgressVisible = false;
Future<T> wrap<T>({
required Future<T> Function() asyncFunction,
bool showing = true,
}) async {
await Future.delayed(1.milliseconds);
if (showing) show();
T data;
try {
data = await asyncFunction();
} on Exception catch (_) {
rethrow;
} finally {
dismiss();
}
return data;
}
void show() async {
if (_isVisible) return;
_overlayState = Overlay.of(Get.overlayContext!);
_overlayEntry = OverlayEntry(
builder: (BuildContext context) => Container(
width: MediaQuery.of(context).size.width,
color: Colors.transparent,
child: Center(
child: SpinKitCircle(color: Styles.c_0089FF),
),
),
);
_isVisible = true;
_overlayState?.insert(_overlayEntry!);
}
dismiss() async {
if (!_isVisible && !isProgressVisible) return;
_overlayEntry?.remove();
_progressOverlayEntry?.remove();
_isVisible = false;
isProgressVisible = false;
}
void progress(Stream<double> stream) async {
_progressOverlayState = Overlay.of(Get.overlayContext!);
_progressOverlayEntry = OverlayEntry(
builder: (BuildContext context) => GestureDetector(
onTap: dismiss,
child: Container(
width: MediaQuery.of(context).size.width,
color: const Color.fromARGB(0, 37, 33, 33),
child: Center(
child: Container(
alignment: Alignment.center,
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Styles.c_0C1C33,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CupertinoActivityIndicator(
color: Colors.white,
radius: 20,
),
StreamBuilder(
stream: stream,
builder: (BuildContext context, AsyncSnapshot<double> snapshot) {
if (!snapshot.hasData) return Container();
final progress = snapshot.data ?? 0.0;
return Text('${(progress * 100).toStringAsFixed(1)}%',
style: const TextStyle(color: Colors.white));
}),
],
),
),
),
),
),
);
isProgressVisible = true;
_progressOverlayState?.insert(_progressOverlayEntry!);
}
}
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:latlong2/latlong.dart';
import 'package:map_launcher/map_launcher.dart' as ml;
import 'package:openim_common/openim_common.dart';
class MapView extends StatelessWidget {
const MapView({
Key? key,
required this.latitude,
required this.longitude,
required this.address1,
required this.address2,
}) : super(key: key);
final double latitude;
final double longitude;
final String address1;
final String address2;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: TitleBar.back(title: StrRes.location),
body: Column(
children: [
Expanded(
child: FlutterMap(
options: MapOptions(
initialCenter: LatLng(latitude, longitude),
initialZoom: 15.0,
maxZoom: 18.0,
),
children: [
TileLayer(
urlTemplate: 'https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}',
userAgentPackageName: '',
),
MarkerLayer(
markers: [
Marker(
point: LatLng(latitude, longitude),
child: Icon(
Icons.location_on_sharp,
color: Styles.c_FF381F,
size: 30,
),
),
],
),
],
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 22.w, vertical: 10.h),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
address1.toText..style = Styles.ts_0C1C33_17sp_semibold,
8.verticalSpace,
address2.toText..style = Styles.ts_8E9AB0_14sp,
],
),
),
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _openMapSheet,
child: Container(
width: 35.w,
height: 35.w,
decoration: const BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
child: const Icon(Icons.map, color: Colors.white),
),
),
],
),
)
],
),
);
}
_openMapSheet() async {
final availableMaps = await ml.MapLauncher.installedMaps;
Get.bottomSheet(
BottomSheetView(
items: availableMaps
.map((e) => SheetItem(
label: _mapLabel(e),
onTap: () async {
_launcherMap(e);
},
))
.toList(),
),
);
}
String _mapLabel(ml.AvailableMap map) {
if (map.mapType == ml.MapType.google) {
return StrRes.googleMap;
} else if (map.mapType == ml.MapType.apple) {
return StrRes.appleMap;
} else if (map.mapType == ml.MapType.baidu) {
return StrRes.baiduMap;
} else if (map.mapType == ml.MapType.amap) {
return StrRes.amapMap;
} else if (map.mapType == ml.MapType.tencent) {
return StrRes.tencentMap;
}
return map.mapName;
}
_launcherMap(ml.AvailableMap map) async {
await ml.MapLauncher.showMarker(
mapType: map.mapType,
coords: ml.Coords(latitude, longitude),
title: address1,
description: address2,
);
}
}
@@ -0,0 +1,615 @@
import 'dart:async';
import 'package:chewie/src/center_play_button.dart';
import 'package:chewie/src/chewie_player.dart';
import 'package:chewie/src/chewie_progress_colors.dart';
import 'package:chewie/src/helpers/utils.dart';
import 'package:chewie/src/material/material_progress_bar.dart';
import 'package:chewie/src/material/widgets/options_dialog.dart';
import 'package:chewie/src/material/widgets/playback_speed_dialog.dart';
import 'package:chewie/src/models/option_item.dart';
import 'package:chewie/src/models/subtitle_model.dart';
import 'package:chewie/src/notifiers/index.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
class CustomMaterialControls extends StatefulWidget {
const CustomMaterialControls({
this.showPlayButton = true,
Key? key,
}) : super(key: key);
final bool showPlayButton;
@override
State<StatefulWidget> createState() {
return _MaterialControlsState();
}
}
class _MaterialControlsState extends State<CustomMaterialControls> with SingleTickerProviderStateMixin {
late PlayerNotifier notifier;
late VideoPlayerValue _latestValue;
double? _latestVolume;
Timer? _hideTimer;
Timer? _initTimer;
late var _subtitlesPosition = Duration.zero;
bool _subtitleOn = false;
Timer? _showAfterExpandCollapseTimer;
bool _dragging = false;
bool _displayTapped = false;
Timer? _bufferingDisplayTimer;
bool _displayBufferingIndicator = false;
final barHeight = 48.0 * 1.5;
final marginSize = 5.0;
late VideoPlayerController controller;
ChewieController? _chewieController;
ChewieController get chewieController => _chewieController!;
@override
void initState() {
super.initState();
notifier = Provider.of<PlayerNotifier>(context, listen: false);
}
@override
Widget build(BuildContext context) {
if (_latestValue.hasError) {
return 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: notifier.hideStuff,
child: Stack(
children: [
if (_displayBufferingIndicator)
const Center(
child: CircularProgressIndicator(),
)
else
_buildHitArea(),
_buildActionBar(),
Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
if (_subtitleOn)
Transform.translate(
offset: Offset(
0.0,
notifier.hideStuff ? barHeight * 0.8 : 0.0,
),
child: _buildSubtitles(context, chewieController.subtitle!),
),
_buildBottomBar(context),
],
),
],
),
),
),
);
}
@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 _buildActionBar() {
return Positioned(
top: 0,
width: 1.sw,
child: SafeArea(
child: AnimatedOpacity(
opacity: notifier.hideStuff ? 0.0 : 1.0,
duration: const Duration(milliseconds: 250),
child: Row(
children: [
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => Get.back(),
child: Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: Colors.black87.withOpacity(0.4),
shape: BoxShape.rectangle,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.close, color: Colors.white),
),
),
const Spacer(),
_buildSubtitleToggle(),
if (chewieController.showOptions) _buildOptionsButton(),
],
),
),
),
);
}
Widget _buildOptionsButton() {
final options = <OptionItem>[
OptionItem(
onTap: () async {
Navigator.pop(context);
_onSpeedButtonTap();
},
iconData: Icons.speed,
title: chewieController.optionsTranslation?.playbackSpeedButtonText ?? 'Playback speed',
)
];
if (chewieController.additionalOptions != null && chewieController.additionalOptions!(context).isNotEmpty) {
options.addAll(chewieController.additionalOptions!(context));
}
return AnimatedOpacity(
opacity: notifier.hideStuff ? 0.0 : 1.0,
duration: const Duration(milliseconds: 250),
child: IconButton(
onPressed: () async {
_hideTimer?.cancel();
if (chewieController.optionsBuilder != null) {
await chewieController.optionsBuilder!(context, options);
} else {
await showModalBottomSheet<OptionItem>(
context: context,
isScrollControlled: true,
useRootNavigator: chewieController.useRootNavigator,
builder: (context) => OptionsDialog(
options: options,
cancelButtonText: chewieController.optionsTranslation?.cancelButtonText,
),
);
}
if (_latestValue.isPlaying) {
_startHideTimer();
}
},
icon: const Icon(
Icons.more_vert,
color: Colors.white,
),
),
);
}
Widget _buildSubtitles(BuildContext context, Subtitles subtitles) {
if (!_subtitleOn) {
return const SizedBox();
}
final currentSubtitle = subtitles.getByPosition(_subtitlesPosition);
if (currentSubtitle.isEmpty) {
return const SizedBox();
}
if (chewieController.subtitleBuilder != null) {
return chewieController.subtitleBuilder!(
context,
currentSubtitle.first!.text,
);
}
return Padding(
padding: EdgeInsets.all(marginSize),
child: Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: const Color(0x96000000),
borderRadius: BorderRadius.circular(10.0),
),
child: Text(
currentSubtitle.first!.text.toString(),
style: const TextStyle(
fontSize: 18,
),
textAlign: TextAlign.center,
),
),
);
}
AnimatedOpacity _buildBottomBar(
BuildContext context,
) {
final iconColor = Theme.of(context).textTheme.labelLarge!.color;
return AnimatedOpacity(
opacity: notifier.hideStuff ? 0.0 : 1.0,
duration: const Duration(milliseconds: 300),
child: Container(
height: barHeight + (chewieController.isFullScreen ? 10.0 : 0),
padding: EdgeInsets.only(
left: 20,
bottom: !chewieController.isFullScreen ? 10.0 : 0,
),
child: SafeArea(
bottom: chewieController.isFullScreen,
minimum: chewieController.controlsSafeAreaMinimum,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
if (chewieController.isLive) const Expanded(child: Text('LIVE')) else _buildPosition(iconColor),
if (chewieController.allowMuting) _buildMuteButton(controller),
const Spacer(),
if (chewieController.allowFullScreen) _buildExpandButton(),
],
),
),
SizedBox(
height: chewieController.isFullScreen ? 15.0 : 0,
),
if (!chewieController.isLive)
Expanded(
child: Container(
padding: const EdgeInsets.only(right: 20),
child: Row(
children: [
_buildProgressBar(),
],
),
),
),
],
),
),
),
);
}
GestureDetector _buildMuteButton(
VideoPlayerController controller,
) {
return GestureDetector(
onTap: () {
_cancelAndRestartTimer();
if (_latestValue.volume == 0) {
controller.setVolume(_latestVolume ?? 0.5);
} else {
_latestVolume = controller.value.volume;
controller.setVolume(0.0);
}
},
child: AnimatedOpacity(
opacity: notifier.hideStuff ? 0.0 : 1.0,
duration: const Duration(milliseconds: 300),
child: ClipRect(
child: Container(
height: barHeight,
padding: const EdgeInsets.only(
left: 6.0,
),
child: Icon(
_latestValue.volume > 0 ? Icons.volume_up : Icons.volume_off,
color: Colors.white,
),
),
),
),
);
}
GestureDetector _buildExpandButton() {
return GestureDetector(
onTap: _onExpandCollapse,
child: AnimatedOpacity(
opacity: notifier.hideStuff ? 0.0 : 1.0,
duration: const Duration(milliseconds: 300),
child: Container(
height: barHeight + (chewieController.isFullScreen ? 15.0 : 0),
margin: const EdgeInsets.only(right: 12.0),
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
),
child: Center(
child: Icon(
chewieController.isFullScreen ? Icons.fullscreen_exit : Icons.fullscreen,
color: Colors.white,
),
),
),
),
);
}
Widget _buildHitArea() {
final bool isFinished = _latestValue.position >= _latestValue.duration;
final bool showPlayButton = widget.showPlayButton && !_dragging && !notifier.hideStuff;
return GestureDetector(
onTap: () {
if (_latestValue.isPlaying) {
if (_displayTapped) {
setState(() {
notifier.hideStuff = true;
});
} else {
_cancelAndRestartTimer();
}
} else {
_playPause();
setState(() {
notifier.hideStuff = true;
});
}
},
child: CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: isFinished,
isPlaying: controller.value.isPlaying,
show: showPlayButton,
onPressed: _playPause,
),
);
}
Future<void> _onSpeedButtonTap() async {
_hideTimer?.cancel();
final chosenSpeed = await showModalBottomSheet<double>(
context: context,
isScrollControlled: true,
useRootNavigator: chewieController.useRootNavigator,
builder: (context) => PlaybackSpeedDialog(
speeds: chewieController.playbackSpeeds,
selected: _latestValue.playbackSpeed,
),
);
if (chosenSpeed != null) {
controller.setPlaybackSpeed(chosenSpeed);
}
if (_latestValue.isPlaying) {
_startHideTimer();
}
}
Widget _buildPosition(Color? iconColor) {
final position = _latestValue.position;
final duration = _latestValue.duration;
return RichText(
text: TextSpan(
text: '${formatDuration(position)} ',
children: <InlineSpan>[
TextSpan(
text: '/ ${formatDuration(duration)}',
style: TextStyle(
fontSize: 14.0,
color: Colors.white.withOpacity(.75),
fontWeight: FontWeight.normal,
),
)
],
style: const TextStyle(
fontSize: 14.0,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
);
}
Widget _buildSubtitleToggle() {
if (chewieController.subtitle?.isEmpty ?? true) {
return const SizedBox();
}
return GestureDetector(
onTap: _onSubtitleTap,
child: Container(
height: barHeight,
color: Colors.transparent,
padding: const EdgeInsets.only(
left: 12.0,
right: 12.0,
),
child: Icon(
_subtitleOn ? Icons.closed_caption : Icons.closed_caption_off_outlined,
color: _subtitleOn ? Colors.white : Colors.grey[700],
),
),
);
}
void _onSubtitleTap() {
setState(() {
_subtitleOn = !_subtitleOn;
});
}
void _cancelAndRestartTimer() {
_hideTimer?.cancel();
_startHideTimer();
setState(() {
notifier.hideStuff = false;
_displayTapped = true;
});
}
Future<void> _initialize() async {
_subtitleOn = chewieController.subtitle?.isNotEmpty ?? false;
controller.addListener(_updateState);
_updateState();
if (controller.value.isPlaying || chewieController.autoPlay) {
_startHideTimer();
}
if (chewieController.showControlsOnInitialize) {
_initTimer = Timer(const Duration(milliseconds: 200), () {
setState(() {
notifier.hideStuff = false;
});
});
}
}
void _onExpandCollapse() {
setState(() {
notifier.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) {
notifier.hideStuff = false;
_hideTimer?.cancel();
controller.pause();
} else {
_cancelAndRestartTimer();
if (!controller.value.isInitialized) {
controller.initialize().then((_) {
controller.play();
});
} else {
if (isFinished) {
controller.seekTo(Duration.zero);
}
controller.play();
}
}
});
}
void _startHideTimer() {
final hideControlsTimer = chewieController.hideControlsTimer.isNegative
? ChewieController.defaultHideControlsTimer
: chewieController.hideControlsTimer;
_hideTimer = Timer(hideControlsTimer, () {
setState(() {
notifier.hideStuff = true;
});
});
}
void _bufferingTimerTimeout() {
_displayBufferingIndicator = true;
if (mounted) {
setState(() {});
}
}
void _updateState() {
if (!mounted) return;
if (chewieController.progressIndicatorDelay != null) {
if (controller.value.isBuffering) {
_bufferingDisplayTimer ??= Timer(
chewieController.progressIndicatorDelay!,
_bufferingTimerTimeout,
);
} else {
_bufferingDisplayTimer?.cancel();
_bufferingDisplayTimer = null;
_displayBufferingIndicator = false;
}
} else {
_displayBufferingIndicator = controller.value.isBuffering;
}
setState(() {
_latestValue = controller.value;
_subtitlesPosition = controller.value.position;
});
}
Widget _buildProgressBar() {
return Expanded(
child: MaterialVideoProgressBar(
controller,
onDragStart: () {
setState(() {
_dragging = true;
});
_hideTimer?.cancel();
},
onDragEnd: () {
setState(() {
_dragging = false;
});
_startHideTimer();
},
colors: chewieController.materialProgressColors ??
ChewieProgressColors(
playedColor: Theme.of(context).colorScheme.secondary,
handleColor: Theme.of(context).colorScheme.secondary,
bufferedColor: Theme.of(context).colorScheme.background.withOpacity(0.5),
backgroundColor: Theme.of(context).disabledColor.withOpacity(.5),
),
),
);
}
}
@@ -0,0 +1,441 @@
import 'dart:async';
import 'package:flutter/material.dart';
class OverlayWidget {
static final OverlayWidget singleton = OverlayWidget._();
factory OverlayWidget() => singleton;
OverlayWidget._();
OverlayState? _overlayState;
OverlayEntry? _overlayEntry;
bool _isVisible = false;
OverlayState? _dialogOverlayState;
OverlayEntry? _dialogOverlayEntry;
bool _isDialogVisible = false;
OverlayState? _toastOverlayState;
OverlayEntry? _toastOverlayEntry;
bool _isToastVisible = false;
Timer? _toastTimer;
void showDialog({
required BuildContext context,
required Widget child,
}) async {
if (_isDialogVisible) return;
_dialogOverlayState = Overlay.of(context);
_dialogOverlayEntry = OverlayEntry(
builder: (BuildContext context) => DialogContainer(
onDismiss: hideDialog,
child: child,
),
);
_isDialogVisible = true;
_dialogOverlayState?.insert(_dialogOverlayEntry!, above: _overlayEntry);
}
void showBottomSheet({
required BuildContext context,
required Widget Function(AnimationController? controller) child,
}) {
if (_isVisible) return;
_overlayState = Overlay.of(context);
_overlayEntry = OverlayEntry(
builder: (BuildContext context) => BottomSheetContainer(
onDismiss: dismiss,
child: child,
),
);
_isVisible = true;
_overlayState?.insert(_overlayEntry!);
}
void showToast({
required BuildContext context,
required String text,
VoidCallback? onDelayDismiss,
}) async {
if (_isToastVisible) return;
var count = 3;
_toastTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
count--;
if (count == 0) {
timer.cancel;
hideToast();
onDelayDismiss?.call();
}
});
_toastOverlayState = Overlay.of(context);
_toastOverlayEntry = OverlayEntry(
builder: (BuildContext context) => DialogContainer(
onDismiss: hideToast,
backgroundColor: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(8),
),
child: Text(
text,
style: const TextStyle(color: Colors.white),
),
),
),
);
_isToastVisible = true;
_toastOverlayState?.insert(_toastOverlayEntry!, above: _overlayEntry);
}
void hideDialog() {
if (!_isDialogVisible) return;
_dialogOverlayEntry?.remove();
_dialogOverlayEntry = null;
_isDialogVisible = false;
}
void hideToast() {
if (!_isToastVisible) return;
_toastTimer = null;
_toastOverlayEntry?.remove();
_toastOverlayEntry = null;
_isToastVisible = false;
}
dismiss() async {
if (!_isVisible && !_isDialogVisible && !_isToastVisible) return;
_overlayEntry?.remove();
_overlayEntry = null;
_isVisible = false;
_dialogOverlayEntry?.remove();
_dialogOverlayEntry = null;
_isDialogVisible = false;
_toastOverlayEntry?.remove();
_toastOverlayEntry = null;
_isToastVisible = false;
}
}
class DialogContainer extends StatefulWidget {
const DialogContainer({
Key? key,
required this.child,
this.backgroundColor,
this.onDismiss,
}) : super(key: key);
final Widget child;
final Color? backgroundColor;
final VoidCallback? onDismiss;
@override
State<DialogContainer> createState() => _DialogContainerState();
}
class _DialogContainerState extends State<DialogContainer> with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
_controller = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
)..addStatusListener((status) {
if (status == AnimationStatus.completed) {
} else if (status == AnimationStatus.dismissed) {
widget.onDismiss?.call();
}
});
_animation = Tween(begin: 0.0, end: 1.0).animate(_controller)
/*..addListener(() {
setState(() {});
})*/
;
_controller.forward();
super.initState();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _animation,
child: GestureDetector(
onTap: () {
_controller.reverse();
},
behavior: HitTestBehavior.translucent,
child: Material(
color: widget.backgroundColor ?? Colors.black.withAlpha(150),
child: Center(child: widget.child),
),
),
);
}
}
class BottomSheetContainer extends StatefulWidget {
const BottomSheetContainer({
Key? key,
required this.child,
this.onDismiss,
}) : super(key: key);
final Widget Function(AnimationController? controller) child;
final Function()? onDismiss;
@override
State<BottomSheetContainer> createState() => _BottomSheetContainerState();
}
class _BottomSheetContainerState extends State<BottomSheetContainer> with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _childAnimation;
late Animation<double> _bgAnimation;
@override
void initState() {
_controller = AnimationController(
duration: const Duration(milliseconds: 150),
vsync: this,
)..addStatusListener((status) {
if (status == AnimationStatus.completed) {
} else if (status == AnimationStatus.dismissed) {
widget.onDismiss?.call();
}
});
_childAnimation = Tween(begin: const Offset(0, 1), end: const Offset(0, 0)).animate(_controller)
/*..addListener(() {
setState(() {});
})*/
;
_bgAnimation = Tween(begin: 0.5, end: 1.0).animate(_controller)
/*..addListener(() {
setState(() {});
})*/
;
_controller.forward();
super.initState();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
_controller.reverse();
},
onVerticalDragEnd: (detail) {
_controller.reverse();
},
behavior: HitTestBehavior.translucent,
child: FadeTransition(
opacity: _bgAnimation,
child: Material(
color: Colors.black.withAlpha(150),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
SlideTransition(
position: _childAnimation,
child: widget.child.call(_controller),
),
],
),
)),
);
}
}
class PopupMenuButtonContainer extends StatefulWidget {
const PopupMenuButtonContainer({
Key? key,
required this.builder,
this.alignment = Alignment.topRight,
this.onStartCloseAnimation,
this.onCloseAnimationEnd,
}) : super(key: key);
final Widget Function(AnimationController? controller) builder;
final Alignment alignment;
final Future<bool> Function()? onStartCloseAnimation;
final Function()? onCloseAnimationEnd;
@override
State<PopupMenuButtonContainer> createState() => _PopupMenuButtonContainerState();
}
class _PopupMenuButtonContainerState extends State<PopupMenuButtonContainer> with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
_controller = AnimationController(
duration: const Duration(milliseconds: 80),
vsync: this,
)..addStatusListener((status) {
if (status == AnimationStatus.completed) {
} else if (status == AnimationStatus.dismissed) {
widget.onCloseAnimationEnd?.call();
}
});
_animation = Tween(begin: 0.0, end: 1.0).animate(_controller)
/*..addListener(() {
setState(() {});
})*/
;
_controller.forward();
widget.onStartCloseAnimation?.call().then((value) {
if (value) _controller.reverse();
});
super.initState();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: _animation,
alignment: widget.alignment,
child: widget.builder.call(_controller),
);
}
}
class OverlayPopupMenuButton extends StatefulWidget {
const OverlayPopupMenuButton({
Key? key,
required this.child,
required this.builder,
this.closePopMenuCompleter,
}) : super(key: key);
final Widget child;
final Widget Function(AnimationController? controller) builder;
final Completer<bool>? closePopMenuCompleter;
@override
State<OverlayPopupMenuButton> createState() => OverlayPopupMenuButtonState();
}
class OverlayPopupMenuButtonState extends State<OverlayPopupMenuButton> {
OverlayState? _overlayState;
OverlayEntry? _overlayEntry;
bool _isVisible = false;
final _globalKey = GlobalKey();
@override
void initState() {
super.initState();
}
dismiss() async {
if (!_isVisible) return;
_overlayEntry?.remove();
_overlayEntry = null;
_isVisible = false;
}
Rect getWidgetGlobalRect() {
final RenderBox renderBox = context.findRenderObject() as RenderBox;
final topLeft = renderBox.localToGlobal(Offset.zero);
final bottomRight = renderBox.localToGlobal(renderBox.size.bottomRight(Offset.zero));
return Rect.fromPoints(topLeft, bottomRight);
}
@override
Widget build(BuildContext context) {
widget.closePopMenuCompleter?.future.then((value) => dismiss());
return GestureDetector(
onTapDown: (details) {
if (_isVisible) return;
_isVisible = true;
final rect = getWidgetGlobalRect();
final completer = Completer<bool>();
final screenWidth = MediaQuery.of(context).size.width;
final screenHeight = MediaQuery.of(context).size.height;
final contentWidth = _globalKey.currentContext?.size?.width ?? 0;
final contentHeight = _globalKey.currentContext?.size?.height ?? 0;
final popupMenuButtonWidth = rect.right - rect.left;
final popupMenuButtonHeight = rect.bottom - rect.top;
double? left = rect.left + popupMenuButtonWidth / 2 - contentWidth / 2;
double? top = rect.top + popupMenuButtonHeight;
double? right = rect.right - popupMenuButtonWidth / 2;
double? bottom;
bool reverse = false;
if (left < 0) {
left = 0;
} else if (left + contentWidth > screenWidth) {
left = screenWidth - contentWidth;
}
if (top + contentHeight > screenHeight) {
top = null;
bottom = screenHeight - rect.top;
reverse = true;
}
_overlayState = Overlay.of(context);
_overlayEntry = OverlayEntry(
builder: (BuildContext context) => GestureDetector(
onTap: () {
dismiss();
},
behavior: HitTestBehavior.translucent,
child: Stack(
children: [
Positioned(
top: top,
bottom: bottom,
left: left,
child: PopupMenuButtonContainer(
onCloseAnimationEnd: dismiss,
onStartCloseAnimation: () => completer.future,
alignment: reverse ? Alignment.bottomCenter : Alignment.topCenter,
builder: widget.builder,
),
)
],
),
),
);
_overlayState?.insert(_overlayEntry!);
},
child: Stack(
children: [
Offstage(
child: SizedBox(key: _globalKey, child: widget.builder.call(null)),
),
widget.child,
],
),
);
}
}
@@ -0,0 +1,391 @@
import 'dart:async';
import 'dart:io';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:media_kit_video/media_kit_video_controls/media_kit_video_controls.dart' as media_kit_video_controls;
import 'package:openim_common/openim_common.dart';
import 'custom_mk_controls.dart';
import 'photo_browser_hero.dart';
class MediaSource {
final String? url;
final String thumbnail;
final File? file;
final bool isVideo;
final String? tag;
MediaSource({required this.thumbnail, this.url, this.file, this.isVideo = false, this.tag});
}
class MediaBrowser extends StatefulWidget {
const MediaBrowser({
super.key,
required this.sources,
required this.initialIndex,
this.muted = false,
this.onAutoPlay,
this.onSave,
this.onLongPress,
});
final int initialIndex;
final List<MediaSource> sources;
final bool muted;
final bool Function(int index)? onAutoPlay;
final ValueChanged<int>? onSave;
final ValueChanged<int>? onLongPress;
@override
State<MediaBrowser> createState() => _MediaBrowserState();
}
class _MediaBrowserState extends State<MediaBrowser> with TickerProviderStateMixin {
GlobalKey<ExtendedImageSlidePageState> slidePagekey = GlobalKey<ExtendedImageSlidePageState>();
final List<int> _cachedIndexes = <int>[];
int currentIndex = 0;
List<double> doubleTapScales = <double>[1.0, 2.0];
late AnimationController _doubleClickAnimationController;
Animation<double>? _doubleClickAnimation;
late VoidCallback _doubleClickAnimationListener;
@override
void initState() {
currentIndex = widget.initialIndex;
_doubleClickAnimationController = AnimationController(duration: const Duration(milliseconds: 150), vsync: this);
super.initState();
}
@override
void dispose() {
Logger.print('[MediaBrowser] dispose', fileName: 'media_browser.dart');
_doubleClickAnimationController.dispose();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_preloadImage(currentIndex - 1 < 0 ? 0 : currentIndex - 1);
_preloadImage(currentIndex + 1);
}
void _preloadImage(int index) {
if (_cachedIndexes.contains(index)) {
return;
}
if (0 <= index && index < widget.sources.length) {
final s = widget.sources[index];
final url = s.isVideo ? s.thumbnail : s.url!;
precacheImage(ExtendedNetworkImageProvider(url, cache: true), context);
_cachedIndexes.add(index);
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return Material(
color: Colors.transparent,
shadowColor: Colors.transparent,
child: ExtendedImageSlidePage(
key: slidePagekey,
slideAxis: SlideAxis.both,
slideType: SlideType.wholePage,
resetPageDuration: const Duration(milliseconds: 300),
slidePageBackgroundHandler: (offset, pageSize) {
double rate = 1 - (offset.dy.abs() / (size.height / 2));
rate = rate > 0 ? rate : 0;
return Colors.black.withOpacity(rate);
},
child: GestureDetector(
onTap: () {
slidePagekey.currentState!.popPage();
Navigator.pop(context);
},
onLongPress: () => widget.onLongPress?.call(currentIndex),
child: ExtendedImageGesturePageView.builder(
controller: ExtendedPageController(
initialPage: currentIndex,
pageSpacing: 8,
shouldIgnorePointerWhenScrolling: true,
),
itemCount: widget.sources.length,
onPageChanged: (int page) {
_preloadImage(page - 1);
_preloadImage(page + 1);
},
itemBuilder: (BuildContext context, int index) {
final s = widget.sources[index];
return s.isVideo
? ExtendedImageSlidePageHandler(
child: VideoPlayerView(
url: s.url,
coverUrl: s.thumbnail,
file: s.file,
heroTag: s.tag,
autoPlay: widget.onAutoPlay?.call(index) ?? false,
muted: widget.muted,
onDownload: (url, file) => widget.onSave?.call(currentIndex),
),
heroBuilderForSlidingPage: (Widget result) {
return Hero(
tag: s.tag ?? s.thumbnail,
child: result,
flightShuttleBuilder: (BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection flightDirection,
BuildContext fromHeroContext,
BuildContext toHeroContext) {
final Hero hero = (flightDirection == HeroFlightDirection.pop
? fromHeroContext.widget
: toHeroContext.widget) as Hero;
return hero.child;
},
);
},
)
: HeroWidget(
tag: s.tag ?? s.thumbnail,
slideType: SlideType.onlyImage,
slidePagekey: slidePagekey,
child: s.file != null && s.file!.existsSync()
? ExtendedImage.file(
s.file!,
enableSlideOutPage: true,
fit: BoxFit.contain,
mode: ExtendedImageMode.gesture,
)
: ExtendedImage.network(
s.url ?? s.thumbnail,
enableSlideOutPage: true,
fit: BoxFit.contain,
mode: ExtendedImageMode.gesture,
initGestureConfigHandler: (ExtendedImageState state) {
return GestureConfig(
minScale: 0.9,
animationMinScale: 0.7,
maxScale: 3.0,
animationMaxScale: 3.5,
speed: 1.0,
inPageView: true,
initialAlignment: InitialAlignment.center,
);
},
onDoubleTap: (state) {
final Offset? pointerDownPosition = state.pointerDownPosition;
final double? begin = state.gestureDetails!.totalScale;
double end;
_doubleClickAnimation?.removeListener(_doubleClickAnimationListener);
_doubleClickAnimationController.stop();
_doubleClickAnimationController.reset();
if (begin == doubleTapScales[0]) {
end = doubleTapScales[1];
} else {
end = doubleTapScales[0];
}
_doubleClickAnimationListener = () {
state.handleDoubleTap(
scale: _doubleClickAnimation!.value, doubleTapPosition: pointerDownPosition);
};
_doubleClickAnimation =
_doubleClickAnimationController.drive(Tween<double>(begin: begin, end: end));
_doubleClickAnimation!.addListener(_doubleClickAnimationListener);
_doubleClickAnimationController.forward();
},
loadStateChanged: (state) {
if (state.extendedImageLoadState == LoadState.loading) {
return Stack(
alignment: AlignmentDirectional.center,
children: [
ExtendedImage.network(
s.thumbnail,
enableLoadState: false,
),
const CupertinoActivityIndicator(
radius: 15,
),
],
);
} else if (state.extendedImageLoadState == LoadState.failed) {
state.imageProvider.evict();
return ImageRes.pictureError.toImage;
}
return null;
},
),
);
},
),
),
),
);
}
}
class VideoPlayerView extends StatefulWidget {
const VideoPlayerView({
super.key,
this.path,
this.url,
this.coverUrl,
this.file,
this.heroTag,
this.onDownload,
this.autoPlay = true,
this.muted = false,
});
final String? path;
final String? url;
final File? file;
final String? coverUrl;
final String? heroTag;
final bool autoPlay;
final bool muted;
final Function(String? url, File? file)? onDownload;
@override
State<VideoPlayerView> createState() => _VideoPlayerViewState();
}
class _VideoPlayerViewState extends State<VideoPlayerView> {
late final player = Player();
late final controller = VideoController(player);
final _cacheManager = DefaultCacheManager();
bool _showCover = true;
@override
void initState() {
super.initState();
player.stream.playing.listen((event) {
if (event && _showCover) {
setState(() {
_showCover = false;
});
}
});
unawaited(_cacheManager.downloadFile(widget.url!));
() async {
final fileInfo = await _cacheManager.getFileFromCache(widget.url!);
if (fileInfo?.file != null) {
player.open(Media(fileInfo!.file.path));
} else {
player.open(Media(widget.url!));
}
}();
media_kit_video_controls.kDefaultMaterialVideoControlsThemeDataFullscreen.copyWith();
}
@override
void dispose() {
player.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
MaterialVideoControlsTheme(
normal: media_kit_video_controls.kDefaultMaterialVideoControlsThemeData.copyWith(
bottomButtonBarMargin: const EdgeInsets.only(bottom: 70),
seekBarMargin: const EdgeInsets.only(bottom: 60, left: 24, right: 24),
seekBarThumbColor: Colors.white,
seekBarPositionColor: Colors.white,
bottomButtonBar: [
const MaterialPlayOrPauseButton(),
const MaterialPositionIndicator(),
const Spacer(),
MaterialCustomButton(
icon: const Icon(Icons.more_vert),
onPressed: () {
_showActionSheet(context);
}),
],
),
fullscreen: media_kit_video_controls.kDefaultMaterialVideoControlsThemeDataFullscreen,
child: Video(
controller: controller,
fit: BoxFit.contain,
controls: (state) {
return CustomMKMaterialVideoControls(state);
},
),
),
if (_showCover) _buildCoverView(context)
],
);
}
void _showActionSheet(BuildContext context) {
showCupertinoModalPopup(
context: context,
builder: (BuildContext context) {
return CupertinoActionSheet(
actions: [
CupertinoActionSheetAction(
onPressed: () async {
Navigator.pop(context);
final file = await _cacheManager.getFileFromCache(widget.url!);
widget.onDownload?.call(widget.url, file?.file);
},
child: Text(StrRes.download),
),
],
cancelButton: CupertinoActionSheetAction(
onPressed: () {
Navigator.pop(context);
},
isDestructiveAction: true,
child: Text(StrRes.cancel),
),
);
},
);
}
Widget _buildCoverView(BuildContext context) {
if (widget.coverUrl == null) {
return const SizedBox.shrink();
}
final screenSize = MediaQuery.of(context).size;
return Stack(
alignment: Alignment.center,
children: [
ImageUtil.networkImage(
url: widget.coverUrl!,
loadProgress: false,
height: screenSize.height,
width: screenSize.width,
fit: BoxFit.fitWidth,
),
const CupertinoActivityIndicator(
color: Colors.white,
radius: 15,
),
],
);
}
}
@@ -0,0 +1,103 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
enum OperateType {
forward,
save,
}
class PhotoBrowserBottomBar extends StatelessWidget {
PhotoBrowserBottomBar({super.key, this.onPressedButton, this.onlySave});
ValueChanged<OperateType>? onPressedButton;
bool? onlySave;
PhotoBrowserBottomBar.show(BuildContext context,
{super.key, bool onlySave = false, ValueChanged<OperateType>? onPressedButton}) {
showModalBottomSheet(
isScrollControlled: true,
backgroundColor: Colors.transparent,
context: context,
builder: (context) {
return PhotoBrowserBottomBar(
onPressedButton: onPressedButton,
onlySave: onlySave,
);
});
}
@override
Widget build(BuildContext context) {
return _buildBar(context);
}
Widget _buildBar(BuildContext context) {
return Container(
constraints: BoxConstraints(maxHeight: 142),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: CupertinoColors.systemGrey6,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(8.0),
topRight: const Radius.circular(8.0),
),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
if (onlySave == false)
_buildItem(ImageRes.forwardIcon.toImage, StrRes.menuForward, onPressed: () {
Navigator.of(context).pop();
onPressedButton?.call(OperateType.forward);
}),
_buildItem(
ImageRes.saveIcon.toImage
..width = 20
..height = 20,
StrRes.save, onPressed: () {
Navigator.of(context).pop();
onPressedButton?.call(OperateType.save);
})
],
),
Divider(
height: 6.h,
),
ConstrainedBox(
constraints: BoxConstraints(minWidth: MediaQuery.of(context).size.width, maxHeight: 40.h),
child: CupertinoButton(
padding: EdgeInsets.zero,
minSize: 40.h,
child: Text(StrRes.cancel, style: Styles.ts_0C1C33_12sp),
onPressed: () {
Navigator.of(context).pop();
}),
)
],
),
);
}
Widget _buildItem(Widget icon, String title, {VoidCallback? onPressed}) {
return Column(children: [
CupertinoButton(
padding: EdgeInsets.only(top: 16, bottom: 8),
child: Container(
decoration: BoxDecoration(color: CupertinoColors.white, borderRadius: BorderRadius.all(Radius.circular(5))),
height: 48,
width: 48,
child: Center(child: icon),
),
onPressed: () {
onPressed?.call();
}),
Text(
title,
textAlign: TextAlign.center,
style: Styles.ts_0C1C33_10sp,
)
]);
}
}
@@ -0,0 +1,94 @@
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
class HeroWidget extends StatefulWidget {
const HeroWidget({
super.key,
required this.child,
required this.tag,
required this.slidePagekey,
this.slideType = SlideType.onlyImage,
});
final Widget child;
final SlideType slideType;
final Object tag;
final GlobalKey<ExtendedImageSlidePageState> slidePagekey;
@override
State<HeroWidget> createState() => _HeroWidgetState();
}
class _HeroWidgetState extends State<HeroWidget> {
RectTween? _rectTween;
@override
Widget build(BuildContext context) {
return Hero(
tag: widget.tag,
createRectTween: (Rect? begin, Rect? end) {
_rectTween = RectTween(begin: begin, end: end);
return _rectTween!;
},
flightShuttleBuilder: (BuildContext flightContext, Animation<double> animation,
HeroFlightDirection flightDirection, BuildContext fromHeroContext, BuildContext toHeroContext) {
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;
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,
)
],
);
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,169 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class PopMenuInfo {
final String? icon;
final Widget? iconWidget;
final String text;
final Function()? onTap;
PopMenuInfo({
this.icon,
this.iconWidget,
required this.text,
this.onTap,
});
}
class PopButton extends StatelessWidget {
final List<PopMenuInfo> menus;
final Widget child;
final CustomPopupMenuController? popCtrl;
final PressType pressType;
final bool showArrow;
final Color arrowColor;
final Color barrierColor;
final double horizontalMargin;
final double verticalMargin;
final double arrowSize;
final Color? bgColor;
final double? bgRadius;
final Color? bgShadowColor;
final Offset? bgShadowOffset;
final double? bgShadowBlurRadius;
final double? bgShadowSpreadRadius;
final double? menuItemHeight;
final double? menuItemWidth;
final EdgeInsetsGeometry? menuItemPadding;
final TextStyle? menuItemTextStyle;
final double? menuItemIconWidth;
final double? menuItemIconHeight;
final Color? lineColor;
final double? lineWidth;
const PopButton({
Key? key,
required this.menus,
required this.child,
this.popCtrl,
this.arrowColor = const Color(0xFFFFFFFF),
this.showArrow = false,
this.barrierColor = Colors.transparent,
this.arrowSize = 10.0,
this.horizontalMargin = 10.0,
this.verticalMargin = 10.0,
this.pressType = PressType.singleClick,
this.bgColor,
this.bgRadius,
this.bgShadowColor,
this.bgShadowOffset,
this.bgShadowBlurRadius,
this.bgShadowSpreadRadius,
this.menuItemHeight,
this.menuItemWidth,
this.menuItemTextStyle,
this.menuItemIconWidth,
this.menuItemIconHeight,
this.menuItemPadding,
this.lineColor,
this.lineWidth = 1.0,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return CopyCustomPopupMenu(
controller: popCtrl,
arrowColor: arrowColor,
showArrow: showArrow,
barrierColor: barrierColor,
arrowSize: arrowSize,
verticalMargin: verticalMargin,
horizontalMargin: horizontalMargin,
pressType: pressType,
child: child,
menuBuilder: () => _buildPopBgView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: menus.map((e) => _buildPopItemView(e, showLine: menus.lastOrNull != e)).toList(),
),
),
);
}
_clickArea(double dy) {
for (var i = 0; i < menus.length; i++) {
if (dy > i * menuItemHeight! && dy <= (i + 1) * menuItemHeight!) {
menus.elementAt(i).onTap?.call();
popCtrl?.hideMenu();
}
}
}
Widget _buildPopBgView({Widget? child}) => Container(
decoration: BoxDecoration(
color: bgColor ?? Styles.c_FFFFFF,
borderRadius: BorderRadius.circular(bgRadius ?? 8.r),
boxShadow: [
BoxShadow(
color: bgShadowColor ?? Styles.c_8E9AB0_opacity16,
offset: bgShadowOffset ?? Offset(0, 6.h),
blurRadius: bgShadowBlurRadius ?? 16.r,
spreadRadius: bgShadowSpreadRadius ?? 1.r,
)
],
),
child: child,
);
Widget _buildPopItemView(PopMenuInfo info, {bool showLine = true}) => GestureDetector(
onTap: () {
popCtrl?.hideMenu();
info.onTap?.call();
},
behavior: HitTestBehavior.translucent,
child: Container(
height: menuItemHeight ?? 48.h,
width: menuItemWidth,
padding: menuItemPadding,
constraints: BoxConstraints(minWidth: 117.w),
margin: EdgeInsets.symmetric(horizontal: 12.w),
decoration: showLine
? BoxDecoration(
border: BorderDirectional(
bottom: BorderSide(
color: lineColor ?? Styles.c_E8EAEF,
width: lineWidth ?? 1,
),
),
)
: null,
child: info.iconWidget != null
? Row(
mainAxisSize: MainAxisSize.min,
children: [
if (null != info.iconWidget)
Padding(
padding: EdgeInsets.only(right: 12.w),
child: info.iconWidget,
),
info.text.toText..style = (menuItemTextStyle ?? Styles.ts_0C1C33_17sp),
],
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (null != info.icon)
Padding(
padding: EdgeInsets.only(right: 12.w),
child: info.icon!.toImage
..width = (menuItemIconWidth ?? 20.w)
..height = (menuItemIconHeight ?? 20.h),
),
info.text.toText..style = (menuItemTextStyle ?? Styles.ts_0C1C33_17sp),
],
),
),
);
}
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
class QrScanBoxPainter extends CustomPainter {
final double animationValue;
final bool isForward;
final Color boxLineColor;
QrScanBoxPainter({required this.animationValue, required this.isForward, required this.boxLineColor});
@override
void paint(Canvas canvas, Size size) {
final borderRadius = const BorderRadius.all(Radius.circular(12)).toRRect(
Rect.fromLTWH(0, 0, size.width, size.height),
);
canvas.drawRRect(
borderRadius,
Paint()
..color = Colors.white54
..style = PaintingStyle.stroke
..strokeWidth = 1,
);
final borderPaint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 2;
final path = Path();
path.moveTo(0, 50);
path.lineTo(0, 12);
path.quadraticBezierTo(0, 0, 12, 0);
path.lineTo(50, 0);
path.moveTo(size.width - 50, 0);
path.lineTo(size.width - 12, 0);
path.quadraticBezierTo(size.width, 0, size.width, 12);
path.lineTo(size.width, 50);
path.moveTo(size.width, size.height - 50);
path.lineTo(size.width, size.height - 12);
path.quadraticBezierTo(size.width, size.height, size.width - 12, size.height);
path.lineTo(size.width - 50, size.height);
path.moveTo(50, size.height);
path.lineTo(12, size.height);
path.quadraticBezierTo(0, size.height, 0, size.height - 12);
path.lineTo(0, size.height - 50);
canvas.drawPath(path, borderPaint);
canvas.clipRRect(const BorderRadius.all(Radius.circular(12)).toRRect(Offset.zero & size));
final linePaint = Paint();
final lineSize = size.height * 0.45;
final leftPress = (size.height + lineSize) * animationValue - lineSize;
linePaint.style = PaintingStyle.stroke;
linePaint.shader = LinearGradient(
colors: [Colors.transparent, boxLineColor],
begin: isForward ? Alignment.topCenter : const Alignment(0.0, 2.0),
end: isForward ? const Alignment(0.0, 0.5) : Alignment.topCenter,
).createShader(Rect.fromLTWH(0, leftPress, size.width, lineSize));
for (int i = 0; i < size.height / 5; i++) {
canvas.drawLine(
Offset(
i * 5.0,
leftPress,
),
Offset(i * 5.0, leftPress + lineSize),
linePaint,
);
}
for (int i = 0; i < lineSize / 5; i++) {
canvas.drawLine(
Offset(0, leftPress + i * 5.0),
Offset(
size.width,
leftPress + i * 5.0,
),
linePaint,
);
}
}
@override
bool shouldRepaint(QrScanBoxPainter oldDelegate) => animationValue != oldDelegate.animationValue;
@override
bool shouldRebuildSemantics(QrScanBoxPainter oldDelegate) => animationValue != oldDelegate.animationValue;
}
@@ -0,0 +1,269 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:openim_common/openim_common.dart';
import 'package:scan/scan.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
import 'qr_scan_box.dart';
class QrcodeView extends StatefulWidget {
const QrcodeView({Key? key}) : super(key: key);
@override
State<QrcodeView> createState() => _QrcodeViewState();
}
class _QrcodeViewState extends State<QrcodeView> with TickerProviderStateMixin {
final _picker = ImagePicker();
Barcode? result;
QRViewController? controller;
final GlobalKey qrKey = GlobalKey(debugLabel: 'QR');
AnimationController? _animationController;
Timer? _timer;
var scanArea = 300.w;
var cutOutBottomOffset = 40.h;
void _upState() {
setState(() {});
}
@override
void reassemble() {
super.reassemble();
if (Platform.isAndroid) {
controller!.pauseCamera();
}
controller!.resumeCamera();
}
@override
void initState() {
_initAnimation();
super.initState();
}
@override
void dispose() {
controller?.dispose();
_clearAnimation();
super.dispose();
}
void _initAnimation() {
_animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 1000));
_animationController!
..addListener(_upState)
..addStatusListener((state) {
if (state == AnimationStatus.completed) {
_timer = Timer(const Duration(seconds: 1), () {
_animationController?.reverse(from: 1.0);
});
} else if (state == AnimationStatus.dismissed) {
_timer = Timer(const Duration(seconds: 1), () {
_animationController?.forward(from: 0.0);
});
}
});
_animationController!.forward(from: 0.0);
}
void _clearAnimation() {
_timer?.cancel();
if (_animationController != null) {
_animationController?.dispose();
_animationController = null;
}
}
void _readImage() {
Permissions.storage(() async {
final XFile? image = await _picker.pickImage(
source: ImageSource.gallery,
);
if (null != image) {
final result = await Scan.parse(image.path);
_parse(result);
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: <Widget>[
_buildQrView(context),
_scanOverlay(),
_buildBackView(),
_buildTools(),
],
),
);
}
Widget _buildTools() => Align(
alignment: Alignment.bottomCenter,
child: Container(
margin: EdgeInsets.only(bottom: 40.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => _readImage(),
child: Container(
width: 45.w,
height: 45.h,
alignment: Alignment.center,
child: Image.asset(
"assets/images/tool_img.png",
width: 35.w,
height: 35.h,
color: Colors.white54,
package: 'openim_common',
),
),
),
GestureDetector(
onTap: () async {
await controller?.toggleFlash();
setState(() {});
},
child: Container(
width: 80.w,
height: 80.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(40.r)),
border: Border.all(color: Colors.white30, width: 12.w),
),
alignment: Alignment.center,
child: FutureBuilder(
future: controller?.getFlashStatus(),
builder: (context, snapshot) {
return snapshot.data == true ? flashOpen : flashClose;
},
),
),
),
SizedBox(width: 45.w, height: 45.h),
],
),
),
);
final flashOpen = Image.asset(
"assets/images/tool_flashlight_open.png",
width: 35.w,
height: 35.h,
color: Colors.white,
package: 'openim_common',
);
final flashClose = Image.asset(
"assets/images/tool_flashlight_close.png",
width: 35.w,
height: 35.h,
color: Colors.white,
package: 'openim_common',
);
Widget _scanOverlay() => Align(
alignment: Alignment.center,
child: Container(
padding: EdgeInsets.only(bottom: cutOutBottomOffset * 2),
child: CustomPaint(
size: Size(scanArea, scanArea),
painter: QrScanBoxPainter(
boxLineColor: Colors.cyanAccent,
animationValue: _animationController?.value ?? 0,
isForward: _animationController?.status == AnimationStatus.forward,
),
),
),
);
Widget _buildBackView() => Positioned(
top: 44.h,
left: 22.w,
child: IconButton(
onPressed: () => Get.back(),
icon: ImageRes.backBlack.toImage
..width = 24.w
..height = 24.h
..color = Colors.white,
),
);
Widget _buildQrView(BuildContext context) {
return QRView(
key: qrKey,
onQRViewCreated: _onQRViewCreated,
overlay: QrScannerOverlayShape(
borderColor: Colors.white,
borderRadius: 12.r,
borderLength: 0,
borderWidth: 0,
cutOutBottomOffset: cutOutBottomOffset,
cutOutSize: scanArea,
),
onPermissionSet: (ctrl, p) => _onPermissionSet(context, ctrl, p),
);
}
void _onQRViewCreated(QRViewController controller) {
setState(() {
this.controller = controller;
});
if (Platform.isAndroid) {
controller.resumeCamera();
}
controller.scannedDataStream.asBroadcastStream().listen((scanData) {
if (!mounted) return;
_parse(scanData.code);
});
}
void _onPermissionSet(BuildContext context, QRViewController ctrl, bool p) {
if (!p) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('no Permission')),
);
}
}
void _parse(String? result) async {
if (null != result) {
controller?.pauseCamera();
if (result.startsWith(Config.friendScheme)) {
var userID = result.substring(Config.friendScheme.length);
PackageBridge.scanBridge?.scanOutUserID(userID);
} else if (result.startsWith(Config.groupScheme)) {
var groupID = result.substring(Config.groupScheme.length);
PackageBridge.scanBridge?.scanOutGroupID(groupID);
} else if (IMUtils.isUrlValid(result)) {
final uri = Uri.parse(Uri.encodeFull(result));
if (!await launchUrl(uri)) {
IMViews.showToast('无法识别!');
controller?.resumeCamera();
}
} else {
Get.back(result: result);
IMViews.showToast('扫码结果:$result');
}
} else {
Get.back();
IMViews.showToast('无法识别');
}
}
}
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
class RegisterBgView extends StatelessWidget {
const RegisterBgView({
Key? key,
required this.child,
}) : super(key: key);
final Widget child;
@override
Widget build(BuildContext context) => Material(
child: TouchCloseSoftKeyboard(
isGradientBg: true,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
54.verticalSpace,
Padding(
padding: EdgeInsets.only(left: 22.w),
child: ImageRes.backBlack.toImage
..width = 24.w
..height = 24.h
..onTap = () => Get.back(),
),
38.verticalSpace,
Padding(
padding: EdgeInsets.symmetric(horizontal: 32.w),
child: child,
),
],
),
),
),
);
}
@@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class RichTextInputBox extends StatefulWidget {
const RichTextInputBox({
Key? key,
required this.voiceRecordBar,
this.enabled = true,
this.controller,
this.focusNode,
this.onTapCamera,
this.showAlbumIcon = true,
this.showCameraIcon = true,
this.showCardIcon = true,
this.showFileIcon = true,
this.showLocationIcon = true,
this.onTapAlbum,
this.onTapCard,
this.onTapFile,
this.onTapLocation,
this.onSend,
}) : super(key: key);
final TextEditingController? controller;
final FocusNode? focusNode;
final Widget voiceRecordBar;
final bool enabled;
final bool showAlbumIcon;
final bool showCameraIcon;
final bool showFileIcon;
final bool showCardIcon;
final bool showLocationIcon;
final Function()? onTapAlbum;
final Function()? onTapCamera;
final Function()? onTapFile;
final Function()? onTapCard;
final Function()? onTapLocation;
final Function()? onSend;
@override
State<RichTextInputBox> createState() => _RichTextInputBoxState();
}
class _RichTextInputBoxState extends State<RichTextInputBox> {
bool _leftKeyboardButton = false;
@override
void initState() {
widget.focusNode?.addListener(() {
if (widget.focusNode!.hasFocus) {
setState(() {
_leftKeyboardButton = false;
});
}
});
super.initState();
}
double get _opacity => (widget.enabled ? 1 : .4);
focus() => FocusScope.of(context).requestFocus(widget.focusNode);
unfocus() => FocusScope.of(context).requestFocus(FocusNode());
void onTapSpeak() {
if (!widget.enabled) return;
Permissions.microphone(() => setState(() {
_leftKeyboardButton = true;
unfocus();
}));
}
void onTapLeftKeyboard() {
if (!widget.enabled) return;
setState(() {
_leftKeyboardButton = false;
focus();
});
}
@override
Widget build(BuildContext context) {
return Container(
color: Styles.c_F0F2F6,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 22.w,
children: [
if (widget.showAlbumIcon)
ImageRes.toolboxAlbum1.toImage
..width = 26.w
..height = 22.h
..opacity = _opacity
..onTap = widget.onTapAlbum,
if (widget.showCameraIcon)
ImageRes.toolboxCamera1.toImage
..width = 26.w
..height = 22.h
..opacity = _opacity
..onTap = widget.onTapCamera,
if (widget.showFileIcon)
ImageRes.toolboxFile1.toImage
..width = 26.w
..height = 22.h
..opacity = _opacity
..onTap = widget.onTapFile,
if (widget.showCardIcon)
ImageRes.toolboxCard1.toImage
..width = 26.w
..height = 22.h
..opacity = _opacity
..onTap = widget.onTapCard,
if (widget.showLocationIcon)
ImageRes.toolboxLocation1.toImage
..width = 16.w
..height = 22.h
..opacity = _opacity
..onTap = widget.onTapLocation,
],
),
if (widget.showAlbumIcon ||
widget.showCameraIcon ||
widget.showCardIcon ||
widget.showFileIcon ||
widget.showLocationIcon)
15.verticalSpace,
Row(
children: [
(_leftKeyboardButton
? (ImageRes.openKeyboard.toImage..onTap = onTapLeftKeyboard)
: (ImageRes.openVoice.toImage..onTap = onTapSpeak))
..width = 32.w
..height = 32.h
..opacity = _opacity,
12.horizontalSpace,
Expanded(
child: Stack(
children: [
Offstage(
offstage: _leftKeyboardButton,
child: _textFiled,
),
Offstage(
offstage: !_leftKeyboardButton,
child: widget.voiceRecordBar,
),
],
),
),
10.horizontalSpace,
if (!_leftKeyboardButton)
SizedBox(
width: 78.w,
child: Button(
text: StrRes.send,
height: 36.h,
enabled: widget.enabled,
onTap: widget.onSend,
),
),
],
),
],
),
);
}
Widget get _textFiled => Container(
decoration: BoxDecoration(
color: Styles.c_FFFFFF,
borderRadius: BorderRadius.circular(4.r),
),
child: ChatTextField(
controller: widget.controller,
focusNode: widget.focusNode,
style: Styles.ts_0C1C33_17sp,
atStyle: Styles.ts_0089FF_17sp,
enabled: true,
textAlign: TextAlign.start,
),
);
}
@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class SearchBox extends StatefulWidget {
const SearchBox({
Key? key,
this.controller,
this.focusNode,
this.textStyle,
this.hintStyle,
this.hintText,
this.searchIconColor,
this.backgroundColor,
this.searchIconHeight,
this.searchIconWidth,
this.margin,
this.padding,
this.enabled = false,
this.autofocus = false,
this.height,
this.onSubmitted,
this.onCleared,
this.onChanged,
}) : super(key: key);
final TextEditingController? controller;
final FocusNode? focusNode;
final TextStyle? hintStyle;
final TextStyle? textStyle;
final String? hintText;
final Color? searchIconColor;
final Color? backgroundColor;
final double? searchIconWidth;
final double? searchIconHeight;
final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? padding;
final bool enabled;
final bool autofocus;
final double? height;
final Function(String)? onSubmitted;
final Function()? onCleared;
final ValueChanged<String>? onChanged;
@override
State<SearchBox> createState() => _SearchBoxState();
}
class _SearchBoxState extends State<SearchBox> {
bool _showClearBtn = false;
@override
void initState() {
widget.controller?.addListener(() {
setState(() {
_showClearBtn = widget.controller!.text.isNotEmpty;
});
});
super.initState();
}
@override
Widget build(BuildContext context) {
return Container(
height: widget.height ?? 36.h,
margin: widget.margin,
padding: widget.padding ?? EdgeInsets.symmetric(horizontal: 14.w),
decoration: BoxDecoration(
color: widget.backgroundColor ?? Styles.c_8E9AB0_opacity15,
borderRadius: BorderRadius.circular(6.r),
),
child: Row(
children: [
ImageRes.searchGrey.toImage
..color = widget.searchIconColor
..width = widget.searchIconWidth ?? 18.w
..height = widget.searchIconHeight ?? 18.h,
8.horizontalSpace,
Expanded(
child: TextField(
controller: widget.controller,
focusNode: widget.focusNode,
style: widget.textStyle ?? Styles.ts_0C1C33_17sp,
autofocus: widget.autofocus,
enabled: widget.enabled,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: widget.hintText ?? StrRes.search,
hintStyle: widget.hintStyle ?? Styles.ts_8E9AB0_17sp,
isDense: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
),
onSubmitted: widget.onSubmitted,
onChanged: widget.onChanged,
),
),
if (_showClearBtn) _clearBtn,
],
),
);
}
Widget get _clearBtn => Visibility(
visible: _showClearBtn,
child: GestureDetector(
onTap: () {
widget.controller?.clear();
widget.onCleared?.call();
},
behavior: HitTestBehavior.translucent,
child: ImageRes.clearText.toImage
..width = widget.searchIconWidth ?? 24.w
..height = widget.searchIconHeight ?? 24.h
..color = widget.searchIconColor,
),
);
}
@@ -0,0 +1,97 @@
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
class SlideHeroWidget extends StatefulWidget {
const SlideHeroWidget({
super.key,
required this.child,
required this.tag,
required this.slidePagekey,
this.slideType = SlideType.onlyImage,
});
final Widget child;
final SlideType slideType;
final Object tag;
final GlobalKey<ExtendedImageSlidePageState> slidePagekey;
@override
State<SlideHeroWidget> createState() => _HeroWidgetState();
}
class _HeroWidgetState extends State<SlideHeroWidget> {
RectTween? _rectTween;
@override
Widget build(BuildContext context) {
return Hero(
tag: widget.tag,
createRectTween: (Rect? begin, Rect? end) {
_rectTween = RectTween(begin: begin, end: end);
return _rectTween!;
},
flightShuttleBuilder: (BuildContext flightContext, Animation<double> animation,
HeroFlightDirection flightDirection, BuildContext fromHeroContext, BuildContext toHeroContext) {
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;
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,
)
],
);
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,44 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class SyncStatusView extends StatelessWidget {
const SyncStatusView({
Key? key,
required this.isFailed,
required this.statusStr,
}) : super(key: key);
final bool isFailed;
final String statusStr;
@override
Widget build(BuildContext context) {
Logger.print('Sync Status View: $isFailed, $statusStr');
return Container(
padding: EdgeInsets.symmetric(vertical: 3.h, horizontal: 12.w),
decoration: BoxDecoration(
color: isFailed ? Styles.c_FFE1DD : Styles.c_F2F8FF,
borderRadius: BorderRadius.circular(6.r),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
isFailed
? (ImageRes.syncFailed.toImage
..width = 12.w
..height = 12.h)
: SizedBox(
width: 12.w,
height: 12.h,
child: CupertinoActivityIndicator(
color: Styles.c_0089FF,
radius: 6.r,
),
),
4.horizontalSpace,
statusStr.toText..style = (isFailed ? Styles.ts_FF381F_12sp : Styles.ts_0089FF_12sp),
],
),
);
}
}
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class CustomTabBar extends StatelessWidget {
const CustomTabBar({
Key? key,
required this.index,
required this.labels,
this.selectedStyle,
this.unselectedStyle,
this.indicatorColor,
this.indicatorHeight,
this.indicatorWidth,
this.onTabChanged,
this.height,
this.showUnderline = false,
}) : super(key: key);
final int index;
final List<String> labels;
final TextStyle? selectedStyle;
final TextStyle? unselectedStyle;
final double? height;
final Color? indicatorColor;
final double? indicatorHeight;
final double? indicatorWidth;
final Function(int index)? onTabChanged;
final bool showUnderline;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Styles.c_FFFFFF,
border: showUnderline
? BorderDirectional(
bottom: BorderSide(color: Styles.c_E8EAEF, width: 1))
: null,
),
child: Row(
children: List.generate(labels.length, (i) => _buildItemView(i)),
),
);
}
Widget _buildItemView(int i) => Expanded(
child: GestureDetector(
onTap: () {
if (null != onTabChanged) onTabChanged!(i);
},
behavior: HitTestBehavior.translucent,
child: SizedBox(
height: height ?? 42.h,
child: Stack(
children: [
Align(
alignment: Alignment.center,
child: labels.elementAt(i).toText
..style = Styles.ts_0C1C33_17sp,
),
Align(
alignment: Alignment.bottomCenter,
child: Visibility(
visible: i == index,
child: Container(
margin: EdgeInsets.only(bottom: 4.h),
decoration: BoxDecoration(
color: Styles.c_0C1C33,
borderRadius: BorderRadius.circular(1.5.r),
),
height: indicatorHeight ?? 3.h,
width: indicatorWidth ?? 20.w,
),
),
),
],
),
),
),
);
}
class TabInfo {
String label;
TextStyle styleSel;
TextStyle styleUnsel;
double iconHeight;
double iconWidth;
String iconSel;
String iconUnsel;
TabInfo({
required this.label,
required this.styleSel,
required this.styleUnsel,
required this.iconSel,
required this.iconUnsel,
required this.iconHeight,
required this.iconWidth,
});
}
@@ -0,0 +1,74 @@
import 'package:flutter/cupertino.dart';
class TextWithMidEllipsis extends StatelessWidget {
final String data;
final TextStyle style;
final TextAlign? textAlign;
final TextDirection textDirection;
final int endPartLength;
const TextWithMidEllipsis(
this.data, {
Key? key,
this.textAlign,
this.style = const TextStyle(),
this.endPartLength = 10,
}) : textDirection = TextDirection.ltr,
super(key: key);
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraint) {
if (constraint.maxWidth <= _textSize(data, style).width &&
data.length > endPartLength) {
var endPart = data.trim().substring(data.length - endPartLength);
return Row(
children: [
Flexible(
child: Text(
data.fixOverflowEllipsis,
style: style,
textAlign: textAlign,
overflow: TextOverflow.ellipsis,
textDirection: textDirection,
),
),
Text(
endPart,
style: style,
textDirection: textDirection,
textAlign: textAlign,
),
],
);
}
return Text(
data,
style: style,
textAlign: textAlign,
maxLines: 1,
textDirection: textDirection,
);
},
);
}
Size _textSize(String text, TextStyle style) {
final TextPainter textPainter = TextPainter(
text: TextSpan(
text: text,
style: style,
),
maxLines: 1,
textDirection: textDirection,
)..layout(minWidth: 0, maxWidth: double.infinity);
return textPainter.size;
}
}
extension AppStringExtension on String {
String get fixOverflowEllipsis => Characters(this)
.replaceAll(Characters(''), Characters('\u{200B}'))
.toString();
}
@@ -0,0 +1,178 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:openim_common/openim_common.dart';
enum TextModel { match, normal }
class MatchTextView extends StatelessWidget {
final String text;
final TextStyle? textStyle;
final TextStyle? matchTextStyle;
final InlineSpan? prefixSpan;
final TextAlign textAlign;
final TextOverflow overflow;
final int? maxLines;
final double textScaleFactor;
final List<MatchPattern> patterns;
final TextModel model;
final Function(String? text)? onVisibleTrulyText;
final bool isSupportCopy;
final FocusNode? copyFocusNode;
const MatchTextView(
{Key? key,
required this.text,
this.prefixSpan,
this.patterns = const <MatchPattern>[],
this.textAlign = TextAlign.left,
this.overflow = TextOverflow.clip,
this.textStyle,
this.matchTextStyle,
this.maxLines,
this.textScaleFactor = 1.0,
this.model = TextModel.match,
this.onVisibleTrulyText,
this.isSupportCopy = false,
this.copyFocusNode})
: super(key: key);
@override
Widget build(BuildContext context) {
final List<InlineSpan> children = <InlineSpan>[];
if (prefixSpan != null) children.add(prefixSpan!);
if (model == TextModel.normal) {
_normalModel(children);
} else {
_matchModel(children);
}
final textSpan = TextSpan(children: children);
onVisibleTrulyText?.call(textSpan.toPlainText());
var text = Text.rich(
textSpan,
textAlign: textAlign,
overflow: overflow,
maxLines: maxLines,
textScaler: TextScaler.linear(textScaleFactor),
);
return Container(
constraints: BoxConstraints(maxWidth: maxWidth),
child: isSupportCopy ? SelectionArea(focusNode: copyFocusNode, child: text) : text);
}
_normalModel(List<InlineSpan> children) {
children.add(TextSpan(text: text, style: textStyle));
}
_matchModel(List<InlineSpan> children) {
final mappingMap = <String, MatchPattern>{};
for (var e in patterns) {
if (e.type == PatternType.email) {
mappingMap[regexEmail] = e;
} else if (e.type == PatternType.mobile) {
mappingMap[regexMobile] = e;
} else if (e.type == PatternType.tel) {
mappingMap[regexTel] = e;
} else if (e.type == PatternType.url) {
mappingMap[regexUrl] = e;
} else {
mappingMap[e.pattern!] = e;
}
}
var regexEmoji = emojiFaces.keys.toList().join('|').replaceAll('[', '\\[').replaceAll(']', '\\]');
mappingMap[regexEmoji] = MatchPattern(type: PatternType.email);
String pattern;
if (mappingMap.length > 1) {
pattern = '(${mappingMap.keys.toList().join('|')})';
} else {
pattern = regexEmoji;
}
stripHtmlIfNeeded(text).splitMapJoin(
RegExp(pattern),
onMatch: (Match match) {
var matchText = match[0]!;
InlineSpan inlineSpan;
final mapping = mappingMap[matchText] ??
mappingMap[mappingMap.keys.firstWhere((element) {
final reg = RegExp(element);
return reg.hasMatch(matchText);
}, orElse: () {
return '';
})];
if (mapping != null) {
inlineSpan = TextSpan(
text: matchText.split('').join('\u200B'),
style: mapping.style ?? matchTextStyle ?? textStyle,
recognizer: mapping.onTap == null
? null
: (TapGestureRecognizer()
..onTap = () => mapping.onTap!(_getUrl(matchText, mapping.type), mapping.type)),
);
} else {
inlineSpan = TextSpan(text: matchText, style: textStyle);
}
children.add(inlineSpan);
return '';
},
onNonMatch: (text) {
children.add(TextSpan(text: text, style: textStyle));
return '';
},
);
}
_getUrl(String text, PatternType type) {
switch (type) {
case PatternType.url:
return text.substring(0, 4) == 'http' ? text : 'http://$text';
case PatternType.email:
return text.substring(0, 7) == 'mailto:' ? text : 'mailto:$text';
case PatternType.tel:
case PatternType.mobile:
return text.substring(0, 4) == 'tel:' ? text : 'tel:$text';
default:
return text;
}
}
static String stripHtmlIfNeeded(String text) {
return text.replaceAll(RegExp(r'<[^>]*>|&[^;]+;|[]'), ' ');
}
}
class MatchPattern {
PatternType type;
String? pattern;
TextStyle? style;
Function(String link, PatternType? type)? onTap;
MatchPattern({required this.type, this.pattern, this.style, this.onTap});
}
enum PatternType { email, mobile, tel, url, emoji, custom }
const regexEmail = r"\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b";
const regexUrl =
r"((http|https):\/\/)(([a-zA-Z0-9@:._\+-~#=]{2,256}\.[a-z]{2,6})|(\d{1,3}(\.\d{1,3}){3}))(:\d+)?(\/[-a-zA-Z0-9@:%_\+.~#?&//=]*)?";
const String regexMobile =
'^(\\+?86)?((13[0-9])|(14[57])|(15[0-35-9])|(16[2567])|(17[01235-8])|(18[0-9])|(19[1589]))\\d{8}\$';
const String regexTel = '^0\\d{2,3}[-]?\\d{7,8}';
const emojiFaces = <String, String>{'[]': '[]'};
@@ -0,0 +1,52 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:openim_common/openim_common.dart';
class TimingView extends StatefulWidget {
const TimingView({
Key? key,
required this.sec,
this.onFinished,
}) : super(key: key);
final int sec;
final Function()? onFinished;
@override
State<TimingView> createState() => _TimingViewState();
}
class _TimingViewState extends State<TimingView> {
Timer? _timer;
late int _sec;
@override
void initState() {
_sec = widget.sec;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) return;
--_sec;
if (_sec <= 0) {
_timer?.cancel();
_timer = null;
widget.onFinished?.call();
}
setState(() {
if (_sec <= 0) {
_sec = 0;
}
});
});
super.initState();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) =>
'$_sec s'.toText..style = Styles.ts_0089FF_12sp;
}
@@ -0,0 +1,286 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
class TitleBar extends StatelessWidget implements PreferredSizeWidget {
const TitleBar({
Key? key,
this.height,
this.left,
this.center,
this.right,
this.backgroundColor,
this.showUnderline = false,
}) : super(key: key);
final double? height;
final Widget? left;
final Widget? center;
final Widget? right;
final Color? backgroundColor;
final bool showUnderline;
@override
Widget build(BuildContext context) {
final mq = MediaQuery.of(context);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark,
child: Container(
color: backgroundColor ?? Styles.c_FFFFFF,
padding: EdgeInsets.only(top: mq.padding.top),
child: Container(
height: height,
padding: EdgeInsets.symmetric(horizontal: 16.w),
decoration: showUnderline
? BoxDecoration(
border: BorderDirectional(
bottom: BorderSide(color: Styles.c_E8EAEF, width: .5),
),
)
: null,
child: Row(
children: [
if (null != left) left!,
if (null != center) center!,
if (null != right) right!,
],
),
),
),
);
}
@override
Size get preferredSize => Size.fromHeight(height ?? 44.h);
TitleBar.conversation(
{super.key,
String? statusStr,
bool isFailed = false,
Function()? onScan,
Function()? onAddFriend,
Function()? onAddGroup,
Function()? onCreateGroup,
CustomPopupMenuController? popCtrl,
this.left})
: backgroundColor = null,
height = 62.h,
showUnderline = false,
center = null,
right = Row(
mainAxisSize: MainAxisSize.min,
children: [
PopButton(
popCtrl: popCtrl,
menus: [
PopMenuInfo(
text: StrRes.scan,
icon: ImageRes.popMenuScan,
onTap: onScan,
),
PopMenuInfo(
text: StrRes.addFriend,
icon: ImageRes.popMenuAddFriend,
onTap: onAddFriend,
),
PopMenuInfo(
text: StrRes.addGroup,
icon: ImageRes.popMenuAddGroup,
onTap: onAddGroup,
),
PopMenuInfo(
text: StrRes.createGroup,
icon: ImageRes.popMenuCreateGroup,
onTap: onCreateGroup,
),
],
child: ImageRes.addBlack.toImage
..width = 28.w
..height = 28.h /*..onTap = onClickAddBtn*/,
),
],
);
TitleBar.chat({
super.key,
String? title,
String? member,
String? subTitle,
bool showOnlineStatus = false,
bool isOnline = false,
bool isMultiModel = false,
bool showCallBtn = true,
bool isMuted = false,
Function()? onClickCallBtn,
Function()? onClickMoreBtn,
Function()? onCloseMultiModel,
}) : backgroundColor = null,
height = 48.h,
showUnderline = true,
center = Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (null != title)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
flex: 5,
child: Container(
child: title.trim().toText
..style = Styles.ts_0C1C33_17sp_semibold
..maxLines = 1
..overflow = TextOverflow.ellipsis
..textAlign = TextAlign.center,
)),
if (null != member)
Flexible(
flex: 2,
child: Container(
child: member.toText
..style = Styles.ts_0C1C33_17sp_semibold
..maxLines = 1))
],
),
if (subTitle?.isNotEmpty == true)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (showOnlineStatus)
Container(
width: 6.w,
height: 6.h,
margin: EdgeInsets.only(right: 4.w),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isOnline ? Styles.c_18E875 : Styles.c_8E9AB0,
),
),
subTitle!.toText..style = Styles.ts_8E9AB0_10sp,
],
),
],
)),
left = SizedBox(
width: showCallBtn ? 48.w : 24.w,
child: isMultiModel
? (StrRes.cancel.toText
..style = Styles.ts_0C1C33_17sp
..onTap = onCloseMultiModel)
: (ImageRes.backBlack.toImage
..width = 24.w
..height = 24.h
..onTap = (() => Get.back()))),
right = SizedBox(
width: 16.w + (showCallBtn ? 56.w : 28.w),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (showCallBtn)
ImageRes.callBack.toImage
..width = 28.w
..height = 28.h
..opacity = isMuted ? 0.4 : 1
..onTap = isMuted ? null : onClickCallBtn,
16.horizontalSpace,
ImageRes.moreBlack.toImage
..width = 28.w
..height = 28.h
..onTap = onClickMoreBtn,
],
));
TitleBar.back({
super.key,
String? title,
String? leftTitle,
TextStyle? titleStyle,
TextStyle? leftTitleStyle,
String? result,
Color? backgroundColor,
Color? backIconColor,
this.right,
this.showUnderline = false,
Function()? onTap,
}) : height = 44.h,
backgroundColor = backgroundColor ?? Styles.c_FFFFFF,
center = Expanded(
child: (title ?? '').toText
..style = (titleStyle ?? Styles.ts_0C1C33_17sp_semibold)
..textAlign = TextAlign.center),
left = GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onTap ?? (() => Get.back(result: result)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ImageRes.backBlack.toImage
..width = 24.w
..height = 24.h
..color = backIconColor,
if (null != leftTitle) leftTitle.toText..style = (leftTitleStyle ?? Styles.ts_0C1C33_17sp_semibold),
],
),
);
TitleBar.contacts({
super.key,
this.showUnderline = false,
Function()? onClickAddContacts,
}) : height = 44.h,
backgroundColor = Styles.c_FFFFFF,
center = Spacer(),
left = StrRes.contacts.toText..style = Styles.ts_0C1C33_20sp_semibold,
right = Row(
children: [
16.horizontalSpace,
ImageRes.addContacts.toImage
..width = 28.w
..height = 28.h
..onTap = onClickAddContacts,
],
);
TitleBar.workbench({
super.key,
this.showUnderline = false,
}) : height = 44.h,
backgroundColor = Styles.c_FFFFFF,
center = null,
left = StrRes.workbench.toText..style = Styles.ts_0C1C33_20sp_semibold,
right = null;
TitleBar.search({
super.key,
String? hintText,
TextEditingController? controller,
FocusNode? focusNode,
bool autofocus = true,
Function(String)? onSubmitted,
Function()? onCleared,
ValueChanged<String>? onChanged,
}) : height = 44.h,
backgroundColor = Styles.c_FFFFFF,
center = Expanded(
child: Container(
child: SearchBox(
enabled: true,
autofocus: autofocus,
hintText: hintText,
controller: controller,
focusNode: focusNode,
onSubmitted: onSubmitted,
onCleared: onCleared,
onChanged: onChanged,
)),
),
showUnderline = true,
right = null,
left = ImageRes.backBlack.toImage
..width = 24.w
..height = 24.h
..onTap = (() => Get.back());
}
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:openim_common/openim_common.dart';
class TouchCloseSoftKeyboard extends StatelessWidget {
final Widget child;
final Function? onTouch;
final bool isGradientBg;
const TouchCloseSoftKeyboard({
Key? key,
required this.child,
this.onTouch,
this.isGradientBg = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
FocusScope.of(context).requestFocus(FocusNode());
onTouch?.call();
},
child: isGradientBg
? Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Styles.c_0089FF_opacity10,
Styles.c_FFFFFF_opacity0,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: child,
)
: child,
);
}
}
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
class TransparentRoute extends PageRoute {
TransparentRoute({
required this.builder,
RouteSettings? settings,
}) : super(settings: settings, fullscreenDialog: false);
final WidgetBuilder builder;
@override
bool get opaque => false;
@override
Color? get barrierColor => null;
@override
String? get barrierLabel => null;
@override
bool get maintainState => true;
@override
Duration get transitionDuration => const Duration(milliseconds: 350);
@override
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
final result = builder(context);
return FadeTransition(
opacity: Tween<double>(begin: 0, end: 1).animate(animation),
child: Semantics(
scopesRoute: true,
explicitChildNodes: true,
child: result,
),
);
}
}
class TransparentPageRoute<T> extends PageRouteBuilder<T> {
TransparentPageRoute({
RouteSettings? settings,
required RoutePageBuilder pageBuilder,
RouteTransitionsBuilder transitionsBuilder = _defaultTransitionsBuilder,
Duration transitionDuration = const Duration(milliseconds: 150),
bool barrierDismissible = false,
Color? barrierColor,
String? barrierLabel,
bool maintainState = true,
}) : super(
settings: settings,
opaque: false,
pageBuilder: pageBuilder,
transitionsBuilder: transitionsBuilder,
transitionDuration: transitionDuration,
barrierDismissible: barrierDismissible,
barrierColor: barrierColor,
barrierLabel: barrierLabel,
maintainState: maintainState,
);
}
Widget _defaultTransitionsBuilder(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return FadeTransition(
opacity: CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
),
child: child,
);
}
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class UnreadCountView extends StatelessWidget {
const UnreadCountView({
Key? key,
this.count = 0,
this.size = 13,
this.margin,
}) : super(key: key);
final int count;
final double size;
final EdgeInsetsGeometry? margin;
@override
Widget build(BuildContext context) {
return Visibility(
visible: count > 0,
child: Container(
alignment: Alignment.center,
margin: margin,
padding: count > 99 ? EdgeInsets.symmetric(horizontal: 4.w) : null,
constraints: BoxConstraints(maxHeight: size, minWidth: size),
decoration: _decoration,
child: _text,
),
);
}
Text get _text => Text(
'${count > 99 ? '99+' : count}',
style: TextStyle(
fontSize: 8.sp,
color: const Color(0xFFFFFFFF),
),
textAlign: TextAlign.center,
);
Decoration get _decoration => BoxDecoration(
color: Styles.c_FF381F,
shape: count > 99 ? BoxShape.rectangle : BoxShape.circle,
borderRadius: count > 99 ? BorderRadius.circular(10.r) : null,
boxShadow: [
BoxShadow(
color: const Color(0x26C61B4A),
offset: Offset(1.15.w, 1.15.h),
blurRadius: 57.58.r,
),
BoxShadow(
color: const Color(0x1AC61B4A),
offset: Offset(2.3.w, 2.3.h),
blurRadius: 11.52.r,
),
BoxShadow(
color: const Color(0x0DC61B4A),
offset: Offset(4.61.w, 4.61.h),
blurRadius: 17.28.r,
),
],
);
}
@@ -0,0 +1,102 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
import 'package:sprintf/sprintf.dart';
class VerifyCodeSendButton extends StatefulWidget {
final int sec;
final Future<bool> Function() onTapCallback;
final bool auto;
const VerifyCodeSendButton({
Key? key,
this.sec = 60,
this.auto = true,
required this.onTapCallback,
}) : super(key: key);
@override
State<VerifyCodeSendButton> createState() => _VerifyCodeSendButtonState();
}
class _VerifyCodeSendButtonState extends State<VerifyCodeSendButton> {
Timer? _timer;
late int _seconds;
bool _firstTime = true;
@override
void dispose() {
_cancel();
super.dispose();
}
@override
void initState() {
super.initState();
_seconds = widget.sec;
if (widget.auto) {
_start();
} else {
_seconds = 0;
}
}
void _start() {
_firstTime = false;
_timer = Timer.periodic(1.seconds, (timer) {
if (!mounted) return;
if (_seconds == 0) {
_cancel();
setState(() {});
return;
}
_seconds--;
setState(() {});
});
}
void _cancel() {
if (null != _timer) {
_timer?.cancel();
_timer = null;
}
}
void _reset() {
if (_seconds != widget.sec) {
_seconds = widget.sec;
}
_cancel();
setState(() {});
}
void _restart() {
_reset();
_start();
}
@override
Widget build(BuildContext context) => _firstTime && !widget.auto
? (StrRes.sendVerificationCode.toText
..style = Styles.ts_0089FF_12sp
..onTap = () {
widget.onTapCallback().then((start) {
if (start) _restart();
});
})
: (_isEnabled
? (StrRes.resendVerificationCode.toText
..style = Styles.ts_0089FF_12sp
..onTap = () {
widget.onTapCallback().then((start) {
if (start) _restart();
});
})
: (sprintf(StrRes.verificationCodeTimingReminder, [_seconds]).toText..style = Styles.ts_8E9AB0_12sp));
bool get _isEnabled => _seconds == 0;
}
@@ -0,0 +1,386 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:common_utils/common_utils.dart';
import 'package:country_picker/country_picker.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:flutter_picker_plus/flutter_picker_plus.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:image_cropper/image_cropper.dart';
import 'package:image_picker/image_picker.dart';
import 'package:openim_common/openim_common.dart';
import 'package:pull_to_refresh_new/pull_to_refresh.dart';
import 'package:uuid/uuid.dart';
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
import 'package:wechat_camera_picker/wechat_camera_picker.dart';
class IMViews {
IMViews._();
static final ImagePicker _picker = ImagePicker();
static Future showToast(String msg, {Duration? duration}) {
if (msg.trim().isNotEmpty) {
return EasyLoading.showToast(msg, duration: duration);
} else {
return Future.value();
}
}
static Widget buildHeader([double distance = 60]) => WaterDropMaterialHeader(
backgroundColor: Styles.c_0089FF,
distance: distance,
);
static Widget buildFooter() => CustomFooter(
builder: (BuildContext context, LoadStatus? mode) {
Widget body;
if (mode == LoadStatus.idle) {
body = const CupertinoActivityIndicator();
} else if (mode == LoadStatus.loading) {
body = const CupertinoActivityIndicator();
} else if (mode == LoadStatus.failed) {
body = const CupertinoActivityIndicator();
} else if (mode == LoadStatus.canLoading) {
body = const CupertinoActivityIndicator();
} else {
body = const SizedBox();
}
return SizedBox(
height: 55.0,
child: Center(child: body),
);
},
);
static openIMCallSheet(
String label,
Function(int index) onTapSheetItem,
) {
return Get.bottomSheet(
BottomSheetView(
mainAxisAlignment: MainAxisAlignment.start,
items: [
SheetItem(
label: StrRes.callVoice,
icon: ImageRes.callVoice,
alignment: MainAxisAlignment.start,
onTap: () => onTapSheetItem.call(0),
),
SheetItem(
label: StrRes.callVideo,
icon: ImageRes.callVideo,
alignment: MainAxisAlignment.start,
onTap: () => onTapSheetItem.call(1),
),
],
),
);
}
static openIMGroupCallSheet(
String groupID,
Function(int index) onTapSheetItem,
) {
return Get.bottomSheet(
BottomSheetView(
mainAxisAlignment: MainAxisAlignment.start,
items: [
SheetItem(
label: StrRes.callVoice,
icon: ImageRes.callVoice,
onTap: () => onTapSheetItem.call(0),
),
SheetItem(
label: StrRes.callVideo,
icon: ImageRes.callVideo,
onTap: () => onTapSheetItem.call(1),
),
],
),
);
}
static void openPhotoSheet(
{Function(dynamic path, dynamic url)? onData,
bool crop = true,
bool toUrl = true,
bool fromGallery = true,
bool fromCamera = true,
List<SheetItem> items = const [],
int quality = 80}) {
bool allowSendImageTypeHelper(String? mimeType) {
final result = mimeType?.contains('png') == true || mimeType?.contains('jpeg') == true;
return result;
}
Future<bool> allowSendImageType(AssetEntity entity) async {
final mimeType = await entity.mimeTypeAsync;
return allowSendImageTypeHelper(mimeType);
}
Get.bottomSheet(
BottomSheetView(
items: [
...items,
if (fromGallery)
SheetItem(
label: StrRes.toolboxAlbum,
onTap: () async {
final List<AssetEntity>? assets = await AssetPicker.pickAssets(Get.context!,
pickerConfig: AssetPickerConfig(
requestType: RequestType.image,
maxAssets: 1,
selectPredicate: (_, entity, isSelected) async {
if (await allowSendImageType(entity)) {
return true;
}
IMViews.showToast(StrRes.supportsTypeHint);
return false;
}));
final file = await assets?.firstOrNull?.file;
if (file?.path != null) {
final map = await uCropPic(file!.path, crop: crop, toUrl: toUrl, quality: quality);
onData?.call(map['path'], map['url']);
}
},
),
if (fromCamera)
SheetItem(
label: StrRes.toolboxCamera,
onTap: () async {
final AssetEntity? entity = await CameraPicker.pickFromCamera(
Get.context!,
locale: Get.locale,
pickerConfig: CameraPickerConfig(
enableAudio: true,
enableRecording: true,
enableScaledPreview: false,
maximumRecordingDuration: 60.seconds,
onMinimumRecordDurationNotMet: () {
IMViews.showToast(StrRes.tapTooShort);
},
),
);
final file = await entity?.file;
if (file?.path != null) {
final map = await uCropPic(file!.path, crop: crop, toUrl: toUrl, quality: quality);
onData?.call(map['path'], map['url']);
}
},
),
],
),
);
}
static Future<Map<String, dynamic>> uCropPic(
String path, {
bool crop = true,
bool toUrl = true,
int quality = 80,
}) async {
CroppedFile? cropFile;
String? url;
if (crop && !path.endsWith('.gif')) {
cropFile = await IMUtils.uCrop(path);
if (cropFile == null) {
return {'path': null, 'url': null};
}
}
if (toUrl) {
String putID = const Uuid().v4();
dynamic result;
if (null != cropFile) {
Logger.print('-----------crop path: ${cropFile.path}');
result = await LoadingView.singleton.wrap(asyncFunction: () async {
final image = await IMUtils.compressImageAndGetFile(File(cropFile!.path), quality: quality);
return OpenIM.iMManager.uploadFile(
id: putID,
filePath: image!.path,
fileName: image.path.split('/').last,
);
});
} else {
Logger.print('-----------source path: $path');
result = await LoadingView.singleton.wrap(asyncFunction: () async {
final image = await IMUtils.compressImageAndGetFile(File(path), quality: quality);
return OpenIM.iMManager.uploadFile(
id: putID,
filePath: image!.path,
fileName: image.path,
);
});
}
if (result is String) {
url = jsonDecode(result)['url'];
Logger.print('url:$url');
}
}
return {'path': cropFile?.path ?? path, 'url': url};
}
static void openDownloadSheet(
String url, {
Function()? onDownload,
}) {
Get.bottomSheet(
BottomSheetView(
items: [
SheetItem(
label: StrRes.download,
onTap: () {
Permissions.storage(() => onDownload?.call());
},
),
],
),
barrierColor: Colors.transparent,
);
}
static TextSpan getTimelineTextSpan(int ms) {
int locTimeMs = DateTime.now().millisecondsSinceEpoch;
var languageCode = Get.locale?.languageCode ?? 'zh';
if (DateUtil.isToday(ms, locMs: locTimeMs)) {
return TextSpan(
text: languageCode == 'zh' ? '今天' : 'Today',
style: Styles.ts_0C1C33_17sp_medium,
);
}
if (DateUtil.isYesterdayByMs(ms, locTimeMs)) {
return TextSpan(
text: languageCode == 'zh' ? '昨天' : 'Yesterday',
style: Styles.ts_0C1C33_17sp_medium,
);
}
if (DateUtil.isWeek(ms, locMs: locTimeMs)) {
final weekday = DateUtil.getWeekdayByMs(ms, languageCode: languageCode);
if (weekday.contains('星期')) {
return TextSpan(
text: weekday.replaceAll('星期', ''),
style: Styles.ts_0C1C33_17sp_medium,
children: [
TextSpan(
text: '\n星期',
style: Styles.ts_0C1C33_12sp_medium,
),
],
);
}
return TextSpan(text: weekday, style: Styles.ts_0C1C33_17sp_medium);
}
final date = IMUtils.formatDateMs(ms, format: 'MM月dd');
final one = date.split('')[0];
final two = date.split('')[1];
return TextSpan(
text: '${int.parse(two)}',
style: Styles.ts_0C1C33_17sp_medium,
children: [
TextSpan(
text: '\n${int.parse(one)}${languageCode == 'zh' ? '' : ''}',
style: Styles.ts_0C1C33_12sp_medium,
),
],
);
}
static Future<String?> showCountryCodePicker() async {
Completer<String> completer = Completer();
showCountryPicker(
context: Get.context!,
showPhoneCode: true,
countryListTheme: CountryListThemeData(
flagSize: 25,
backgroundColor: Colors.white,
textStyle: TextStyle(fontSize: 16.sp, color: Colors.blueGrey),
bottomSheetHeight: 500.h,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8.0.r),
topRight: Radius.circular(8.0.r),
),
inputDecoration: InputDecoration(
labelText: StrRes.search,
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderSide: BorderSide(
color: const Color(0xFF8C98A8).withOpacity(0.2),
),
),
),
),
onSelect: (Country country) {
completer.complete("+${country.phoneCode}");
},
);
return completer.future;
}
static void showSinglePicker({
required String title,
required String description,
required dynamic pickerData,
bool isArray = false,
List<int>? selected,
Function(List<int> indexList, List valueList)? onConfirm,
}) {
Picker(
adapter: PickerDataAdapter<String>(
pickerData: pickerData,
isArray: isArray,
),
changeToFirst: true,
hideHeader: false,
containerColor: Styles.c_0089FF,
textStyle: Styles.ts_0C1C33_17sp,
selectedTextStyle: Styles.ts_0C1C33_17sp,
itemExtent: 45.h,
cancelTextStyle: Styles.ts_0C1C33_17sp,
confirmTextStyle: Styles.ts_0089FF_17sp,
cancelText: StrRes.cancel,
confirmText: StrRes.confirm,
selecteds: selected,
builderHeader: (_) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
alignment: Alignment.centerLeft,
margin: EdgeInsets.only(bottom: 7.h),
child: title.toText..style = Styles.ts_0C1C33_17sp,
),
description.toText..style = Styles.ts_8E9AB0_14sp,
],
),
selectionOverlay: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
border: BorderDirectional(
bottom: BorderSide(color: Styles.c_E8EAEF, width: 1),
top: BorderSide(color: Styles.c_E8EAEF, width: 1),
),
),
),
onConfirm: (Picker picker, List value) {
onConfirm?.call(picker.selecteds, picker.getSelectedValues());
},
).showDialog(Get.context!);
}
}