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:
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+43
@@ -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!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user