diff --git a/flutter_common/lib/calendarcalendar/calendar_choose_widget.dart b/flutter_common/lib/calendarcalendar/calendar_choose_widget.dart new file mode 100644 index 0000000..5a94536 --- /dev/null +++ b/flutter_common/lib/calendarcalendar/calendar_choose_widget.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_common/calendarcalendar/custom_calendar_range_picker_widget.dart'; +import 'package:flutter_common/calendarcalendar/custom_date_picker.dart'; +import 'package:flutter_common/utils/date_utils.dart'; +import 'package:flutter_common/utils/toast_utils.dart'; + +///公用区间日期选选择组件 +class CalendarChooseWidget extends StatefulWidget { + final Function? tapAction; + final int? intervalDays; //间隔天数 (包括选中第一天和选中的最后一天) + final DateTime? selectedDate; //默认选中日期 + final int? chooseIndex; + final double? fontSize; + final DateTimeUtilsType? dateTimeUtilsType; + final FontWeight? fontWeight; + final bool? onlyShow; + final Color? textColor; + final bool? isScafforrd; + + const CalendarChooseWidget({ + super.key, + this.tapAction, + this.intervalDays, + this.selectedDate, + this.chooseIndex = 0, + this.fontSize, + this.onlyShow, + this.fontWeight, + this.dateTimeUtilsType, + this.textColor, + this.isScafforrd, + }); + + @override + _CalendarChooseWidgetState createState() => _CalendarChooseWidgetState(); +} + +class _CalendarChooseWidgetState extends State { + ///开始时间 + DateTime? startTime = DateTime.now(); + + ///结束时间 + DateTime? endTime; + + ///选择的时间区间 + DateTime? rangeStartTime = DateTime.now(); + DateTime? rangeEndTime; + + ///日期选择方法 + onDateSelected(DateTime? startDate, DateTime? endDate) { + setState(() { + rangeStartTime = startDate; + rangeEndTime = endDate; + }); + } + + ///确定按钮 + onConfirm() { + if (widget.intervalDays != null && rangeEndTime != null) { + var difference = rangeEndTime!.difference(rangeStartTime!); + if (difference.inDays + 1 > widget.intervalDays!) { + ToastUtils.showToast(msg: "时间差不能大于${widget.intervalDays}天"); + } else { + changeDate(); + } + } else { + changeDate(); + } + } + + ///把选中的时间数据赋值给initialStartDate、initialEndDate,并且返回选中的时间 + changeDate() { + setState(() { + startTime = rangeStartTime; + endTime = rangeEndTime; + }); + widget.tapAction + ?.call({"startTime": startTime, "endTime": endTime ?? startTime}); + // Navigator.of(context).pop(); + } + + ///日期显示 + String get dealTimeString { + String? time = ""; + if (endTime == null) { + time = DateTimeUtils.dateTimeUtilsTool( + dateTime: startTime.toString(), + dateTimeUtilsType: + widget.dateTimeUtilsType ?? DateTimeUtilsType.yearMonthDay, + ); + } else if (endTime == startTime) { + time = DateTimeUtils.dateTimeUtilsTool( + dateTime: startTime.toString(), + dateTimeUtilsType: + widget.dateTimeUtilsType ?? DateTimeUtilsType.yearMonthDay, + ); + } else { + time = "${DateTimeUtils.dateTimeUtilsTool( + dateTime: startTime.toString(), + dateTimeUtilsType: + widget.dateTimeUtilsType ?? DateTimeUtilsType.yearMonthDay, + )} - ${DateTimeUtils.dateTimeUtilsTool( + dateTime: endTime.toString(), + dateTimeUtilsType: + widget.dateTimeUtilsType ?? DateTimeUtilsType.yearMonthDay, + )}"; + } + return time; + } + + ///日历弹窗 + onTapDate() { + if (widget.chooseIndex == 1) { + ToastUtils.showBottomSheet( + context: context, + title: '选择时间', + height: MediaQuery.of(context).size.height / 2, + isShowConfirm: true, + contentWidget: CustomDatePicker( + initialDate: DateTime.now(), + firstDate: DateTime(DateTime.now().year - 2), + lastDate: DateTime(DateTime.now().year + 2), + onDateChanged: (dateTime) { + rangeStartTime = dateTime; + rangeEndTime = dateTime; + }, + ), + onConfirm: onConfirm, + ); + } else { + ToastUtils.showBottomSheet( + context: context, + title: '选择时间', + height: MediaQuery.of(context).size.height / 2, + isShowConfirm: true, + contentWidget: CustomCalendarRangePickerWidget( + firstDate: DateTime(DateTime.now().year - 2), + lastDate: DateTime(DateTime.now().year + 2), + initialStartDate: startTime, + initialEndDate: endTime, + selectedDateDecoration: BoxDecoration( + color: const Color(0xff4D6FD5), + shape: BoxShape.rectangle, + borderRadius: BorderRadius.circular(4), + ), + rangeBackgroundColor: const Color(0xffEDF0FF), + weekendTextStyle: const TextStyle( + color: Color(0xff4D6FD5), + fontSize: 12, + ), + weekTextStyle: const TextStyle( + color: Color(0xff333333), + fontSize: 12, + ), + yearTextStyle: const TextStyle( + color: Color(0xff333333), + fontSize: 17, + fontWeight: FontWeight.bold, + ), + onDateSelected: onDateSelected, + ), + onConfirm: onConfirm, + ); + } + } + + @override + void initState() { + super.initState(); + if (widget.selectedDate != null) { + startTime = widget.selectedDate; + rangeStartTime = widget.selectedDate; + } + } + + @override + Widget build(BuildContext context) { + return widget.isScafforrd == true + ? Scaffold( + body: InkWell( + onTap: widget.onlyShow == true ? null : onTapDate, + child: Row( + children: [ + Text( + dealTimeString, + style: TextStyle( + color: widget.textColor ?? const Color(0xff1A1A1A), + fontSize: widget.fontSize ?? 16, + fontWeight: widget.fontWeight, + ), + ), + widget.onlyShow == true + ? const SizedBox() + : const Icon( + Icons.keyboard_arrow_down_rounded, + size: 15, + ) + ], + ), + ), + ) + : InkWell( + onTap: widget.onlyShow == true ? null : onTapDate, + child: Row( + children: [ + Text( + dealTimeString, + style: TextStyle( + color: widget.textColor ?? const Color(0xff1A1A1A), + fontSize: widget.fontSize ?? 16, + fontWeight: widget.fontWeight, + ), + ), + widget.onlyShow == true + ? const SizedBox() + : const Icon( + Icons.keyboard_arrow_down_rounded, + size: 15, + ) + ], + ), + ); + } +} diff --git a/flutter_common/lib/calendarcalendar/custom_calendar_range_picker_widget.dart b/flutter_common/lib/calendarcalendar/custom_calendar_range_picker_widget.dart new file mode 100644 index 0000000..6da89be --- /dev/null +++ b/flutter_common/lib/calendarcalendar/custom_calendar_range_picker_widget.dart @@ -0,0 +1,1093 @@ +import 'dart:math' as math; + +import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:sticky_headers/sticky_headers.dart'; + +///自定义日期区间选择 +class CustomCalendarRangePickerWidget extends StatefulWidget { + CustomCalendarRangePickerWidget( + {Key? key, + DateTime? initialStartDate, + DateTime? initialEndDate, + required DateTime firstDate, + required DateTime lastDate, + required this.onDateSelected, + DateTime? currentDate, + this.selectedDateDecoration, + this.currentDateDecoration, + this.rangeBackgroundColor, + this.weekendTextStyle, + this.weekTextStyle, + this.yearTextStyle}) + : initialStartDate = initialStartDate != null + ? DateUtils.dateOnly(initialStartDate) + : null, + initialEndDate = + initialEndDate != null ? DateUtils.dateOnly(initialEndDate) : null, + assert(firstDate != null), + assert(lastDate != null), + firstDate = DateUtils.dateOnly(firstDate), + lastDate = DateUtils.dateOnly(lastDate), + currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()), + super(key: key) { + assert( + this.initialStartDate == null || + this.initialEndDate == null || + !this.initialStartDate!.isAfter(initialEndDate!), + 'initialStartDate must be on or before initialEndDate.', + ); + assert( + !this.lastDate.isBefore(this.firstDate), + 'firstDate must be on or before lastDate.', + ); + } + + final DateTime? initialStartDate; + + final DateTime? initialEndDate; + + final DateTime firstDate; + + final DateTime lastDate; + + final DateTime currentDate; + + final Function(DateTime? startDate, DateTime? endDate) onDateSelected; + + ///开始时间和结束时间按钮样式 + final BoxDecoration? selectedDateDecoration; + + ///当前时间样式 + final BoxDecoration? currentDateDecoration; + + ///选中时间区间的背景颜色 + final Color? rangeBackgroundColor; + + ///周六周天字体颜色 + final TextStyle? weekendTextStyle; + + ///周字体颜色 + final TextStyle? weekTextStyle; + + ///年月的字体 + final TextStyle? yearTextStyle; + + @override + _CustomCalendarRangePickerWidgetState createState() => + _CustomCalendarRangePickerWidgetState(); +} + +class _CustomCalendarRangePickerWidgetState + extends State { + final GlobalKey _scrollViewKey = GlobalKey(); + DateTime? _startDate; + DateTime? _endDate; + int _initialMonthIndex = 0; + late ScrollController _controller; + late bool _showWeekBottomDivider; + + @override + void initState() { + super.initState(); + _controller = ScrollController(); + _controller.addListener(_scrollListener); + + _startDate = widget.initialStartDate; + _endDate = widget.initialEndDate; + + // Calculate the index for the initially displayed month. This is needed to + // divide the list of months into two `SliverList`s. + final DateTime initialDate = widget.initialStartDate ?? widget.currentDate; + if (!initialDate.isBefore(widget.firstDate) && + !initialDate.isAfter(widget.lastDate)) { + _initialMonthIndex = DateUtils.monthDelta(widget.firstDate, initialDate); + } + + _showWeekBottomDivider = _initialMonthIndex != 0; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _scrollListener() { + if (_controller.offset <= _controller.position.minScrollExtent) { + setState(() { + _showWeekBottomDivider = false; + }); + } else if (!_showWeekBottomDivider) { + setState(() { + _showWeekBottomDivider = true; + }); + } + } + + int get _numberOfMonths => + DateUtils.monthDelta(widget.firstDate, widget.lastDate) + 1; + + void _vibrate() { + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + HapticFeedback.vibrate(); + break; + default: + break; + } + } + + // This updates the selected date range using this logic: + // + // * From the unselected state, selecting one date creates the start date. + // * If the next selection is before the start date, reset date range and + // set the start date to that selection. + // * If the next selection is on or after the start date, set the end date + // to that selection. + // * After both start and end dates are selected, any subsequent selection + // resets the date range and sets start date to that selection. + void _updateSelection(DateTime date) { + _vibrate(); + setState(() { + if (_startDate != null && + _endDate == null && + !date.isBefore(_startDate!)) { + _endDate = date; + } else { + _startDate = date; + if (_endDate != null) { + _endDate = null; + } + } + }); + widget.onDateSelected(_startDate ?? null, _endDate ?? null); + } + + Widget _buildMonthItem( + BuildContext context, int index, bool beforeInitialMonth) { + final int monthIndex = beforeInitialMonth + ? _initialMonthIndex - index - 1 + : _initialMonthIndex + index; + final DateTime month = + DateUtils.addMonthsToMonthDate(widget.firstDate, monthIndex); + return _MonthItem( + selectedDateStart: _startDate, + selectedDateEnd: _endDate, + currentDate: widget.currentDate, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + displayedMonth: month, + onChanged: _updateSelection, + selectedDateDecoration: widget.selectedDateDecoration, + currentDateDecoration: widget.currentDateDecoration, + rangeBackgroundColor: widget.rangeBackgroundColor); + } + + ///年月展示 + Widget _yearMonthWidget(int index, bool beforeInitialMonth) { + final int monthIndex = beforeInitialMonth + ? _initialMonthIndex - index - 1 + : _initialMonthIndex + index; + final DateTime month = + DateUtils.addMonthsToMonthDate(widget.firstDate, monthIndex); + final MaterialLocalizations localizations = + MaterialLocalizations.of(context); + final ThemeData themeData = Theme.of(context); + final TextTheme textTheme = themeData.textTheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 7), + alignment: AlignmentDirectional.centerStart, + color: const Color(0xffF8F8F8), + child: ExcludeSemantics( + child: Text( + localizations.formatMonthYear(month), + style: widget.yearTextStyle ?? + textTheme.bodyMedium + ?.apply(color: themeData.colorScheme.onSurface), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + const Key sliverAfterKey = Key('sliverAfterKey'); + + return Container( + color: Colors.white, + child: Column( + children: [ + _DayHeaders( + weekendTextStyle: widget.weekendTextStyle, + weekTextStyle: widget.weekTextStyle, + ), + if (_showWeekBottomDivider) const Divider(height: 0), + Expanded( + child: _CalendarKeyboardNavigator( + firstDate: widget.firstDate, + lastDate: widget.lastDate, + initialFocusedDay: + _startDate ?? widget.initialStartDate ?? widget.currentDate, + // In order to prevent performance issues when displaying the + // correct initial month, 2 `SliverList`s are used to split the + // months. The first item in the second SliverList is the initial + // month to be displayed. + child: CustomScrollView( + key: _scrollViewKey, + controller: _controller, + center: sliverAfterKey, + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) => StickyHeader( + header: _yearMonthWidget(index, true), + content: _buildMonthItem(context, index, true), + ), + childCount: _initialMonthIndex, + ), + ), + SliverList( + key: sliverAfterKey, + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) => StickyHeader( + header: _yearMonthWidget(index, false), + content: _buildMonthItem(context, index, false), + ), + childCount: _numberOfMonths - _initialMonthIndex, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +///月份选择 +class _MonthItem extends StatefulWidget { + _MonthItem( + {Key? key, + required this.selectedDateStart, + required this.selectedDateEnd, + required this.currentDate, + required this.onChanged, + required this.firstDate, + required this.lastDate, + required this.displayedMonth, + this.dragStartBehavior = DragStartBehavior.start, + this.selectedDateDecoration, + this.currentDateDecoration, + this.rangeBackgroundColor}) + : assert(firstDate != null), + assert(lastDate != null), + assert(!firstDate.isAfter(lastDate)), + assert(selectedDateStart == null || + !selectedDateStart.isBefore(firstDate)), + assert(selectedDateEnd == null || !selectedDateEnd.isBefore(firstDate)), + assert( + selectedDateStart == null || !selectedDateStart.isAfter(lastDate)), + assert(selectedDateEnd == null || !selectedDateEnd.isAfter(lastDate)), + assert(selectedDateStart == null || + selectedDateEnd == null || + !selectedDateStart.isAfter(selectedDateEnd)), + assert(currentDate != null), + assert(onChanged != null), + assert(displayedMonth != null), + assert(dragStartBehavior != null), + super(key: key); + + /// The currently selected start date. + /// + /// This date is highlighted in the picker. + final DateTime? selectedDateStart; + + /// The currently selected end date. + /// + /// This date is highlighted in the picker. + final DateTime? selectedDateEnd; + + /// The current date at the time the picker is displayed. + final DateTime currentDate; + + /// Called when the user picks a day. + final ValueChanged onChanged; + + /// The earliest date the user is permitted to pick. + final DateTime firstDate; + + /// The latest date the user is permitted to pick. + final DateTime lastDate; + + /// The month whose days are displayed by this picker. + final DateTime displayedMonth; + + /// Determines the way that drag start behavior is handled. + /// + /// If set to [DragStartBehavior.start], the drag gesture used to scroll a + /// date picker wheel will begin upon the detection of a drag gesture. If set + /// to [DragStartBehavior.down] it will begin when a down event is first + /// detected. + /// + /// In general, setting this to [DragStartBehavior.start] will make drag + /// animation smoother and setting it to [DragStartBehavior.down] will make + /// drag behavior feel slightly more reactive. + /// + /// By default, the drag start behavior is [DragStartBehavior.start]. + /// + /// See also: + /// + /// * [DragGestureRecognizer.dragStartBehavior], which gives an example for + /// the different behaviors. + final DragStartBehavior dragStartBehavior; + + final BoxDecoration? selectedDateDecoration; + + final BoxDecoration? currentDateDecoration; + + final Color? rangeBackgroundColor; + + @override + _MonthItemState createState() => _MonthItemState(); +} + +class _MonthItemState extends State<_MonthItem> { + /// List of [FocusNode]s, one for each day of the month. + late List _dayFocusNodes; + + @override + void initState() { + super.initState(); + final int daysInMonth = DateUtils.getDaysInMonth( + widget.displayedMonth.year, widget.displayedMonth.month); + _dayFocusNodes = List.generate( + daysInMonth, + (int index) => + FocusNode(skipTraversal: true, debugLabel: 'Day ${index + 1}'), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Check to see if the focused date is in this month, if so focus it. + final DateTime? focusedDate = _FocusedDate.of(context)?.date; + if (focusedDate != null && + DateUtils.isSameMonth(widget.displayedMonth, focusedDate)) { + _dayFocusNodes[focusedDate.day - 1].requestFocus(); + } + } + + @override + void dispose() { + for (final FocusNode node in _dayFocusNodes) { + node.dispose(); + } + super.dispose(); + } + + Color _highlightColor(BuildContext context) { + return widget.rangeBackgroundColor ?? + Theme.of(context).colorScheme.primary.withOpacity(0.12); + } + + void _dayFocusChanged(bool focused) { + if (focused) { + final TraversalDirection? focusDirection = + _FocusedDate.of(context)?.scrollDirection; + if (focusDirection != null) { + ScrollPositionAlignmentPolicy policy = + ScrollPositionAlignmentPolicy.explicit; + switch (focusDirection) { + case TraversalDirection.up: + case TraversalDirection.left: + policy = ScrollPositionAlignmentPolicy.keepVisibleAtStart; + break; + case TraversalDirection.right: + case TraversalDirection.down: + policy = ScrollPositionAlignmentPolicy.keepVisibleAtEnd; + break; + } + Scrollable.ensureVisible( + primaryFocus!.context!, + duration: _monthScrollDuration, + alignmentPolicy: policy, + ); + } + } + } + + Widget _buildDayItem(BuildContext context, DateTime dayToBuild, + int firstDayOffset, int daysInMonth) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + final TextTheme textTheme = theme.textTheme; + final MaterialLocalizations localizations = + MaterialLocalizations.of(context); + final TextDirection textDirection = Directionality.of(context); + final Color highlightColor = _highlightColor(context); + final int day = dayToBuild.day; + + final bool isDisabled = dayToBuild.isAfter(widget.lastDate) || + dayToBuild.isBefore(widget.firstDate); + + BoxDecoration? decoration; + TextStyle? itemStyle = textTheme.bodyMedium; + + final bool isRangeSelected = + widget.selectedDateStart != null && widget.selectedDateEnd != null; + final bool isSelectedDayStart = widget.selectedDateStart != null && + dayToBuild.isAtSameMomentAs(widget.selectedDateStart!); + final bool isSelectedDayEnd = widget.selectedDateEnd != null && + dayToBuild.isAtSameMomentAs(widget.selectedDateEnd!); + final bool isInRange = isRangeSelected && + dayToBuild.isAfter(widget.selectedDateStart!) && + dayToBuild.isBefore(widget.selectedDateEnd!); + + _HighlightPainter? highlightPainter; + + if (isSelectedDayStart || isSelectedDayEnd) { + // The selected start and end dates gets a circle background + // highlight, and a contrasting text color. + itemStyle = textTheme.bodyMedium?.apply(color: colorScheme.onPrimary); + decoration = widget.selectedDateDecoration ?? + BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ); + + if (isRangeSelected && + widget.selectedDateStart != widget.selectedDateEnd) { + final _HighlightPainterStyle style = isSelectedDayStart + ? _HighlightPainterStyle.highlightTrailing + : _HighlightPainterStyle.highlightLeading; + highlightPainter = _HighlightPainter( + color: highlightColor, + style: style, + textDirection: textDirection, + ); + } + } else if (isInRange) { + // The days within the range get a light background highlight. + highlightPainter = _HighlightPainter( + color: highlightColor, + style: _HighlightPainterStyle.highlightAll, + textDirection: textDirection, + ); + } else if (isDisabled) { + itemStyle = textTheme.bodyMedium + ?.apply(color: colorScheme.onSurface.withOpacity(0.38)); + } else if (DateUtils.isSameDay(widget.currentDate, dayToBuild)) { + // The current day gets a different text color and a circle stroke + // border. + itemStyle = textTheme.bodyMedium?.apply(color: const Color(0xff4D6FD5)); + decoration = widget.currentDateDecoration ?? + BoxDecoration( + border: Border.all(color: const Color(0xff4D6FD5), width: 1), + shape: BoxShape.circle, + ); + } + + // We want the day of month to be spoken first irrespective of the + // locale-specific preferences or TextDirection. This is because + // an accessibility user is more likely to be interested in the + // day of month before the rest of the date, as they are looking + // for the day of month. To do that we prepend day of month to the + // formatted full date. + String semanticLabel = + '${localizations.formatDecimal(day)}, ${localizations.formatFullDate(dayToBuild)}'; + if (isSelectedDayStart) { + semanticLabel = + localizations.dateRangeStartDateSemanticLabel(semanticLabel); + } else if (isSelectedDayEnd) { + semanticLabel = + localizations.dateRangeEndDateSemanticLabel(semanticLabel); + } + + Widget dayWidget = Container( + decoration: decoration, + child: Center( + child: Semantics( + label: semanticLabel, + selected: isSelectedDayStart || isSelectedDayEnd, + child: ExcludeSemantics( + child: Text(localizations.formatDecimal(day), style: itemStyle), + ), + ), + ), + ); + + if (highlightPainter != null) { + dayWidget = CustomPaint( + painter: highlightPainter, + child: dayWidget, + ); + } + + if (!isDisabled) { + dayWidget = InkResponse( + focusNode: _dayFocusNodes[day - 1], + onTap: () => widget.onChanged(dayToBuild), + radius: _monthItemRowHeight / 2 + 4, + splashColor: colorScheme.primary.withOpacity(0.38), + onFocusChange: _dayFocusChanged, + child: dayWidget, + ); + } + + return dayWidget; + } + + Widget _buildEdgeContainer(BuildContext context, bool isHighlighted) { + return Container(color: isHighlighted ? _highlightColor(context) : null); + } + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + final TextTheme textTheme = themeData.textTheme; + final MaterialLocalizations localizations = + MaterialLocalizations.of(context); + final int year = widget.displayedMonth.year; + final int month = widget.displayedMonth.month; + final int daysInMonth = DateUtils.getDaysInMonth(year, month); + final int dayOffset = DateUtils.firstDayOffset(year, month, localizations); + final int weeks = ((daysInMonth + dayOffset) / DateTime.daysPerWeek).ceil(); + final double gridHeight = + weeks * _monthItemRowHeight + (weeks - 1) * _monthItemSpaceBetweenRows; + final List dayItems = []; + + for (int i = 0; true; i += 1) { + // 1-based day of month, e.g. 1-31 for January, and 1-29 for February on + // a leap year. + final int day = i - dayOffset + 1; + if (day > daysInMonth) break; + if (day < 1) { + dayItems.add(Container()); + } else { + final DateTime dayToBuild = DateTime(year, month, day); + final Widget dayItem = _buildDayItem( + context, + dayToBuild, + dayOffset, + daysInMonth, + ); + dayItems.add(dayItem); + } + } + + // Add the leading/trailing edge containers to each week in order to + // correctly extend the range highlight. + final List paddedDayItems = []; + for (int i = 0; i < weeks; i++) { + final int start = i * DateTime.daysPerWeek; + final int end = math.min( + start + DateTime.daysPerWeek, + dayItems.length, + ); + final List weekList = dayItems.sublist(start, end); + + final DateTime dateAfterLeadingPadding = + DateTime(year, month, start - dayOffset + 1); + // Only color the edge container if it is after the start date and + // on/before the end date. + final bool isLeadingInRange = !(dayOffset > 0 && i == 0) && + widget.selectedDateStart != null && + widget.selectedDateEnd != null && + dateAfterLeadingPadding.isAfter(widget.selectedDateStart!) && + !dateAfterLeadingPadding.isAfter(widget.selectedDateEnd!); + weekList.insert(0, _buildEdgeContainer(context, isLeadingInRange)); + + // Only add a trailing edge container if it is for a full week and not a + // partial week. + if (end < dayItems.length || + (end == dayItems.length && + dayItems.length % DateTime.daysPerWeek == 0)) { + final DateTime dateBeforeTrailingPadding = + DateTime(year, month, end - dayOffset); + // Only color the edge container if it is on/after the start date and + // before the end date. + final bool isTrailingInRange = widget.selectedDateStart != null && + widget.selectedDateEnd != null && + !dateBeforeTrailingPadding.isBefore(widget.selectedDateStart!) && + dateBeforeTrailingPadding.isBefore(widget.selectedDateEnd!); + weekList.add(_buildEdgeContainer(context, isTrailingInRange)); + } + + paddedDayItems.addAll(weekList); + } + + final double maxWidth = + MediaQuery.of(context).orientation == Orientation.landscape + ? _maxCalendarWidthLandscape + : _maxCalendarWidthPortrait; + return Column( + children: [ + Container( + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: gridHeight, + ), + child: GridView.custom( + physics: const NeverScrollableScrollPhysics(), + gridDelegate: _monthItemGridDelegate, + padding: EdgeInsets.zero, + childrenDelegate: SliverChildListDelegate( + paddedDayItems, + addRepaintBoundaries: false, + ), + ), + ), + const SizedBox(height: _monthItemFooterHeight), + ], + ); + } +} + +///变量 +const Duration _monthScrollDuration = Duration(milliseconds: 200); + +const double _monthItemFooterHeight = 12.0; +const double _monthItemRowHeight = 42.0; +const double _monthItemSpaceBetweenRows = 8.0; +const double _horizontalPadding = 8.0; +const double _maxCalendarWidthLandscape = 384.0; +const double _maxCalendarWidthPortrait = 480.0; + +class _FocusedDate extends InheritedWidget { + const _FocusedDate({ + Key? key, + required Widget child, + this.date, + this.scrollDirection, + }) : super(key: key, child: child); + + final DateTime? date; + final TraversalDirection? scrollDirection; + + @override + bool updateShouldNotify(_FocusedDate oldWidget) { + return !DateUtils.isSameDay(date, oldWidget.date) || + scrollDirection != oldWidget.scrollDirection; + } + + static _FocusedDate? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_FocusedDate>(); + } +} + +///日期格式 +class _MonthItemGridDelegate extends SliverGridDelegate { + const _MonthItemGridDelegate(); + + @override + SliverGridLayout getLayout(SliverConstraints constraints) { + final double tileWidth = + (constraints.crossAxisExtent - 2 * _horizontalPadding) / + DateTime.daysPerWeek; + return _MonthSliverGridLayout( + crossAxisCount: DateTime.daysPerWeek + 2, + dayChildWidth: tileWidth, + edgeChildWidth: _horizontalPadding, + reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), + ); + } + + @override + bool shouldRelayout(_MonthItemGridDelegate oldDelegate) => false; +} + +const _MonthItemGridDelegate _monthItemGridDelegate = _MonthItemGridDelegate(); + +class _MonthSliverGridLayout extends SliverGridLayout { + /// Creates a layout that uses equally sized and spaced tiles for each day of + /// the week and an additional edge tile for padding at the start and end of + /// each row. + /// + /// This is necessary to facilitate the painting of the range highlight + /// correctly. + const _MonthSliverGridLayout({ + required this.crossAxisCount, + required this.dayChildWidth, + required this.edgeChildWidth, + required this.reverseCrossAxis, + }) : assert(crossAxisCount != null && crossAxisCount > 0), + assert(dayChildWidth != null && dayChildWidth >= 0), + assert(edgeChildWidth != null && edgeChildWidth >= 0), + assert(reverseCrossAxis != null); + + /// The number of children in the cross axis. + final int crossAxisCount; + + /// The width in logical pixels of the day child widgets. + final double dayChildWidth; + + /// The width in logical pixels of the edge child widgets. + final double edgeChildWidth; + + /// Whether the children should be placed in the opposite order of increasing + /// coordinates in the cross axis. + /// + /// For example, if the cross axis is horizontal, the children are placed from + /// left to right when [reverseCrossAxis] is false and from right to left when + /// [reverseCrossAxis] is true. + /// + /// Typically set to the return value of [axisDirectionIsReversed] applied to + /// the [SliverConstraints.crossAxisDirection]. + final bool reverseCrossAxis; + + /// The number of logical pixels from the leading edge of one row to the + /// leading edge of the next row. + double get _rowHeight { + return _monthItemRowHeight + _monthItemSpaceBetweenRows; + } + + /// The height in logical pixels of the children widgets. + double get _childHeight { + return _monthItemRowHeight; + } + + @override + int getMinChildIndexForScrollOffset(double scrollOffset) { + return crossAxisCount * (scrollOffset ~/ _rowHeight); + } + + @override + int getMaxChildIndexForScrollOffset(double scrollOffset) { + final int mainAxisCount = (scrollOffset / _rowHeight).ceil(); + return math.max(0, crossAxisCount * mainAxisCount - 1); + } + + double _getCrossAxisOffset(double crossAxisStart, bool isPadding) { + if (reverseCrossAxis) { + return ((crossAxisCount - 2) * dayChildWidth + 2 * edgeChildWidth) - + crossAxisStart - + (isPadding ? edgeChildWidth : dayChildWidth); + } + return crossAxisStart; + } + + @override + SliverGridGeometry getGeometryForChildIndex(int index) { + final int adjustedIndex = index % crossAxisCount; + final bool isEdge = + adjustedIndex == 0 || adjustedIndex == crossAxisCount - 1; + final double crossAxisStart = + math.max(0, (adjustedIndex - 1) * dayChildWidth + edgeChildWidth); + + return SliverGridGeometry( + scrollOffset: (index ~/ crossAxisCount) * _rowHeight, + crossAxisOffset: _getCrossAxisOffset(crossAxisStart, isEdge), + mainAxisExtent: _childHeight, + crossAxisExtent: isEdge ? edgeChildWidth : dayChildWidth, + ); + } + + @override + double computeMaxScrollOffset(int childCount) { + assert(childCount >= 0); + final int mainAxisCount = ((childCount - 1) ~/ crossAxisCount) + 1; + final double mainAxisSpacing = _rowHeight - _childHeight; + return _rowHeight * mainAxisCount - mainAxisSpacing; + } +} + +///星期显示 +class _DayHeaders extends StatelessWidget { + /// Builds widgets showing abbreviated days of week. The first widget in the + /// returned list corresponds to the first day of week for the current locale. + /// + /// Examples: + /// + /// ``` + /// ┌ Sunday is the first day of week in the US (en_US) + /// | + /// S M T W T F S <-- the returned list contains these widgets + /// _ _ _ _ _ 1 2 + /// 3 4 5 6 7 8 9 + /// + /// ┌ But it's Monday in the UK (en_GB) + /// | + /// M T W T F S S <-- the returned list contains these widgets + /// _ _ _ _ 1 2 3 + /// 4 5 6 7 8 9 10 + /// ``` + + _DayHeaders({this.weekTextStyle, this.weekendTextStyle}); + + ///周六周天字体颜色 + final TextStyle? weekendTextStyle; + + ///周字体颜色 + final TextStyle? weekTextStyle; + + List _getDayHeaders( + TextStyle headerStyle, MaterialLocalizations localizations) { + final List result = []; + for (int i = localizations.firstDayOfWeekIndex; true; i = (i + 1) % 7) { + final String weekday = localizations.narrowWeekdays[i]; + result.add(ExcludeSemantics( + child: Center( + child: Text(weekday, + style: i == 0 || i == 6 + ? weekendTextStyle ?? weekTextStyle + : headerStyle)), + )); + if (i == (localizations.firstDayOfWeekIndex - 1) % 7) break; + } + return result; + } + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + final ColorScheme colorScheme = themeData.colorScheme; + final TextStyle textStyle = weekTextStyle ?? + themeData.textTheme.bodyMedium!.apply(color: colorScheme.onSurface); + final MaterialLocalizations localizations = + MaterialLocalizations.of(context); + final List labels = _getDayHeaders(textStyle, localizations); + + // Add leading and trailing containers for edges of the custom grid layout. + labels.insert(0, Container()); + labels.add(Container()); + + return Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).orientation == Orientation.landscape + ? _maxCalendarWidthLandscape + : _maxCalendarWidthPortrait, + maxHeight: _monthItemRowHeight, + ), + child: GridView.custom( + shrinkWrap: true, + gridDelegate: _monthItemGridDelegate, + childrenDelegate: SliverChildListDelegate( + labels, + addRepaintBoundaries: false, + ), + ), + ); + } +} + +///日期滚动控制组件 +class _CalendarKeyboardNavigator extends StatefulWidget { + const _CalendarKeyboardNavigator({ + Key? key, + required this.child, + required this.firstDate, + required this.lastDate, + required this.initialFocusedDay, + }) : super(key: key); + + final Widget child; + final DateTime firstDate; + final DateTime lastDate; + final DateTime initialFocusedDay; + + @override + _CalendarKeyboardNavigatorState createState() => + _CalendarKeyboardNavigatorState(); +} + +class _CalendarKeyboardNavigatorState + extends State<_CalendarKeyboardNavigator> { + late Map _shortcutMap; + late Map> _actionMap; + late FocusNode _dayGridFocus; + TraversalDirection? _dayTraversalDirection; + DateTime? _focusedDay; + + @override + void initState() { + super.initState(); + + _shortcutMap = { + LogicalKeySet(LogicalKeyboardKey.arrowLeft): + const DirectionalFocusIntent(TraversalDirection.left), + LogicalKeySet(LogicalKeyboardKey.arrowRight): + const DirectionalFocusIntent(TraversalDirection.right), + LogicalKeySet(LogicalKeyboardKey.arrowDown): + const DirectionalFocusIntent(TraversalDirection.down), + LogicalKeySet(LogicalKeyboardKey.arrowUp): + const DirectionalFocusIntent(TraversalDirection.up), + }; + _actionMap = >{ + NextFocusIntent: + CallbackAction(onInvoke: _handleGridNextFocus), + PreviousFocusIntent: CallbackAction( + onInvoke: _handleGridPreviousFocus), + DirectionalFocusIntent: CallbackAction( + onInvoke: _handleDirectionFocus), + }; + _dayGridFocus = FocusNode(debugLabel: 'Day Grid'); + } + + @override + void dispose() { + _dayGridFocus.dispose(); + super.dispose(); + } + + void _handleGridFocusChange(bool focused) { + setState(() { + if (focused) { + _focusedDay ??= widget.initialFocusedDay; + } + }); + } + + /// Move focus to the next element after the day grid. + void _handleGridNextFocus(NextFocusIntent intent) { + _dayGridFocus.requestFocus(); + _dayGridFocus.nextFocus(); + } + + /// Move focus to the previous element before the day grid. + void _handleGridPreviousFocus(PreviousFocusIntent intent) { + _dayGridFocus.requestFocus(); + _dayGridFocus.previousFocus(); + } + + /// Move the internal focus date in the direction of the given intent. + /// + /// This will attempt to move the focused day to the next selectable day in + /// the given direction. If the new date is not in the current month, then + /// the page view will be scrolled to show the new date's month. + /// + /// For horizontal directions, it will move forward or backward a day (depending + /// on the current [TextDirection]). For vertical directions it will move up and + /// down a week at a time. + void _handleDirectionFocus(DirectionalFocusIntent intent) { + assert(_focusedDay != null); + setState(() { + final DateTime? nextDate = + _nextDateInDirection(_focusedDay!, intent.direction); + if (nextDate != null) { + _focusedDay = nextDate; + _dayTraversalDirection = intent.direction; + } + }); + } + + static const Map _directionOffset = + { + TraversalDirection.up: -DateTime.daysPerWeek, + TraversalDirection.right: 1, + TraversalDirection.down: DateTime.daysPerWeek, + TraversalDirection.left: -1, + }; + + int _dayDirectionOffset( + TraversalDirection traversalDirection, TextDirection textDirection) { + // Swap left and right if the text direction if RTL + if (textDirection == TextDirection.rtl) { + if (traversalDirection == TraversalDirection.left) + traversalDirection = TraversalDirection.right; + else if (traversalDirection == TraversalDirection.right) + traversalDirection = TraversalDirection.left; + } + return _directionOffset[traversalDirection]!; + } + + DateTime? _nextDateInDirection(DateTime date, TraversalDirection direction) { + final TextDirection textDirection = Directionality.of(context); + final DateTime nextDate = DateUtils.addDaysToDate( + date, _dayDirectionOffset(direction, textDirection)); + if (!nextDate.isBefore(widget.firstDate) && + !nextDate.isAfter(widget.lastDate)) { + return nextDate; + } + return null; + } + + @override + Widget build(BuildContext context) { + return FocusableActionDetector( + shortcuts: _shortcutMap, + actions: _actionMap, + focusNode: _dayGridFocus, + onFocusChange: _handleGridFocusChange, + child: _FocusedDate( + date: _dayGridFocus.hasFocus ? _focusedDay : null, + scrollDirection: _dayGridFocus.hasFocus ? _dayTraversalDirection : null, + child: widget.child, + ), + ); + } +} + +///高度绘制 +class _HighlightPainter extends CustomPainter { + _HighlightPainter({ + required this.color, + this.style = _HighlightPainterStyle.none, + this.textDirection, + }); + + final Color color; + final _HighlightPainterStyle style; + final TextDirection? textDirection; + + @override + void paint(Canvas canvas, Size size) { + if (style == _HighlightPainterStyle.none) { + return; + } + + final Paint paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + final Rect rectLeft = Rect.fromLTWH(0, 0, size.width / 2, size.height); + final Rect rectRight = + Rect.fromLTWH(size.width / 2, 0, size.width / 2, size.height); + + switch (style) { + case _HighlightPainterStyle.highlightTrailing: + canvas.drawRect( + textDirection == TextDirection.ltr ? rectRight : rectLeft, + paint, + ); + break; + case _HighlightPainterStyle.highlightLeading: + canvas.drawRect( + textDirection == TextDirection.ltr ? rectLeft : rectRight, + paint, + ); + break; + case _HighlightPainterStyle.highlightAll: + canvas.drawRect( + Rect.fromLTWH(0, 0, size.width, size.height), + paint, + ); + break; + case _HighlightPainterStyle.none: + break; + } + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} + +enum _HighlightPainterStyle { + /// Paints nothing. + none, + + /// Paints a rectangle that occupies the leading half of the space. + highlightLeading, + + /// Paints a rectangle that occupies the trailing half of the space. + highlightTrailing, + + /// Paints a rectangle that occupies all available space. + highlightAll, +} diff --git a/flutter_common/lib/calendarcalendar/custom_date_picker.dart b/flutter_common/lib/calendarcalendar/custom_date_picker.dart new file mode 100644 index 0000000..761c178 --- /dev/null +++ b/flutter_common/lib/calendarcalendar/custom_date_picker.dart @@ -0,0 +1,1343 @@ +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +///日期弹窗显示 +class CustomDatePicker extends StatefulWidget { + CustomDatePicker( + {Key? key, + required DateTime initialDate, + required DateTime firstDate, + required DateTime lastDate, + DateTime? currentDate, + required this.onDateChanged, + this.onDisplayedMonthChanged, + this.initialCalendarMode = DatePickerMode.day, + this.selectableDayPredicate, + this.selectedDateDecoration, + this.currentDateDecoration, + this.weekendTextStyle, + this.weekTextStyle, + this.yearTextStyle}) + : assert(initialDate != null), + assert(firstDate != null), + assert(lastDate != null), + initialDate = DateUtils.dateOnly(initialDate), + firstDate = DateUtils.dateOnly(firstDate), + lastDate = DateUtils.dateOnly(lastDate), + currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()), + assert(onDateChanged != null), + assert(initialCalendarMode != null), + super(key: key) { + assert( + !this.lastDate.isBefore(this.firstDate), + 'lastDate ${this.lastDate} must be on or after firstDate ${this.firstDate}.', + ); + assert( + !this.initialDate.isBefore(this.firstDate), + 'initialDate ${this.initialDate} must be on or after firstDate ${this.firstDate}.', + ); + assert( + !this.initialDate.isAfter(this.lastDate), + 'initialDate ${this.initialDate} must be on or before lastDate ${this.lastDate}.', + ); + assert( + selectableDayPredicate == null || + selectableDayPredicate!(this.initialDate), + 'Provided initialDate ${this.initialDate} must satisfy provided selectableDayPredicate.', + ); + } + + /// The initially selected [DateTime] that the picker should display. + final DateTime initialDate; + + /// The earliest allowable [DateTime] that the user can select. + final DateTime firstDate; + + /// The latest allowable [DateTime] that the user can select. + final DateTime lastDate; + + /// The [DateTime] representing today. It will be highlighted in the day grid. + final DateTime currentDate; + + /// Called when the user selects a date in the picker. + final ValueChanged onDateChanged; + + /// Called when the user navigates to a new month/year in the picker. + final ValueChanged? onDisplayedMonthChanged; + + /// The initial display of the calendar picker. + final DatePickerMode initialCalendarMode; + + /// Function to provide full control over which dates in the calendar can be selected. + final SelectableDayPredicate? selectableDayPredicate; + + ///开始时间和结束时间按钮样式 + final BoxDecoration? selectedDateDecoration; + + ///当前时间样式 + final BoxDecoration? currentDateDecoration; + + ///周六周天字体颜色 + final TextStyle? weekendTextStyle; + + ///周字体颜色 + final TextStyle? weekTextStyle; + + ///年月的字体 + final TextStyle? yearTextStyle; + + @override + _CustomDatePickerState createState() => _CustomDatePickerState(); +} + +class _CustomDatePickerState extends State { + bool _announcedInitialDate = false; + late DatePickerMode _mode; + late DateTime _currentDisplayedMonthDate; + late DateTime _selectedDate; + final GlobalKey _monthPickerKey = GlobalKey(); + final GlobalKey _yearPickerKey = GlobalKey(); + late MaterialLocalizations _localizations; + late TextDirection _textDirection; + + @override + void initState() { + super.initState(); + _mode = widget.initialCalendarMode; + _currentDisplayedMonthDate = + DateTime(widget.initialDate.year, widget.initialDate.month); + _selectedDate = widget.initialDate; + } + + @override + void didUpdateWidget(CustomDatePicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialCalendarMode != oldWidget.initialCalendarMode) { + _mode = widget.initialCalendarMode; + } + if (!DateUtils.isSameDay(widget.initialDate, oldWidget.initialDate)) { + _currentDisplayedMonthDate = + DateTime(widget.initialDate.year, widget.initialDate.month); + _selectedDate = widget.initialDate; + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMaterialLocalizations(context)); + assert(debugCheckHasDirectionality(context)); + _localizations = MaterialLocalizations.of(context); + _textDirection = Directionality.of(context); + if (!_announcedInitialDate) { + _announcedInitialDate = true; + SemanticsService.announce( + _localizations.formatFullDate(_selectedDate), + _textDirection, + ); + } + } + + void _vibrate() { + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + HapticFeedback.vibrate(); + break; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + break; + } + } + + void _handleModeChanged(DatePickerMode mode) { + _vibrate(); + setState(() { + _mode = mode; + if (_mode == DatePickerMode.day) { + SemanticsService.announce( + _localizations.formatMonthYear(_selectedDate), + _textDirection, + ); + } else { + SemanticsService.announce( + _localizations.formatYear(_selectedDate), + _textDirection, + ); + } + }); + } + + void _handleMonthChanged(DateTime date) { + setState(() { + if (_currentDisplayedMonthDate.year != date.year || + _currentDisplayedMonthDate.month != date.month) { + _currentDisplayedMonthDate = DateTime(date.year, date.month); + widget.onDisplayedMonthChanged?.call(_currentDisplayedMonthDate); + } + }); + } + + void _handleYearChanged(DateTime value) { + _vibrate(); + + if (value.isBefore(widget.firstDate)) { + value = widget.firstDate; + } else if (value.isAfter(widget.lastDate)) { + value = widget.lastDate; + } + + setState(() { + _mode = DatePickerMode.day; + _handleMonthChanged(value); + }); + } + + void _handleDayChanged(DateTime value) { + _vibrate(); + setState(() { + _selectedDate = value; + widget.onDateChanged(_selectedDate); + }); + } + + Widget _buildPicker() { + switch (_mode) { + case DatePickerMode.day: + return _MonthPicker( + key: _monthPickerKey, + initialMonth: _currentDisplayedMonthDate, + currentDate: widget.currentDate, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + selectedDate: _selectedDate, + onChanged: _handleDayChanged, + onDisplayedMonthChanged: _handleMonthChanged, + selectableDayPredicate: widget.selectableDayPredicate, + weekTextStyle: widget.weekTextStyle, + weekendTextStyle: widget.weekendTextStyle, + selectedDateDecoration: widget.selectedDateDecoration, + currentDateDecoration: widget.currentDateDecoration, + ); + case DatePickerMode.year: + return Padding( + padding: const EdgeInsets.only(top: _subHeaderHeight), + child: _YearPicker( + key: _yearPickerKey, + currentDate: widget.currentDate, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + initialDate: _currentDisplayedMonthDate, + selectedDate: _selectedDate, + onChanged: _handleYearChanged, + selectedYearDecoration: widget.selectedDateDecoration, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMaterialLocalizations(context)); + assert(debugCheckHasDirectionality(context)); + return Stack( + children: [ + SizedBox( + height: _subHeaderHeight + _maxDayPickerHeight, + child: _buildPicker(), + ), + // Put the mode toggle button on top so that it won't be covered up by the _MonthPicker + _DatePickerModeToggleButton( + mode: _mode, + title: _localizations.formatMonthYear(_currentDisplayedMonthDate), + onTitlePressed: () { + // Toggle the day/year mode. + _handleModeChanged(_mode == DatePickerMode.day + ? DatePickerMode.year + : DatePickerMode.day); + }, + textStyle: widget.yearTextStyle, + ), + ], + ); + } +} + +///月份绘制 +class _MonthPicker extends StatefulWidget { + /// Creates a month picker. + _MonthPicker( + {Key? key, + required this.initialMonth, + required this.currentDate, + required this.firstDate, + required this.lastDate, + required this.selectedDate, + required this.onChanged, + required this.onDisplayedMonthChanged, + this.selectableDayPredicate, + this.weekTextStyle, + this.weekendTextStyle, + this.selectedDateDecoration, + this.currentDateDecoration}) + : assert(selectedDate != null), + assert(currentDate != null), + assert(onChanged != null), + assert(firstDate != null), + assert(lastDate != null), + assert(!firstDate.isAfter(lastDate)), + assert(!selectedDate.isBefore(firstDate)), + assert(!selectedDate.isAfter(lastDate)), + super(key: key); + + /// The initial month to display. + final DateTime initialMonth; + + /// The current date. + /// + /// This date is subtly highlighted in the picker. + final DateTime currentDate; + + /// The earliest date the user is permitted to pick. + /// + /// This date must be on or before the [lastDate]. + final DateTime firstDate; + + /// The latest date the user is permitted to pick. + /// + /// This date must be on or after the [firstDate]. + final DateTime lastDate; + + /// The currently selected date. + /// + /// This date is highlighted in the picker. + final DateTime selectedDate; + + /// Called when the user picks a day. + final ValueChanged onChanged; + + /// Called when the user navigates to a new month. + final ValueChanged onDisplayedMonthChanged; + + /// Optional user supplied predicate function to customize selectable days. + final SelectableDayPredicate? selectableDayPredicate; + + ///开始时间和结束时间按钮样式 + final BoxDecoration? selectedDateDecoration; + + ///当前时间样式 + final BoxDecoration? currentDateDecoration; + + ///周六周天字体颜色 + final TextStyle? weekendTextStyle; + + ///周字体颜色 + final TextStyle? weekTextStyle; + + @override + _MonthPickerState createState() => _MonthPickerState(); +} + +class _MonthPickerState extends State<_MonthPicker> { + final GlobalKey _pageViewKey = GlobalKey(); + late DateTime _currentMonth; + late DateTime _nextMonthDate; + late DateTime _previousMonthDate; + late PageController _pageController; + late MaterialLocalizations _localizations; + late TextDirection _textDirection; + Map? _shortcutMap; + Map>? _actionMap; + late FocusNode _dayGridFocus; + DateTime? _focusedDay; + + @override + void initState() { + super.initState(); + _currentMonth = widget.initialMonth; + _previousMonthDate = DateUtils.addMonthsToMonthDate(_currentMonth, -1); + _nextMonthDate = DateUtils.addMonthsToMonthDate(_currentMonth, 1); + _pageController = PageController( + initialPage: DateUtils.monthDelta(widget.firstDate, _currentMonth)); + _shortcutMap = const { + SingleActivator(LogicalKeyboardKey.arrowLeft): + DirectionalFocusIntent(TraversalDirection.left), + SingleActivator(LogicalKeyboardKey.arrowRight): + DirectionalFocusIntent(TraversalDirection.right), + SingleActivator(LogicalKeyboardKey.arrowDown): + DirectionalFocusIntent(TraversalDirection.down), + SingleActivator(LogicalKeyboardKey.arrowUp): + DirectionalFocusIntent(TraversalDirection.up), + }; + _actionMap = >{ + NextFocusIntent: + CallbackAction(onInvoke: _handleGridNextFocus), + PreviousFocusIntent: CallbackAction( + onInvoke: _handleGridPreviousFocus), + DirectionalFocusIntent: CallbackAction( + onInvoke: _handleDirectionFocus), + }; + _dayGridFocus = FocusNode(debugLabel: 'Day Grid'); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _localizations = MaterialLocalizations.of(context); + _textDirection = Directionality.of(context); + } + + @override + void didUpdateWidget(_MonthPicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialMonth != oldWidget.initialMonth && + widget.initialMonth != _currentMonth) { + // We can't interrupt this widget build with a scroll, so do it next frame + WidgetsBinding.instance!.addPostFrameCallback( + (Duration timeStamp) => _showMonth(widget.initialMonth, jump: true), + ); + } + } + + @override + void dispose() { + _pageController.dispose(); + _dayGridFocus.dispose(); + super.dispose(); + } + + void _handleDateSelected(DateTime selectedDate) { + _focusedDay = selectedDate; + widget.onChanged(selectedDate); + } + + void _handleMonthPageChanged(int monthPage) { + setState(() { + final DateTime monthDate = + DateUtils.addMonthsToMonthDate(widget.firstDate, monthPage); + if (!DateUtils.isSameMonth(_currentMonth, monthDate)) { + _currentMonth = DateTime(monthDate.year, monthDate.month); + _previousMonthDate = DateUtils.addMonthsToMonthDate(_currentMonth, -1); + _nextMonthDate = DateUtils.addMonthsToMonthDate(_currentMonth, 1); + widget.onDisplayedMonthChanged(_currentMonth); + if (_focusedDay != null && + !DateUtils.isSameMonth(_focusedDay, _currentMonth)) { + // We have navigated to a new month with the grid focused, but the + // focused day is not in this month. Choose a new one trying to keep + // the same day of the month. + _focusedDay = _focusableDayForMonth(_currentMonth, _focusedDay!.day); + } + } + }); + } + + /// Returns a focusable date for the given month. + /// + /// If the preferredDay is available in the month it will be returned, + /// otherwise the first selectable day in the month will be returned. If + /// no dates are selectable in the month, then it will return null. + DateTime? _focusableDayForMonth(DateTime month, int preferredDay) { + final int daysInMonth = DateUtils.getDaysInMonth(month.year, month.month); + + // Can we use the preferred day in this month? + if (preferredDay <= daysInMonth) { + final DateTime newFocus = DateTime(month.year, month.month, preferredDay); + if (_isSelectable(newFocus)) return newFocus; + } + + // Start at the 1st and take the first selectable date. + for (int day = 1; day <= daysInMonth; day++) { + final DateTime newFocus = DateTime(month.year, month.month, day); + if (_isSelectable(newFocus)) return newFocus; + } + return null; + } + + /// Navigate to the next month. + void _handleNextMonth() { + if (!_isDisplayingLastMonth) { + SemanticsService.announce( + _localizations.formatMonthYear(_nextMonthDate), + _textDirection, + ); + _pageController.nextPage( + duration: _monthScrollDuration, + curve: Curves.ease, + ); + } + } + + /// Navigate to the previous month. + void _handlePreviousMonth() { + if (!_isDisplayingFirstMonth) { + SemanticsService.announce( + _localizations.formatMonthYear(_previousMonthDate), + _textDirection, + ); + _pageController.previousPage( + duration: _monthScrollDuration, + curve: Curves.ease, + ); + } + } + + /// Navigate to the given month. + void _showMonth(DateTime month, {bool jump = false}) { + final int monthPage = DateUtils.monthDelta(widget.firstDate, month); + if (jump) { + _pageController.jumpToPage(monthPage); + } else { + _pageController.animateToPage( + monthPage, + duration: _monthScrollDuration, + curve: Curves.ease, + ); + } + } + + /// True if the earliest allowable month is displayed. + bool get _isDisplayingFirstMonth { + return !_currentMonth.isAfter( + DateTime(widget.firstDate.year, widget.firstDate.month), + ); + } + + /// True if the latest allowable month is displayed. + bool get _isDisplayingLastMonth { + return !_currentMonth.isBefore( + DateTime(widget.lastDate.year, widget.lastDate.month), + ); + } + + /// Handler for when the overall day grid obtains or loses focus. + void _handleGridFocusChange(bool focused) { + setState(() { + if (focused && _focusedDay == null) { + if (DateUtils.isSameMonth(widget.selectedDate, _currentMonth)) { + _focusedDay = widget.selectedDate; + } else if (DateUtils.isSameMonth(widget.currentDate, _currentMonth)) { + _focusedDay = + _focusableDayForMonth(_currentMonth, widget.currentDate.day); + } else { + _focusedDay = _focusableDayForMonth(_currentMonth, 1); + } + } + }); + } + + /// Move focus to the next element after the day grid. + void _handleGridNextFocus(NextFocusIntent intent) { + _dayGridFocus.requestFocus(); + _dayGridFocus.nextFocus(); + } + + /// Move focus to the previous element before the day grid. + void _handleGridPreviousFocus(PreviousFocusIntent intent) { + _dayGridFocus.requestFocus(); + _dayGridFocus.previousFocus(); + } + + /// Move the internal focus date in the direction of the given intent. + /// + /// This will attempt to move the focused day to the next selectable day in + /// the given direction. If the new date is not in the current month, then + /// the page view will be scrolled to show the new date's month. + /// + /// For horizontal directions, it will move forward or backward a day (depending + /// on the current [TextDirection]). For vertical directions it will move up and + /// down a week at a time. + void _handleDirectionFocus(DirectionalFocusIntent intent) { + assert(_focusedDay != null); + setState(() { + final DateTime? nextDate = + _nextDateInDirection(_focusedDay!, intent.direction); + if (nextDate != null) { + _focusedDay = nextDate; + if (!DateUtils.isSameMonth(_focusedDay, _currentMonth)) { + _showMonth(_focusedDay!); + } + } + }); + } + + static const Map _directionOffset = + { + TraversalDirection.up: -DateTime.daysPerWeek, + TraversalDirection.right: 1, + TraversalDirection.down: DateTime.daysPerWeek, + TraversalDirection.left: -1, + }; + + int _dayDirectionOffset( + TraversalDirection traversalDirection, TextDirection textDirection) { + // Swap left and right if the text direction if RTL + if (textDirection == TextDirection.rtl) { + if (traversalDirection == TraversalDirection.left) + traversalDirection = TraversalDirection.right; + else if (traversalDirection == TraversalDirection.right) + traversalDirection = TraversalDirection.left; + } + return _directionOffset[traversalDirection]!; + } + + DateTime? _nextDateInDirection(DateTime date, TraversalDirection direction) { + final TextDirection textDirection = Directionality.of(context); + DateTime nextDate = DateUtils.addDaysToDate( + date, _dayDirectionOffset(direction, textDirection)); + while (!nextDate.isBefore(widget.firstDate) && + !nextDate.isAfter(widget.lastDate)) { + if (_isSelectable(nextDate)) { + return nextDate; + } + nextDate = DateUtils.addDaysToDate( + nextDate, _dayDirectionOffset(direction, textDirection)); + } + return null; + } + + bool _isSelectable(DateTime date) { + return widget.selectableDayPredicate == null || + widget.selectableDayPredicate!.call(date); + } + + Widget _buildItems(BuildContext context, int index) { + final DateTime month = + DateUtils.addMonthsToMonthDate(widget.firstDate, index); + return _DayPicker( + key: ValueKey(month), + selectedDate: widget.selectedDate, + currentDate: widget.currentDate, + onChanged: _handleDateSelected, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + displayedMonth: month, + selectableDayPredicate: widget.selectableDayPredicate, + weekTextStyle: widget.weekTextStyle, + weekendTextStyle: widget.weekendTextStyle, + selectedDateDecoration: widget.selectedDateDecoration, + currentDateDecoration: widget.currentDateDecoration, + ); + } + + @override + Widget build(BuildContext context) { + final String previousTooltipText = + '${_localizations.previousMonthTooltip} ${_localizations.formatMonthYear(_previousMonthDate)}'; + final String nextTooltipText = + '${_localizations.nextMonthTooltip} ${_localizations.formatMonthYear(_nextMonthDate)}'; + final Color controlColor = + Theme.of(context).colorScheme.onSurface.withOpacity(0.60); + + return Semantics( + child: Column( + children: [ + Container( + padding: const EdgeInsetsDirectional.only(start: 16, end: 4), + height: _subHeaderHeight, + child: Row( + children: [ + const Spacer(), + IconButton( + icon: const Icon(Icons.chevron_left), + color: controlColor, + tooltip: _isDisplayingFirstMonth ? null : previousTooltipText, + onPressed: + _isDisplayingFirstMonth ? null : _handlePreviousMonth, + ), + IconButton( + icon: const Icon(Icons.chevron_right), + color: controlColor, + tooltip: _isDisplayingLastMonth ? null : nextTooltipText, + onPressed: _isDisplayingLastMonth ? null : _handleNextMonth, + ), + ], + ), + ), + Expanded( + child: FocusableActionDetector( + shortcuts: _shortcutMap, + actions: _actionMap, + focusNode: _dayGridFocus, + onFocusChange: _handleGridFocusChange, + child: _FocusedDate( + date: _dayGridFocus.hasFocus ? _focusedDay : null, + child: PageView.builder( + key: _pageViewKey, + controller: _pageController, + itemBuilder: _buildItems, + itemCount: + DateUtils.monthDelta(widget.firstDate, widget.lastDate) + + 1, + scrollDirection: Axis.vertical, + onPageChanged: _handleMonthPageChanged, + ), + ), + ), + ), + ], + ), + ); + } +} + +///年月时间按钮展示 +class _DatePickerModeToggleButton extends StatefulWidget { + const _DatePickerModeToggleButton( + {required this.mode, + required this.title, + required this.onTitlePressed, + this.textStyle}); + + /// The current display of the calendar picker. + final DatePickerMode mode; + + /// The text that displays the current month/year being viewed. + final String title; + + /// The callback when the title is pressed. + final VoidCallback onTitlePressed; + + final TextStyle? textStyle; + + @override + _DatePickerModeToggleButtonState createState() => + _DatePickerModeToggleButtonState(); +} + +class _DatePickerModeToggleButtonState + extends State<_DatePickerModeToggleButton> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + value: widget.mode == DatePickerMode.year ? 0.5 : 0, + upperBound: 0.5, + duration: const Duration(milliseconds: 200), + vsync: this, + ); + } + + @override + void didUpdateWidget(_DatePickerModeToggleButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.mode == widget.mode) { + return; + } + + if (widget.mode == DatePickerMode.year) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final TextTheme textTheme = Theme.of(context).textTheme; + final Color controlColor = colorScheme.onSurface.withOpacity(0.60); + + return Container( + padding: const EdgeInsetsDirectional.only(start: 16, end: 4), + height: _subHeaderHeight, + child: Row( + children: [ + Flexible( + child: Semantics( + label: MaterialLocalizations.of(context).selectYearSemanticsLabel, + excludeSemantics: true, + button: true, + child: SizedBox( + height: _subHeaderHeight, + child: InkWell( + onTap: widget.onTitlePressed, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Flexible( + child: Text( + widget.title, + overflow: TextOverflow.ellipsis, + style: widget.textStyle ?? + textTheme.titleMedium?.copyWith( + color: controlColor, + ), + ), + ), + RotationTransition( + turns: _controller, + child: Icon( + Icons.arrow_drop_down, + color: controlColor, + ), + ), + ], + ), + ), + ), + ), + ), + ), + if (widget.mode == DatePickerMode.day) + // Give space for the prev/next month buttons that are underneath this row + const SizedBox(width: _monthNavButtonsWidth), + ], + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} + +///全局变量 +const double _subHeaderHeight = 52.0; +const Duration _monthScrollDuration = Duration(milliseconds: 200); +const double _dayPickerRowHeight = 42.0; +const double _monthPickerHorizontalPadding = 8.0; +const _DayPickerGridDelegate _dayPickerGridDelegate = _DayPickerGridDelegate(); +const int _maxDayPickerRowCount = 6; +const double _maxDayPickerHeight = + _dayPickerRowHeight * (_maxDayPickerRowCount + 1); +const double _monthNavButtonsWidth = 108.0; +const double _yearPickerPadding = 16.0; + +const int _yearPickerColumnCount = 3; +const double _yearPickerRowHeight = 52.0; +const double _yearPickerRowSpacing = 8.0; + +class _FocusedDate extends InheritedWidget { + const _FocusedDate({ + Key? key, + required Widget child, + this.date, + }) : super(key: key, child: child); + + final DateTime? date; + + @override + bool updateShouldNotify(_FocusedDate oldWidget) { + return !DateUtils.isSameDay(date, oldWidget.date); + } + + static DateTime? of(BuildContext context) { + final _FocusedDate? focusedDate = + context.dependOnInheritedWidgetOfExactType<_FocusedDate>(); + return focusedDate?.date; + } +} + +///绘制日 +class _DayPicker extends StatefulWidget { + /// Creates a day picker. + _DayPicker( + {Key? key, + required this.currentDate, + required this.displayedMonth, + required this.firstDate, + required this.lastDate, + required this.selectedDate, + required this.onChanged, + this.selectableDayPredicate, + this.weekTextStyle, + this.weekendTextStyle, + this.currentDateDecoration, + this.selectedDateDecoration}) + : assert(currentDate != null), + assert(displayedMonth != null), + assert(firstDate != null), + assert(lastDate != null), + assert(selectedDate != null), + assert(onChanged != null), + assert(!firstDate.isAfter(lastDate)), + assert(!selectedDate.isBefore(firstDate)), + assert(!selectedDate.isAfter(lastDate)), + super(key: key); + + /// The currently selected date. + /// + /// This date is highlighted in the picker. + final DateTime selectedDate; + + /// The current date at the time the picker is displayed. + final DateTime currentDate; + + /// Called when the user picks a day. + final ValueChanged onChanged; + + /// The earliest date the user is permitted to pick. + /// + /// This date must be on or before the [lastDate]. + final DateTime firstDate; + + /// The latest date the user is permitted to pick. + /// + /// This date must be on or after the [firstDate]. + final DateTime lastDate; + + /// The month whose days are displayed by this picker. + final DateTime displayedMonth; + + /// Optional user supplied predicate function to customize selectable days. + final SelectableDayPredicate? selectableDayPredicate; + + ///开始时间和结束时间按钮样式 + final BoxDecoration? selectedDateDecoration; + + ///当前时间样式 + final BoxDecoration? currentDateDecoration; + + ///周六周天字体颜色 + final TextStyle? weekendTextStyle; + + ///周字体颜色 + final TextStyle? weekTextStyle; + + @override + _DayPickerState createState() => _DayPickerState(); +} + +class _DayPickerState extends State<_DayPicker> { + /// List of [FocusNode]s, one for each day of the month. + late List _dayFocusNodes; + + @override + void initState() { + super.initState(); + final int daysInMonth = DateUtils.getDaysInMonth( + widget.displayedMonth.year, widget.displayedMonth.month); + _dayFocusNodes = List.generate( + daysInMonth, + (int index) => + FocusNode(skipTraversal: true, debugLabel: 'Day ${index + 1}'), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Check to see if the focused date is in this month, if so focus it. + final DateTime? focusedDate = _FocusedDate.of(context); + if (focusedDate != null && + DateUtils.isSameMonth(widget.displayedMonth, focusedDate)) { + _dayFocusNodes[focusedDate.day - 1].requestFocus(); + } + } + + @override + void dispose() { + for (final FocusNode node in _dayFocusNodes) { + node.dispose(); + } + super.dispose(); + } + + /// Builds widgets showing abbreviated days of week. The first widget in the + /// returned list corresponds to the first day of week for the current locale. + /// + /// Examples: + /// + /// ``` + /// ┌ Sunday is the first day of week in the US (en_US) + /// | + /// S M T W T F S <-- the returned list contains these widgets + /// _ _ _ _ _ 1 2 + /// 3 4 5 6 7 8 9 + /// + /// ┌ But it's Monday in the UK (en_GB) + /// | + /// M T W T F S S <-- the returned list contains these widgets + /// _ _ _ _ 1 2 3 + /// 4 5 6 7 8 9 10 + /// ``` + List _dayHeaders( + TextStyle? headerStyle, MaterialLocalizations localizations) { + final List result = []; + for (int i = localizations.firstDayOfWeekIndex; true; i = (i + 1) % 7) { + final String weekday = localizations.narrowWeekdays[i]; + result.add(ExcludeSemantics( + child: Center( + child: Text(weekday, + style: i == 0 || i == 6 + ? widget.weekendTextStyle ?? widget.weekTextStyle + : headerStyle)), + )); + if (i == (localizations.firstDayOfWeekIndex - 1) % 7) break; + } + return result; + } + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final MaterialLocalizations localizations = + MaterialLocalizations.of(context); + final TextTheme textTheme = Theme.of(context).textTheme; + final TextStyle? headerStyle = textTheme.bodyMedium?.apply( + color: colorScheme.onSurface.withOpacity(0.60), + ); + final TextStyle dayStyle = textTheme.bodyMedium!; + final Color enabledDayColor = colorScheme.onSurface.withOpacity(0.87); + final Color disabledDayColor = colorScheme.onSurface.withOpacity(0.38); + final Color selectedDayColor = colorScheme.onPrimary; + final Color selectedDayBackground = colorScheme.primary; + // final Color todayColor = colorScheme.primary; + final Color todayColor = Color(0xff4D6FD5); + + final int year = widget.displayedMonth.year; + final int month = widget.displayedMonth.month; + + final int daysInMonth = DateUtils.getDaysInMonth(year, month); + final int dayOffset = DateUtils.firstDayOffset(year, month, localizations); + + final List dayItems = _dayHeaders(headerStyle, localizations); + // 1-based day of month, e.g. 1-31 for January, and 1-29 for February on + // a leap year. + int day = -dayOffset; + while (day < daysInMonth) { + day++; + if (day < 1) { + dayItems.add(Container()); + } else { + final DateTime dayToBuild = DateTime(year, month, day); + final bool isDisabled = dayToBuild.isAfter(widget.lastDate) || + dayToBuild.isBefore(widget.firstDate) || + (widget.selectableDayPredicate != null && + !widget.selectableDayPredicate!(dayToBuild)); + final bool isSelectedDay = + DateUtils.isSameDay(widget.selectedDate, dayToBuild); + final bool isToday = + DateUtils.isSameDay(widget.currentDate, dayToBuild); + + BoxDecoration? decoration; + Color dayColor = enabledDayColor; + if (isSelectedDay) { + // The selected day gets a circle background highlight, and a + // contrasting text color. + dayColor = selectedDayColor; + decoration = widget.selectedDateDecoration ?? + BoxDecoration( + color: selectedDayBackground, + shape: BoxShape.circle, + ); + } else if (isDisabled) { + dayColor = disabledDayColor; + } else if (isToday) { + // The current day gets a different text color and a circle stroke + // border. + dayColor = todayColor; + decoration = BoxDecoration( + border: Border.all(color: todayColor, width: 1), + shape: BoxShape.circle, + ); + } + + Widget dayWidget = Container( + decoration: decoration, + child: Center( + child: Text(localizations.formatDecimal(day), + style: dayStyle.apply(color: dayColor)), + ), + ); + + if (isDisabled) { + dayWidget = ExcludeSemantics( + child: dayWidget, + ); + } else { + dayWidget = InkResponse( + focusNode: _dayFocusNodes[day - 1], + onTap: () => widget.onChanged(dayToBuild), + radius: _dayPickerRowHeight / 2 + 4, + splashColor: selectedDayBackground.withOpacity(0.38), + child: Semantics( + // We want the day of month to be spoken first irrespective of the + // locale-specific preferences or TextDirection. This is because + // an accessibility user is more likely to be interested in the + // day of month before the rest of the date, as they are looking + // for the day of month. To do that we prepend day of month to the + // formatted full date. + label: + '${localizations.formatDecimal(day)}, ${localizations.formatFullDate(dayToBuild)}', + selected: isSelectedDay, + excludeSemantics: true, + child: dayWidget, + ), + ); + } + + dayItems.add(dayWidget); + } + } + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: _monthPickerHorizontalPadding, + ), + child: GridView.custom( + padding: EdgeInsets.zero, + physics: const ClampingScrollPhysics(), + gridDelegate: _dayPickerGridDelegate, + childrenDelegate: SliverChildListDelegate( + dayItems, + addRepaintBoundaries: false, + ), + ), + ); + } +} + +class _DayPickerGridDelegate extends SliverGridDelegate { + const _DayPickerGridDelegate(); + + @override + SliverGridLayout getLayout(SliverConstraints constraints) { + const int columnCount = DateTime.daysPerWeek; + final double tileWidth = constraints.crossAxisExtent / columnCount; + final double tileHeight = math.min( + _dayPickerRowHeight, + constraints.viewportMainAxisExtent / (_maxDayPickerRowCount + 1), + ); + return SliverGridRegularTileLayout( + childCrossAxisExtent: tileWidth, + childMainAxisExtent: tileHeight, + crossAxisCount: columnCount, + crossAxisStride: tileWidth, + mainAxisStride: tileHeight, + reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), + ); + } + + @override + bool shouldRelayout(_DayPickerGridDelegate oldDelegate) => false; +} + +///绘制年 +class _YearPicker extends StatefulWidget { + /// Creates a year picker. + /// + /// The [firstDate], [lastDate], [selectedDate], and [onChanged] + /// arguments must be non-null. The [lastDate] must be after the [firstDate]. + _YearPicker( + {Key? key, + DateTime? currentDate, + required this.firstDate, + required this.lastDate, + DateTime? initialDate, + required this.selectedDate, + required this.onChanged, + this.dragStartBehavior = DragStartBehavior.start, + this.selectedYearDecoration}) + : assert(firstDate != null), + assert(lastDate != null), + assert(selectedDate != null), + assert(onChanged != null), + assert(!firstDate.isAfter(lastDate)), + currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()), + initialDate = DateUtils.dateOnly(initialDate ?? selectedDate), + super(key: key); + + /// The current date. + /// + /// This date is subtly highlighted in the picker. + final DateTime currentDate; + + /// The earliest date the user is permitted to pick. + final DateTime firstDate; + + /// The latest date the user is permitted to pick. + final DateTime lastDate; + + /// The initial date to center the year display around. + final DateTime initialDate; + + /// The currently selected date. + /// + /// This date is highlighted in the picker. + final DateTime selectedDate; + + /// Called when the user picks a year. + final ValueChanged onChanged; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + ///开始时间和结束时间按钮样式 + final BoxDecoration? selectedYearDecoration; + + @override + _YearPickerState createState() => _YearPickerState(); +} + +class _YearPickerState extends State<_YearPicker> { + late ScrollController _scrollController; + + // The approximate number of years necessary to fill the available space. + static const int minYears = 18; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController( + initialScrollOffset: _scrollOffsetForYear(widget.selectedDate)); + } + + @override + void didUpdateWidget(_YearPicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.selectedDate != oldWidget.selectedDate) { + _scrollController.jumpTo(_scrollOffsetForYear(widget.selectedDate)); + } + } + + double _scrollOffsetForYear(DateTime date) { + final int initialYearIndex = date.year - widget.firstDate.year; + final int initialYearRow = initialYearIndex ~/ _yearPickerColumnCount; + // Move the offset down by 2 rows to approximately center it. + final int centeredYearRow = initialYearRow - 2; + return _itemCount < minYears ? 0 : centeredYearRow * _yearPickerRowHeight; + } + + Widget _buildYearItem(BuildContext context, int index) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final TextTheme textTheme = Theme.of(context).textTheme; + + // Backfill the _YearPicker with disabled years if necessary. + final int offset = _itemCount < minYears ? (minYears - _itemCount) ~/ 2 : 0; + final int year = widget.firstDate.year + index - offset; + final bool isSelected = year == widget.selectedDate.year; + final bool isCurrentYear = year == widget.currentDate.year; + final bool isDisabled = + year < widget.firstDate.year || year > widget.lastDate.year; + const double decorationHeight = 36.0; + const double decorationWidth = 72.0; + + final Color textColor; + if (isSelected) { + textColor = colorScheme.onPrimary; + } else if (isDisabled) { + textColor = colorScheme.onSurface.withOpacity(0.38); + } else if (isCurrentYear) { + // textColor = colorScheme.primary; + textColor = Color(0xff4D6FD5); + } else { + textColor = colorScheme.onSurface.withOpacity(0.87); + } + final TextStyle? itemStyle = textTheme.bodyMedium?.apply(color: textColor); + + BoxDecoration? decoration; + if (isSelected) { + decoration = widget.selectedYearDecoration ?? + BoxDecoration( + color: colorScheme.primary, + borderRadius: BorderRadius.circular(decorationHeight / 2), + shape: BoxShape.rectangle, + ); + } else if (isCurrentYear && !isDisabled) { + decoration = BoxDecoration( + border: Border.all( + // color: colorScheme.primary, + color: Color(0xff4D6FD5), + width: 1, + ), + borderRadius: BorderRadius.circular(decorationHeight / 2), + shape: BoxShape.rectangle, + ); + } + + Widget yearItem = Center( + child: Container( + decoration: decoration, + height: decorationHeight, + width: decorationWidth, + child: Center( + child: Semantics( + selected: isSelected, + child: Text(year.toString(), style: itemStyle), + ), + ), + ), + ); + + if (isDisabled) { + yearItem = ExcludeSemantics( + child: yearItem, + ); + } else { + yearItem = InkWell( + key: ValueKey(year), + onTap: () => + widget.onChanged(DateTime(year, widget.initialDate.month, 1)), + child: yearItem, + ); + } + + return yearItem; + } + + int get _itemCount { + return widget.lastDate.year - widget.firstDate.year + 1; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + return Column( + children: [ + const Divider(), + Expanded( + child: GridView.builder( + controller: _scrollController, + dragStartBehavior: widget.dragStartBehavior, + gridDelegate: _yearPickerGridDelegate, + itemBuilder: _buildYearItem, + itemCount: math.max(_itemCount, minYears), + padding: const EdgeInsets.symmetric(horizontal: _yearPickerPadding), + ), + ), + const Divider(), + ], + ); + } +} + +class _YearPickerGridDelegate extends SliverGridDelegate { + const _YearPickerGridDelegate(); + + @override + SliverGridLayout getLayout(SliverConstraints constraints) { + final double tileWidth = (constraints.crossAxisExtent - + (_yearPickerColumnCount - 1) * _yearPickerRowSpacing) / + _yearPickerColumnCount; + return SliverGridRegularTileLayout( + childCrossAxisExtent: tileWidth, + childMainAxisExtent: _yearPickerRowHeight, + crossAxisCount: _yearPickerColumnCount, + crossAxisStride: tileWidth + _yearPickerRowSpacing, + mainAxisStride: _yearPickerRowHeight, + reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), + ); + } + + @override + bool shouldRelayout(_YearPickerGridDelegate oldDelegate) => false; +} + +const _YearPickerGridDelegate _yearPickerGridDelegate = + _YearPickerGridDelegate(); diff --git a/flutter_common/lib/calendarcalendar/custom_multiple_choose_date_picker.dart b/flutter_common/lib/calendarcalendar/custom_multiple_choose_date_picker.dart new file mode 100755 index 0000000..2fd26ac --- /dev/null +++ b/flutter_common/lib/calendarcalendar/custom_multiple_choose_date_picker.dart @@ -0,0 +1,1093 @@ +import 'dart:math' as math; + +import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:sticky_headers/sticky_headers.dart'; + +///自定义日期区间选择 +class CustomMultipleChooseDatePicker extends StatefulWidget { + CustomMultipleChooseDatePicker({ + Key? key, + DateTime? initialStartDate, + DateTime? initialEndDate, + required DateTime firstDate, + required DateTime lastDate, + required this.onDateSelected, + DateTime? currentDate, + this.selectedDateDecoration, + this.currentDateDecoration, + this.rangeBackgroundColor, + this.weekendTextStyle, + this.weekTextStyle, + this.yearTextStyle, + }) : initialStartDate = initialStartDate != null + ? DateUtils.dateOnly(initialStartDate) + : null, + initialEndDate = + initialEndDate != null ? DateUtils.dateOnly(initialEndDate) : null, + assert(firstDate != null), + assert(lastDate != null), + firstDate = DateUtils.dateOnly(firstDate), + lastDate = DateUtils.dateOnly(lastDate), + currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()), + super(key: key) { + assert( + this.initialStartDate == null || + this.initialEndDate == null || + !this.initialStartDate!.isAfter(initialEndDate!), + 'initialStartDate must be on or before initialEndDate.', + ); + assert( + !this.lastDate.isBefore(this.firstDate), + 'firstDate must be on or before lastDate.', + ); + } + + final DateTime? initialStartDate; + + final DateTime? initialEndDate; + + final DateTime firstDate; + + final DateTime lastDate; + + final DateTime currentDate; + + final Function(DateTime? startDate, DateTime? endDate) onDateSelected; + + ///开始时间和结束时间按钮样式 + final BoxDecoration? selectedDateDecoration; + + ///当前时间样式 + final BoxDecoration? currentDateDecoration; + + ///选中时间区间的背景颜色 + final Color? rangeBackgroundColor; + + ///周六周天字体颜色 + final TextStyle? weekendTextStyle; + + ///周字体颜色 + final TextStyle? weekTextStyle; + + ///年月的字体 + final TextStyle? yearTextStyle; + + @override + CustomMultipleChooseDatePickerState createState() => + CustomMultipleChooseDatePickerState(); +} + +class CustomMultipleChooseDatePickerState + extends State { + final GlobalKey _scrollViewKey = GlobalKey(); + DateTime? _startDate; + DateTime? _endDate; + int _initialMonthIndex = 0; + late ScrollController _controller; + late bool _showWeekBottomDivider; + + @override + void initState() { + super.initState(); + _controller = ScrollController(); + _controller.addListener(_scrollListener); + + _startDate = widget.initialStartDate; + _endDate = widget.initialEndDate; + + // Calculate the index for the initially displayed month. This is needed to + // divide the list of months into two `SliverList`s. + final DateTime initialDate = widget.initialStartDate ?? widget.currentDate; + if (!initialDate.isBefore(widget.firstDate) && + !initialDate.isAfter(widget.lastDate)) { + _initialMonthIndex = DateUtils.monthDelta(widget.firstDate, initialDate); + } + + _showWeekBottomDivider = _initialMonthIndex != 0; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _scrollListener() { + if (_controller.offset <= _controller.position.minScrollExtent) { + setState(() { + _showWeekBottomDivider = false; + }); + } else if (!_showWeekBottomDivider) { + setState(() { + _showWeekBottomDivider = true; + }); + } + } + + int get _numberOfMonths => + DateUtils.monthDelta(widget.firstDate, widget.lastDate) + 1; + + void _vibrate() { + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + HapticFeedback.vibrate(); + break; + default: + break; + } + } + + // This updates the selected date range using this logic: + // + // * From the unselected state, selecting one date creates the start date. + // * If the next selection is before the start date, reset date range and + // set the start date to that selection. + // * If the next selection is on or after the start date, set the end date + // to that selection. + // * After both start and end dates are selected, any subsequent selection + // resets the date range and sets start date to that selection. + void _updateSelection(DateTime date) { + _vibrate(); + setState(() { + if (_startDate != null && + _endDate == null && + !date.isBefore(_startDate!)) { + _endDate = date; + } else { + _startDate = date; + if (_endDate != null) { + _endDate = null; + } + } + }); + widget.onDateSelected(_startDate ?? null, _endDate ?? null); + } + + Widget _buildMonthItem( + BuildContext context, int index, bool beforeInitialMonth) { + final int monthIndex = beforeInitialMonth + ? _initialMonthIndex - index - 1 + : _initialMonthIndex + index; + final DateTime month = + DateUtils.addMonthsToMonthDate(widget.firstDate, monthIndex); + return _MonthItem( + selectedDateStart: _startDate, + selectedDateEnd: _endDate, + currentDate: widget.currentDate, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + displayedMonth: month, + onChanged: _updateSelection, + selectedDateDecoration: widget.selectedDateDecoration, + currentDateDecoration: widget.currentDateDecoration, + rangeBackgroundColor: widget.rangeBackgroundColor); + } + + ///年月展示 + Widget _yearMonthWidget(int index, bool beforeInitialMonth) { + final int monthIndex = beforeInitialMonth + ? _initialMonthIndex - index - 1 + : _initialMonthIndex + index; + final DateTime month = + DateUtils.addMonthsToMonthDate(widget.firstDate, monthIndex); + final MaterialLocalizations localizations = + MaterialLocalizations.of(context); + final ThemeData themeData = Theme.of(context); + final TextTheme textTheme = themeData.textTheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 7), + alignment: AlignmentDirectional.centerStart, + color: const Color(0xffF8F8F8), + child: ExcludeSemantics( + child: Text( + localizations.formatMonthYear(month), + style: widget.yearTextStyle ?? + textTheme.bodyMedium! + .apply(color: themeData.colorScheme.onSurface), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + const Key sliverAfterKey = Key('sliverAfterKey'); + + return Container( + color: Colors.white, + child: Column( + children: [ + _DayHeaders( + weekendTextStyle: widget.weekendTextStyle, + weekTextStyle: widget.weekTextStyle, + ), + if (_showWeekBottomDivider) const Divider(height: 0), + Expanded( + child: _CalendarKeyboardNavigator( + firstDate: widget.firstDate, + lastDate: widget.lastDate, + initialFocusedDay: + _startDate ?? widget.initialStartDate ?? widget.currentDate, + // In order to prevent performance issues when displaying the + // correct initial month, 2 `SliverList`s are used to split the + // months. The first item in the second SliverList is the initial + // month to be displayed. + child: CustomScrollView( + key: _scrollViewKey, + controller: _controller, + center: sliverAfterKey, + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) => StickyHeader( + header: _yearMonthWidget(index, true), + content: _buildMonthItem(context, index, true), + ), + childCount: _initialMonthIndex, + ), + ), + SliverList( + key: sliverAfterKey, + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) => StickyHeader( + header: _yearMonthWidget(index, false), + content: _buildMonthItem(context, index, false), + ), + childCount: _numberOfMonths - _initialMonthIndex, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +///月份选择 +class _MonthItem extends StatefulWidget { + _MonthItem( + {Key? key, + required this.selectedDateStart, + required this.selectedDateEnd, + required this.currentDate, + required this.onChanged, + required this.firstDate, + required this.lastDate, + required this.displayedMonth, + this.dragStartBehavior = DragStartBehavior.start, + this.selectedDateDecoration, + this.currentDateDecoration, + this.rangeBackgroundColor}) + : assert(firstDate != null), + assert(lastDate != null), + assert(!firstDate.isAfter(lastDate)), + assert(selectedDateStart == null || + !selectedDateStart.isBefore(firstDate)), + assert(selectedDateEnd == null || !selectedDateEnd.isBefore(firstDate)), + assert( + selectedDateStart == null || !selectedDateStart.isAfter(lastDate)), + assert(selectedDateEnd == null || !selectedDateEnd.isAfter(lastDate)), + assert(selectedDateStart == null || + selectedDateEnd == null || + !selectedDateStart.isAfter(selectedDateEnd)), + assert(currentDate != null), + assert(onChanged != null), + assert(displayedMonth != null), + assert(dragStartBehavior != null), + super(key: key); + + /// The currently selected start date. + /// + /// This date is highlighted in the picker. + final DateTime? selectedDateStart; + + /// The currently selected end date. + /// + /// This date is highlighted in the picker. + final DateTime? selectedDateEnd; + + /// The current date at the time the picker is displayed. + final DateTime currentDate; + + /// Called when the user picks a day. + final ValueChanged onChanged; + + /// The earliest date the user is permitted to pick. + final DateTime firstDate; + + /// The latest date the user is permitted to pick. + final DateTime lastDate; + + /// The month whose days are displayed by this picker. + final DateTime displayedMonth; + + /// Determines the way that drag start behavior is handled. + /// + /// If set to [DragStartBehavior.start], the drag gesture used to scroll a + /// date picker wheel will begin upon the detection of a drag gesture. If set + /// to [DragStartBehavior.down] it will begin when a down event is first + /// detected. + /// + /// In general, setting this to [DragStartBehavior.start] will make drag + /// animation smoother and setting it to [DragStartBehavior.down] will make + /// drag behavior feel slightly more reactive. + /// + /// By default, the drag start behavior is [DragStartBehavior.start]. + /// + /// See also: + /// + /// * [DragGestureRecognizer.dragStartBehavior], which gives an example for + /// the different behaviors. + final DragStartBehavior dragStartBehavior; + + final BoxDecoration? selectedDateDecoration; + + final BoxDecoration? currentDateDecoration; + + final Color? rangeBackgroundColor; + + @override + _MonthItemState createState() => _MonthItemState(); +} + +class _MonthItemState extends State<_MonthItem> { + /// List of [FocusNode]s, one for each day of the month. + late List _dayFocusNodes; + + @override + void initState() { + super.initState(); + final int daysInMonth = DateUtils.getDaysInMonth( + widget.displayedMonth.year, widget.displayedMonth.month); + _dayFocusNodes = List.generate( + daysInMonth, + (int index) => + FocusNode(skipTraversal: true, debugLabel: 'Day ${index + 1}'), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Check to see if the focused date is in this month, if so focus it. + final DateTime? focusedDate = _FocusedDate.of(context)?.date; + if (focusedDate != null && + DateUtils.isSameMonth(widget.displayedMonth, focusedDate)) { + _dayFocusNodes[focusedDate.day - 1].requestFocus(); + } + } + + @override + void dispose() { + for (final FocusNode node in _dayFocusNodes) { + node.dispose(); + } + super.dispose(); + } + + Color _highlightColor(BuildContext context) { + return widget.rangeBackgroundColor ?? + Theme.of(context).colorScheme.primary.withOpacity(0.12); + } + + void _dayFocusChanged(bool focused) { + if (focused) { + final TraversalDirection? focusDirection = + _FocusedDate.of(context)?.scrollDirection; + if (focusDirection != null) { + ScrollPositionAlignmentPolicy policy = + ScrollPositionAlignmentPolicy.explicit; + switch (focusDirection) { + case TraversalDirection.up: + case TraversalDirection.left: + policy = ScrollPositionAlignmentPolicy.keepVisibleAtStart; + break; + case TraversalDirection.right: + case TraversalDirection.down: + policy = ScrollPositionAlignmentPolicy.keepVisibleAtEnd; + break; + } + Scrollable.ensureVisible( + primaryFocus!.context!, + duration: _monthScrollDuration, + alignmentPolicy: policy, + ); + } + } + } + + Widget _buildDayItem(BuildContext context, DateTime dayToBuild, + int firstDayOffset, int daysInMonth) { + final ThemeData theme = Theme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + final TextTheme textTheme = theme.textTheme; + final MaterialLocalizations localizations = + MaterialLocalizations.of(context); + final TextDirection textDirection = Directionality.of(context); + final Color highlightColor = _highlightColor(context); + final int day = dayToBuild.day; + + final bool isDisabled = dayToBuild.isAfter(widget.lastDate) || + dayToBuild.isBefore(widget.firstDate); + + BoxDecoration? decoration; + TextStyle? itemStyle = textTheme.bodyMedium; + + final bool isRangeSelected = + widget.selectedDateStart != null && widget.selectedDateEnd != null; + final bool isSelectedDayStart = widget.selectedDateStart != null && + dayToBuild.isAtSameMomentAs(widget.selectedDateStart!); + final bool isSelectedDayEnd = widget.selectedDateEnd != null && + dayToBuild.isAtSameMomentAs(widget.selectedDateEnd!); + final bool isInRange = isRangeSelected && + dayToBuild.isAfter(widget.selectedDateStart!) && + dayToBuild.isBefore(widget.selectedDateEnd!); + + _HighlightPainter? highlightPainter; + + if (isSelectedDayStart || isSelectedDayEnd) { + // The selected start and end dates gets a circle background + // highlight, and a contrasting text color. + itemStyle = textTheme.bodyMedium?.apply(color: colorScheme.onPrimary); + decoration = widget.selectedDateDecoration ?? + BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ); + + if (isRangeSelected && + widget.selectedDateStart != widget.selectedDateEnd) { + final _HighlightPainterStyle style = isSelectedDayStart + ? _HighlightPainterStyle.highlightTrailing + : _HighlightPainterStyle.highlightLeading; + highlightPainter = _HighlightPainter( + color: highlightColor, + style: style, + textDirection: textDirection, + ); + } + } else if (isInRange) { + // The days within the range get a light background highlight. + highlightPainter = _HighlightPainter( + color: highlightColor, + style: _HighlightPainterStyle.highlightAll, + textDirection: textDirection, + ); + } else if (isDisabled) { + itemStyle = textTheme.bodyMedium + ?.apply(color: colorScheme.onSurface.withOpacity(0.38)); + } else if (DateUtils.isSameDay(widget.currentDate, dayToBuild)) { + // The current day gets a different text color and a circle stroke + // border. + itemStyle = textTheme.bodyMedium?.apply(color: Color(0xff4D6FD5)); + decoration = widget.currentDateDecoration ?? + BoxDecoration( + border: Border.all(color: Color(0xff4D6FD5), width: 1), + shape: BoxShape.circle, + ); + } + + // We want the day of month to be spoken first irrespective of the + // locale-specific preferences or TextDirection. This is because + // an accessibility user is more likely to be interested in the + // day of month before the rest of the date, as they are looking + // for the day of month. To do that we prepend day of month to the + // formatted full date. + String semanticLabel = + '${localizations.formatDecimal(day)}, ${localizations.formatFullDate(dayToBuild)}'; + if (isSelectedDayStart) { + semanticLabel = + localizations.dateRangeStartDateSemanticLabel(semanticLabel); + } else if (isSelectedDayEnd) { + semanticLabel = + localizations.dateRangeEndDateSemanticLabel(semanticLabel); + } + + Widget dayWidget = Container( + decoration: decoration, + child: Center( + child: Semantics( + label: semanticLabel, + selected: isSelectedDayStart || isSelectedDayEnd, + child: ExcludeSemantics( + child: Text(localizations.formatDecimal(day), style: itemStyle), + ), + ), + ), + ); + + if (highlightPainter != null) { + dayWidget = CustomPaint( + painter: highlightPainter, + child: dayWidget, + ); + } + + if (!isDisabled) { + dayWidget = InkResponse( + focusNode: _dayFocusNodes[day - 1], + onTap: () => widget.onChanged(dayToBuild), + radius: _monthItemRowHeight / 2 + 4, + splashColor: colorScheme.primary.withOpacity(0.38), + onFocusChange: _dayFocusChanged, + child: dayWidget, + ); + } + + return dayWidget; + } + + Widget _buildEdgeContainer(BuildContext context, bool isHighlighted) { + return Container(color: isHighlighted ? _highlightColor(context) : null); + } + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + final TextTheme textTheme = themeData.textTheme; + final MaterialLocalizations localizations = + MaterialLocalizations.of(context); + final int year = widget.displayedMonth.year; + final int month = widget.displayedMonth.month; + final int daysInMonth = DateUtils.getDaysInMonth(year, month); + final int dayOffset = DateUtils.firstDayOffset(year, month, localizations); + final int weeks = ((daysInMonth + dayOffset) / DateTime.daysPerWeek).ceil(); + final double gridHeight = + weeks * _monthItemRowHeight + (weeks - 1) * _monthItemSpaceBetweenRows; + final List dayItems = []; + + for (int i = 0; true; i += 1) { + // 1-based day of month, e.g. 1-31 for January, and 1-29 for February on + // a leap year. + final int day = i - dayOffset + 1; + if (day > daysInMonth) break; + if (day < 1) { + dayItems.add(Container()); + } else { + final DateTime dayToBuild = DateTime(year, month, day); + final Widget dayItem = _buildDayItem( + context, + dayToBuild, + dayOffset, + daysInMonth, + ); + dayItems.add(dayItem); + } + } + + // Add the leading/trailing edge containers to each week in order to + // correctly extend the range highlight. + final List paddedDayItems = []; + for (int i = 0; i < weeks; i++) { + final int start = i * DateTime.daysPerWeek; + final int end = math.min( + start + DateTime.daysPerWeek, + dayItems.length, + ); + final List weekList = dayItems.sublist(start, end); + + final DateTime dateAfterLeadingPadding = + DateTime(year, month, start - dayOffset + 1); + // Only color the edge container if it is after the start date and + // on/before the end date. + final bool isLeadingInRange = !(dayOffset > 0 && i == 0) && + widget.selectedDateStart != null && + widget.selectedDateEnd != null && + dateAfterLeadingPadding.isAfter(widget.selectedDateStart!) && + !dateAfterLeadingPadding.isAfter(widget.selectedDateEnd!); + weekList.insert(0, _buildEdgeContainer(context, isLeadingInRange)); + + // Only add a trailing edge container if it is for a full week and not a + // partial week. + if (end < dayItems.length || + (end == dayItems.length && + dayItems.length % DateTime.daysPerWeek == 0)) { + final DateTime dateBeforeTrailingPadding = + DateTime(year, month, end - dayOffset); + // Only color the edge container if it is on/after the start date and + // before the end date. + final bool isTrailingInRange = widget.selectedDateStart != null && + widget.selectedDateEnd != null && + !dateBeforeTrailingPadding.isBefore(widget.selectedDateStart!) && + dateBeforeTrailingPadding.isBefore(widget.selectedDateEnd!); + weekList.add(_buildEdgeContainer(context, isTrailingInRange)); + } + + paddedDayItems.addAll(weekList); + } + + final double maxWidth = + MediaQuery.of(context).orientation == Orientation.landscape + ? _maxCalendarWidthLandscape + : _maxCalendarWidthPortrait; + return Column( + children: [ + Container( + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: gridHeight, + ), + child: GridView.custom( + physics: const NeverScrollableScrollPhysics(), + gridDelegate: _monthItemGridDelegate, + padding: EdgeInsets.zero, + childrenDelegate: SliverChildListDelegate( + paddedDayItems, + addRepaintBoundaries: false, + ), + ), + ), + const SizedBox(height: _monthItemFooterHeight), + ], + ); + } +} + +///变量 +const Duration _monthScrollDuration = Duration(milliseconds: 200); + +const double _monthItemFooterHeight = 12.0; +const double _monthItemRowHeight = 42.0; +const double _monthItemSpaceBetweenRows = 8.0; +const double _horizontalPadding = 8.0; +const double _maxCalendarWidthLandscape = 384.0; +const double _maxCalendarWidthPortrait = 480.0; + +class _FocusedDate extends InheritedWidget { + const _FocusedDate({ + Key? key, + required Widget child, + this.date, + this.scrollDirection, + }) : super(key: key, child: child); + + final DateTime? date; + final TraversalDirection? scrollDirection; + + @override + bool updateShouldNotify(_FocusedDate oldWidget) { + return !DateUtils.isSameDay(date, oldWidget.date) || + scrollDirection != oldWidget.scrollDirection; + } + + static _FocusedDate? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_FocusedDate>(); + } +} + +///日期格式 +class _MonthItemGridDelegate extends SliverGridDelegate { + const _MonthItemGridDelegate(); + + @override + SliverGridLayout getLayout(SliverConstraints constraints) { + final double tileWidth = + (constraints.crossAxisExtent - 2 * _horizontalPadding) / + DateTime.daysPerWeek; + return _MonthSliverGridLayout( + crossAxisCount: DateTime.daysPerWeek + 2, + dayChildWidth: tileWidth, + edgeChildWidth: _horizontalPadding, + reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), + ); + } + + @override + bool shouldRelayout(_MonthItemGridDelegate oldDelegate) => false; +} + +const _MonthItemGridDelegate _monthItemGridDelegate = _MonthItemGridDelegate(); + +class _MonthSliverGridLayout extends SliverGridLayout { + /// Creates a layout that uses equally sized and spaced tiles for each day of + /// the week and an additional edge tile for padding at the start and end of + /// each row. + /// + /// This is necessary to facilitate the painting of the range highlight + /// correctly. + const _MonthSliverGridLayout({ + required this.crossAxisCount, + required this.dayChildWidth, + required this.edgeChildWidth, + required this.reverseCrossAxis, + }) : assert(crossAxisCount != null && crossAxisCount > 0), + assert(dayChildWidth != null && dayChildWidth >= 0), + assert(edgeChildWidth != null && edgeChildWidth >= 0), + assert(reverseCrossAxis != null); + + /// The number of children in the cross axis. + final int crossAxisCount; + + /// The width in logical pixels of the day child widgets. + final double dayChildWidth; + + /// The width in logical pixels of the edge child widgets. + final double edgeChildWidth; + + /// Whether the children should be placed in the opposite order of increasing + /// coordinates in the cross axis. + /// + /// For example, if the cross axis is horizontal, the children are placed from + /// left to right when [reverseCrossAxis] is false and from right to left when + /// [reverseCrossAxis] is true. + /// + /// Typically set to the return value of [axisDirectionIsReversed] applied to + /// the [SliverConstraints.crossAxisDirection]. + final bool reverseCrossAxis; + + /// The number of logical pixels from the leading edge of one row to the + /// leading edge of the next row. + double get _rowHeight { + return _monthItemRowHeight + _monthItemSpaceBetweenRows; + } + + /// The height in logical pixels of the children widgets. + double get _childHeight { + return _monthItemRowHeight; + } + + @override + int getMinChildIndexForScrollOffset(double scrollOffset) { + return crossAxisCount * (scrollOffset ~/ _rowHeight); + } + + @override + int getMaxChildIndexForScrollOffset(double scrollOffset) { + final int mainAxisCount = (scrollOffset / _rowHeight).ceil(); + return math.max(0, crossAxisCount * mainAxisCount - 1); + } + + double _getCrossAxisOffset(double crossAxisStart, bool isPadding) { + if (reverseCrossAxis) { + return ((crossAxisCount - 2) * dayChildWidth + 2 * edgeChildWidth) - + crossAxisStart - + (isPadding ? edgeChildWidth : dayChildWidth); + } + return crossAxisStart; + } + + @override + SliverGridGeometry getGeometryForChildIndex(int index) { + final int adjustedIndex = index % crossAxisCount; + final bool isEdge = + adjustedIndex == 0 || adjustedIndex == crossAxisCount - 1; + final double crossAxisStart = + math.max(0, (adjustedIndex - 1) * dayChildWidth + edgeChildWidth); + + return SliverGridGeometry( + scrollOffset: (index ~/ crossAxisCount) * _rowHeight, + crossAxisOffset: _getCrossAxisOffset(crossAxisStart, isEdge), + mainAxisExtent: _childHeight, + crossAxisExtent: isEdge ? edgeChildWidth : dayChildWidth, + ); + } + + @override + double computeMaxScrollOffset(int childCount) { + assert(childCount >= 0); + final int mainAxisCount = ((childCount - 1) ~/ crossAxisCount) + 1; + final double mainAxisSpacing = _rowHeight - _childHeight; + return _rowHeight * mainAxisCount - mainAxisSpacing; + } +} + +///星期显示 +class _DayHeaders extends StatelessWidget { + /// Builds widgets showing abbreviated days of week. The first widget in the + /// returned list corresponds to the first day of week for the current locale. + /// + /// Examples: + /// + /// ``` + /// ┌ Sunday is the first day of week in the US (en_US) + /// | + /// S M T W T F S <-- the returned list contains these widgets + /// _ _ _ _ _ 1 2 + /// 3 4 5 6 7 8 9 + /// + /// ┌ But it's Monday in the UK (en_GB) + /// | + /// M T W T F S S <-- the returned list contains these widgets + /// _ _ _ _ 1 2 3 + /// 4 5 6 7 8 9 10 + /// ``` + + _DayHeaders({this.weekTextStyle, this.weekendTextStyle}); + + ///周六周天字体颜色 + final TextStyle? weekendTextStyle; + + ///周字体颜色 + final TextStyle? weekTextStyle; + + List _getDayHeaders( + TextStyle headerStyle, MaterialLocalizations localizations) { + final List result = []; + for (int i = localizations.firstDayOfWeekIndex; true; i = (i + 1) % 7) { + final String weekday = localizations.narrowWeekdays[i]; + result.add(ExcludeSemantics( + child: Center( + child: Text(weekday, + style: i == 0 || i == 6 + ? weekendTextStyle ?? weekTextStyle + : headerStyle)), + )); + if (i == (localizations.firstDayOfWeekIndex - 1) % 7) break; + } + return result; + } + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + final ColorScheme colorScheme = themeData.colorScheme; + final TextStyle textStyle = weekTextStyle ?? + themeData.textTheme.titleMedium!.apply(color: colorScheme.onSurface); + final MaterialLocalizations localizations = + MaterialLocalizations.of(context); + final List labels = _getDayHeaders(textStyle, localizations); + + // Add leading and trailing containers for edges of the custom grid layout. + labels.insert(0, Container()); + labels.add(Container()); + + return Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).orientation == Orientation.landscape + ? _maxCalendarWidthLandscape + : _maxCalendarWidthPortrait, + maxHeight: _monthItemRowHeight, + ), + child: GridView.custom( + shrinkWrap: true, + gridDelegate: _monthItemGridDelegate, + childrenDelegate: SliverChildListDelegate( + labels, + addRepaintBoundaries: false, + ), + ), + ); + } +} + +///日期滚动控制组件 +class _CalendarKeyboardNavigator extends StatefulWidget { + const _CalendarKeyboardNavigator({ + Key? key, + required this.child, + required this.firstDate, + required this.lastDate, + required this.initialFocusedDay, + }) : super(key: key); + + final Widget child; + final DateTime firstDate; + final DateTime lastDate; + final DateTime initialFocusedDay; + + @override + _CalendarKeyboardNavigatorState createState() => + _CalendarKeyboardNavigatorState(); +} + +class _CalendarKeyboardNavigatorState + extends State<_CalendarKeyboardNavigator> { + late Map _shortcutMap; + late Map> _actionMap; + late FocusNode _dayGridFocus; + TraversalDirection? _dayTraversalDirection; + DateTime? _focusedDay; + + @override + void initState() { + super.initState(); + + _shortcutMap = { + LogicalKeySet(LogicalKeyboardKey.arrowLeft): + const DirectionalFocusIntent(TraversalDirection.left), + LogicalKeySet(LogicalKeyboardKey.arrowRight): + const DirectionalFocusIntent(TraversalDirection.right), + LogicalKeySet(LogicalKeyboardKey.arrowDown): + const DirectionalFocusIntent(TraversalDirection.down), + LogicalKeySet(LogicalKeyboardKey.arrowUp): + const DirectionalFocusIntent(TraversalDirection.up), + }; + _actionMap = >{ + NextFocusIntent: + CallbackAction(onInvoke: _handleGridNextFocus), + PreviousFocusIntent: CallbackAction( + onInvoke: _handleGridPreviousFocus), + DirectionalFocusIntent: CallbackAction( + onInvoke: _handleDirectionFocus), + }; + _dayGridFocus = FocusNode(debugLabel: 'Day Grid'); + } + + @override + void dispose() { + _dayGridFocus.dispose(); + super.dispose(); + } + + void _handleGridFocusChange(bool focused) { + setState(() { + if (focused) { + _focusedDay ??= widget.initialFocusedDay; + } + }); + } + + /// Move focus to the next element after the day grid. + void _handleGridNextFocus(NextFocusIntent intent) { + _dayGridFocus.requestFocus(); + _dayGridFocus.nextFocus(); + } + + /// Move focus to the previous element before the day grid. + void _handleGridPreviousFocus(PreviousFocusIntent intent) { + _dayGridFocus.requestFocus(); + _dayGridFocus.previousFocus(); + } + + /// Move the internal focus date in the direction of the given intent. + /// + /// This will attempt to move the focused day to the next selectable day in + /// the given direction. If the new date is not in the current month, then + /// the page view will be scrolled to show the new date's month. + /// + /// For horizontal directions, it will move forward or backward a day (depending + /// on the current [TextDirection]). For vertical directions it will move up and + /// down a week at a time. + void _handleDirectionFocus(DirectionalFocusIntent intent) { + assert(_focusedDay != null); + setState(() { + final DateTime? nextDate = + _nextDateInDirection(_focusedDay!, intent.direction); + if (nextDate != null) { + _focusedDay = nextDate; + _dayTraversalDirection = intent.direction; + } + }); + } + + static const Map _directionOffset = + { + TraversalDirection.up: -DateTime.daysPerWeek, + TraversalDirection.right: 1, + TraversalDirection.down: DateTime.daysPerWeek, + TraversalDirection.left: -1, + }; + + int _dayDirectionOffset( + TraversalDirection traversalDirection, TextDirection textDirection) { + // Swap left and right if the text direction if RTL + if (textDirection == TextDirection.rtl) { + if (traversalDirection == TraversalDirection.left) + traversalDirection = TraversalDirection.right; + else if (traversalDirection == TraversalDirection.right) + traversalDirection = TraversalDirection.left; + } + return _directionOffset[traversalDirection]!; + } + + DateTime? _nextDateInDirection(DateTime date, TraversalDirection direction) { + final TextDirection textDirection = Directionality.of(context); + final DateTime nextDate = DateUtils.addDaysToDate( + date, _dayDirectionOffset(direction, textDirection)); + if (!nextDate.isBefore(widget.firstDate) && + !nextDate.isAfter(widget.lastDate)) { + return nextDate; + } + return null; + } + + @override + Widget build(BuildContext context) { + return FocusableActionDetector( + shortcuts: _shortcutMap, + actions: _actionMap, + focusNode: _dayGridFocus, + onFocusChange: _handleGridFocusChange, + child: _FocusedDate( + date: _dayGridFocus.hasFocus ? _focusedDay : null, + scrollDirection: _dayGridFocus.hasFocus ? _dayTraversalDirection : null, + child: widget.child, + ), + ); + } +} + +///高度绘制 +class _HighlightPainter extends CustomPainter { + _HighlightPainter({ + required this.color, + this.style = _HighlightPainterStyle.none, + this.textDirection, + }); + + final Color color; + final _HighlightPainterStyle style; + final TextDirection? textDirection; + + @override + void paint(Canvas canvas, Size size) { + if (style == _HighlightPainterStyle.none) { + return; + } + + final Paint paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + final Rect rectLeft = Rect.fromLTWH(0, 0, size.width / 2, size.height); + final Rect rectRight = + Rect.fromLTWH(size.width / 2, 0, size.width / 2, size.height); + + switch (style) { + case _HighlightPainterStyle.highlightTrailing: + canvas.drawRect( + textDirection == TextDirection.ltr ? rectRight : rectLeft, + paint, + ); + break; + case _HighlightPainterStyle.highlightLeading: + canvas.drawRect( + textDirection == TextDirection.ltr ? rectLeft : rectRight, + paint, + ); + break; + case _HighlightPainterStyle.highlightAll: + canvas.drawRect( + Rect.fromLTWH(0, 0, size.width, size.height), + paint, + ); + break; + case _HighlightPainterStyle.none: + break; + } + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} + +enum _HighlightPainterStyle { + /// Paints nothing. + none, + + /// Paints a rectangle that occupies the leading half of the space. + highlightLeading, + + /// Paints a rectangle that occupies the trailing half of the space. + highlightTrailing, + + /// Paints a rectangle that occupies all available space. + highlightAll, +} diff --git a/flutter_common/lib/calendarcalendar/custom_single_choose_date_picker.dart b/flutter_common/lib/calendarcalendar/custom_single_choose_date_picker.dart new file mode 100755 index 0000000..2f37e82 --- /dev/null +++ b/flutter_common/lib/calendarcalendar/custom_single_choose_date_picker.dart @@ -0,0 +1,1344 @@ +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +///日期弹窗显示 +class CustomSingleChooseDatePicker extends StatefulWidget { + CustomSingleChooseDatePicker({ + Key? key, + required DateTime initialDate, + required DateTime firstDate, + required DateTime lastDate, + DateTime? currentDate, + required this.onDateChanged, + this.onDisplayedMonthChanged, + this.initialCalendarMode = DatePickerMode.day, + this.selectableDayPredicate, + this.selectedDateDecoration, + this.currentDateDecoration, + this.weekendTextStyle, + this.weekTextStyle, + this.yearTextStyle, + }) : assert(initialDate != null), + assert(firstDate != null), + assert(lastDate != null), + initialDate = DateUtils.dateOnly(initialDate), + firstDate = DateUtils.dateOnly(firstDate), + lastDate = DateUtils.dateOnly(lastDate), + currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()), + assert(onDateChanged != null), + assert(initialCalendarMode != null), + super(key: key) { + assert( + !this.lastDate.isBefore(this.firstDate), + 'lastDate ${this.lastDate} must be on or after firstDate ${this.firstDate}.', + ); + assert( + !this.initialDate.isBefore(this.firstDate), + 'initialDate ${this.initialDate} must be on or after firstDate ${this.firstDate}.', + ); + assert( + !this.initialDate.isAfter(this.lastDate), + 'initialDate ${this.initialDate} must be on or before lastDate ${this.lastDate}.', + ); + assert( + selectableDayPredicate == null || + selectableDayPredicate!(this.initialDate), + 'Provided initialDate ${this.initialDate} must satisfy provided selectableDayPredicate.', + ); + } + + /// The initially selected [DateTime] that the picker should display. + final DateTime initialDate; + + /// The earliest allowable [DateTime] that the user can select. + final DateTime firstDate; + + /// The latest allowable [DateTime] that the user can select. + final DateTime lastDate; + + /// The [DateTime] representing today. It will be highlighted in the day grid. + final DateTime currentDate; + + /// Called when the user selects a date in the picker. + final ValueChanged onDateChanged; + + /// Called when the user navigates to a new month/year in the picker. + final ValueChanged? onDisplayedMonthChanged; + + /// The initial display of the calendar picker. + final DatePickerMode initialCalendarMode; + + /// Function to provide full control over which dates in the calendar can be selected. + final SelectableDayPredicate? selectableDayPredicate; + + ///开始时间和结束时间按钮样式 + final BoxDecoration? selectedDateDecoration; + + ///当前时间样式 + final BoxDecoration? currentDateDecoration; + + ///周六周天字体颜色 + final TextStyle? weekendTextStyle; + + ///周字体颜色 + final TextStyle? weekTextStyle; + + ///年月的字体 + final TextStyle? yearTextStyle; + + @override + CustomSingleChooseDatePickerState createState() => + CustomSingleChooseDatePickerState(); +} + +class CustomSingleChooseDatePickerState + extends State { + bool _announcedInitialDate = false; + late DatePickerMode _mode; + late DateTime _currentDisplayedMonthDate; + late DateTime _selectedDate; + final GlobalKey _monthPickerKey = GlobalKey(); + final GlobalKey _yearPickerKey = GlobalKey(); + late MaterialLocalizations _localizations; + late TextDirection _textDirection; + + @override + void initState() { + super.initState(); + _mode = widget.initialCalendarMode; + _currentDisplayedMonthDate = + DateTime(widget.initialDate.year, widget.initialDate.month); + _selectedDate = widget.initialDate; + } + + @override + void didUpdateWidget(CustomSingleChooseDatePicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialCalendarMode != oldWidget.initialCalendarMode) { + _mode = widget.initialCalendarMode; + } + if (!DateUtils.isSameDay(widget.initialDate, oldWidget.initialDate)) { + _currentDisplayedMonthDate = + DateTime(widget.initialDate.year, widget.initialDate.month); + _selectedDate = widget.initialDate; + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMaterialLocalizations(context)); + assert(debugCheckHasDirectionality(context)); + _localizations = MaterialLocalizations.of(context); + _textDirection = Directionality.of(context); + if (!_announcedInitialDate) { + _announcedInitialDate = true; + SemanticsService.announce( + _localizations.formatFullDate(_selectedDate), + _textDirection, + ); + } + } + + void _vibrate() { + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + HapticFeedback.vibrate(); + break; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + break; + } + } + + void _handleModeChanged(DatePickerMode mode) { + _vibrate(); + setState(() { + _mode = mode; + if (_mode == DatePickerMode.day) { + SemanticsService.announce( + _localizations.formatMonthYear(_selectedDate), + _textDirection, + ); + } else { + SemanticsService.announce( + _localizations.formatYear(_selectedDate), + _textDirection, + ); + } + }); + } + + void _handleMonthChanged(DateTime date) { + setState(() { + if (_currentDisplayedMonthDate.year != date.year || + _currentDisplayedMonthDate.month != date.month) { + _currentDisplayedMonthDate = DateTime(date.year, date.month); + widget.onDisplayedMonthChanged?.call(_currentDisplayedMonthDate); + } + }); + } + + void _handleYearChanged(DateTime value) { + _vibrate(); + + if (value.isBefore(widget.firstDate)) { + value = widget.firstDate; + } else if (value.isAfter(widget.lastDate)) { + value = widget.lastDate; + } + + setState(() { + _mode = DatePickerMode.day; + _handleMonthChanged(value); + }); + } + + void _handleDayChanged(DateTime value) { + _vibrate(); + setState(() { + _selectedDate = value; + widget.onDateChanged(_selectedDate); + }); + } + + Widget _buildPicker() { + switch (_mode) { + case DatePickerMode.day: + return _MonthPicker( + key: _monthPickerKey, + initialMonth: _currentDisplayedMonthDate, + currentDate: widget.currentDate, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + selectedDate: _selectedDate, + onChanged: _handleDayChanged, + onDisplayedMonthChanged: _handleMonthChanged, + selectableDayPredicate: widget.selectableDayPredicate, + weekTextStyle: widget.weekTextStyle, + weekendTextStyle: widget.weekendTextStyle, + selectedDateDecoration: widget.selectedDateDecoration, + currentDateDecoration: widget.currentDateDecoration, + ); + case DatePickerMode.year: + return Padding( + padding: const EdgeInsets.only(top: _subHeaderHeight), + child: _YearPicker( + key: _yearPickerKey, + currentDate: widget.currentDate, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + initialDate: _currentDisplayedMonthDate, + selectedDate: _selectedDate, + onChanged: _handleYearChanged, + selectedYearDecoration: widget.selectedDateDecoration, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMaterialLocalizations(context)); + assert(debugCheckHasDirectionality(context)); + return Stack( + children: [ + SizedBox( + height: _subHeaderHeight + _maxDayPickerHeight, + child: _buildPicker(), + ), + // Put the mode toggle button on top so that it won't be covered up by the _MonthPicker + _DatePickerModeToggleButton( + mode: _mode, + title: _localizations.formatMonthYear(_currentDisplayedMonthDate), + onTitlePressed: () { + // Toggle the day/year mode. + _handleModeChanged(_mode == DatePickerMode.day + ? DatePickerMode.year + : DatePickerMode.day); + }, + textStyle: widget.yearTextStyle, + ), + ], + ); + } +} + +///月份绘制 +class _MonthPicker extends StatefulWidget { + /// Creates a month picker. + _MonthPicker( + {Key? key, + required this.initialMonth, + required this.currentDate, + required this.firstDate, + required this.lastDate, + required this.selectedDate, + required this.onChanged, + required this.onDisplayedMonthChanged, + this.selectableDayPredicate, + this.weekTextStyle, + this.weekendTextStyle, + this.selectedDateDecoration, + this.currentDateDecoration}) + : assert(selectedDate != null), + assert(currentDate != null), + assert(onChanged != null), + assert(firstDate != null), + assert(lastDate != null), + assert(!firstDate.isAfter(lastDate)), + assert(!selectedDate.isBefore(firstDate)), + assert(!selectedDate.isAfter(lastDate)), + super(key: key); + + /// The initial month to display. + final DateTime initialMonth; + + /// The current date. + /// + /// This date is subtly highlighted in the picker. + final DateTime currentDate; + + /// The earliest date the user is permitted to pick. + /// + /// This date must be on or before the [lastDate]. + final DateTime firstDate; + + /// The latest date the user is permitted to pick. + /// + /// This date must be on or after the [firstDate]. + final DateTime lastDate; + + /// The currently selected date. + /// + /// This date is highlighted in the picker. + final DateTime selectedDate; + + /// Called when the user picks a day. + final ValueChanged onChanged; + + /// Called when the user navigates to a new month. + final ValueChanged onDisplayedMonthChanged; + + /// Optional user supplied predicate function to customize selectable days. + final SelectableDayPredicate? selectableDayPredicate; + + ///开始时间和结束时间按钮样式 + final BoxDecoration? selectedDateDecoration; + + ///当前时间样式 + final BoxDecoration? currentDateDecoration; + + ///周六周天字体颜色 + final TextStyle? weekendTextStyle; + + ///周字体颜色 + final TextStyle? weekTextStyle; + + @override + _MonthPickerState createState() => _MonthPickerState(); +} + +class _MonthPickerState extends State<_MonthPicker> { + final GlobalKey _pageViewKey = GlobalKey(); + late DateTime _currentMonth; + late DateTime _nextMonthDate; + late DateTime _previousMonthDate; + late PageController _pageController; + late MaterialLocalizations _localizations; + late TextDirection _textDirection; + Map? _shortcutMap; + Map>? _actionMap; + late FocusNode _dayGridFocus; + DateTime? _focusedDay; + + @override + void initState() { + super.initState(); + _currentMonth = widget.initialMonth; + _previousMonthDate = DateUtils.addMonthsToMonthDate(_currentMonth, -1); + _nextMonthDate = DateUtils.addMonthsToMonthDate(_currentMonth, 1); + _pageController = PageController( + initialPage: DateUtils.monthDelta(widget.firstDate, _currentMonth)); + _shortcutMap = const { + SingleActivator(LogicalKeyboardKey.arrowLeft): + DirectionalFocusIntent(TraversalDirection.left), + SingleActivator(LogicalKeyboardKey.arrowRight): + DirectionalFocusIntent(TraversalDirection.right), + SingleActivator(LogicalKeyboardKey.arrowDown): + DirectionalFocusIntent(TraversalDirection.down), + SingleActivator(LogicalKeyboardKey.arrowUp): + DirectionalFocusIntent(TraversalDirection.up), + }; + _actionMap = >{ + NextFocusIntent: + CallbackAction(onInvoke: _handleGridNextFocus), + PreviousFocusIntent: CallbackAction( + onInvoke: _handleGridPreviousFocus), + DirectionalFocusIntent: CallbackAction( + onInvoke: _handleDirectionFocus), + }; + _dayGridFocus = FocusNode(debugLabel: 'Day Grid'); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _localizations = MaterialLocalizations.of(context); + _textDirection = Directionality.of(context); + } + + @override + void didUpdateWidget(_MonthPicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialMonth != oldWidget.initialMonth && + widget.initialMonth != _currentMonth) { + // We can't interrupt this widget build with a scroll, so do it next frame + WidgetsBinding.instance!.addPostFrameCallback( + (Duration timeStamp) => _showMonth(widget.initialMonth, jump: true), + ); + } + } + + @override + void dispose() { + _pageController.dispose(); + _dayGridFocus.dispose(); + super.dispose(); + } + + void _handleDateSelected(DateTime selectedDate) { + _focusedDay = selectedDate; + widget.onChanged(selectedDate); + } + + void _handleMonthPageChanged(int monthPage) { + setState(() { + final DateTime monthDate = + DateUtils.addMonthsToMonthDate(widget.firstDate, monthPage); + if (!DateUtils.isSameMonth(_currentMonth, monthDate)) { + _currentMonth = DateTime(monthDate.year, monthDate.month); + _previousMonthDate = DateUtils.addMonthsToMonthDate(_currentMonth, -1); + _nextMonthDate = DateUtils.addMonthsToMonthDate(_currentMonth, 1); + widget.onDisplayedMonthChanged(_currentMonth); + if (_focusedDay != null && + !DateUtils.isSameMonth(_focusedDay, _currentMonth)) { + // We have navigated to a new month with the grid focused, but the + // focused day is not in this month. Choose a new one trying to keep + // the same day of the month. + _focusedDay = _focusableDayForMonth(_currentMonth, _focusedDay!.day); + } + } + }); + } + + /// Returns a focusable date for the given month. + /// + /// If the preferredDay is available in the month it will be returned, + /// otherwise the first selectable day in the month will be returned. If + /// no dates are selectable in the month, then it will return null. + DateTime? _focusableDayForMonth(DateTime month, int preferredDay) { + final int daysInMonth = DateUtils.getDaysInMonth(month.year, month.month); + + // Can we use the preferred day in this month? + if (preferredDay <= daysInMonth) { + final DateTime newFocus = DateTime(month.year, month.month, preferredDay); + if (_isSelectable(newFocus)) return newFocus; + } + + // Start at the 1st and take the first selectable date. + for (int day = 1; day <= daysInMonth; day++) { + final DateTime newFocus = DateTime(month.year, month.month, day); + if (_isSelectable(newFocus)) return newFocus; + } + return null; + } + + /// Navigate to the next month. + void _handleNextMonth() { + if (!_isDisplayingLastMonth) { + SemanticsService.announce( + _localizations.formatMonthYear(_nextMonthDate), + _textDirection, + ); + _pageController.nextPage( + duration: _monthScrollDuration, + curve: Curves.ease, + ); + } + } + + /// Navigate to the previous month. + void _handlePreviousMonth() { + if (!_isDisplayingFirstMonth) { + SemanticsService.announce( + _localizations.formatMonthYear(_previousMonthDate), + _textDirection, + ); + _pageController.previousPage( + duration: _monthScrollDuration, + curve: Curves.ease, + ); + } + } + + /// Navigate to the given month. + void _showMonth(DateTime month, {bool jump = false}) { + final int monthPage = DateUtils.monthDelta(widget.firstDate, month); + if (jump) { + _pageController.jumpToPage(monthPage); + } else { + _pageController.animateToPage( + monthPage, + duration: _monthScrollDuration, + curve: Curves.ease, + ); + } + } + + /// True if the earliest allowable month is displayed. + bool get _isDisplayingFirstMonth { + return !_currentMonth.isAfter( + DateTime(widget.firstDate.year, widget.firstDate.month), + ); + } + + /// True if the latest allowable month is displayed. + bool get _isDisplayingLastMonth { + return !_currentMonth.isBefore( + DateTime(widget.lastDate.year, widget.lastDate.month), + ); + } + + /// Handler for when the overall day grid obtains or loses focus. + void _handleGridFocusChange(bool focused) { + setState(() { + if (focused && _focusedDay == null) { + if (DateUtils.isSameMonth(widget.selectedDate, _currentMonth)) { + _focusedDay = widget.selectedDate; + } else if (DateUtils.isSameMonth(widget.currentDate, _currentMonth)) { + _focusedDay = + _focusableDayForMonth(_currentMonth, widget.currentDate.day); + } else { + _focusedDay = _focusableDayForMonth(_currentMonth, 1); + } + } + }); + } + + /// Move focus to the next element after the day grid. + void _handleGridNextFocus(NextFocusIntent intent) { + _dayGridFocus.requestFocus(); + _dayGridFocus.nextFocus(); + } + + /// Move focus to the previous element before the day grid. + void _handleGridPreviousFocus(PreviousFocusIntent intent) { + _dayGridFocus.requestFocus(); + _dayGridFocus.previousFocus(); + } + + /// Move the internal focus date in the direction of the given intent. + /// + /// This will attempt to move the focused day to the next selectable day in + /// the given direction. If the new date is not in the current month, then + /// the page view will be scrolled to show the new date's month. + /// + /// For horizontal directions, it will move forward or backward a day (depending + /// on the current [TextDirection]). For vertical directions it will move up and + /// down a week at a time. + void _handleDirectionFocus(DirectionalFocusIntent intent) { + assert(_focusedDay != null); + setState(() { + final DateTime? nextDate = + _nextDateInDirection(_focusedDay!, intent.direction); + if (nextDate != null) { + _focusedDay = nextDate; + if (!DateUtils.isSameMonth(_focusedDay, _currentMonth)) { + _showMonth(_focusedDay!); + } + } + }); + } + + static const Map _directionOffset = + { + TraversalDirection.up: -DateTime.daysPerWeek, + TraversalDirection.right: 1, + TraversalDirection.down: DateTime.daysPerWeek, + TraversalDirection.left: -1, + }; + + int _dayDirectionOffset( + TraversalDirection traversalDirection, TextDirection textDirection) { + // Swap left and right if the text direction if RTL + if (textDirection == TextDirection.rtl) { + if (traversalDirection == TraversalDirection.left) + traversalDirection = TraversalDirection.right; + else if (traversalDirection == TraversalDirection.right) + traversalDirection = TraversalDirection.left; + } + return _directionOffset[traversalDirection]!; + } + + DateTime? _nextDateInDirection(DateTime date, TraversalDirection direction) { + final TextDirection textDirection = Directionality.of(context); + DateTime nextDate = DateUtils.addDaysToDate( + date, _dayDirectionOffset(direction, textDirection)); + while (!nextDate.isBefore(widget.firstDate) && + !nextDate.isAfter(widget.lastDate)) { + if (_isSelectable(nextDate)) { + return nextDate; + } + nextDate = DateUtils.addDaysToDate( + nextDate, _dayDirectionOffset(direction, textDirection)); + } + return null; + } + + bool _isSelectable(DateTime date) { + return widget.selectableDayPredicate == null || + widget.selectableDayPredicate!.call(date); + } + + Widget _buildItems(BuildContext context, int index) { + final DateTime month = + DateUtils.addMonthsToMonthDate(widget.firstDate, index); + return _DayPicker( + key: ValueKey(month), + selectedDate: widget.selectedDate, + currentDate: widget.currentDate, + onChanged: _handleDateSelected, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + displayedMonth: month, + selectableDayPredicate: widget.selectableDayPredicate, + weekTextStyle: widget.weekTextStyle, + weekendTextStyle: widget.weekendTextStyle, + selectedDateDecoration: widget.selectedDateDecoration, + currentDateDecoration: widget.currentDateDecoration, + ); + } + + @override + Widget build(BuildContext context) { + final String previousTooltipText = + '${_localizations.previousMonthTooltip} ${_localizations.formatMonthYear(_previousMonthDate)}'; + final String nextTooltipText = + '${_localizations.nextMonthTooltip} ${_localizations.formatMonthYear(_nextMonthDate)}'; + final Color controlColor = + Theme.of(context).colorScheme.onSurface.withOpacity(0.60); + + return Semantics( + child: Column( + children: [ + Container( + padding: const EdgeInsetsDirectional.only(start: 16, end: 4), + height: _subHeaderHeight, + child: Row( + children: [ + const Spacer(), + IconButton( + icon: const Icon(Icons.chevron_left), + color: controlColor, + tooltip: _isDisplayingFirstMonth ? null : previousTooltipText, + onPressed: + _isDisplayingFirstMonth ? null : _handlePreviousMonth, + ), + IconButton( + icon: const Icon(Icons.chevron_right), + color: controlColor, + tooltip: _isDisplayingLastMonth ? null : nextTooltipText, + onPressed: _isDisplayingLastMonth ? null : _handleNextMonth, + ), + ], + ), + ), + Expanded( + child: FocusableActionDetector( + shortcuts: _shortcutMap, + actions: _actionMap, + focusNode: _dayGridFocus, + onFocusChange: _handleGridFocusChange, + child: _FocusedDate( + date: _dayGridFocus.hasFocus ? _focusedDay : null, + child: PageView.builder( + key: _pageViewKey, + controller: _pageController, + itemBuilder: _buildItems, + itemCount: + DateUtils.monthDelta(widget.firstDate, widget.lastDate) + + 1, + scrollDirection: Axis.vertical, + onPageChanged: _handleMonthPageChanged, + ), + ), + ), + ), + ], + ), + ); + } +} + +///年月时间按钮展示 +class _DatePickerModeToggleButton extends StatefulWidget { + const _DatePickerModeToggleButton( + {required this.mode, + required this.title, + required this.onTitlePressed, + this.textStyle}); + + /// The current display of the calendar picker. + final DatePickerMode mode; + + /// The text that displays the current month/year being viewed. + final String title; + + /// The callback when the title is pressed. + final VoidCallback onTitlePressed; + + final TextStyle? textStyle; + + @override + _DatePickerModeToggleButtonState createState() => + _DatePickerModeToggleButtonState(); +} + +class _DatePickerModeToggleButtonState + extends State<_DatePickerModeToggleButton> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + value: widget.mode == DatePickerMode.year ? 0.5 : 0, + upperBound: 0.5, + duration: const Duration(milliseconds: 200), + vsync: this, + ); + } + + @override + void didUpdateWidget(_DatePickerModeToggleButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.mode == widget.mode) { + return; + } + + if (widget.mode == DatePickerMode.year) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final TextTheme textTheme = Theme.of(context).textTheme; + final Color controlColor = colorScheme.onSurface.withOpacity(0.60); + + return Container( + padding: const EdgeInsetsDirectional.only(start: 16, end: 4), + height: _subHeaderHeight, + child: Row( + children: [ + Flexible( + child: Semantics( + label: MaterialLocalizations.of(context).selectYearSemanticsLabel, + excludeSemantics: true, + button: true, + child: SizedBox( + height: _subHeaderHeight, + child: InkWell( + onTap: widget.onTitlePressed, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Flexible( + child: Text( + widget.title, + overflow: TextOverflow.ellipsis, + style: widget.textStyle ?? + textTheme.titleMedium?.copyWith( + color: controlColor, + ), + ), + ), + RotationTransition( + turns: _controller, + child: Icon( + Icons.arrow_drop_down, + color: controlColor, + ), + ), + ], + ), + ), + ), + ), + ), + ), + if (widget.mode == DatePickerMode.day) + // Give space for the prev/next month buttons that are underneath this row + const SizedBox(width: _monthNavButtonsWidth), + ], + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} + +///全局变量 +const double _subHeaderHeight = 52.0; +const Duration _monthScrollDuration = Duration(milliseconds: 200); +const double _dayPickerRowHeight = 42.0; +const double _monthPickerHorizontalPadding = 8.0; +const _DayPickerGridDelegate _dayPickerGridDelegate = _DayPickerGridDelegate(); +const int _maxDayPickerRowCount = 6; +const double _maxDayPickerHeight = + _dayPickerRowHeight * (_maxDayPickerRowCount + 1); +const double _monthNavButtonsWidth = 108.0; +const double _yearPickerPadding = 16.0; + +const int _yearPickerColumnCount = 3; +const double _yearPickerRowHeight = 52.0; +const double _yearPickerRowSpacing = 8.0; + +class _FocusedDate extends InheritedWidget { + const _FocusedDate({ + Key? key, + required Widget child, + this.date, + }) : super(key: key, child: child); + + final DateTime? date; + + @override + bool updateShouldNotify(_FocusedDate oldWidget) { + return !DateUtils.isSameDay(date, oldWidget.date); + } + + static DateTime? of(BuildContext context) { + final _FocusedDate? focusedDate = + context.dependOnInheritedWidgetOfExactType<_FocusedDate>(); + return focusedDate?.date; + } +} + +///绘制日 +class _DayPicker extends StatefulWidget { + /// Creates a day picker. + _DayPicker( + {Key? key, + required this.currentDate, + required this.displayedMonth, + required this.firstDate, + required this.lastDate, + required this.selectedDate, + required this.onChanged, + this.selectableDayPredicate, + this.weekTextStyle, + this.weekendTextStyle, + this.currentDateDecoration, + this.selectedDateDecoration}) + : assert(currentDate != null), + assert(displayedMonth != null), + assert(firstDate != null), + assert(lastDate != null), + assert(selectedDate != null), + assert(onChanged != null), + assert(!firstDate.isAfter(lastDate)), + assert(!selectedDate.isBefore(firstDate)), + assert(!selectedDate.isAfter(lastDate)), + super(key: key); + + /// The currently selected date. + /// + /// This date is highlighted in the picker. + final DateTime selectedDate; + + /// The current date at the time the picker is displayed. + final DateTime currentDate; + + /// Called when the user picks a day. + final ValueChanged onChanged; + + /// The earliest date the user is permitted to pick. + /// + /// This date must be on or before the [lastDate]. + final DateTime firstDate; + + /// The latest date the user is permitted to pick. + /// + /// This date must be on or after the [firstDate]. + final DateTime lastDate; + + /// The month whose days are displayed by this picker. + final DateTime displayedMonth; + + /// Optional user supplied predicate function to customize selectable days. + final SelectableDayPredicate? selectableDayPredicate; + + ///开始时间和结束时间按钮样式 + final BoxDecoration? selectedDateDecoration; + + ///当前时间样式 + final BoxDecoration? currentDateDecoration; + + ///周六周天字体颜色 + final TextStyle? weekendTextStyle; + + ///周字体颜色 + final TextStyle? weekTextStyle; + + @override + _DayPickerState createState() => _DayPickerState(); +} + +class _DayPickerState extends State<_DayPicker> { + /// List of [FocusNode]s, one for each day of the month. + late List _dayFocusNodes; + + @override + void initState() { + super.initState(); + final int daysInMonth = DateUtils.getDaysInMonth( + widget.displayedMonth.year, widget.displayedMonth.month); + _dayFocusNodes = List.generate( + daysInMonth, + (int index) => + FocusNode(skipTraversal: true, debugLabel: 'Day ${index + 1}'), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Check to see if the focused date is in this month, if so focus it. + final DateTime? focusedDate = _FocusedDate.of(context); + if (focusedDate != null && + DateUtils.isSameMonth(widget.displayedMonth, focusedDate)) { + _dayFocusNodes[focusedDate.day - 1].requestFocus(); + } + } + + @override + void dispose() { + for (final FocusNode node in _dayFocusNodes) { + node.dispose(); + } + super.dispose(); + } + + /// Builds widgets showing abbreviated days of week. The first widget in the + /// returned list corresponds to the first day of week for the current locale. + /// + /// Examples: + /// + /// ``` + /// ┌ Sunday is the first day of week in the US (en_US) + /// | + /// S M T W T F S <-- the returned list contains these widgets + /// _ _ _ _ _ 1 2 + /// 3 4 5 6 7 8 9 + /// + /// ┌ But it's Monday in the UK (en_GB) + /// | + /// M T W T F S S <-- the returned list contains these widgets + /// _ _ _ _ 1 2 3 + /// 4 5 6 7 8 9 10 + /// ``` + List _dayHeaders( + TextStyle? headerStyle, MaterialLocalizations localizations) { + final List result = []; + for (int i = localizations.firstDayOfWeekIndex; true; i = (i + 1) % 7) { + final String weekday = localizations.narrowWeekdays[i]; + result.add(ExcludeSemantics( + child: Center( + child: Text(weekday, + style: i == 0 || i == 6 + ? widget.weekendTextStyle ?? widget.weekTextStyle + : headerStyle)), + )); + if (i == (localizations.firstDayOfWeekIndex - 1) % 7) break; + } + return result; + } + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final MaterialLocalizations localizations = + MaterialLocalizations.of(context); + final TextTheme textTheme = Theme.of(context).textTheme; + final TextStyle? headerStyle = textTheme.titleSmall?.apply( + color: colorScheme.onSurface.withOpacity(0.60), + ); + final TextStyle dayStyle = textTheme.titleSmall!; + final Color enabledDayColor = colorScheme.onSurface.withOpacity(0.87); + final Color disabledDayColor = colorScheme.onSurface.withOpacity(0.38); + final Color selectedDayColor = colorScheme.onPrimary; + final Color selectedDayBackground = colorScheme.primary; + // final Color todayColor = colorScheme.primary; + final Color todayColor = Color(0xff4D6FD5); + + final int year = widget.displayedMonth.year; + final int month = widget.displayedMonth.month; + + final int daysInMonth = DateUtils.getDaysInMonth(year, month); + final int dayOffset = DateUtils.firstDayOffset(year, month, localizations); + + final List dayItems = _dayHeaders(headerStyle, localizations); + // 1-based day of month, e.g. 1-31 for January, and 1-29 for February on + // a leap year. + int day = -dayOffset; + while (day < daysInMonth) { + day++; + if (day < 1) { + dayItems.add(Container()); + } else { + final DateTime dayToBuild = DateTime(year, month, day); + final bool isDisabled = dayToBuild.isAfter(widget.lastDate) || + dayToBuild.isBefore(widget.firstDate) || + (widget.selectableDayPredicate != null && + !widget.selectableDayPredicate!(dayToBuild)); + final bool isSelectedDay = + DateUtils.isSameDay(widget.selectedDate, dayToBuild); + final bool isToday = + DateUtils.isSameDay(widget.currentDate, dayToBuild); + + BoxDecoration? decoration; + Color dayColor = enabledDayColor; + if (isSelectedDay) { + // The selected day gets a circle background highlight, and a + // contrasting text color. + dayColor = selectedDayColor; + decoration = widget.selectedDateDecoration ?? + BoxDecoration( + color: selectedDayBackground, + shape: BoxShape.circle, + ); + } else if (isDisabled) { + dayColor = disabledDayColor; + } else if (isToday) { + // The current day gets a different text color and a circle stroke + // border. + dayColor = todayColor; + decoration = BoxDecoration( + border: Border.all(color: todayColor, width: 1), + shape: BoxShape.circle, + ); + } + + Widget dayWidget = Container( + decoration: decoration, + child: Center( + child: Text(localizations.formatDecimal(day), + style: dayStyle.apply(color: dayColor)), + ), + ); + + if (isDisabled) { + dayWidget = ExcludeSemantics( + child: dayWidget, + ); + } else { + dayWidget = InkResponse( + focusNode: _dayFocusNodes[day - 1], + onTap: () => widget.onChanged(dayToBuild), + radius: _dayPickerRowHeight / 2 + 4, + splashColor: selectedDayBackground.withOpacity(0.38), + child: Semantics( + // We want the day of month to be spoken first irrespective of the + // locale-specific preferences or TextDirection. This is because + // an accessibility user is more likely to be interested in the + // day of month before the rest of the date, as they are looking + // for the day of month. To do that we prepend day of month to the + // formatted full date. + label: + '${localizations.formatDecimal(day)}, ${localizations.formatFullDate(dayToBuild)}', + selected: isSelectedDay, + excludeSemantics: true, + child: dayWidget, + ), + ); + } + + dayItems.add(dayWidget); + } + } + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: _monthPickerHorizontalPadding, + ), + child: GridView.custom( + padding: EdgeInsets.zero, + physics: const ClampingScrollPhysics(), + gridDelegate: _dayPickerGridDelegate, + childrenDelegate: SliverChildListDelegate( + dayItems, + addRepaintBoundaries: false, + ), + ), + ); + } +} + +class _DayPickerGridDelegate extends SliverGridDelegate { + const _DayPickerGridDelegate(); + + @override + SliverGridLayout getLayout(SliverConstraints constraints) { + const int columnCount = DateTime.daysPerWeek; + final double tileWidth = constraints.crossAxisExtent / columnCount; + final double tileHeight = math.min( + _dayPickerRowHeight, + constraints.viewportMainAxisExtent / (_maxDayPickerRowCount + 1), + ); + return SliverGridRegularTileLayout( + childCrossAxisExtent: tileWidth, + childMainAxisExtent: tileHeight, + crossAxisCount: columnCount, + crossAxisStride: tileWidth, + mainAxisStride: tileHeight, + reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), + ); + } + + @override + bool shouldRelayout(_DayPickerGridDelegate oldDelegate) => false; +} + +///绘制年 +class _YearPicker extends StatefulWidget { + /// Creates a year picker. + /// + /// The [firstDate], [lastDate], [selectedDate], and [onChanged] + /// arguments must be non-null. The [lastDate] must be after the [firstDate]. + _YearPicker( + {Key? key, + DateTime? currentDate, + required this.firstDate, + required this.lastDate, + DateTime? initialDate, + required this.selectedDate, + required this.onChanged, + this.dragStartBehavior = DragStartBehavior.start, + this.selectedYearDecoration}) + : assert(firstDate != null), + assert(lastDate != null), + assert(selectedDate != null), + assert(onChanged != null), + assert(!firstDate.isAfter(lastDate)), + currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()), + initialDate = DateUtils.dateOnly(initialDate ?? selectedDate), + super(key: key); + + /// The current date. + /// + /// This date is subtly highlighted in the picker. + final DateTime currentDate; + + /// The earliest date the user is permitted to pick. + final DateTime firstDate; + + /// The latest date the user is permitted to pick. + final DateTime lastDate; + + /// The initial date to center the year display around. + final DateTime initialDate; + + /// The currently selected date. + /// + /// This date is highlighted in the picker. + final DateTime selectedDate; + + /// Called when the user picks a year. + final ValueChanged onChanged; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + ///开始时间和结束时间按钮样式 + final BoxDecoration? selectedYearDecoration; + + @override + _YearPickerState createState() => _YearPickerState(); +} + +class _YearPickerState extends State<_YearPicker> { + late ScrollController _scrollController; + + // The approximate number of years necessary to fill the available space. + static const int minYears = 18; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController( + initialScrollOffset: _scrollOffsetForYear(widget.selectedDate)); + } + + @override + void didUpdateWidget(_YearPicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.selectedDate != oldWidget.selectedDate) { + _scrollController.jumpTo(_scrollOffsetForYear(widget.selectedDate)); + } + } + + double _scrollOffsetForYear(DateTime date) { + final int initialYearIndex = date.year - widget.firstDate.year; + final int initialYearRow = initialYearIndex ~/ _yearPickerColumnCount; + // Move the offset down by 2 rows to approximately center it. + final int centeredYearRow = initialYearRow - 2; + return _itemCount < minYears ? 0 : centeredYearRow * _yearPickerRowHeight; + } + + Widget _buildYearItem(BuildContext context, int index) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final TextTheme textTheme = Theme.of(context).textTheme; + + // Backfill the _YearPicker with disabled years if necessary. + final int offset = _itemCount < minYears ? (minYears - _itemCount) ~/ 2 : 0; + final int year = widget.firstDate.year + index - offset; + final bool isSelected = year == widget.selectedDate.year; + final bool isCurrentYear = year == widget.currentDate.year; + final bool isDisabled = + year < widget.firstDate.year || year > widget.lastDate.year; + const double decorationHeight = 36.0; + const double decorationWidth = 72.0; + + final Color textColor; + if (isSelected) { + textColor = colorScheme.onPrimary; + } else if (isDisabled) { + textColor = colorScheme.onSurface.withOpacity(0.38); + } else if (isCurrentYear) { + // textColor = colorScheme.primary; + textColor = Color(0xff4D6FD5); + } else { + textColor = colorScheme.onSurface.withOpacity(0.87); + } + final TextStyle? itemStyle = textTheme.bodyMedium?.apply(color: textColor); + + BoxDecoration? decoration; + if (isSelected) { + decoration = widget.selectedYearDecoration ?? + BoxDecoration( + color: colorScheme.primary, + borderRadius: BorderRadius.circular(decorationHeight / 2), + shape: BoxShape.rectangle, + ); + } else if (isCurrentYear && !isDisabled) { + decoration = BoxDecoration( + border: Border.all( + // color: colorScheme.primary, + color: Color(0xff4D6FD5), + width: 1, + ), + borderRadius: BorderRadius.circular(decorationHeight / 2), + shape: BoxShape.rectangle, + ); + } + + Widget yearItem = Center( + child: Container( + decoration: decoration, + height: decorationHeight, + width: decorationWidth, + child: Center( + child: Semantics( + selected: isSelected, + child: Text(year.toString(), style: itemStyle), + ), + ), + ), + ); + + if (isDisabled) { + yearItem = ExcludeSemantics( + child: yearItem, + ); + } else { + yearItem = InkWell( + key: ValueKey(year), + onTap: () => + widget.onChanged(DateTime(year, widget.initialDate.month, 1)), + child: yearItem, + ); + } + + return yearItem; + } + + int get _itemCount { + return widget.lastDate.year - widget.firstDate.year + 1; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + return Column( + children: [ + const Divider(), + Expanded( + child: GridView.builder( + controller: _scrollController, + dragStartBehavior: widget.dragStartBehavior, + gridDelegate: _yearPickerGridDelegate, + itemBuilder: _buildYearItem, + itemCount: math.max(_itemCount, minYears), + padding: const EdgeInsets.symmetric(horizontal: _yearPickerPadding), + ), + ), + const Divider(), + ], + ); + } +} + +class _YearPickerGridDelegate extends SliverGridDelegate { + const _YearPickerGridDelegate(); + + @override + SliverGridLayout getLayout(SliverConstraints constraints) { + final double tileWidth = (constraints.crossAxisExtent - + (_yearPickerColumnCount - 1) * _yearPickerRowSpacing) / + _yearPickerColumnCount; + return SliverGridRegularTileLayout( + childCrossAxisExtent: tileWidth, + childMainAxisExtent: _yearPickerRowHeight, + crossAxisCount: _yearPickerColumnCount, + crossAxisStride: tileWidth + _yearPickerRowSpacing, + mainAxisStride: _yearPickerRowHeight, + reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), + ); + } + + @override + bool shouldRelayout(_YearPickerGridDelegate oldDelegate) => false; +} + +const _YearPickerGridDelegate _yearPickerGridDelegate = + _YearPickerGridDelegate(); diff --git a/flutter_common/lib/http/http_utils.dart b/flutter_common/lib/http/http_utils.dart new file mode 100755 index 0000000..6a319cd --- /dev/null +++ b/flutter_common/lib/http/http_utils.dart @@ -0,0 +1,326 @@ +import 'dart:convert'; +import 'dart:developer'; +import 'dart:ui'; +import 'package:dio/dio.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import '../utils/toast_utils.dart'; + +///请求方式-枚举类型 +enum MethodType { get, post, delete, put, patch } + +class HttpUtils { + static final HttpUtils _instance = HttpUtils._internal(baseUrl: ''); + + factory HttpUtils() => _instance; + + late Dio dio; + + HttpUtils._internal({required String baseUrl}) { + BaseOptions options = BaseOptions( + baseUrl: baseUrl, + contentType: 'application/json; charset=utf-8', + connectTimeout: const Duration(milliseconds: 15000), + receiveTimeout: const Duration(milliseconds: 15000), + responseType: ResponseType.json, + validateStatus: (status) { + return true; + }, + ); + + dio = Dio(options); + + /// 添加请求日志 + dio.interceptors.add(LogInterceptor( + requestBody: true, + responseHeader: false, + responseBody: false, + error: false, + )); + + /// 添加拦截器 + dio.interceptors.add(InterceptorsWrapper( + onResponse: (response, handler) { + /// 打印返回值 + const bool inProduction = bool.fromEnvironment("dart.vm.product"); + if (!inProduction) { + log( + jsonEncode(response.data), + name: 'Response Text: ', + ); + } + + /// 对接口抛出异常的检查 + _apiVerify(response); + return handler.next(response); + }, + )); + } + + ///统一请求入口 + static Future request( + String baseUrl, + String path, { + String? token, + MethodType method = MethodType.get, + Map? headers, + Map? body, + Map? queryParameters, + bool isShowLoading = false, + CancelToken? cancelToken, + VoidCallback? unLoginAction, + }) async { + ///开始网络请求 + late NetResult netResult; + Dio dio = HttpUtils._internal(baseUrl: baseUrl).dio; + // String? token1 = token ?? ''; + Map headerMap = {}; + if (headers != null) headerMap.addAll(headers); + headerMap['token'] = token; + headerMap['Authorization'] = token; + Options options = Options( + method: method.name, + headers: headerMap, + ); + CancelToken newCancelToken = cancelToken ?? CancelToken(); + try { + if (isShowLoading) { + await EasyLoading.show( + // status: 'loading...', + maskType: EasyLoadingMaskType.black, + ); + } + + final response = await dio.request( + path, + options: options, + queryParameters: queryParameters, + data: body, + cancelToken: newCancelToken, + ); + // if (response.headers["authorization"] != null) { + // LocalStore.setValue( + // key: StoreValue.tokenKey, + // value: response.headers["authorization"]![0]); + // } + if (isShowLoading) EasyLoading.dismiss(); + netResult = NetResult( + headers: response.headers, + result: response.data, + statusCode: response.statusCode ?? 0, + error: _apiIsSucceed(response), + ); + } on DioError catch (error) { + if (isShowLoading) EasyLoading.dismiss(); + _formatError(error); + netResult = NetResult( + errorMeg: error.message ?? '服务器出现错误', + ); + } + return netResult; + } + + ///下载 + static Future download( + String path, + String savePath, { + ProgressCallback? onReceiveProgress, + Map? queryParameters, + CancelToken? cancelToken, + bool deleteOnError = true, + String lengthHeader = Headers.contentLengthHeader, + data, + Options? options, + }) async { + ///开始网络请求 + late NetResult netResult; + try { + Response response = await Dio().download( + path, + savePath, + onReceiveProgress: onReceiveProgress, + queryParameters: queryParameters, + cancelToken: cancelToken, + deleteOnError: deleteOnError, + lengthHeader: lengthHeader, + data: data, + options: options, + ); + netResult = NetResult( + headers: response.headers, + result: 'Download successful', + statusCode: response.statusCode ?? 0, + error: response.statusCode != 200, + ); + } on DioError catch (error) { + _formatError(error); + netResult = NetResult( + errorMeg: error.message ?? '服务器出现错误', + result: 'Download failure', + ); + } + return netResult; + } + + ///判断接口是否成功 + static bool _apiIsSucceed(Response response) { + if (response.data != null && response.data != '') { + Map map = response.data; + return map['resultCode'] == 'OK' ? false : true; + } + return false; + } + + ///接口校验 + static void _apiVerify(Response response, {VoidCallback? unLoginAction}) { + if (response.data != null && response.data != '') { + ///判断返回值是否是Map + if (response.data is Map) { + Map map = response.data; + String resultCode = map['resultCode']; + + if (resultCode.toUpperCase() == "FAIL") { + throw DioException( + requestOptions: RequestOptions(path: ''), + response: response, + type: DioExceptionType.unknown, + error: response.data, + message: map['resultMsg'], + ); + } + + if (resultCode.toUpperCase() == "UNLOGIN") { + unLoginAction?.call(); + return; + } + } else if (response.data is String) { + throw DioException( + requestOptions: RequestOptions(path: ''), + response: response, + type: DioExceptionType.unknown, + error: response.data, + message: response.data, + ); + } + } else { + throw DioException( + requestOptions: RequestOptions(path: ''), + response: response, + type: DioExceptionType.unknown, + error: response.data, + message: response.data, + ); + } + } + + /* + * Dio库error统一处理 + */ + static void _formatError(DioException error) { + switch (error.type) { + case DioExceptionType.cancel: + break; + case DioExceptionType.connectionTimeout: + ToastUtils.showToast(msg: '网络连接超时'); + break; + case DioExceptionType.sendTimeout: + ToastUtils.showToast(msg: '网络请求超时'); + break; + case DioExceptionType.receiveTimeout: + ToastUtils.showToast(msg: '服务响应超时'); + break; + case DioExceptionType.unknown: + ToastUtils.showToast(msg: error.message); + break; + case DioExceptionType.badResponse: + try { + int? errCode = error.response!.statusCode; + String? errMsg = error.response!.statusMessage; + switch (errCode) { + case 400: + ToastUtils.errorToast(errorText: '请求语法错误'); + break; + case 401: + ToastUtils.errorToast(errorText: '没有权限'); + break; + case 403: + ToastUtils.errorToast(errorText: '服务器拒绝执行'); + break; + case 404: + ToastUtils.errorToast(errorText: '无法连接服务器'); + break; + case 405: + ToastUtils.errorToast(errorText: '请求方法被禁止'); + break; + case 500: + ToastUtils.errorToast(errorText: '服务器出现问题'); + break; + case 502: + ToastUtils.errorToast(errorText: '无效的请求'); + break; + case 503: + ToastUtils.errorToast(errorText: '服务器挂了'); + break; + case 505: + ToastUtils.errorToast(errorText: '不支持HTTP协议请求'); + break; + default: + ToastUtils.errorToast(errorText: errMsg ?? '未知错误'); + break; + } + } on Exception catch (_) { + ToastUtils.errorToast(errorText: '未知错误'); + break; + } + break; + default: + ToastUtils.errorToast(errorText: error.message); + break; + } + } +} + +/// 自定义Dio返回类 +class NetResult { + dynamic result; + + dynamic headers; + + int total; + + bool error; + + int statusCode; + + String errorMeg; + + bool get success => !error; + + NetResult({ + this.result, + this.headers, + this.statusCode = -1, + this.error = true, + this.total = 0, + this.errorMeg = '', + }); + + @override + String toString() { + return 'NetResult:statusCode: $statusCode \n' + 'NetResult: headers: $headers \n' + 'NetResult: error: $error \n' + 'NetResult: total: $total \n' + 'NetResult: errorMeg: $errorMeg \n' + 'NetResult: result: ${result.toString()}'; + } +} + +/// 自定义Dio取消请求处理类 +class CustomCancelToken { + static CancelToken cancelToken = CancelToken(); + + static cancel() { + cancelToken.cancel('取消请求'); + cancelToken = CancelToken(); + } +} diff --git a/flutter_common/lib/kola/colors.dart b/flutter_common/lib/kola/colors.dart new file mode 100644 index 0000000..e6ce5a4 --- /dev/null +++ b/flutter_common/lib/kola/colors.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +class JHYJColors { + ///背景色 + static const Color backgroundColor = Color(0xffF5F6F7); + +// Color(0xffF0F4F8) + static const Color themeColor = Color(0xffF3C5BB); + + //JHYJColors.themeTextColor + static const Color themeTextColor = Color(0xff1A1A1A); + + static const Color appBarColor = Color(0xff4B67F8); + + static const Color contentGreyColor = Color(0xffAAAAAA); + + ///7 + static const Color content7Color = Color(0xff777777); + static const Color contentF5Color = Color(0xfff5f5f5); + static const Color contentCCCColor = Color(0xffCCCCCC); + static const Color contentRedColor = Color(0xffE62222); + + static const Color redTextColor = Color(0xffF84B4B); + static const Color contentE62222 = Color(0xffE62222); + static const Color content443F4E = Color(0xff443F4E); + static const Color contentFF7A3B = Color(0xffFF7A3B); + static const Color content4572E2 = Color(0xff4572E2); + static const Color contentF4F5FF = Color(0xffF4F5FF); + static const Color content46A5FF = Color(0xff46A5FF); + static const Color contentE0D0EA = Color(0xffE0D0EA); + static const Color contentD5F3F4 = Color(0xffD5F3F4); + static const Color content56C1D2 = Color(0xff56C1D2); + + + + //内容影响力颜色 + static const List contentColor = [ + Color(0xff0152C7), + Color(0xffF84B4B), + Color(0xffD25DD6), + Color(0xff5ED3E5), + Color(0xff4B67F8), + Color(0xff5CD1F7), + ]; +} \ No newline at end of file diff --git a/flutter_common/lib/kola/goods_edite_title_widget.dart b/flutter_common/lib/kola/goods_edite_title_widget.dart new file mode 100644 index 0000000..2e0415d --- /dev/null +++ b/flutter_common/lib/kola/goods_edite_title_widget.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_common/utils/customer.dart'; +import 'package:flutter_common/value/string.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +///商品文字标题 +class GoodsEditeTitleWidget extends StatelessWidget { + final String title; + final TextStyle? style; + final String? hint; //灰色提示 + final bool? isRed; //是否有红点 + final Widget? rightChild; + final Widget? contentChild; + + const GoodsEditeTitleWidget({ + super.key, + required this.title, + this.hint, + this.isRed, + this.rightChild, + this.contentChild, + this.style, + }); + + @override + Widget build(BuildContext context) { + return Column( + key: Key(BaseStringValue.cellKeyString(string: title)), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + title, + style: style ?? + CustomerTextStyle( + customerFontSize: 30.sp, + customerFontWeight: FontWeight.bold, + ), + ), + isRed == true + ? Text( + ' * ', + textAlign: TextAlign.start, + style: CustomerTextStyle( + customerColor: Colors.red, + customerFontSize: 20.sp, + customerFontWeight: FontWeight.bold, + ), + ) + : const SizedBox(), + Text( + hint ?? '', + textAlign: TextAlign.start, + style: CustomerTextStyle( + customerColor: const Color(0xff777777), + customerFontSize: 24.sp, + ), + ), + const Expanded(child: SizedBox()), + rightChild ?? const SizedBox(), + ], + ), + Padding( + padding: EdgeInsets.only(top: 16.h, bottom: 16.h), + child: contentChild ?? const SizedBox(), + ), + ], + ); + } +} + +/// +class GoodsEditeContentWidget extends StatelessWidget { + final Widget? child; + final EdgeInsetsGeometry? padding; + final double? borderRadius; + final double? height; + final Color? backColor; + final VoidCallback? onTap; + + const GoodsEditeContentWidget({ + super.key, + this.child, + this.padding, + this.borderRadius, + this.height, + this.backColor, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + height: height, + padding: padding ?? const EdgeInsets.all(10), + decoration: BoxDecoration( + color: backColor ?? Colors.white, + borderRadius: BorderRadius.circular(borderRadius?.h ?? 30.h), + ), + child: child ?? const SizedBox(), + ), + ); + } +} + +///输入框 +class GoodsEditeTextFiled extends StatefulWidget { + final String? hint; + final TextInputType? keyboardType; + final String? prefixText; + final String? suffixText; + final int? maxLines; + final Function? onChanged; + final String? content; + final double? borderRadius; + final double? height; + final bool? obscureText; + final bool? readOnly; + final Color? backColor; + final int? maxNumberText; + final bool? hadOver; //是否必须输入才返回,默认是false + final TextInputAction? textInputAction; + final TextAlign? textAlign; + + const GoodsEditeTextFiled({ + super.key, + this.hint, + this.keyboardType, + this.prefixText, + this.suffixText, + this.maxLines, + this.onChanged, + this.content, + this.borderRadius, + this.obscureText = false, + this.height, + this.readOnly = false, + this.backColor, + this.maxNumberText, + this.hadOver = false, + this.textInputAction, + this.textAlign, + }); + + @override + State createState() => _GoodsEditeTextFiledState(); +} + +class _GoodsEditeTextFiledState extends State { + TextEditingController controller = TextEditingController(); + FocusNode focusNode = FocusNode(); + + @override + void initState() { + controller = TextEditingController(text: widget.content); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return GoodsEditeContentWidget( + padding: EdgeInsets.zero, + backColor: widget.backColor, + borderRadius: widget.borderRadius, + height: widget.height, + child: Stack( + alignment: Alignment.bottomRight, + children: [ + TextField( + textAlign: widget.textAlign ?? TextAlign.start, + controller: controller, + focusNode: focusNode, + maxLength: widget.maxNumberText, + // textInputAction: TextInputAction.done, + maxLines: widget.maxLines, + style: CustomerTextStyle( + customerFontSize: 28.sp, + ), + readOnly: widget.readOnly ?? false, + obscureText: widget.obscureText ?? false, + keyboardType: widget.keyboardType ?? TextInputType.text, + textInputAction: widget.textInputAction ?? TextInputAction.done, + decoration: InputDecoration( + border: const OutlineInputBorder( + ///设置边框四个角的弧度 + // borderRadius: BorderRadius.all( + // Radius.circular( + // 12.5, + // ), + // ), + borderSide: BorderSide.none, + ), + counterText: '', + contentPadding: const EdgeInsets.all(0), + hintText: widget.hint ?? '', + hintStyle: CustomerTextStyle( + customerColor: const Color(0xffAAAAAA), + customerFontSize: 28.sp, + ), + prefixText: widget.prefixText ?? ' ', + prefixStyle: CustomerTextStyle( + customerFontSize: 28.sp, + ), + ), + onEditingComplete: () { + widget.onChanged?.call(controller.text); + }, + onSubmitted: widget.hadOver == true + ? (value) { + focusNode.unfocus(); + widget.onChanged?.call(value); + } + : (value) { + focusNode.unfocus(); + }, + onChanged: widget.hadOver == true + ? null + : (value) => widget.onChanged?.call(value), + ), + widget.maxNumberText == null + ? const SizedBox() + : Padding( + padding: EdgeInsets.only(bottom: 20.h, right: 30.w), + child: Text( + '${controller.text.length}/${widget.maxNumberText}', + style: CustomerTextStyle( + customerFontSize: 28.sp, + customerColor: const Color(0xffCCCCCC), + ), + ), + ), + ], + ), + ); + } +} diff --git a/flutter_common/lib/lineline/dash_line.dart b/flutter_common/lib/lineline/dash_line.dart new file mode 100755 index 0000000..bd8a4c0 --- /dev/null +++ b/flutter_common/lib/lineline/dash_line.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +///@Desc 虚线 +class DashLine extends StatelessWidget { + final Color? color; // 虚线颜色 + final Axis direction; // 虚线方向 + final double? dottedLength; //虚线长度 + + const DashLine({ + Key? key, + this.color, + this.direction = Axis.horizontal, + this.dottedLength, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: DashedLinePainter( + color: color ?? const Color(0xff36C2FD), + direction: direction, + dottedLength: dottedLength, + ), + ); + } +} + +class DashedLinePainter extends CustomPainter { + final Color color; + final double width; + final Axis direction; //方向 + final double? dottedLength; //虚线长度 + + DashedLinePainter({ + this.width = 1, + this.color = Colors.black, + this.direction = Axis.horizontal, + this.dottedLength, +}); + + @override + void paint(Canvas canvas, Size size) { + final Paint paint = Paint() + ..color = color + ..strokeWidth = width + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..isAntiAlias = true; + Path dottedPath = _createDashedPath( + Path() + ..moveTo(0, 0) + ..lineTo( + direction == Axis.horizontal ? size.width : 0, + direction == Axis.horizontal ? 0 : size.height, + ), + length: dottedLength, + ); + canvas.drawPath(dottedPath, paint); + } + + Path _createDashedPath(Path path,{double? length}) { + Path targetPath = Path(); + double dottedLength = length ?? 10; + double dottedGap = length ?? 10; + for (var metric in path.computeMetrics()) { + double distance = 0; + bool isDrawDotted = true; + while (distance < metric.length) { + if (isDrawDotted) { + targetPath.addPath( + metric.extractPath(distance, distance + dottedLength), + Offset.zero, + ); + distance += dottedLength; + } else { + distance += dottedGap; + } + isDrawDotted = !isDrawDotted; + } + } + return targetPath; + } + + @override + bool shouldRepaint(DashedLinePainter oldDelegate) => false; +} diff --git a/flutter_common/lib/lineline/time_line.dart b/flutter_common/lib/lineline/time_line.dart new file mode 100755 index 0000000..094d25b --- /dev/null +++ b/flutter_common/lib/lineline/time_line.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import 'dash_line.dart'; + +///@Desc 时间线 +class TimeLine extends StatelessWidget { + final bool isLast; + final Widget child; + + const TimeLine({ + Key? key, + required this.isLast, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return IntrinsicHeight( + child: Row( + children: [ + _buildDot(), + const SizedBox(width: 16), + Expanded(child: child), + ], + ), + ); + } + + Widget _buildDot() { + return Column( + children: [ + Container( + width: 24, + height: 24, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xff36C2FD), + ), + alignment: Alignment.center, + child: Container( + width: 12, + height: 12, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + ), + ), + ), + if (isLast) + const SizedBox.shrink() + else + const Expanded( + child: DashLine( + color: Color(0xff36C2FD), + direction: Axis.vertical, + ), + ) + ], + ); + } +} diff --git a/flutter_common/lib/rank_list/customer_rank_list.dart b/flutter_common/lib/rank_list/customer_rank_list.dart new file mode 100644 index 0000000..e57534a --- /dev/null +++ b/flutter_common/lib/rank_list/customer_rank_list.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_common/utils/customer.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +class CustomerRankListModel { + final List? content; + final String? detailTitle; + final List? detail; + + CustomerRankListModel({ + this.detailTitle, + this.content, + this.detail, + }); +} + +class CustomerRankListContent { + final String? content; + final TextAlign? textAlign; + + CustomerRankListContent({ + this.content, + this.textAlign, + }); +} + +class CustomerRankDetailContent { + final String? content; + final String? label; + + CustomerRankDetailContent({ + this.content, + this.label, + }); +} + +class CustomerRankListWidget extends StatelessWidget { + // final String? title; + final Widget? titleWidget; + final Color? titleBackgroundColor; + final Color? contentBackgroundColor; + final List titleList; + final List? contentList; + final double? titleHeight; + final double? contentHeight; + final Function? onTap; + final EdgeInsetsGeometry? padding; + final double? titleFontSize; + final double? contentFontSize; + + const CustomerRankListWidget({ + super.key, + required this.titleList, + this.contentList, + this.titleHeight, + this.contentHeight, + this.onTap, + this.padding, + this.titleWidget, + this.titleBackgroundColor = Colors.transparent, + this.contentBackgroundColor = Colors.transparent, + this.titleFontSize = 34, + this.contentFontSize = 30, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(50), + ), + padding: padding ?? EdgeInsets.symmetric(horizontal: 60.w), + child: Column( + children: [ + titleWidget ?? const SizedBox(), + Padding( + padding: EdgeInsets.only(top: 16.h), + child: detailWidget( + content: titleList, + isTitle: true, + ), + ), + Expanded( + child: ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.only( + top: 30.h, + bottom: 16.h, + ), + itemBuilder: (_, index) { + CustomerRankListModel? model = contentList?[index]; + return Column( + children: [ + detailWidget( + content: model?.content ?? [], + isTitle: false, + onTap: () => onTap?.call(model?.content?[1]), + ), + model?.detail == null + ? const SizedBox() + : contentDetailListWidget( + detailTitle: model?.detailTitle, + detail: model?.detail, + ), + ], + ); + }, + separatorBuilder: (_, index) { + return Divider( + height: 0.5, + color: const Color(0xff399FFF).withOpacity(0.05), + ); + }, + itemCount: contentList?.length ?? 0, + ), + ), + ], + ), + ); + } + + dealContentData({ + required Map content, + required int index, + }) { + return []; + } + + Widget detailWidget({ + required List content, + required bool isTitle, + VoidCallback? onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + height: + isTitle == true ? titleHeight?.h ?? 30.h : contentHeight?.h ?? 40.h, + decoration: BoxDecoration( + color: + isTitle != true ? contentBackgroundColor : titleBackgroundColor, + borderRadius: BorderRadius.circular(10.h), + ), + child: Row( + children: content + .map( + (e) => Expanded( + child: e?.content?.contains('assets/') == true + ? Image.asset( + e?.content ?? '', + width: contentHeight, + height: contentHeight, + fit: BoxFit.contain, + ) + : e?.content?.contains('http') == true + ? CustomerImagesNetworking( + imageUrl: e?.content ?? '', + width: contentHeight, + height: contentHeight, + fit: BoxFit.contain, + ) + : detailText( + e?.content ?? '', + isTitle: isTitle, + textAlign: e?.textAlign, + ), + ), + ) + .toList(), + ), + ), + ); + } + + Widget contentDetailListWidget({ + String? detailTitle, + List? detail, + }) { + return detail?.isEmpty == true + ? const SizedBox() + : CustomerContainer( + color: const Color(0xfff5F5F5), + borderRadius: 30, + margin: EdgeInsets.only(left: 60.w, right: 60.w, bottom: 20.h), + padding: EdgeInsets.all(30.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${detailTitle}TOP10', + style: TextStyle( + fontSize: titleFontSize, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 30.h,), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemBuilder: (_, index) { + CustomerRankDetailContent? detailContent = detail?[index]; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + detailContent?.label ?? '', + style: TextStyle( + fontSize: contentFontSize, + ), + ), + Text( + detailContent?.content ?? '', + style: TextStyle( + fontSize: contentFontSize, + ), + ), + ], + ); + }, + separatorBuilder: (_, index) { + return SizedBox(height: 20.h); + }, + itemCount: detail?.length ?? 0, + ), + ], + ), + ); + } + + detailText( + text, { + bool? isTitle, + TextAlign? textAlign, + }) { + return Padding( + padding: EdgeInsets.only( + left: textAlign == TextAlign.start ? 60.w : 0, + right: textAlign == TextAlign.end ? 60.w : 0, + ), + child: Text( + text, + textAlign: textAlign ?? TextAlign.center, + style: TextStyle( + fontSize: isTitle == true ? titleFontSize : contentFontSize, + ), + ), + ); + } +} diff --git a/flutter_common/lib/upload_image/down_load_image_tool.dart b/flutter_common/lib/upload_image/down_load_image_tool.dart new file mode 100644 index 0000000..5b0f515 --- /dev/null +++ b/flutter_common/lib/upload_image/down_load_image_tool.dart @@ -0,0 +1,142 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_common/utils/toast_utils.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart'; + +// import 'package:image_gallery_saver/image_gallery_saver.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class DownLoadImageTool { + + // 请求照片库权限 + static Future requestPhotoPermission() async { + // 检查当前权限状态 + var status = await Permission.photos.status; + + if (status.isDenied) { + // 请求权限 + status = await Permission.photos.request(); + + // 如果用户拒绝了权限,可以显示一个解释 + if (status.isPermanentlyDenied) { + // 打开应用设置,让用户手动启用权限 + await openAppSettings(); + } + + if (status.isDenied) { + // 打开应用设置,让用户手动启用权限 + await openAppSettings(); + } + } + + return status.isGranted; + } + + // 或者请求存储权限(适用于Android) + static Future requestStoragePermission() async { + if (Platform.isAndroid) { + // 对于Android 13及以上版本 + if (await DeviceInfoPlugin().androidInfo.then((info) => info.version.sdkInt) >= 33) { + var status = await Permission.photos.request(); + if(status == PermissionStatus.denied){ + await requestPhotoPermission(); + } + return status.isGranted; + } else { + // 对于Android 13以下版本 + var status = await Permission.storage.request(); + return status.isGranted; + } + } else { + // iOS使用照片权限 + return await requestPhotoPermission(); + } + } + + ///保存到相册 + static Future savePhoto({ + required String imageUrl, + bool? isHideLoading, + }) async { + //获取保存相册权限,如果没有,则申请改权限 + bool permition = await requestStoragePermission(); + if (permition) { + var result = imageRequest( + imageUrl: imageUrl, + isHideLoading: isHideLoading, + ); + return result; + } else { + //重新请求--第一次请求权限时,保存方法不会走,需要重新调一次 + ToastUtils.showToast(msg: '请打开手机相册权限'); + // savePhoto(imageUrl: imageUrl); + } + } + + static Future imageRequest({ + required String imageUrl, + bool? isHideLoading, + }) async { + if (isHideLoading == true) { + } else { + await EasyLoading.show( + // status: 'loading...', + maskType: EasyLoadingMaskType.black, + ); + } + + var response = await Dio().get( + imageUrl, + options: Options( + responseType: ResponseType.bytes, + ), + ); + if (isHideLoading == true) { + } else { + EasyLoading.dismiss(); + } + + final result = await ImageGallerySaverPlus.saveImage( + Uint8List.fromList(response.data), + quality: 60, + name: "hello", + isReturnImagePathOfIOS: true, + ); + print('=result ============ $result'); + return result; + } + + ///fetchImageAsUint8List + static Future fetchImageAsUint8List(String imageUrl) async { + await EasyLoading.show( + // status: 'loading...', + maskType: EasyLoadingMaskType.black, + ); + try { + var response = await Dio().get( + imageUrl, + options: Options( + responseType: ResponseType.bytes, + ), + ); + // final response = await http.get(Uri.parse(imageUrl)); + if (response.statusCode == 200) { + EasyLoading.dismiss(); + return response.data; + } else { + // debugPrint('Failed to load image: ${response.statusCode}'); + EasyLoading.dismiss(); + return null; + } + + } catch (e) { + EasyLoading.dismiss(); + // debugPrint('Error fetching image: $e'); + return null; + } + } +} diff --git a/flutter_common/lib/upload_image/download_local_tool.dart b/flutter_common/lib/upload_image/download_local_tool.dart new file mode 100644 index 0000000..a7bad13 --- /dev/null +++ b/flutter_common/lib/upload_image/download_local_tool.dart @@ -0,0 +1,93 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart'; +// import 'package:image_gallery_saver/image_gallery_saver.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class DownloadLocalTool { + /// 拿到存储路径 + /// 使用getTemporaryDirectory()方法可以获取应用程序的临时目录,该目录用于存储应用程序的临时数据。这个目录在应用程序退出后会被清空 + Future getTemporaryDirectoryString() async { + final directory = await getTemporaryDirectory(); + return directory.path; + } + + /// 使用getApplicationDocumentsDirectory()方法可以获取应用程序的文档目录,该目录用于存储应用程序的私有数据。 + Future getApplicationDocumentsDirectoryString() async { + final directory = await getApplicationDocumentsDirectory(); + return directory.path; + } + + /// 使用getExternalStorageDirectory()方法可以获取设备的外部存储目录,该目录用于存储应用程序的公共数据。需要注意的是,在某些设备上,外部存储目录可能是不可用的。 + Future getExternalStorageDirectoryString() async { + final directory = await getExternalStorageDirectory(); + return directory?.path ?? ""; + } + + /// 创建对文件位置的引用 + Future localFile(String fileName) async { + final path = await getApplicationDocumentsDirectoryString(); + return File('$path/$fileName'); + } + + ///save + Future saveNetworkVideoFile({ + required String fileName, + required String fileUrl, + }) async { + final path = await getApplicationDocumentsDirectoryString(); + String savePath = "$path/$fileName "; + // String fileUrl = + // "https://s3.cn-north-1.amazonaws.com.cn/mtab.kezaihui.com/video/ForBiggerBlazes.mp4"; + // + await Dio().download(fileUrl, savePath, onReceiveProgress: (count, total) { + print("${(count / total * 100).toStringAsFixed(0)}%"); + }); + final result = await ImageGallerySaverPlus.saveFile(savePath); + return result; + // print(result); + } + + ///save + Future saveNetworkVideoFileExternalStorageDirectory({ + required String fileName, + required String fileUrl, + }) async { + final path = await getApplicationDocumentsDirectoryString(); + String savePath = "$path/$fileName "; + // String fileUrl = + // "https://s3.cn-north-1.amazonaws.com.cn/mtab.kezaihui.com/video/ForBiggerBlazes.mp4"; + // + await Dio().download(fileUrl, savePath, onReceiveProgress: (count, total) { + print("${(count / total * 100).toStringAsFixed(0)}%"); + }); + final result = await ImageGallerySaverPlus.saveFile(savePath); + return result; + // print(result); + } + + + + +// // 将数据写入文件 +// Future writeCounter(int counter) async { +// final file = await _localFile; +// // Write the file +// return file.writeAsString('$counter'); +// } +// +// // 从文件中读取数据 +// Future readCounter() async { +// try { +// final file = await _localFile; +// // Read the file +// String contents = await file.readAsString(); +// +// return int.parse(contents); +// } catch (e) { +// // If we encounter an error, return 0 +// return 0; +// } +// } +} diff --git a/flutter_common/lib/upload_image/look_images_widget.dart b/flutter_common/lib/upload_image/look_images_widget.dart new file mode 100644 index 0000000..07ca616 --- /dev/null +++ b/flutter_common/lib/upload_image/look_images_widget.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:photo_view/photo_view_gallery.dart'; + +class LookImagesTool { + static lookImages({ + required List listData, + int? currentPage, + }) async { + showDialog( + context: Get.context!, + builder: (_) { + return LookImagesWidget( + listData: listData, + currentPage: currentPage, + ); + }); + } +} + +class LookImagesWidget extends StatefulWidget { + final List listData; + final int? currentPage; + + const LookImagesWidget({ + super.key, + required this.listData, + this.currentPage, + }); + + @override + State createState() => _LookImagesWidgetState(); +} + +class _LookImagesWidgetState extends State { + List listData = []; + late int currentPage; + late int initialPage = 0; + + @override + void initState() { + listData = widget.listData; + if (widget.currentPage == null) { + initialPage = 0; + currentPage = 0; + } else { + // initialPage = 0; + currentPage = widget.currentPage ?? 0; + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => Get.back(), + child: Stack( + children: [ + Scaffold( + backgroundColor: Colors.black, + body: Center( + child: PhotoViewGallery.builder( + itemCount: listData.length, + pageController: PageController(initialPage: currentPage), + onPageChanged: (index) { + setState(() { + currentPage = index; + }); + }, + builder: (_, index) { + return PhotoViewGalleryPageOptions( + imageProvider: NetworkImage( + listData[index], + ), + ); + }, + ), + ), + ), + //图片张数指示器 + Positioned( + left: 0, + right: 0, + bottom: 20, + child: Container( + alignment: Alignment.center, + child: Text( + "${currentPage + 1}/${listData.length}", + style: const TextStyle( + color: Colors.white, + fontSize: 16, + decoration: TextDecoration.none, + ), + ), + ), + ) + ], + ), + ); + } +} diff --git a/flutter_common/lib/upload_image/ossUtil.dart b/flutter_common/lib/upload_image/ossUtil.dart new file mode 100755 index 0000000..6aa8419 --- /dev/null +++ b/flutter_common/lib/upload_image/ossUtil.dart @@ -0,0 +1,274 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; +import 'dart:ui' as ui; +import 'dart:ui'; +import 'package:crypto/crypto.dart'; +import "package:dio/dio.dart"; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_common/utils/toast_utils.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart'; + +// import 'package:image_gallery_saver/image_gallery_saver.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class UploadOss { + /* + * @params file 要上传的文件对象 + * @params rootDir 阿里云oss设置的根目录文件夹名字 + * @param fileType 文件类型例如jpg,mp4等 + * @param callback 回调函数我这里用于传cancelToken,方便后期关闭请求 + * @param onSendProgress 上传的进度事件 + */ + + static Future upload( + String path, { + String rootDir = "moment", + required String fileType, + required String oSSAccessKeyId, + required String policy, + required String callback, + required String signature, + required String ossDirectory, + required String ossHost, + }) async { + // 生成oss的路径和文件名我这里目前设置的是moment/20201229/test.mp4 + String pathName = "$rootDir/${getDate()}/app-${getRandom(12)}.$fileType"; + + // 请求参数的form对象 + FormData formdata = FormData.fromMap({ + 'OSSAccessKeyId': oSSAccessKeyId, + 'policy': policy, + 'callback': callback, + 'signature': signature, + 'key': '$ossDirectory$pathName', + //上传后的文件名 + 'success_action_status': '200', + 'file': MultipartFile.fromFileSync( + path, + contentType: fileType == 'mp4' ? null : DioMediaType("image", "jpg"), + filename: "${getRandom(12)}.$fileType", + ), + }); + await EasyLoading.show( + // status: 'loading...', + maskType: EasyLoadingMaskType.black, + ); + Dio dio = Dio(); + dio.options.responseType = ResponseType.plain; + // dio.options.method = 'put'; + // dio.options.contentType = "multipart/form-data;image/jpg"; + try { + // 发送请求 + Response response = await dio.post( + ossHost, + data: formdata, + options: Options( + contentType: "multipart/form-data;image/jpg", + headers: {'Content-Type': 'multipart/form-data;image/jpg'}, + ), + ); + print("response ===== $response"); + EasyLoading.dismiss(); + // 成功后返回文件访问路径 + return "$ossHost/$ossDirectory$pathName"; + } on DioError catch (e) { + EasyLoading.dismiss(); + print("e.message ===== ${e.message}"); + print("e.data ===== ${e.response?.data}"); + // print("e.headers ===== ${e.response?.headers}"); + // print("e.extra ===== ${e.response?.extra}"); + return ''; + } + } + + /* + * 生成固定长度的随机字符串 + * */ + static String getRandom(int num) { + String alphabet = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM"; + String left = ""; + for (var i = 0; i < num; i++) { +// right = right + (min + (Random().nextInt(max - min))).toString(); + left = left + alphabet[Random().nextInt(alphabet.length)]; + } + return left; + } + + /// 获取日期 + static String getDate() { + DateTime now = DateTime.now(); + return "${now.year}${now.month}${now.day}"; + } +} + +class UploadWidgetOss { + static Future uploadWidgetImage( + GlobalKey globalKey, { + required String oSSAccessKeyId, + required String policy, + required String callback, + required String signature, + required String ossDirectory, + required String ossHost, + }) async { + ///通过globalkey将Widget保存为ui.Image + ui.Image _image = await getImageFromWidget(globalKey); + + ///异步将这张图片保存在手机内部存储目录下 + String? localImagePath = await saveImageByUIImage(_image, isEncode: false); + + ///保存完毕后关闭当前页面并将保存的图片路径返回到上一个页面 + String? url = await UploadOss.upload( + localImagePath, + fileType: "png", + oSSAccessKeyId: oSSAccessKeyId, + policy: policy, + callback: callback, + signature: signature, + ossDirectory: ossDirectory, + ossHost: ossHost, + ); + return url; + } + + // 将一个Widget转为image.Image对象 + static Future getImageFromWidget(GlobalKey globalKey) async { + // globalKey为需要图像化的widget的key + RenderRepaintBoundary? boundary = + globalKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + // 转换为图像 + ui.Image img = await boundary!.toImage(pixelRatio: 2); + return img; + } + + ///将指定的文件保存到目录空间中。 + ///[image] 这里是使用的ui包下的Image + ///[picName] 保存到本地的文件(图片)文件名,如test_image + ///[endFormat]保存到本地的文件(图片)文件格式,如png, + ///[isReplace]当本地存在同名的文件(图片)时,true就是替换 + ///[isEncode]对保存的文件(图片)进行编码 + /// 最终保存到本地的文件 (图片)的名称为 picName.endFormat + static Future saveImageByUIImage(ui.Image image, + {String? picName, + String endFormat = "png", + bool isReplace = true, + bool isEncode = true}) async { + ///获取本地磁盘路径 + /* + * 在Android平台中获取的是/data/user/0/com.studyyoun.flutterbookcode/app_flutter + * 此方法在在iOS平台获取的是Documents路径 + */ + Directory appDocDir = await getApplicationDocumentsDirectory(); + String appDocPath = appDocDir.path; + + ///拼接目录 + if (picName == null || picName.trim().isEmpty) { + ///当用户没有指定picName时,取当前的时间命名 + picName = "${DateTime.now().millisecond.toString()}.$endFormat"; + } else { + picName = "$picName.$endFormat"; + } + + if (isEncode) { + ///对保存的图片名字加密 + picName = md5.convert(utf8.encode(picName)).toString(); + } + + appDocPath = "$appDocPath/$picName"; + + ///校验图片是否存在 + var file = File(appDocPath); + bool exist = await file.exists(); + if (exist) { + if (isReplace) { + ///如果图片存在就进行删除替换 + ///如果新的图片加载失败,那么旧的图片也被删除了 + await file.delete(); + } else { + ///如果图片存在就不进行下载 + return ""; + } + } + ByteData? byteData = await image.toByteData(format: ImageByteFormat.png); + Uint8List pngBytes = byteData!.buffer.asUint8List(); + + ///将Uint8List的数据格式保存 + await File(appDocPath).writeAsBytes(pngBytes); + + return appDocPath; + } + + //申请存本地相册权限 + static Future getPormiation() async { + if (Platform.isIOS) { + var status = await Permission.photos.status; + if (status.isDenied) { + Map statuses = await [ + Permission.photos, + ].request(); + // saveImage(globalKey); + } + return status.isGranted; + } else { + var status = await Permission.storage.status; + if (status.isDenied) { + Map statuses = await [ + Permission.storage, + ].request(); + } + return status.isGranted; + } + } + + ///保存到相册 + static void savePhoto(GlobalKey globalKey) async { + RenderRepaintBoundary? boundary = + globalKey.currentContext!.findRenderObject() as RenderRepaintBoundary?; + + double dpr = ui.window.devicePixelRatio; // 获取当前设备的像素比 + var image = await boundary!.toImage(pixelRatio: dpr); + // 将image转化成byte + ByteData? byteData = await image.toByteData(format: ImageByteFormat.png); + //获取保存相册权限,如果没有,则申请改权限 + bool permition = await getPormiation(); + + var status = await Permission.photos.status; + if (permition) { + if (Platform.isIOS) { + if (status.isGranted) { + Uint8List images = byteData!.buffer.asUint8List(); + final result = await ImageGallerySaverPlus.saveImage(images, + quality: 60, name: "hello"); + // EasyLoading.showToast("保存成功"); + ToastUtils.showToast(msg: "保存成功"); + } + if (status.isDenied) { + print("IOS拒绝"); + } + } else { + //安卓 + if (status.isGranted) { + print("Android已授权"); + Uint8List images = byteData!.buffer.asUint8List(); + final result = + await ImageGallerySaverPlus.saveImage(images, quality: 60); + if (result != null) { + // EasyLoading.showToast("保存成功"); + ToastUtils.showToast(msg: "保存成功"); + } else { + print('error'); + // toast("保存失败"); + } + } + } + } else { + //重新请求--第一次请求权限时,保存方法不会走,需要重新调一次 + savePhoto(globalKey); + } + } +} diff --git a/flutter_common/lib/upload_image/upload_image.dart b/flutter_common/lib/upload_image/upload_image.dart new file mode 100644 index 0000000..2ce3451 --- /dev/null +++ b/flutter_common/lib/upload_image/upload_image.dart @@ -0,0 +1,375 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_common/utils/customer.dart'; +import 'package:flutter_common/utils/toast_utils.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:get/get.dart'; +import 'package:image_picker/image_picker.dart'; +// import 'package:images_picker/images_picker.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import 'ossUtil.dart'; + +class UploadImages extends StatefulWidget { + final Function? chooseImages; // List + final int? max; //最大照片数量 + final int? hNumber; //一排最大几个 + final bool? onlyShow; //仅展示,不操作 + final BoxFit? fit; + final List? imagesList; //图片数组 + final String oSSAccessKeyId; + final String policy; + final String callback; + final String signature; + final String ossDirectory; + final String ossHost; + final Widget carmaWidget; + final Function? oneTap; //点击的哪一个 + final Function? deleteTap; //删除一个后 + + const UploadImages({ + super.key, + this.chooseImages, + this.max = 9, + this.onlyShow = false, + this.imagesList, + required this.oSSAccessKeyId, + required this.policy, + required this.callback, + required this.signature, + required this.ossDirectory, + required this.ossHost, + required this.carmaWidget, + this.hNumber = 3, + this.fit, + this.oneTap, + this.deleteTap, + }); + + @override + State createState() => _UploadImagesState(); +} + +class _UploadImagesState extends State { + List imagesList = []; + bool isMax = false; //是否达到最大值 + + @override + void initState() { + // print("widget.maxwidget.max ============ ${widget.max}"); + if (widget.imagesList != null) { + imagesList = widget.imagesList ?? []; + } + super.initState(); + } + + ///数组的数量 + setImageListLength() { + // print("widget.maxwidget.max ============ ${widget.max}"); + // print("imagesList.length ============ ${imagesList.length}"); + if (imagesList.length < (widget.max?.toInt() ?? 9)) { + isMax = false; + return imagesList.length + 1; + } else if (imagesList.length == (widget.max?.toInt() ?? 9)) { + isMax = true; + return imagesList.length; + } else { + isMax = true; + return 9; + } + } + + @override + Widget build(BuildContext context) { + return MasonryGridView.count( + padding: const EdgeInsets.only(top: 0), + crossAxisCount: 3, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: setImageListLength(), + itemBuilder: (_, int index) { + if (isMax == true) { + String? imageUrl = imagesList[index]; + return cellDeleteWidget( + index: index, + child: GestureDetector( + onTap: () => widget.oneTap?.call(index), + child: imageNetworkWidget( + imageUrl: imageUrl, + index: index, + ), + ), + ); + } else { + if (index == setImageListLength() - 1) { + return widget.onlyShow == true + ? const SizedBox() + : uploadWidget(context); + } else { + String? imageUrl = imagesList[index]; + return cellDeleteWidget( + index: index, + child: imageNetworkWidget( + imageUrl: imageUrl, + index: index, + ), + ); + } + } + }, + mainAxisSpacing: 15, + crossAxisSpacing: 8, + ); + } + + Widget imageNetworkWidget({ + required String imageUrl, + required int index, + }) { + return GestureDetector( + onTap: () => widget.oneTap?.call(index), + child: CustomerImagesNetworking( + imageUrl: imageUrl, + fit: widget.fit ?? BoxFit.cover, + ), + ); + } + + ///cell 总样式 + Widget cellWidget({required Widget child}) { + return SizedBox( + key: Key(cellKeyString()), + width: (Get.width - 32 - 32) / (widget.hNumber ?? 3), + height: (Get.width - 32 - 32) / (widget.hNumber ?? 3), + child: child, + ); + } + + ///cell key 随机数+时间 + String cellKeyString({String? string}) { + var random = Random(); + int randomNumber = random.nextInt(10000); // 生成0到10000000000000之间的随机整数 + return '$randomNumber + $string + ${DateTime.now().toString()}'; + } + + ///有删除样式的cell + Widget cellDeleteWidget({ + required Widget child, + required int index, + }) { + return Stack( + alignment: Alignment.topRight, + children: [ + cellWidget(child: child), + widget.onlyShow == true + ? const SizedBox() + : GestureDetector( + onTap: () { + imagesList.removeAt(index); + widget.deleteTap?.call(imagesList); + setState(() {}); + }, + child: ClipOval( + child: Container( + width: 30.w, + height: 30.w, + color: Colors.grey.withOpacity(0.5), + child: Center( + child: Icon( + Icons.close, + size: 25.w, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ); + } + + ///上传的Widget + Widget uploadWidget(BuildContext context) { + return GestureDetector( + onTap: () async { + if (Platform.isIOS) { + await chooseCamera( + context: context, + max: widget.max, + ); + } else { + await chooseCamera( + context: context, + max: widget.max, + ); + // bool b = await requestPermission(context); + // if (b == true) { + // await chooseCamera( + // context: context, + // max: widget.max, + // ); + // } + } + }, + child: cellWidget( + child: widget.carmaWidget, + ), + ); + } + + /// 动态申请权限,需要区分android和ios,很多时候它两配置权限时各自的名称不同 + /// 此处以保存图片需要的配置为例 + Future requestPermission(context) async { + late PermissionStatus status; + // 1、读取系统权限的弹框 + if (Platform.isIOS) { + status = await Permission.photosAddOnly.request(); + } else { + status = await Permission.camera.request(); + } + // 2、假如你点not allow后,下次点击不会在出现系统权限的弹框(系统权限的弹框只会出现一次), + // 这时候需要你自己写一个弹框,然后去打开app权限的页面 + + if (status != PermissionStatus.granted) { + showCupertinoDialog( + context: context, + builder: (BuildContext ctx) { + return CupertinoAlertDialog( + title: const Text('您需要去打开权限'), + content: const Text('请打开您的相机或者相册权限'), + actions: [ + CupertinoDialogAction( + child: const Text('取消'), + onPressed: () { + Navigator.pop(ctx); + }, + ), + CupertinoDialogAction( + child: const Text('确定'), + onPressed: () { + Navigator.pop(ctx); + // 打开手机上该app权限的页面 + openAppSettings(); + }, + ), + ], + ); + }); + } else { + return true; + } + return false; + } + + /// + Future chooseCamera({ + required BuildContext context, + int? max, + }) async { + // + showCupertinoModalPopup( + context: context, + builder: (BuildContext ctx) { + return CupertinoActionSheet( + title: const Text('上传图片'), + message: Text('请选择上传方式\n相册最多${max ?? 9}张'), + actions: [ + CupertinoActionSheetAction( + child: const Text('拍照上传'), + onPressed: () { + openCamera(); + Get.back(); + }, + ), + CupertinoActionSheetAction( + child: const Text('相册'), + onPressed: () { + openGallery(); + Get.back(); + }, + ), + ], + cancelButton: CupertinoActionSheetAction( + isDefaultAction: true, + child: const Text('取消'), + onPressed: () { + Get.back(); + }, + ), + ); + }); + } + + // + openCamera() async { + XFile? file = await ImagePicker().pickImage( + source: ImageSource.camera, + ); + if (file == null) { + // Get.back(); + } else { + String imgPath = await saveNetworkImg( + file, + ); + imagesList.add(imgPath); + widget.chooseImages?.call(imagesList); + setState(() {}); + } + } + + openGallery() async { + int number = (widget.max ?? 9) - imagesList.length; + // List? images = + // await ImagesPicker.pick(count: number, pickType: PickType.image); + List list = []; + List? images = await ImagePicker().pickMultiImage(limit: number,); + if (images.isEmpty != true) { + for (var element in images) { + String path = await saveNetworkImgGallery( + element.path, + ); + list.add(path); + } + imagesList.addAll(list); + widget.chooseImages?.call(imagesList); + setState(() {}); + } else { + ToastUtils.showToast(msg: "请选择图片"); + } + } + + // 保存网络图片 + Future saveNetworkImg(XFile file) async { + // print("file.path ===== ${file.path}"); + String string = await UploadOss.upload( + file.path, + fileType: "jpg", + oSSAccessKeyId: widget.oSSAccessKeyId, + ossHost: widget.ossHost, + ossDirectory: widget.ossDirectory, + policy: widget.policy, + callback: widget.callback, + signature: widget.signature, + ); + return string; + } + + // 保存网络图片 + Future saveNetworkImgGallery(String path) async { + String string = await UploadOss.upload( + path, + fileType: "jpg", + oSSAccessKeyId: widget.oSSAccessKeyId, + ossHost: widget.ossHost, + ossDirectory: widget.ossDirectory, + policy: widget.policy, + callback: widget.callback, + signature: widget.signature, + ); + return string; + } +} diff --git a/flutter_common/lib/upload_image/upload_images_tool.dart b/flutter_common/lib/upload_image/upload_images_tool.dart new file mode 100644 index 0000000..6663257 --- /dev/null +++ b/flutter_common/lib/upload_image/upload_images_tool.dart @@ -0,0 +1,341 @@ +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_common/upload_image/ossUtil.dart'; +import 'package:get/get.dart'; +import 'package:image_picker/image_picker.dart'; + +// import 'package:images_picker/images_picker.dart'; +// import 'package:images_picker/images_picker.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class UploadImagesTool { + static uploadImagesTool({ + String? oSSAccessKeyId, + String? policy, + String? callback, + String? signature, + String? ossDirectory, + String? ossHost, + required BuildContext context, + Function? chooseImagesTap, + int? max, + bool? isVideo, + Widget? isAddOtherWidget, + }) async { + await chooseCamera( + context: context, + max: max ?? 9, + oSSAccessKeyId: oSSAccessKeyId ?? '', + ossHost: ossHost ?? '', + ossDirectory: ossDirectory ?? '', + policy: policy ?? '', + callback: callback ?? '', + signature: signature ?? '', + isVideo: isVideo, + isAddOtherWidget: isAddOtherWidget, + chooseImages: (list) => chooseImagesTap?.call(list), + ); + } + + /// 动态申请权限,需要区分android和ios,很多时候它两配置权限时各自的名称不同 + /// 此处以保存图片需要的配置为例 + static Future requestPermission(context) async { + late PermissionStatus status; + // 1、读取系统权限的弹框 + if (Platform.isIOS) { + status = await Permission.photosAddOnly.request(); + } else { + status = await Permission.camera.request(); + } + // 2、假如你点not allow后,下次点击不会在出现系统权限的弹框(系统权限的弹框只会出现一次), + // 这时候需要你自己写一个弹框,然后去打开app权限的页面 + + if (status != PermissionStatus.granted) { + showCupertinoDialog( + context: context, + builder: (BuildContext ctx) { + return CupertinoAlertDialog( + title: const Text('您需要去打开权限'), + content: const Text('请打开您的相机或者相册权限'), + actions: [ + CupertinoDialogAction( + child: const Text('取消'), + onPressed: () { + Navigator.pop(ctx); + }, + ), + CupertinoDialogAction( + child: const Text('确定'), + onPressed: () { + openAppSettings(); + Navigator.pop(ctx); + // 打开手机上该app权限的页面 + }, + ), + ], + ); + }); + } else { + return true; + } + return false; + } + + /// + static Future chooseCamera({ + required BuildContext context, + int? max, + String? oSSAccessKeyId, + String? policy, + String? callback, + String? signature, + String? ossDirectory, + String? ossHost, + Function? chooseImages, + bool? isVideo, + Widget? isAddOtherWidget, + }) async { + // + showCupertinoModalPopup( + context: context, + builder: (BuildContext ctx) { + return isVideo == true + ? CupertinoActionSheet( + title: const Text('上传视频'), + message: Text('请选择视频'), + actions: [ + CupertinoActionSheetAction( + child: const Text('视频库'), + onPressed: () { + openGallery( + max: max, + isVideo: isVideo, + oSSAccessKeyId: oSSAccessKeyId ?? '', + ossHost: ossHost ?? '', + ossDirectory: ossDirectory ?? '', + policy: policy ?? '', + callback: callback ?? '', + signature: signature ?? '', + chooseImages: (list) => chooseImages?.call(list), + ); + Get.back(); + }, + ), + ], + cancelButton: CupertinoActionSheetAction( + isDefaultAction: true, + child: const Text('取消'), + onPressed: () { + Get.back(); + }, + ), + ) + : CupertinoActionSheet( + title: const Text('上传图片'), + message: Text('请选择上传方式\n相册最多${max ?? 9}张'), + actions: isAddOtherWidget != null + ? [ + isAddOtherWidget, + CupertinoActionSheetAction( + child: const Text('拍照上传'), + onPressed: () { + openCamera( + oSSAccessKeyId: oSSAccessKeyId ?? '', + ossHost: ossHost ?? '', + ossDirectory: ossDirectory ?? '', + policy: policy ?? '', + callback: callback ?? '', + signature: signature ?? '', + chooseImages: (list) => + chooseImages?.call(list), + ); + Get.back(); + }, + ), + CupertinoActionSheetAction( + child: const Text('相册'), + onPressed: () { + openGallery( + max: max, + oSSAccessKeyId: oSSAccessKeyId ?? '', + ossHost: ossHost ?? '', + ossDirectory: ossDirectory ?? '', + policy: policy ?? '', + callback: callback ?? '', + signature: signature ?? '', + chooseImages: (list) => + chooseImages?.call(list), + ); + Get.back(); + }, + ), + ] + : [ + CupertinoActionSheetAction( + child: const Text('拍照上传'), + onPressed: () { + openCamera( + oSSAccessKeyId: oSSAccessKeyId ?? '', + ossHost: ossHost ?? '', + ossDirectory: ossDirectory ?? '', + policy: policy ?? '', + callback: callback ?? '', + signature: signature ?? '', + chooseImages: (list) => + chooseImages?.call(list), + ); + Get.back(); + }, + ), + CupertinoActionSheetAction( + child: const Text('相册'), + onPressed: () { + openGallery( + max: max, + oSSAccessKeyId: oSSAccessKeyId ?? '', + ossHost: ossHost ?? '', + ossDirectory: ossDirectory ?? '', + policy: policy ?? '', + callback: callback ?? '', + signature: signature ?? '', + chooseImages: (list) => + chooseImages?.call(list), + ); + Get.back(); + }, + ), + ], + cancelButton: CupertinoActionSheetAction( + isDefaultAction: true, + child: const Text('取消'), + onPressed: () { + Get.back(); + }, + ), + ); + }); + } + + // + static openCamera({ + Function? chooseImages, + String? oSSAccessKeyId, + String? policy, + String? callback, + String? signature, + String? ossDirectory, + String? ossHost, + }) async { + XFile? file = await ImagePicker().pickImage( + source: ImageSource.camera, + ); + if (file == null) { + Get.back(); + } else { + String imgPath = await saveNetworkImg( + file, + oSSAccessKeyId: oSSAccessKeyId ?? '', + ossHost: ossHost ?? '', + ossDirectory: ossDirectory ?? '', + policy: policy ?? '', + callback: callback ?? '', + signature: signature ?? '', + ); + chooseImages?.call([imgPath]); + } + } + + static openGallery({ + Function? chooseImages, + String? oSSAccessKeyId, + String? policy, + String? callback, + String? signature, + String? ossDirectory, + String? ossHost, + int? max, + bool? isVideo, + }) async { + if (isVideo == true) { + XFile? video = await ImagePicker().pickVideo(source: ImageSource.gallery); + String path = await saveNetworkImgGallery( + video?.path ?? '', + fileType: 'mp4', + oSSAccessKeyId: oSSAccessKeyId ?? '', + ossHost: ossHost ?? '', + ossDirectory: ossDirectory ?? '', + policy: policy ?? '', + callback: callback ?? '', + signature: signature ?? '', + ); + chooseImages?.call([path]); + print('video path ============ $path'); + } else { + List? images = await ImagePicker().pickMultiImage(); + List list = []; + for (var element in images) { + String path = await saveNetworkImgGallery( + element.path, + oSSAccessKeyId: oSSAccessKeyId ?? '', + ossHost: ossHost ?? '', + ossDirectory: ossDirectory ?? '', + policy: policy ?? '', + callback: callback ?? '', + signature: signature ?? '', + ); + list.add(path); + } + chooseImages?.call(list); + } + } + + // 保存网络图片 + static Future saveNetworkImg( + XFile file, { + String? oSSAccessKeyId, + String? policy, + String? callback, + String? signature, + String? ossDirectory, + String? ossHost, + }) async { + // print("file.path ===== ${file.path}"); + String string = await UploadOss.upload( + file.path, + fileType: "jpg", + oSSAccessKeyId: oSSAccessKeyId ?? '', + ossHost: ossHost ?? '', + ossDirectory: ossDirectory ?? '', + policy: policy ?? '', + callback: callback ?? '', + signature: signature ?? '', + ); + // print("Gallery ===string== $string"); + return string; + } + + // 保存网络图片 + static Future saveNetworkImgGallery( + String path, { + String? fileType, + String? oSSAccessKeyId, + String? policy, + String? callback, + String? signature, + String? ossDirectory, + String? ossHost, + }) async { + String string = await UploadOss.upload( + path, + fileType: fileType ?? "jpg", + oSSAccessKeyId: oSSAccessKeyId ?? '', + ossHost: ossHost ?? '', + ossDirectory: ossDirectory ?? '', + policy: policy ?? '', + callback: callback ?? '', + signature: signature ?? '', + ); + return string; + } +} diff --git a/flutter_common/lib/utils/PermissionUtil.dart b/flutter_common/lib/utils/PermissionUtil.dart new file mode 100644 index 0000000..63fbae4 --- /dev/null +++ b/flutter_common/lib/utils/PermissionUtil.dart @@ -0,0 +1,53 @@ +import 'dart:io'; +import 'package:permission_handler/permission_handler.dart'; + +class PermissionUtil{ + /// 安卓权限 + static List androidPermissions = [ + // 在这里添加需要的权限 + Permission.storage + ]; + + /// ios权限 + static List iosPermissions = [ + // 在这里添加需要的权限 + Permission.storage + ]; + + static Future> requestAll() async { + if (Platform.isIOS) { + return await iosPermissions.request(); + } + return await androidPermissions.request(); + } + + static Future> request( + Permission permission) async { + final List permissions = [permission]; + return await permissions.request(); + } + + static bool isDenied(Map result) { + var isDenied = false; + result.forEach((key, value) { + if (value == PermissionStatus.denied) { + isDenied = true; + return; + } + }); + return isDenied; + } + + + /// 检查权限 + static Future checkGranted(Permission permission) async { + PermissionStatus storageStatus = await permission.status; + if (storageStatus == PermissionStatus.granted) { + //已授权 + return true; + } else { + //拒绝授权 + return false; + } + } +} \ No newline at end of file diff --git a/flutter_common/lib/utils/custom_dialog.dart b/flutter_common/lib/utils/custom_dialog.dart new file mode 100755 index 0000000..4f7fcee --- /dev/null +++ b/flutter_common/lib/utils/custom_dialog.dart @@ -0,0 +1,155 @@ +import 'dart:io'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +///@Author ouyangyan +///@Time 2023/3/1 13:43 +///自定义对话框 +class CustomDialog extends Dialog { + final String title; //标题 + final String content; //内容 + final String cancelText; //"取消" 按钮文字 + final String confirmText; //"确定" 按钮文字 + final VoidCallback cancelCall; //取消按钮回调 + final VoidCallback confirmCall; //确定按钮回调 + final TextStyle? cancelTextStyle; //"取消" 按钮文字 + final TextStyle? confirmTextStyle; //"确定" 按钮文字 + + const CustomDialog({ + required this.title, + required this.content, + required this.cancelText, + required this.confirmText, + required this.cancelCall, + required this.confirmCall, + this.cancelTextStyle, + this.confirmTextStyle, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 45), + child: Material( + type: MaterialType.transparency, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + decoration: const ShapeDecoration( + color: Color(0xffffffff), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(10), + ), + ), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 20), + child: Text( + title, + textWidthBasis: TextWidthBasis.parent, + style: const TextStyle( + color: Color(0xff222222), + fontWeight: FontWeight.w500, + fontSize: 15, + ), + ), + ), + Container( + margin: const EdgeInsets.only( + top: 12, + bottom: 20, + left: 10, + right: 10, + ), + child: Text( + content, + textAlign: TextAlign.center, + style: const TextStyle( + color: Color(0xff999999), + fontSize: 13, + ), + overflow: TextOverflow.ellipsis, + maxLines: 5, + ), + ), + lineWidget(height: 0.5), + _bottomButtonGroupWidget(), + ], + ), + ) + ], + ), + ), + ); + } + + ///底部按钮组 + Widget _bottomButtonGroupWidget() { + return Row( + children: [ + Expanded( + child: buttonWidget( + title: cancelText, + callback: cancelCall, + textStyle: cancelTextStyle ?? + const TextStyle( + color: Color(0xff888888), + fontWeight: FontWeight.w500, + ), + ), + ), + lineWidget(width: 0.5, height: 50), + Expanded( + child: buttonWidget( + title: confirmText, + callback: confirmCall, + textStyle: confirmTextStyle ?? + const TextStyle( + color: Color(0xffFF4D1A), + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ); + } + + ///按钮共用 + Widget buttonWidget({ + required String title, + required TextStyle textStyle, + required VoidCallback callback, + }) { + return GestureDetector( + onTap: callback, + behavior: HitTestBehavior.opaque, + child: Container( + height: 50, + width: double.infinity, + alignment: Alignment.center, + child: Text(title, style: textStyle), + ), + ); + } + + ///线 + Widget lineWidget({ + double? width, + double? height, + EdgeInsetsGeometry? margin, + }) { + return Container( + width: width, + color: const Color(0xffe9e9e9), + height: height, + margin: margin, + ); + } +} \ No newline at end of file diff --git a/flutter_common/lib/utils/customer.dart b/flutter_common/lib/utils/customer.dart new file mode 100644 index 0000000..146edd1 --- /dev/null +++ b/flutter_common/lib/utils/customer.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; +// import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; +import 'package:transparent_image/transparent_image.dart'; + + + + +///字体样式 +class CustomerTextStyle extends TextStyle { + final Color? customerColor; + final double? customerFontSize; + final FontWeight? customerFontWeight; + final TextDecoration? customerDecoration; + final Color? customerDecorationColor; + + const CustomerTextStyle({ + this.customerFontSize, + this.customerDecorationColor, + this.customerFontWeight, + this.customerDecoration, + this.customerColor, + }) : super( + color: customerColor ?? const Color(0xff333333), + fontSize: customerFontSize, + fontWeight: customerFontWeight, + decoration: customerDecoration, + decorationColor: customerDecorationColor, + ); +} + +///基础 Container +class CustomerContainer extends StatelessWidget { + final Color? color; + final double? borderRadius; + final Widget? child; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + final double? height; + final Gradient? gradient; + final DecorationImage? image; + final VoidCallback? onTap; + final List? boxShadow; + + const CustomerContainer({ + super.key, + this.color, + this.borderRadius, + this.child, + this.padding, + this.margin, + this.height, + this.gradient, + this.image, + this.onTap, + this.boxShadow, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + height: height, + margin: margin, + padding: padding, + decoration: BoxDecoration( + color: color ?? Colors.white, + borderRadius: BorderRadius.circular(borderRadius?.h ?? 10.h), + gradient: gradient, + image: image, + boxShadow: boxShadow, + ), + child: child, + ), + ); + } +} + +///图片加载 +class CustomerImagesNetworking extends StatelessWidget { + final String imageUrl; + final double? width; + final double? height; + final BoxFit? fit; + final Widget? errorWidget; + + const CustomerImagesNetworking({ + super.key, + required this.imageUrl, + this.width, + this.height, + this.fit, + this.errorWidget, + }); + + @override + Widget build(BuildContext context) { + return Image.network( + key: Key(imageUrl), + imageUrl, + width: width, + height: height, + fit: fit, + errorBuilder: (_, object, s) { + return Container( + width: width, + height: height, + padding: EdgeInsets.all((width ?? 0) / 2), + child: Center( + child: Image.asset( + 'assets/images/noContainer.png', + // width: width /2, + // height: width /2, + fit: fit ?? BoxFit.contain, + ), + ), + ); + }, + ); + return imageUrl.contains('http') == true + ? FadeInImage.memoryNetwork( + placeholder: kTransparentImage, + width: width, + height: height, + image: imageUrl, + fit: fit, + placeholderErrorBuilder: + (_, Object object, StackTrace? stackTrace) { + return errorWidget ?? const SizedBox(); + }, + imageErrorBuilder: (_, Object object, StackTrace? stackTrace) { + return errorWidget ?? const SizedBox(); + }, + ) + : errorWidget ?? const SizedBox(); + } +} + +///money字体样式 +class CustomerMoneyText extends StatelessWidget { + final String money; + final double? moneyFontSize; + final Color? moneyColor; + final FontWeight? moneyFontWeight; + final String? unit; + final double? unitFontSize; + final Color? unitColor; + final FontWeight? unitFontWeight; + final String? rightUnit; + final double? rightUnitFontSize; + final Color? rightUnitColor; + final FontWeight? rightUnitFontWeight; + + const CustomerMoneyText({ + super.key, + required this.money, + this.moneyFontSize, + this.moneyColor, + this.moneyFontWeight, + this.unit, + this.unitFontSize, + this.unitColor, + this.unitFontWeight, + this.rightUnit, + this.rightUnitFontSize, + this.rightUnitColor, + this.rightUnitFontWeight, + }); + + @override + Widget build(BuildContext context) { + return RichText( + text: TextSpan( + children: [ + TextSpan( + text: unit ?? '', + style: CustomerTextStyle( + customerColor: unitColor ?? const Color(0xff333333), + customerFontSize: unitFontSize ?? 12, + customerFontWeight: unitFontWeight ?? FontWeight.bold, + ), + ), + TextSpan( + text: money, + style: CustomerTextStyle( + customerColor: moneyColor ?? const Color(0xff333333), + customerFontSize: moneyFontSize ?? 20, + customerFontWeight: moneyFontWeight ?? FontWeight.bold, + ), + ), + TextSpan( + text: rightUnit ?? '', + style: CustomerTextStyle( + customerColor: rightUnitColor ?? const Color(0xff333333), + customerFontSize: rightUnitFontSize ?? 12, + customerFontWeight: rightUnitFontWeight ?? FontWeight.bold, + ), + ), + ], + ), + ); + } +} + +///Html widget +class CustomerHtmlWidget extends StatelessWidget { + final String html; + final Function? onTap; + + const CustomerHtmlWidget({ + super.key, + required this.html, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return HtmlWidget(html); + // return Html( + // data: html, + // extensions: [ + // TagExtension( + // tagsToExtend: {"flutter"}, + // child: const FlutterLogo(), + // ), + // ], + // style: { + // "p.fancy": Style( + // textAlign: TextAlign.center, + // // padding: EdgeInsets.all(), + // backgroundColor: Colors.grey, + // margin: Margins(left: Margin(50, Unit.px), right: Margin.auto()), + // width: Width(300, Unit.px), + // fontWeight: FontWeight.bold, + // ), + // }, + // ); + // return Html( + // data: html, + // style: const TextStyle( + // letterSpacing: 1.5, + // ), + // onTapUrl: (url) { + // return onTap?.call(url); + // }, + // ); + } +} diff --git a/flutter_common/lib/utils/customer_title_content.dart b/flutter_common/lib/utils/customer_title_content.dart new file mode 100644 index 0000000..6a6bdcb --- /dev/null +++ b/flutter_common/lib/utils/customer_title_content.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_common/utils/customer.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +///上下结构的文字 +///title、unit上 +///content、unit下 +class CustomerTitleAndContentWidget extends StatelessWidget { + final String title; + final FontWeight? titleFontWeight; + final double? titleFontSize; + final Color? titleColor; + final String? unitTitle; + final FontWeight? unitTitleFontWeight; + final double? unitTitleFontSize; + final Color? unitTitleColor; + final String content; + final FontWeight? contentFontWeight; + final double? contentFontSize; + final Color? contentColor; + final String? unitContent; + final FontWeight? unitContentFontWeight; + final double? unitContentFontSize; + final Color? unitContentColor; + final CrossAxisAlignment? crossAxisAlignment; + final MainAxisAlignment? mainAxisAlignment; + final double? spaceHeight; + + const CustomerTitleAndContentWidget({ + super.key, + required this.title, + required this.content, + this.titleFontWeight, + this.titleColor, + this.titleFontSize, + this.contentFontWeight, + this.contentFontSize, + this.contentColor, + this.crossAxisAlignment, + this.mainAxisAlignment, + this.unitContent, + this.unitContentFontWeight, + this.unitContentFontSize, + this.unitContentColor, + this.spaceHeight = 10, + this.unitTitle, + this.unitTitleFontWeight, + this.unitTitleFontSize, + this.unitTitleColor, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: mainAxisAlignment ?? MainAxisAlignment.start, + crossAxisAlignment: crossAxisAlignment ?? CrossAxisAlignment.center, + children: [ + RichText( + text: TextSpan( + children: [ + TextSpan( + text: unitTitle, + style: CustomerTextStyle( + customerFontSize: unitTitleFontSize, + customerColor: unitTitleColor ?? const Color(0xff399FFF), + customerFontWeight: unitTitleFontWeight ?? FontWeight.normal, + ), + ), + TextSpan( + text: title, + style: CustomerTextStyle( + customerFontSize: titleFontSize, + customerColor: titleColor ?? const Color(0xff1A1A1A), + customerFontWeight: titleFontWeight ?? FontWeight.normal, + ), + ), + ], + ), + ), + SizedBox(height: spaceHeight?.h ?? 30.h), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: content, + style: TextStyle( + fontSize: contentFontSize, + color: contentColor ?? const Color(0xff399FFF), + fontWeight: contentFontWeight ?? FontWeight.normal, + ), + ), + TextSpan( + text: unitContent, + style: TextStyle( + fontSize: unitContentFontSize, + color: unitContentColor ?? const Color(0xff399FFF), + fontWeight: unitContentFontWeight ?? FontWeight.normal, + ), + ), + ], + ), + ) + ], + ); + } +} diff --git a/flutter_common/lib/utils/date_utils.dart b/flutter_common/lib/utils/date_utils.dart new file mode 100755 index 0000000..8e87814 --- /dev/null +++ b/flutter_common/lib/utils/date_utils.dart @@ -0,0 +1,368 @@ +import 'package:flustars_flutter3/flustars_flutter3.dart'; + +enum DateTimeUtilsType { + yearMonthDayHourMinuteSecond, // 2024-01-01 01:01:01 + yearMonthDayHourMinute, // 2024-01-01 01:01 + yearMonthDay, // 2024-01-01 + monthDay, //01-01 + monthDayPoint, //01.01 + monthDayWord, //01月01日 + yearMonthDayPoint, // 2024.01.01 + yearMonthDayHourMinutePoint, // 2024.01.01 + yearMonthDayHourMinuteSecondPoint, // 2024.01.01 01:01:01 + yearMonthDayWord, // 2024年01月01日 + yearMonthWord, // 2024年01月 + yearMonth, // 2024-01 + yearMonthPoint, // 2024.01 + hourMinuteSecond, // 01:01:01 + hourMinute, // 01:01 + monthDayLine, // 01:01 + monthWord, //01月 + monthDayHourMinutePoint, //01月 +} + +///日期处理类 +class DateTimeUtils { + static String dateTimeUtilsTool({ + DateTimeUtilsType? dateTimeUtilsType, + String? dateTime, + String nullString = '-- --', + }) { + if (dateTime == null || dateTime == '') return nullString; + DateTime date = DateTime.parse(dateTime).toLocal(); + switch (dateTimeUtilsType) { + case null: + return nullString; + case DateTimeUtilsType.yearMonthDayHourMinuteSecond: + return '${date.year.toString()}' + '-${date.month.toString().padLeft(2, '0')}' + '-${date.day.toString().padLeft(2, '0')}' + ' ${date.hour.toString().padLeft(2, '0')}' + ':${date.minute.toString().padLeft(2, '0')}' + ':${date.second.toString().padLeft(2, '0')}'; + case DateTimeUtilsType.yearMonthDayHourMinute: + return '${date.year.toString()}' + '-${date.month.toString().padLeft(2, '0')}' + '-${date.day.toString().padLeft(2, '0')}' + ' ${date.hour.toString().padLeft(2, '0')}' + ':${date.minute.toString().padLeft(2, '0')}'; + case DateTimeUtilsType.yearMonthDay: + return '${date.year.toString()}' + '-${date.month.toString().padLeft(2, '0')}' + '-${date.day.toString().padLeft(2, '0')}'; + case DateTimeUtilsType.monthDay: + return '${date.month.toString().padLeft(2, '0')}' + '-${date.day.toString().padLeft(2, '0')}'; + case DateTimeUtilsType.monthDayPoint: + return '${date.month.toString().padLeft(2, '0')}' + '.${date.day.toString().padLeft(2, '0')}'; + case DateTimeUtilsType.monthDayWord: + return '${date.month.toString().padLeft(2, '0')}月' + '${date.day.toString().padLeft(2, '0')}日'; + case DateTimeUtilsType.yearMonthDayPoint: + return '${date.year.toString()}' + '.${date.month.toString().padLeft(2, '0')}' + '.${date.day.toString().padLeft(2, '0')}'; + case DateTimeUtilsType.yearMonthDayHourMinuteSecondPoint: + return '${date.year.toString()}' + '.${date.month.toString().padLeft(2, '0')}' + '.${date.day.toString().padLeft(2, '0')}' + ' ${date.hour.toString().padLeft(2, '0')}' + ':${date.minute.toString().padLeft(2, '0')}' + ':${date.second.toString().padLeft(2, '0')}'; + case DateTimeUtilsType.yearMonthDayHourMinutePoint: + return '${date.year.toString()}' + '.${date.month.toString().padLeft(2, '0')}' + '.${date.day.toString().padLeft(2, '0')}' + ' ${date.hour.toString().padLeft(2, '0')}' + ':${date.minute.toString().padLeft(2, '0')}'; + case DateTimeUtilsType.yearMonthDayWord: + return '${date.year.toString()}' + '年${date.month.toString().padLeft(2, '0')}' + '月${date.day.toString().padLeft(2, '0')}日'; + case DateTimeUtilsType.yearMonthWord: + return '${date.year.toString()}' + '年${date.month.toString().padLeft(2, '0')}月'; + case DateTimeUtilsType.yearMonth: + return '${date.year.toString()}' + '-${date.month.toString().padLeft(2, '0')}'; + case DateTimeUtilsType.yearMonthPoint: + return '${date.year.toString()}' + '.${date.month.toString().padLeft(2, '0')}'; + case DateTimeUtilsType.hourMinuteSecond: + return '${date.hour.toString().padLeft(2, '0')}' + ':${date.minute.toString().padLeft(2, '0')}' + ':${date.second.toString().padLeft(2, '0')}'; + case DateTimeUtilsType.hourMinute: + return '${date.hour.toString().padLeft(2, '0')}' + ':${date.minute.toString().padLeft(2, '0')}'; + case DateTimeUtilsType.monthDayLine: + return '${date.month.toString().padLeft(2, '0')}' + '/${date.day.toString().padLeft(2, '0')}'; + case DateTimeUtilsType.monthWord: + return '${date.month.toString().padLeft(2, '0')}月'; + case DateTimeUtilsType.monthDayHourMinutePoint: + return '${date.month.toString().padLeft(2, '0')}' + '.${date.day.toString().padLeft(2, '0')}' + ' ${date.hour.toString().padLeft(2, '0')}' + ':${date.minute.toString().padLeft(2, '0')}'; + } + } + + /// 计算两个日期相差多少天? + static int differenceTwoTimes({ + String? startTime, + String? endTime, + }) { + var startDate = + startTime == null ? DateTime.now() : DateTime.parse(startTime); + var endDate = endTime == null ? DateTime.now() : DateTime.parse(endTime); + var days = endDate.difference(startDate).inDays; + return days; + } + + /// 计算两个日期相差inMinutes? + static int differenceTwoInMinutesTimes({ + String? startTime, + String? endTime, + }) { + var startDate = + startTime == null ? DateTime.now() : DateTime.parse(startTime); + var endDate = endTime == null ? DateTime.now() : DateTime.parse(endTime); + var days = endDate.difference(startDate).inMinutes; + return days; + } + + + /// 计算两个日期相差day huor min? + static String differenceTwoDayHourTimes({ + String? startTime, + String? endTime, + + }) { + + String xxxxx = '0'; + var startDate = + startTime == null ? DateTime.now() : DateTime.parse(startTime); + var endDate = endTime == null ? DateTime.now() : DateTime.parse(endTime); + var days = endDate.difference(startDate).inDays; + if(days == 0){ + //days == 0 相当 + var hours = endDate.difference(startDate).inHours; + + if(hours == 0){ + var minutes = endDate.difference(startDate).inMinutes; + xxxxx = '$minutes分'; + }else{ + // xxxxx = hours; + var minutes = endDate.difference(startDate).inMinutes; + + xxxxx = '${minutes ~/ 60}小时${minutes % 60}分'; + } + }else{ + // xxxxx = days; + var hours = endDate.difference(startDate).inHours; + int divisor = 24; // 除数 + int quotient = hours ~/ divisor; // 取整除法,得到商 + int remainder = hours % divisor; // 取余数,得到余数 + // + // print('商: $quotient'); + // print('余数: $remainder'); + xxxxx = '$quotient天$remainder小时'; + } + return xxxxx; + } + + + + ///获取当前月份 + static String getCurrentMonth() { + DateTime date = DateTime.now(); + return date.month.toString().padLeft(2, '0'); + } + + ///获取当前的年月日 + static String getCurrentYMD() { + DateTime date = DateTime.now(); + return '${date.year.toString()}' + '-${date.month.toString().padLeft(2, '0')}' + '-${date.day.toString().padLeft(2, '0')}'; + } + + ///判断时间是否在某个时间区间内 + static bool isTimeInRange({ + required DateTime startTime, + required DateTime endTime, + required DateTime dateTime, + }) { + return dateTime.isAfter(startTime) && dateTime.isBefore(endTime); + } + + ///获取当月份 + static String get getMonth { + return DateTime.now().month.toString(); + } + + ///获取当年份 + static String get getYear { + return DateTime.now().year.toString(); + } + + ///获取当年份 + static String getGMTString() { + + return DateTime.now().year.toString(); + } + + ///获取当前属于第几周 + static String getWeekDay(DateTime dateTime) { + List weekday = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]; + return weekday[dateTime.weekday - 1]; + } + + ///根据日期获取某月的第一天和最后一天 + static Map getMonthStartAndMonthEnd( + {required DateTime dateTime}) { + DateTime monthStart = DateTime( + dateTime.year, + dateTime.month, + 1, + ); // 获取本月第一天的日期时间 + DateTime monthEnd = DateTime( + dateTime.year, + dateTime.month + 1, + 0, + 23, + 59, + 59, + ); // 获取本月最后一天的日期时间(时间为23:59:59) + return { + 'monthStart': monthStart, + 'monthEnd': monthEnd, + }; + } +// +// DateTime monthStart = DateTime( +// int.parse(time.toString().split('-').first), +// int.parse(time.toString().split('-').last), +// 1); // 获取本月第一天的日期时间 +// DateTime monthEnd = DateTime( +// int.parse(time.toString().split('-').first), +// int.parse(time.toString().split('-').last) + 1, +// 0, +// 23, +// 59, +// 59); // 获取本月最后一天的日期时间(时间为23:59:59) + /// / +} + +class TimeMachineUtil { + /// 获取某一年的第一个月的第一天和最后一个月的最后一天 + static getStartEndYearDate(int iYear) { + Map mapDate = {}; + int yearNow = DateTime.now().year; + yearNow = yearNow + iYear; + + String newStartYear = '$yearNow-01-01'; + String newEndtYear = '${yearNow + 1}-01-00'; + + mapDate['startTime'] = DateUtil.formatDate( + DateTime.fromMillisecondsSinceEpoch(turnTimestamp(newStartYear)), + format: 'yyyy-MM-dd'); + mapDate['endTime'] = DateUtil.formatDate( + DateTime.fromMillisecondsSinceEpoch(turnTimestamp(newEndtYear)), + format: 'yyyy-MM-dd'); + + mapDate['startStamp'] = turnTimestamp(mapDate['startTime'] + ' 00:00:00'); + mapDate['endStamp'] = turnTimestamp(mapDate['endTime'] + ' 23:59:59'); + print('某一年初和年末:$mapDate'); + } + + /// 获得当前日期 未来/过去 第某个月第一天和最后一天时间 + static Map getMonthDate(int iMonth) { + //获取当前日期 + var currentDate = DateTime.now(); + if (iMonth + currentDate.month > 0) { + return timeConversion( + iMonth + currentDate.month, (currentDate.year).toString()); + } else { + int beforeYear = (iMonth + currentDate.month) ~/ 12; + String yearNew = (currentDate.year + beforeYear - 1).toString(); + int monthNew = (iMonth + currentDate.month) - beforeYear * 12; + return timeConversion(12 + monthNew, yearNew); + } + } + + static Map timeConversion(int monthTime, String yearTime) { + Map dateMap = {}; + dateMap['startDate'] = + '$yearTime-${monthTime < 10 ? '0$monthTime' : '$monthTime'}-01'; + //转时间戳再转时间格式 防止出错 + dateMap['startDate'] = DateUtil.formatDate( + DateTime.fromMillisecondsSinceEpoch( + turnTimestamp(dateMap['startDate'] ?? "")), + format: 'yyyy-MM-dd'); + //某个月结束时间,转时间戳再转 + String endMonth = + '$yearTime-${(monthTime + 1) < 10 ? '0${monthTime + 1}' : (monthTime + 1)}-00'; + var endMonthTimeStamp = turnTimestamp(endMonth); + endMonth = DateUtil.formatDate( + DateTime.fromMillisecondsSinceEpoch(endMonthTimeStamp), + format: 'yyyy-MM-dd'); + dateMap['endDate'] = endMonth; + //这里是为了公司后台接口 需加时间段的时间戳 但不显示在格式化实践中 + dateMap['startDateStamp'] = + turnTimestamp('${dateMap['startDate']} 00:00:00').toString(); + dateMap['endDateStamp'] = + turnTimestamp('${dateMap['endDate']} 23:59:59').toString(); + // print('过去未来某个月初月末:$dateMap'); + return dateMap; + } + + /// 转时间戳 + static int turnTimestamp(String timestamp) { + return DateTime.parse(timestamp).millisecondsSinceEpoch; + } + + /// 当前时间 过去/未来 某个周的周一和周日 + static Map getWeeksDate(int weeks) { + Map mapTime = {}; + DateTime now = DateTime.now(); + int weekday = now.weekday; //今天周几 + + var sunDay = getTimestampLatest(false, 7 - weekday + weeks * 7); //周末 + var monDay = getTimestampLatest(true, -weekday + 1 + weeks * 7); //周一 + + mapTime['monDay'] = DateUtil.formatDate( + DateTime.fromMillisecondsSinceEpoch(monDay), + format: 'yyyy-MM-dd'); //周一 时间格式化 + mapTime['sunDay'] = DateUtil.formatDate( + DateTime.fromMillisecondsSinceEpoch(sunDay), + format: 'yyyy-MM-dd'); //周一 时间格式化 + mapTime['monDayStamp'] = '$monDay'; //周一 时间戳 + mapTime['sunDayStamp'] = '$sunDay'; //周日 时间戳 + // print('某个周的周一和周日:$mapTime'); + return mapTime; + } + + /// phase : 是零点还是23:59:59 + static int getTimestampLatest(bool phase, int day) { + String newHours; + DateTime now = DateTime.now(); + DateTime sixtyDaysFromNow = now.add(Duration(days: day)); + String formattedDate = + DateUtil.formatDate(sixtyDaysFromNow, format: 'yyyy-MM-dd'); + if (phase) { + newHours = '$formattedDate 00:00:00'; + } else { + newHours = '$formattedDate 23:59:59'; + } + + DateTime newDate = DateTime.parse(newHours); + // String newFormattedDate = + // DateUtil.formatDate(newDate, format: 'yyyy-MM-dd HH:mm:ss'); + int timeStamp = newDate.millisecondsSinceEpoch; + // print('时间' + newFormattedDate); + return timeStamp; + } +} diff --git a/flutter_common/lib/utils/diolog_alter_view.dart b/flutter_common/lib/utils/diolog_alter_view.dart new file mode 100644 index 0000000..58fc58e --- /dev/null +++ b/flutter_common/lib/utils/diolog_alter_view.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +///弹窗widget +///ctx showDialog context +///padding 位置 +class ShowDialogWidget extends StatelessWidget { + final BuildContext? ctx; + final Widget child; + final EdgeInsetsGeometry padding; + final ScrollController? scrollController; + + const ShowDialogWidget({ + super.key, + this.ctx, + required this.child, + required this.padding, + this.scrollController, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + GestureDetector( + onTap: ctx == null ? null : () => Navigator.pop(ctx!), + child: Container( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + color: Colors.transparent, + ), + ), + Center( + child: Padding( + padding: padding, + child: SingleChildScrollView( + controller: scrollController, + child: child, + ), + ), + ), + ], + ), + ); + } +} diff --git a/flutter_common/lib/utils/file/customer_file.dart b/flutter_common/lib/utils/file/customer_file.dart new file mode 100644 index 0000000..6faa31c --- /dev/null +++ b/flutter_common/lib/utils/file/customer_file.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_common/utils/toast_utils.dart'; +import 'play/video_play_page.dart'; + +enum OpenType { + file, //文件 + image, //图片 + video, //视频 + url, //网址 +} + +class CustomerFile { + static Future openTypeFile({ + required OpenType type, + required BuildContext context, + required int isLock, + required String filePath, + String? fileName, + }) async { + if (filePath == "" || filePath == null) { + ToastUtils.showToast(msg: "暂无内容"); + } else { + if (type == OpenType.file) { + CustomerAction.openFileAction( + context: context, + filePath: filePath, + fileName: fileName, + ); + } + if (type == OpenType.video) { + CustomerAction.openVideoAction( + context: context, + videoUrl: filePath, + ); + } + } + } +} + +///公共方法 +class CustomerAction { + ///打开文件 + static Future openFileAction({ + required BuildContext context, + required String filePath, + String? fileName, + }) async { + if (filePath == "") { + ToastUtils.showToast(msg: "暂无文件"); + } else { + if (filePath.contains("http")) { + // showDialog( + // context: context, + // builder: (BuildContext ctx) { + // + // // return CustomerPDFPage( + // // filePath: filePath, + // // ); + // }); + } else { + ToastUtils.showToast(msg: "地址无效"); + } + } + } + + static _dealFileName(String filePath) { + return "${DateTime.now()}.${filePath.split(".").last}"; + } + + ///打开视频 + static Future openVideoAction({ + required BuildContext context, + required String videoUrl, + }) async { + showDialog( + context: context, + builder: (BuildContext ctx) { + return SizedBox( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + child: VideoPlayPage( + videoUrl: videoUrl, + ), + ); + }); + } + + ///打开网页 + // static Future openWebview({ + // required BuildContext context, + // required String url, + // }) async { + // showDialog( + // context: context, + // builder: (BuildContext ctx) { + // return CustomerWebView(url: url); + // }, + // ); + // } + + static Future openTypeFile({ + required OpenType type, + required BuildContext context, + required int isLock, + required String filePath, + String? fileName, + }) async { + if (filePath == "" || filePath == null) { + ToastUtils.showToast(msg: "暂无内容"); + } else { + if (type == OpenType.file) { + CustomerAction.openFileAction( + context: context, + filePath: filePath, + fileName: fileName, + ); + } + if (type == OpenType.video) { + CustomerAction.openVideoAction( + context: context, + videoUrl: filePath, + ); + } + } + } +} diff --git a/flutter_common/lib/utils/file/play/video_play_page.dart b/flutter_common/lib/utils/file/play/video_play_page.dart new file mode 100755 index 0000000..f2fea1c --- /dev/null +++ b/flutter_common/lib/utils/file/play/video_play_page.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_common/utils/file/video/lib/chewie.dart'; +import 'package:video_player/video_player.dart'; + + + + +class VideoPlayPage extends StatefulWidget { + final String? videoUrl; + const VideoPlayPage({super.key, this.videoUrl}); + + @override + State createState() => _VideoPlayPageState(); +} + +class _VideoPlayPageState extends State { + var videoController = VideoPlayerController.network(""); + var chewieController; +//监听视频是否在播放 + bool isPlaying = false; +//视频地址 https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4 +// String videoUrl = +// "https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4"; +// String videoUrl = +// "http://static.dongmenshijing.com/upload/20230720/cf742bbf-8a96-46d5-b187-edeffa168556.mp4"; + + @override + void initState() { + // Uri url = Uri.parse(videoUrl); + // videoController = VideoPlayerController.networkUrl(url) + // ..initialize().then((value) { + // setState(() {}); + // }); + // videoController = VideoPlayerController.network( + // videoUrl, + // videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), + // )..initialize().then((value) { + // setState(() {}); + // }); + // videoController.addListener(() { + // setState(() {}); + // }); + initVideoController(); + super.initState(); + } + + ///初始化 + initVideoController({String? url}) { + videoController = VideoPlayerController.network( + widget.videoUrl ?? "", + )..initialize().then((_) { + setState(() { + chewieController = ChewieController( + // autoInitialize: true, + fullScreenByDefault: true, + videoPlayerController: videoController, + allowFullScreen: true, + // aspectRatio: MediaQuery.of(context).size.height / + // MediaQuery.of(context).size.width, + autoPlay: true, + looping: false, + ); + }); + }); + videoController.addListener(() { + setState(() {}); + }); + + setState(() {}); + } + + @override + void dispose() { + videoController.dispose(); + chewieController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return chewieController == null + ? Container() + : Chewie( + controller: chewieController, + ); + } +} diff --git a/flutter_common/lib/utils/file/video/lib/chewie.dart b/flutter_common/lib/utils/file/video/lib/chewie.dart new file mode 100755 index 0000000..73b73c4 --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/chewie.dart @@ -0,0 +1,8 @@ +library chewie; + +export 'src/chewie_player.dart'; +export 'src/chewie_progress_colors.dart'; +export 'src/cupertino/cupertino_controls.dart'; +export 'src/material/material_controls.dart'; +export 'src/material/material_desktop_controls.dart'; +export 'src/models/index.dart'; diff --git a/flutter_common/lib/utils/file/video/lib/src/animated_play_pause.dart b/flutter_common/lib/utils/file/video/lib/src/animated_play_pause.dart new file mode 100755 index 0000000..bef2dc3 --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/animated_play_pause.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +/// A widget that animates implicitly between a play and a pause icon. +class AnimatedPlayPause extends StatefulWidget { + const AnimatedPlayPause({ + Key? key, + required this.playing, + this.size, + this.color, + }) : super(key: key); + + final double? size; + final bool playing; + final Color? color; + + @override + State createState() => AnimatedPlayPauseState(); +} + +class AnimatedPlayPauseState extends State + with SingleTickerProviderStateMixin { + late final animationController = AnimationController( + vsync: this, + value: widget.playing ? 1 : 0, + duration: const Duration(milliseconds: 400), + ); + + @override + void didUpdateWidget(AnimatedPlayPause oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.playing != oldWidget.playing) { + if (widget.playing) { + animationController.forward(); + } else { + animationController.reverse(); + } + } + } + + @override + void dispose() { + animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: AnimatedIcon( + color: widget.color, + size: widget.size, + icon: AnimatedIcons.play_pause, + progress: animationController, + ), + ); + } +} diff --git a/flutter_common/lib/utils/file/video/lib/src/center_play_button.dart b/flutter_common/lib/utils/file/video/lib/src/center_play_button.dart new file mode 100755 index 0000000..509a1b8 --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/center_play_button.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import 'animated_play_pause.dart'; + +class CenterPlayButton extends StatelessWidget { + const CenterPlayButton({ + Key? key, + required this.backgroundColor, + this.iconColor, + required this.show, + required this.isPlaying, + required this.isFinished, + this.onPressed, + }) : super(key: key); + + final Color backgroundColor; + final Color? iconColor; + final bool show; + final bool isPlaying; + final bool isFinished; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: Colors.transparent, + child: Center( + child: UnconstrainedBox( + child: AnimatedOpacity( + opacity: show ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + shape: BoxShape.circle, + ), + // Always set the iconSize on the IconButton, not on the Icon itself: + // https://github.com/flutter/flutter/issues/52980 + child: IconButton( + iconSize: 32, + padding: const EdgeInsets.all(12.0), + icon: isFinished + ? Icon(Icons.replay, color: iconColor) + : AnimatedPlayPause( + color: iconColor, + playing: isPlaying, + ), + onPressed: onPressed, + ), + ), + ), + ), + ), + ); + } +} diff --git a/flutter_common/lib/utils/file/video/lib/src/chewie_player.dart b/flutter_common/lib/utils/file/video/lib/src/chewie_player.dart new file mode 100755 index 0000000..c73af92 --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/chewie_player.dart @@ -0,0 +1,633 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:video_player/video_player.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; +// import 'package:wakelock/wakelock.dart'; + +import '../chewie.dart'; +import 'notifiers/index.dart'; +import 'player_with_controls.dart'; + +typedef ChewieRoutePageBuilder = Widget Function( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ChewieControllerProvider controllerProvider, +); + +/// A Video Player with Material and Cupertino skins. +/// +/// `video_player` is pretty low level. Chewie wraps it in a friendly skin to +/// make it easy to use! +class Chewie extends StatefulWidget { + const Chewie({ + Key? key, + required this.controller, + }) : super(key: key); + + /// The [ChewieController] + final ChewieController controller; + + @override + ChewieState createState() { + return ChewieState(); + } +} + +class ChewieState extends State { + bool _isFullScreen = false; + + bool get isControllerFullScreen => widget.controller.isFullScreen; + late PlayerNotifier notifier; + + @override + void initState() { + super.initState(); + widget.controller.addListener(listener); + notifier = PlayerNotifier.init(); + } + + @override + void dispose() { + widget.controller.removeListener(listener); + super.dispose(); + } + + @override + void didUpdateWidget(Chewie oldWidget) { + if (oldWidget.controller != widget.controller) { + widget.controller.addListener(listener); + } + super.didUpdateWidget(oldWidget); + if (_isFullScreen != isControllerFullScreen) { + widget.controller._isFullScreen = _isFullScreen; + } + } + + Future listener() async { + if (isControllerFullScreen && !_isFullScreen) { + _isFullScreen = isControllerFullScreen; + await _pushFullScreenWidget(context); + } else if (_isFullScreen) { + Navigator.of( + context, + rootNavigator: widget.controller.useRootNavigator, + ).pop(); + _isFullScreen = false; + } + } + + @override + Widget build(BuildContext context) { + return ChewieControllerProvider( + controller: widget.controller, + child: ChangeNotifierProvider.value( + value: notifier, + builder: (context, w) => const PlayerWithControls(), + ), + ); + } + + Widget _buildFullScreenVideo( + BuildContext context, + Animation animation, + ChewieControllerProvider controllerProvider, + ) { + return Scaffold( + resizeToAvoidBottomInset: false, + body: Container( + alignment: Alignment.center, + color: Colors.black, + child: controllerProvider, + ), + ); + } + + AnimatedWidget _defaultRoutePageBuilder( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ChewieControllerProvider controllerProvider, + ) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + return _buildFullScreenVideo(context, animation, controllerProvider); + }, + ); + } + + Widget _fullScreenRoutePageBuilder( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + final controllerProvider = ChewieControllerProvider( + controller: widget.controller, + child: ChangeNotifierProvider.value( + value: notifier, + builder: (context, w) => const PlayerWithControls(), + ), + ); + + if (widget.controller.routePageBuilder == null) { + return _defaultRoutePageBuilder( + context, + animation, + secondaryAnimation, + controllerProvider, + ); + } + return widget.controller.routePageBuilder!( + context, + animation, + secondaryAnimation, + controllerProvider, + ); + } + + Future _pushFullScreenWidget(BuildContext context) async { + final TransitionRoute route = PageRouteBuilder( + pageBuilder: _fullScreenRoutePageBuilder, + ); + + onEnterFullScreen(); + + if (!widget.controller.allowedScreenSleep) { + WakelockPlus.enable; + } + + await Navigator.of( + context, + rootNavigator: widget.controller.useRootNavigator, + ).push(route); + _isFullScreen = false; + widget.controller.exitFullScreen(); + + // The wakelock plugins checks whether it needs to perform an action internally, + // so we do not need to check Wakelock.isEnabled. + WakelockPlus.disable(); + + SystemChrome.setEnabledSystemUIMode( + SystemUiMode.manual, + overlays: widget.controller.systemOverlaysAfterFullScreen, + ); + SystemChrome.setPreferredOrientations( + widget.controller.deviceOrientationsAfterFullScreen, + ); + } + + void onEnterFullScreen() { + final videoWidth = widget.controller.videoPlayerController.value.size.width; + final videoHeight = + widget.controller.videoPlayerController.value.size.height; + + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []); + + // if (widget.controller.systemOverlaysOnEnterFullScreen != null) { + // /// Optional user preferred settings + // SystemChrome.setEnabledSystemUIMode( + // SystemUiMode.manual, + // overlays: widget.controller.systemOverlaysOnEnterFullScreen, + // ); + // } else { + // /// Default behavior + // SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); + // } + + if (widget.controller.deviceOrientationsOnEnterFullScreen != null) { + /// Optional user preferred settings + SystemChrome.setPreferredOrientations( + widget.controller.deviceOrientationsOnEnterFullScreen!, + ); + } else { + final isLandscapeVideo = videoWidth > videoHeight; + final isPortraitVideo = videoWidth < videoHeight; + + /// Default behavior + /// Video w > h means we force landscape + if (isLandscapeVideo) { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } + + /// Video h > w means we force portrait + else if (isPortraitVideo) { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + } + + /// Otherwise if h == w (square video) + else { + SystemChrome.setPreferredOrientations(DeviceOrientation.values); + } + } + } +} + +/// The ChewieController is used to configure and drive the Chewie Player +/// Widgets. It provides methods to control playback, such as [pause] and +/// [play], as well as methods that control the visual appearance of the player, +/// such as [enterFullScreen] or [exitFullScreen]. +/// +/// In addition, you can listen to the ChewieController for presentational +/// changes, such as entering and exiting full screen mode. To listen for +/// changes to the playback, such as a change to the seek position of the +/// player, please use the standard information provided by the +/// `VideoPlayerController`. +class ChewieController extends ChangeNotifier { + ChewieController({ + required this.videoPlayerController, + this.optionsTranslation, + this.aspectRatio, + this.autoInitialize = false, + this.autoPlay = false, + this.startAt, + this.looping = false, + this.fullScreenByDefault = false, + this.cupertinoProgressColors, + this.materialProgressColors, + this.placeholder, + this.overlay, + this.showControlsOnInitialize = true, + this.showOptions = true, + this.optionsBuilder, + this.additionalOptions, + this.showControls = true, + this.transformationController, + this.zoomAndPan = false, + this.maxScale = 2.5, + this.subtitle, + this.subtitleBuilder, + this.customControls, + this.errorBuilder, + this.allowedScreenSleep = true, + this.isLive = false, + this.allowFullScreen = true, + this.allowMuting = true, + this.allowPlaybackSpeedChanging = true, + this.useRootNavigator = true, + this.playbackSpeeds = const [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], + this.systemOverlaysOnEnterFullScreen, + this.deviceOrientationsOnEnterFullScreen, + this.systemOverlaysAfterFullScreen = SystemUiOverlay.values, + this.deviceOrientationsAfterFullScreen = DeviceOrientation.values, + this.routePageBuilder, + this.progressIndicatorDelay, + this.hideControlsTimer = defaultHideControlsTimer, + }) : assert( + playbackSpeeds.every((speed) => speed > 0), + 'The playbackSpeeds values must all be greater than 0', + ) { + _initialize(); + } + + ChewieController copyWith({ + VideoPlayerController? videoPlayerController, + OptionsTranslation? optionsTranslation, + double? aspectRatio, + bool? autoInitialize, + bool? autoPlay, + Duration? startAt, + bool? looping, + bool? fullScreenByDefault, + ChewieProgressColors? cupertinoProgressColors, + ChewieProgressColors? materialProgressColors, + Widget? placeholder, + Widget? overlay, + bool? showControlsOnInitialize, + bool? showOptions, + Future Function(BuildContext, List)? optionsBuilder, + List Function(BuildContext)? additionalOptions, + bool? showControls, + TransformationController? transformationController, + bool? zoomAndPan, + double? maxScale, + Subtitles? subtitle, + Widget Function(BuildContext, dynamic)? subtitleBuilder, + Widget? customControls, + Widget Function(BuildContext, String)? errorBuilder, + bool? allowedScreenSleep, + bool? isLive, + bool? allowFullScreen, + bool? allowMuting, + bool? allowPlaybackSpeedChanging, + bool? useRootNavigator, + Duration? hideControlsTimer, + List? playbackSpeeds, + List? systemOverlaysOnEnterFullScreen, + List? deviceOrientationsOnEnterFullScreen, + List? systemOverlaysAfterFullScreen, + List? deviceOrientationsAfterFullScreen, + Duration? progressIndicatorDelay, + Widget Function( + BuildContext, + Animation, + Animation, + ChewieControllerProvider, + )? routePageBuilder, + }) { + return ChewieController( + videoPlayerController: + videoPlayerController ?? this.videoPlayerController, + optionsTranslation: optionsTranslation ?? this.optionsTranslation, + aspectRatio: aspectRatio ?? this.aspectRatio, + autoInitialize: autoInitialize ?? this.autoInitialize, + autoPlay: autoPlay ?? this.autoPlay, + startAt: startAt ?? this.startAt, + looping: looping ?? this.looping, + fullScreenByDefault: fullScreenByDefault ?? this.fullScreenByDefault, + cupertinoProgressColors: + cupertinoProgressColors ?? this.cupertinoProgressColors, + materialProgressColors: + materialProgressColors ?? this.materialProgressColors, + placeholder: placeholder ?? this.placeholder, + overlay: overlay ?? this.overlay, + showControlsOnInitialize: + showControlsOnInitialize ?? this.showControlsOnInitialize, + showOptions: showOptions ?? this.showOptions, + optionsBuilder: optionsBuilder ?? this.optionsBuilder, + additionalOptions: additionalOptions ?? this.additionalOptions, + showControls: showControls ?? this.showControls, + subtitle: subtitle ?? this.subtitle, + subtitleBuilder: subtitleBuilder ?? this.subtitleBuilder, + customControls: customControls ?? this.customControls, + errorBuilder: errorBuilder ?? this.errorBuilder, + allowedScreenSleep: allowedScreenSleep ?? this.allowedScreenSleep, + isLive: isLive ?? this.isLive, + allowFullScreen: allowFullScreen ?? this.allowFullScreen, + allowMuting: allowMuting ?? this.allowMuting, + allowPlaybackSpeedChanging: + allowPlaybackSpeedChanging ?? this.allowPlaybackSpeedChanging, + useRootNavigator: useRootNavigator ?? this.useRootNavigator, + playbackSpeeds: playbackSpeeds ?? this.playbackSpeeds, + systemOverlaysOnEnterFullScreen: systemOverlaysOnEnterFullScreen ?? + this.systemOverlaysOnEnterFullScreen, + deviceOrientationsOnEnterFullScreen: + deviceOrientationsOnEnterFullScreen ?? + this.deviceOrientationsOnEnterFullScreen, + systemOverlaysAfterFullScreen: + systemOverlaysAfterFullScreen ?? this.systemOverlaysAfterFullScreen, + deviceOrientationsAfterFullScreen: deviceOrientationsAfterFullScreen ?? + this.deviceOrientationsAfterFullScreen, + routePageBuilder: routePageBuilder ?? this.routePageBuilder, + hideControlsTimer: hideControlsTimer ?? this.hideControlsTimer, + progressIndicatorDelay: + progressIndicatorDelay ?? this.progressIndicatorDelay, + ); + } + + static const defaultHideControlsTimer = Duration(seconds: 3); + + /// If false, the options button in MaterialUI and MaterialDesktopUI + /// won't be shown. + final bool showOptions; + + /// Pass your translations for the options like: + /// - PlaybackSpeed + /// - Subtitles + /// - Cancel + /// + /// Buttons + /// + /// These are required for the default `OptionItem`'s + final OptionsTranslation? optionsTranslation; + + /// Build your own options with default chewieOptions shiped through + /// the builder method. Just add your own options to the Widget + /// you'll build. If you want to hide the chewieOptions, just leave them + /// out from your Widget. + final Future Function( + BuildContext context, + List chewieOptions, + )? optionsBuilder; + + /// Add your own additional options on top of chewie options + final List Function(BuildContext context)? additionalOptions; + + /// Define here your own Widget on how your n'th subtitle will look like + Widget Function(BuildContext context, dynamic subtitle)? subtitleBuilder; + + /// Add a List of Subtitles here in `Subtitles.subtitle` + Subtitles? subtitle; + + /// The controller for the video you want to play + final VideoPlayerController videoPlayerController; + + /// Initialize the Video on Startup. This will prep the video for playback. + final bool autoInitialize; + + /// Play the video as soon as it's displayed + final bool autoPlay; + + /// Start video at a certain position + final Duration? startAt; + + /// Whether or not the video should loop + final bool looping; + + /// Wether or not to show the controls when initializing the widget. + final bool showControlsOnInitialize; + + /// Whether or not to show the controls at all + final bool showControls; + + /// Controller to pass into the [InteractiveViewer] component + final TransformationController? transformationController; + + /// Whether or not to allow zooming and panning + final bool zoomAndPan; + + /// Max scale when zooming + final double maxScale; + + /// Defines customised controls. Check [MaterialControls] or + /// [CupertinoControls] for reference. + final Widget? customControls; + + /// When the video playback runs into an error, you can build a custom + /// error message. + final Widget Function(BuildContext context, String errorMessage)? + errorBuilder; + + /// The Aspect Ratio of the Video. Important to get the correct size of the + /// video! + /// + /// Will fallback to fitting within the space allowed. + final double? aspectRatio; + + /// The colors to use for controls on iOS. By default, the iOS player uses + /// colors sampled from the original iOS 11 designs. + final ChewieProgressColors? cupertinoProgressColors; + + /// The colors to use for the Material Progress Bar. By default, the Material + /// player uses the colors from your Theme. + final ChewieProgressColors? materialProgressColors; + + /// The placeholder is displayed underneath the Video before it is initialized + /// or played. + final Widget? placeholder; + + /// A widget which is placed between the video and the controls + final Widget? overlay; + + /// Defines if the player will start in fullscreen when play is pressed + final bool fullScreenByDefault; + + /// Defines if the player will sleep in fullscreen or not + final bool allowedScreenSleep; + + /// Defines if the controls should be shown for live stream video + final bool isLive; + + /// Defines if the fullscreen control should be shown + final bool allowFullScreen; + + /// Defines if the mute control should be shown + final bool allowMuting; + + /// Defines if the playback speed control should be shown + final bool allowPlaybackSpeedChanging; + + /// Defines if push/pop navigations use the rootNavigator + final bool useRootNavigator; + + /// Defines the [Duration] before the video controls are hidden. By default, this is set to three seconds. + final Duration hideControlsTimer; + + /// Defines the set of allowed playback speeds user can change + final List playbackSpeeds; + + /// Defines the system overlays visible on entering fullscreen + final List? systemOverlaysOnEnterFullScreen; + + /// Defines the set of allowed device orientations on entering fullscreen + final List? deviceOrientationsOnEnterFullScreen; + + /// Defines the system overlays visible after exiting fullscreen + final List systemOverlaysAfterFullScreen; + + /// Defines the set of allowed device orientations after exiting fullscreen + final List deviceOrientationsAfterFullScreen; + + /// Defines a custom RoutePageBuilder for the fullscreen + final ChewieRoutePageBuilder? routePageBuilder; + + /// Defines a delay in milliseconds between entering buffering state and displaying the loading spinner. Set null (default) to disable it. + final Duration? progressIndicatorDelay; + + static ChewieController of(BuildContext context) { + final chewieControllerProvider = + context.dependOnInheritedWidgetOfExactType()!; + + return chewieControllerProvider.controller; + } + + bool _isFullScreen = false; + + bool get isFullScreen => _isFullScreen; + + bool get isPlaying => videoPlayerController.value.isPlaying; + + Future _initialize() async { + await videoPlayerController.setLooping(looping); + + if ((autoInitialize || autoPlay) && + !videoPlayerController.value.isInitialized) { + await videoPlayerController.initialize(); + } + + if (autoPlay) { + if (fullScreenByDefault) { + enterFullScreen(); + } + + await videoPlayerController.play(); + } + + if (startAt != null) { + await videoPlayerController.seekTo(startAt!); + } + + if (fullScreenByDefault) { + videoPlayerController.addListener(_fullScreenListener); + } + } + + Future _fullScreenListener() async { + if (videoPlayerController.value.isPlaying && !_isFullScreen) { + enterFullScreen(); + videoPlayerController.removeListener(_fullScreenListener); + } + } + + void enterFullScreen() { + _isFullScreen = true; + notifyListeners(); + } + + void exitFullScreen() { + _isFullScreen = false; + SystemChrome.setPreferredOrientations([ + // 强制竖屏 + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown + ]); + notifyListeners(); + } + + void toggleFullScreen() { + _isFullScreen = !_isFullScreen; + notifyListeners(); + } + + void togglePause() { + isPlaying ? pause() : play(); + } + + Future play() async { + await videoPlayerController.play(); + } + + // ignore: avoid_positional_boolean_parameters + Future setLooping(bool looping) async { + await videoPlayerController.setLooping(looping); + } + + Future pause() async { + await videoPlayerController.pause(); + } + + Future seekTo(Duration moment) async { + await videoPlayerController.seekTo(moment); + } + + Future setVolume(double volume) async { + await videoPlayerController.setVolume(volume); + } + + void setSubtitle(List newSubtitle) { + subtitle = Subtitles(newSubtitle); + } +} + +class ChewieControllerProvider extends InheritedWidget { + const ChewieControllerProvider({ + Key? key, + required this.controller, + required Widget child, + }) : super(key: key, child: child); + + final ChewieController controller; + + @override + bool updateShouldNotify(ChewieControllerProvider oldWidget) => + controller != oldWidget.controller; +} diff --git a/flutter_common/lib/utils/file/video/lib/src/chewie_progress_colors.dart b/flutter_common/lib/utils/file/video/lib/src/chewie_progress_colors.dart new file mode 100755 index 0000000..75e48c1 --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/chewie_progress_colors.dart @@ -0,0 +1,18 @@ +import 'package:flutter/rendering.dart'; + +class ChewieProgressColors { + ChewieProgressColors({ + Color playedColor = const Color.fromRGBO(255, 0, 0, 0.7), + Color bufferedColor = const Color.fromRGBO(30, 30, 200, 0.2), + Color handleColor = const Color.fromRGBO(200, 200, 200, 1.0), + Color backgroundColor = const Color.fromRGBO(200, 200, 200, 0.5), + }) : playedPaint = Paint()..color = playedColor, + bufferedPaint = Paint()..color = bufferedColor, + handlePaint = Paint()..color = handleColor, + backgroundPaint = Paint()..color = backgroundColor; + + final Paint playedPaint; + final Paint bufferedPaint; + final Paint handlePaint; + final Paint backgroundPaint; +} diff --git a/flutter_common/lib/utils/file/video/lib/src/cupertino/cupertino_controls.dart b/flutter_common/lib/utils/file/video/lib/src/cupertino/cupertino_controls.dart new file mode 100755 index 0000000..80d3e3a --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/cupertino/cupertino_controls.dart @@ -0,0 +1,955 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +// import 'package:perfect_volume_control/perfect_volume_control.dart'; +import 'package:provider/provider.dart'; +import 'package:screen_brightness/screen_brightness.dart'; +import 'package:video_player/video_player.dart'; + +import '../../chewie.dart'; +import '../animated_play_pause.dart'; +import '../center_play_button.dart'; +import '../helpers/utils.dart'; +import '../notifiers/index.dart'; +import 'cupertino_progress_bar.dart'; +import 'widgets/cupertino_options_dialog.dart'; + +class CupertinoControls extends StatefulWidget { + const CupertinoControls({ + 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 createState() { + return _CupertinoControlsState(); + } +} + +class _CupertinoControlsState extends State + 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; + + // We know that _chewieController is set in didChangeDependencies + ChewieController get chewieController => _chewieController!; + ChewieController? _chewieController; + + @override + void initState() { + super.initState(); + notifier = Provider.of(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; + double volumeStart = 0.0; //开始手指位置 + double volume = 0.0; //开始声音 + double _brightness = 0.0; //屏幕亮度 + double volumeDouble = 0.018; + + return MouseRegion( + onHover: (_) => _cancelAndRestartTimer(), + child: GestureDetector( + onTap: () => _cancelAndRestartTimer(), + onDoubleTap: _playPause, + onLongPress: () { + ///长按快进 + /// + + printInfo(info: "长按快进"); + }, + onLongPressStart: (details) { + controller.setPlaybackSpeed(3); + }, + onLongPressEnd: (details) { + controller.setPlaybackSpeed(1); + }, + onVerticalDragCancel: () { + print("onVerticalDragCancel"); + }, + // onVerticalDragDown: (details) { + // print( + // "onVerticalDragDown---${details.globalPosition}---${details.localPosition}"); + // }, + onVerticalDragEnd: (details) { + // print( + // "onVerticalDragEnd---${details.velocity}---${details.primaryVelocity}"); + }, + onVerticalDragStart: (details) async { + volumeStart = details.localPosition.dy; + // PerfectVolumeControl.getVolume(); + // volume = await PerfectVolumeControl.volume; + _brightness = await ScreenBrightness().current; + print("volumevolume---volume$volume---_brightness $_brightness"); + }, + onVerticalDragUpdate: (details) async { + double midle = MediaQuery.of(context).size.width / 2; + double width = details.localPosition.dx; + print("====== volume $volume"); + if (midle > width) { + if (details.delta.direction > 0) { + _brightness = + _brightness - volumeDouble * details.primaryDelta!.obs.value; + if (_brightness <= 0) { + _brightness = 0; + } + print("====== 左边向下滑 $_brightness"); + } else { + _brightness = + _brightness - volumeDouble * details.primaryDelta!.obs.value; + if (_brightness >= 1) { + _brightness = 1; + } + print("====== 左边向上滑 $_brightness"); + } + await ScreenBrightness().setScreenBrightness(_brightness); + } else { + if (details.delta.direction > 0) { + volume = volume - volumeDouble * details.primaryDelta!.obs.value; + if (volume <= 0) { + volume = 0; + } + print("====== 右边向下滑 $volume"); + } else { + print("====== 右边向上滑 之前 $volume"); + volume = volume - volumeDouble * details.primaryDelta!.obs.value; + if (volume >= 1) { + volume = 1; + } + print("====== 右边向上滑 $volume"); + } + // PerfectVolumeControl.setVolume(volume); + } + + // print( + // "高度====== ${details.localPosition.dy} /250 ${details.localPosition.dy / 250}"); + // + // print( + // "onVerticalDragUpdate---${details.globalPosition}---${details.localPosition}---${details.delta}"); + }, + child: AbsorbPointer( + absorbing: notifier.hideStuff, + child: Stack( + children: [ + if (_displayBufferingIndicator) + const Center( + child: CircularProgressIndicator(), + ) + else + _buildHitArea(), + Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _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 = []; + + 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( + 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, + 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: [ + _buildPlayPause(controller, iconColor, barHeight), + _buildLive(iconColor), + ], + ) + : Row( + children: [ + // _buildSkipBack(iconColor, barHeight), + _buildPlayPause(controller, iconColor, barHeight), + // _buildSkipForward(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), + // if (chewieController.allowFullScreen) + // _buildExpandButton( + // backgroundColor, + // iconColor, + // barHeight, + // MediaQuery.of(context).orientation == + // Orientation.portrait + // ? 16.0 + // : 24.0), + ], + ), + ), + ), + ), + ), + ), + ); + } + + 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, { + bool? showRight, + }) { + 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: showRight == true + ? const Icon( + Icons.keyboard_arrow_left, + size: 22, + color: Colors.white, + ) + : const Icon( + Icons.fullscreen, + size: 22, + color: Colors.white, + ) + // 'Image.asset( + // Assets.imagesFullScreen, + // width: 22, + // height: 22, + // ), + // 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 don't have subtitle hiden button + 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( + 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: [ + if (chewieController.allowFullScreen) + if (chewieController.isFullScreen) + _buildExpandButton( + backgroundColor, + iconColor, + barHeight, + buttonPadding, + showRight: true, + ), + + const Spacer(), + // if (chewieController.allowMuting) + // _buildMuteButton( + // controller, + // backgroundColor, + // iconColor, + // barHeight, + // buttonPadding, + // ), + ], + ), + ); + } + + void _cancelAndRestartTimer() { + _hideTimer?.cancel(); + + setState(() { + notifier.hideStuff = false; + + _startHideTimer(); + }); + } + + Future _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() { + Navigator.of(context).pop(); + setState(() { + Navigator.of(context).pop(); + // 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(); + }, + 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; + + // display the progress bar indicator only after the buffering delay if it has been set + 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 speeds, + required double selected, + }) : _speeds = speeds, + _selected = selected, + super(key: key); + + final List _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(), + ); + } +} diff --git a/flutter_common/lib/utils/file/video/lib/src/cupertino/cupertino_progress_bar.dart b/flutter_common/lib/utils/file/video/lib/src/cupertino/cupertino_progress_bar.dart new file mode 100755 index 0000000..bd081c2 --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/cupertino/cupertino_progress_bar.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:video_player/video_player.dart'; + +import '../../chewie.dart'; +import '../progress_bar.dart'; + +class CupertinoVideoProgressBar extends StatelessWidget { + CupertinoVideoProgressBar( + this.controller, { + ChewieProgressColors? colors, + this.onDragEnd, + this.onDragStart, + this.onDragUpdate, + Key? key, + }) : colors = colors ?? ChewieProgressColors(), + super(key: key); + + final VideoPlayerController controller; + final ChewieProgressColors colors; + final Function()? onDragStart; + final Function()? onDragEnd; + final Function()? onDragUpdate; + + @override + Widget build(BuildContext context) { + return VideoProgressBar( + controller, + barHeight: 5, + handleHeight: 6, + drawShadow: true, + colors: colors, + onDragEnd: onDragEnd, + onDragStart: onDragStart, + onDragUpdate: onDragUpdate, + ); + } +} diff --git a/flutter_common/lib/utils/file/video/lib/src/cupertino/widgets/cupertino_options_dialog.dart b/flutter_common/lib/utils/file/video/lib/src/cupertino/widgets/cupertino_options_dialog.dart new file mode 100755 index 0000000..c94ed55 --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/cupertino/widgets/cupertino_options_dialog.dart @@ -0,0 +1,41 @@ +import 'package:flutter/cupertino.dart'; + +import '../../../chewie.dart'; + +class CupertinoOptionsDialog extends StatefulWidget { + const CupertinoOptionsDialog({ + Key? key, + required this.options, + this.cancelButtonText, + }) : super(key: key); + + final List options; + final String? cancelButtonText; + + @override + // ignore: library_private_types_in_public_api + _CupertinoOptionsDialogState createState() => _CupertinoOptionsDialogState(); +} + +class _CupertinoOptionsDialogState extends State { + @override + Widget build(BuildContext context) { + return SafeArea( + child: CupertinoActionSheet( + actions: widget.options + .map( + (option) => CupertinoActionSheetAction( + onPressed: () => option.onTap!(), + child: Text(option.title), + ), + ) + .toList(), + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(context), + isDestructiveAction: true, + child: Text(widget.cancelButtonText ?? 'Cancel'), + ), + ), + ); + } +} diff --git a/flutter_common/lib/utils/file/video/lib/src/helpers/adaptive_controls.dart b/flutter_common/lib/utils/file/video/lib/src/helpers/adaptive_controls.dart new file mode 100755 index 0000000..b6f5a23 --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/helpers/adaptive_controls.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import '../../chewie.dart'; + +class AdaptiveControls extends StatelessWidget { + const AdaptiveControls({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + return const CupertinoControls( + backgroundColor: Color.fromRGBO(41, 41, 41, 0.7), + iconColor: Color.fromARGB(255, 200, 200, 200), + ); + return const MaterialControls(); + + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + return const MaterialDesktopControls(); + + case TargetPlatform.iOS: + return const CupertinoControls( + backgroundColor: Color.fromRGBO(41, 41, 41, 0.7), + iconColor: Color.fromARGB(255, 200, 200, 200), + ); + default: + return const MaterialControls(); + } + } +} diff --git a/flutter_common/lib/utils/file/video/lib/src/helpers/utils.dart b/flutter_common/lib/utils/file/video/lib/src/helpers/utils.dart new file mode 100755 index 0000000..0f3a2e1 --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/helpers/utils.dart @@ -0,0 +1,32 @@ +String formatDuration(Duration position) { + final ms = position.inMilliseconds; + + int seconds = ms ~/ 1000; + final int hours = seconds ~/ 3600; + seconds = seconds % 3600; + final minutes = seconds ~/ 60; + seconds = seconds % 60; + + final hoursString = hours >= 10 + ? '$hours' + : hours == 0 + ? '00' + : '0$hours'; + + final minutesString = minutes >= 10 + ? '$minutes' + : minutes == 0 + ? '00' + : '0$minutes'; + + final secondsString = seconds >= 10 + ? '$seconds' + : seconds == 0 + ? '00' + : '0$seconds'; + + final formattedTime = + '${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString'; + + return formattedTime; +} diff --git a/flutter_common/lib/utils/file/video/lib/src/material/material_controls.dart b/flutter_common/lib/utils/file/video/lib/src/material/material_controls.dart new file mode 100755 index 0000000..190e150 --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/material/material_controls.dart @@ -0,0 +1,623 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:video_player/video_player.dart'; + +import '../../chewie.dart'; +import '../center_play_button.dart'; +import '../helpers/utils.dart'; +import '../notifiers/index.dart'; +import 'material_progress_bar.dart'; +import 'widgets/options_dialog.dart'; +import 'widgets/playback_speed_dialog.dart'; + +class MaterialControls extends StatefulWidget { + const MaterialControls({ + this.showPlayButton = true, + Key? key, + }) : super(key: key); + + final bool showPlayButton; + + @override + State createState() { + return _MaterialControlsState(); + } +} + +class _MaterialControlsState extends State + 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; + + // We know that _chewieController is set in didChangeDependencies + ChewieController get chewieController => _chewieController!; + + @override + void initState() { + super.initState(); + notifier = Provider.of(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(), + onLongPress: () { + ///长按快进 + /// + }, + onLongPressStart: (details) { + controller.setPlaybackSpeed(3); + }, + onLongPressEnd: (details) { + controller.setPlaybackSpeed(1); + }, + child: AbsorbPointer( + absorbing: notifier.hideStuff, + child: Stack( + children: [ + if (_displayBufferingIndicator) + const Center( + child: CircularProgressIndicator(), + ) + else + _buildHitArea(), + _buildActionBar(), + Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + 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, + right: 0, + child: SafeArea( + child: AnimatedOpacity( + opacity: notifier.hideStuff ? 0.0 : 1.0, + duration: const Duration(milliseconds: 250), + child: Row( + children: [ + _buildSubtitleToggle(), + if (chewieController.showOptions) _buildOptionsButton(), + ], + ), + ), + ), + ); + } + + Widget _buildOptionsButton() { + final options = [ + 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( + 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.labelMedium!.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, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + 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 _onSpeedButtonTap() async { + _hideTimer?.cancel(); + + final chosenSpeed = await showModalBottomSheet( + 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: [ + 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 don't have subtitle hiden button + 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 _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; + + // display the progress bar indicator only after the buffering delay if it has been set + 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).disabledColor.withOpacity(0.5), + backgroundColor: Theme.of(context).disabledColor.withOpacity(.5), + ), + ), + ); + } +} diff --git a/flutter_common/lib/utils/file/video/lib/src/material/material_desktop_controls.dart b/flutter_common/lib/utils/file/video/lib/src/material/material_desktop_controls.dart new file mode 100755 index 0000000..26bb08f --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/material/material_desktop_controls.dart @@ -0,0 +1,593 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_common/utils/file/video/lib/chewie.dart'; +import 'package:flutter_common/utils/file/video/lib/src/chewie_player.dart'; +import 'package:flutter_common/utils/file/video/lib/src/material/widgets/options_dialog.dart'; +import 'package:provider/provider.dart'; +import 'package:video_player/video_player.dart'; + +import '../animated_play_pause.dart'; +import '../center_play_button.dart'; +import '../helpers/utils.dart'; +import '../notifiers/index.dart'; +import 'material_progress_bar.dart'; +import 'widgets/playback_speed_dialog.dart'; + +class MaterialDesktopControls extends StatefulWidget { + const MaterialDesktopControls({ + this.showPlayButton = true, + Key? key, + }) : super(key: key); + + final bool showPlayButton; + + @override + State createState() { + return _MaterialDesktopControlsState(); + } +} + +class _MaterialDesktopControlsState extends State + 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; + + // We know that _chewieController is set in didChangeDependencies + ChewieController get chewieController => _chewieController!; + + @override + void initState() { + super.initState(); + notifier = Provider.of(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(), + Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + 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 _buildSubtitleToggle({IconData? icon, bool isPadded = false}) { + return IconButton( + padding: isPadded ? const EdgeInsets.all(8.0) : EdgeInsets.zero, + icon: Icon(icon, color: _subtitleOn ? Colors.white : Colors.grey[700]), + onPressed: _onSubtitleTap, + ); + } + + Widget _buildOptionsButton({ + IconData? icon, + bool isPadded = false, + }) { + final options = [ + 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( + padding: isPadded ? const EdgeInsets.all(8.0) : EdgeInsets.zero, + onPressed: () async { + _hideTimer?.cancel(); + + if (chewieController.optionsBuilder != null) { + await chewieController.optionsBuilder!(context, options); + } else { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: chewieController.useRootNavigator, + builder: (context) => OptionsDialog( + options: options, + cancelButtonText: + chewieController.optionsTranslation?.cancelButtonText, + ), + ); + } + + if (_latestValue.isPlaying) { + _startHideTimer(); + } + }, + icon: Icon( + 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.labelMedium!.color; + + return AnimatedOpacity( + opacity: notifier.hideStuff ? 0.0 : 1.0, + duration: const Duration(milliseconds: 300), + child: Container( + height: barHeight + (chewieController.isFullScreen ? 20.0 : 0), + padding: + EdgeInsets.only(bottom: chewieController.isFullScreen ? 10.0 : 15), + child: SafeArea( + bottom: chewieController.isFullScreen, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + verticalDirection: VerticalDirection.up, + children: [ + Flexible( + child: Row( + children: [ + _buildPlayPause(controller), + _buildMuteButton(controller), + if (chewieController.isLive) + const Expanded(child: Text('LIVE')) + else + _buildPosition(iconColor), + const Spacer(), + if (chewieController.showControls && + chewieController.subtitle != null && + chewieController.subtitle!.isNotEmpty) + _buildSubtitleToggle(icon: Icons.subtitles), + if (chewieController.showOptions) + _buildOptionsButton(icon: Icons.settings), + if (chewieController.allowFullScreen) _buildExpandButton(), + ], + ), + ), + if (!chewieController.isLive) + Expanded( + child: Container( + padding: EdgeInsets.only( + right: 20, + left: 20, + bottom: chewieController.isFullScreen ? 5.0 : 0, + ), + child: Row( + children: [ + _buildProgressBar(), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + 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 _onSpeedButtonTap() async { + _hideTimer?.cancel(); + + final chosenSpeed = await showModalBottomSheet( + 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(); + } + } + + 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( + right: 15.0, + ), + child: Icon( + _latestValue.volume > 0 ? Icons.volume_up : Icons.volume_off, + color: Colors.white, + ), + ), + ), + ), + ); + } + + GestureDetector _buildPlayPause(VideoPlayerController controller) { + return GestureDetector( + onTap: _playPause, + child: Container( + height: barHeight, + color: Colors.transparent, + margin: const EdgeInsets.only(left: 8.0, right: 4.0), + padding: const EdgeInsets.only( + left: 12.0, + right: 12.0, + ), + child: AnimatedPlayPause( + playing: controller.value.isPlaying, + color: Colors.white, + ), + ), + ); + } + + Widget _buildPosition(Color? iconColor) { + final position = _latestValue.position; + final duration = _latestValue.duration; + + return Text( + '${formatDuration(position)} / ${formatDuration(duration)}', + style: const TextStyle( + fontSize: 14.0, + color: Colors.white, + ), + ); + } + + void _onSubtitleTap() { + setState(() { + _subtitleOn = !_subtitleOn; + }); + } + + void _cancelAndRestartTimer() { + _hideTimer?.cancel(); + _startHideTimer(); + + setState(() { + notifier.hideStuff = false; + _displayTapped = true; + }); + } + + Future _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; + + // display the progress bar indicator only after the buffering delay if it has been set + 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).disabledColor.withOpacity(0.5), + backgroundColor: Theme.of(context).disabledColor.withOpacity(.5), + ), + ), + ); + } +} diff --git a/flutter_common/lib/utils/file/video/lib/src/material/material_progress_bar.dart b/flutter_common/lib/utils/file/video/lib/src/material/material_progress_bar.dart new file mode 100755 index 0000000..65f18d0 --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/material/material_progress_bar.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_common/utils/file/video/lib/src/progress_bar.dart'; +import 'package:video_player/video_player.dart'; + +import '../../chewie.dart'; + +class MaterialVideoProgressBar extends StatelessWidget { + MaterialVideoProgressBar( + this.controller, { + this.height = kToolbarHeight, + ChewieProgressColors? colors, + this.onDragEnd, + this.onDragStart, + this.onDragUpdate, + Key? key, + }) : colors = colors ?? ChewieProgressColors(), + super(key: key); + + final double height; + final VideoPlayerController controller; + final ChewieProgressColors colors; + final Function()? onDragStart; + final Function()? onDragEnd; + final Function()? onDragUpdate; + + @override + Widget build(BuildContext context) { + return VideoProgressBar( + controller, + barHeight: 10, + handleHeight: 6, + drawShadow: true, + colors: colors, + onDragEnd: onDragEnd, + onDragStart: onDragStart, + onDragUpdate: onDragUpdate, + ); + } +} diff --git a/flutter_common/lib/utils/file/video/lib/src/material/widgets/options_dialog.dart b/flutter_common/lib/utils/file/video/lib/src/material/widgets/options_dialog.dart new file mode 100755 index 0000000..e66a8f0 --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/material/widgets/options_dialog.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +import '../../../chewie.dart'; + +class OptionsDialog extends StatefulWidget { + const OptionsDialog({ + Key? key, + required this.options, + this.cancelButtonText, + }) : super(key: key); + + final List options; + final String? cancelButtonText; + + @override + // ignore: library_private_types_in_public_api + _OptionsDialogState createState() => _OptionsDialogState(); +} + +class _OptionsDialogState extends State { + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListView.builder( + shrinkWrap: true, + itemCount: widget.options.length, + itemBuilder: (context, i) { + return ListTile( + onTap: widget.options[i].onTap != null + ? widget.options[i].onTap! + : null, + leading: Icon(widget.options[i].iconData), + title: Text(widget.options[i].title), + subtitle: widget.options[i].subtitle != null + ? Text(widget.options[i].subtitle!) + : null, + ); + }, + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Divider( + thickness: 1.0, + ), + ), + ListTile( + onTap: () => Navigator.pop(context), + leading: const Icon(Icons.close), + title: Text( + widget.cancelButtonText ?? 'Cancel', + ), + ), + ], + ), + ); + } +} diff --git a/flutter_common/lib/utils/file/video/lib/src/material/widgets/playback_speed_dialog.dart b/flutter_common/lib/utils/file/video/lib/src/material/widgets/playback_speed_dialog.dart new file mode 100755 index 0000000..84ff79f --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/material/widgets/playback_speed_dialog.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +class PlaybackSpeedDialog extends StatelessWidget { + const PlaybackSpeedDialog({ + Key? key, + required List speeds, + required double selected, + }) : _speeds = speeds, + _selected = selected, + super(key: key); + + final List _speeds; + final double _selected; + + @override + Widget build(BuildContext context) { + final Color selectedColor = Theme.of(context).primaryColor; + + return ListView.builder( + shrinkWrap: true, + physics: const ScrollPhysics(), + itemBuilder: (context, index) { + final speed = _speeds[index]; + return ListTile( + dense: true, + title: Row( + children: [ + if (speed == _selected) + Icon( + Icons.check, + size: 20.0, + color: selectedColor, + ) + else + Container(width: 20.0), + const SizedBox(width: 16.0), + Text(speed.toString()), + ], + ), + selected: speed == _selected, + onTap: () { + Navigator.of(context).pop(speed); + }, + ); + }, + itemCount: _speeds.length, + ); + } +} diff --git a/flutter_common/lib/utils/file/video/lib/src/models/index.dart b/flutter_common/lib/utils/file/video/lib/src/models/index.dart new file mode 100755 index 0000000..a308c33 --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/models/index.dart @@ -0,0 +1,3 @@ +export 'option_item.dart'; +export 'options_translation.dart'; +export 'subtitle_model.dart'; diff --git a/flutter_common/lib/utils/file/video/lib/src/models/option_item.dart b/flutter_common/lib/utils/file/video/lib/src/models/option_item.dart new file mode 100755 index 0000000..403e5f7 --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/models/option_item.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +class OptionItem { + OptionItem({ + required this.onTap, + required this.iconData, + required this.title, + this.subtitle, + }); + + Function()? onTap; + IconData iconData; + String title; + String? subtitle; + + OptionItem copyWith({ + Function()? onTap, + IconData? iconData, + String? title, + String? subtitle, + }) { + return OptionItem( + onTap: onTap ?? this.onTap, + iconData: iconData ?? this.iconData, + title: title ?? this.title, + subtitle: subtitle ?? this.subtitle, + ); + } + + @override + String toString() => + 'OptionItem(onTap: $onTap, iconData: $iconData, title: $title, subtitle: $subtitle)'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is OptionItem && + other.onTap == onTap && + other.iconData == iconData && + other.title == title && + other.subtitle == subtitle; + } + + @override + int get hashCode => + onTap.hashCode ^ iconData.hashCode ^ title.hashCode ^ subtitle.hashCode; +} diff --git a/flutter_common/lib/utils/file/video/lib/src/models/options_translation.dart b/flutter_common/lib/utils/file/video/lib/src/models/options_translation.dart new file mode 100755 index 0000000..ccbe976 --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/models/options_translation.dart @@ -0,0 +1,44 @@ +class OptionsTranslation { + OptionsTranslation({ + this.playbackSpeedButtonText, + this.subtitlesButtonText, + this.cancelButtonText, + }); + + String? playbackSpeedButtonText; + String? subtitlesButtonText; + String? cancelButtonText; + + OptionsTranslation copyWith({ + String? playbackSpeedButtonText, + String? subtitlesButtonText, + String? cancelButtonText, + }) { + return OptionsTranslation( + playbackSpeedButtonText: + playbackSpeedButtonText ?? this.playbackSpeedButtonText, + subtitlesButtonText: subtitlesButtonText ?? this.subtitlesButtonText, + cancelButtonText: cancelButtonText ?? this.cancelButtonText, + ); + } + + @override + String toString() => + 'OptionsTranslation(playbackSpeedButtonText: $playbackSpeedButtonText, subtitlesButtonText: $subtitlesButtonText, cancelButtonText: $cancelButtonText)'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is OptionsTranslation && + other.playbackSpeedButtonText == playbackSpeedButtonText && + other.subtitlesButtonText == subtitlesButtonText && + other.cancelButtonText == cancelButtonText; + } + + @override + int get hashCode => + playbackSpeedButtonText.hashCode ^ + subtitlesButtonText.hashCode ^ + cancelButtonText.hashCode; +} diff --git a/flutter_common/lib/utils/file/video/lib/src/models/subtitle_model.dart b/flutter_common/lib/utils/file/video/lib/src/models/subtitle_model.dart new file mode 100755 index 0000000..edcac3a --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/models/subtitle_model.dart @@ -0,0 +1,67 @@ +class Subtitles { + Subtitles(this.subtitle); + + final List subtitle; + + bool get isEmpty => subtitle.isEmpty; + + bool get isNotEmpty => !isEmpty; + + List getByPosition(Duration position) { + final found = subtitle.where((item) { + if (item != null) return position >= item.start && position <= item.end; + return false; + }).toList(); + + return found; + } +} + +class Subtitle { + Subtitle({ + required this.index, + required this.start, + required this.end, + required this.text, + }); + + Subtitle copyWith({ + int? index, + Duration? start, + Duration? end, + dynamic text, + }) { + return Subtitle( + index: index ?? this.index, + start: start ?? this.start, + end: end ?? this.end, + text: text ?? this.text, + ); + } + + final int index; + final Duration start; + final Duration end; + final dynamic text; + + @override + String toString() { + return 'Subtitle(index: $index, start: $start, end: $end, text: $text)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is Subtitle && + other.index == index && + other.start == start && + other.end == end && + other.text == text; + } + + @override + int get hashCode { + return index.hashCode ^ start.hashCode ^ end.hashCode ^ text.hashCode; + } +} diff --git a/flutter_common/lib/utils/file/video/lib/src/notifiers/index.dart b/flutter_common/lib/utils/file/video/lib/src/notifiers/index.dart new file mode 100755 index 0000000..2fd693a --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/notifiers/index.dart @@ -0,0 +1 @@ +export 'player_notifier.dart'; diff --git a/flutter_common/lib/utils/file/video/lib/src/notifiers/player_notifier.dart b/flutter_common/lib/utils/file/video/lib/src/notifiers/player_notifier.dart new file mode 100755 index 0000000..dbba4fa --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/notifiers/player_notifier.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +/// +/// The new State-Manager for Chewie! +/// Has to be an instance of Singleton to survive +/// over all State-Changes inside chewie +/// +class PlayerNotifier extends ChangeNotifier { + PlayerNotifier._( + bool hideStuff, + ) : _hideStuff = hideStuff; + + bool _hideStuff; + + bool get hideStuff => _hideStuff; + + set hideStuff(bool value) { + _hideStuff = value; + notifyListeners(); + } + + // ignore: prefer_constructors_over_static_methods + static PlayerNotifier init() { + return PlayerNotifier._( + true, + ); + } +} diff --git a/flutter_common/lib/utils/file/video/lib/src/player_with_controls.dart b/flutter_common/lib/utils/file/video/lib/src/player_with_controls.dart new file mode 100755 index 0000000..f19aa09 --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/player_with_controls.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:video_player/video_player.dart'; +import '../chewie.dart'; +import 'helpers/adaptive_controls.dart'; +import 'notifiers/index.dart'; + +class PlayerWithControls extends StatelessWidget { + const PlayerWithControls({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final ChewieController chewieController = ChewieController.of(context); + + double calculateAspectRatio(BuildContext context) { + final size = MediaQuery.of(context).size; + final width = size.width; + final height = size.height; + + return width > height ? width / height : height / width; + } + + Widget buildControls( + BuildContext context, + ChewieController chewieController, + ) { + return chewieController.showControls + ? chewieController.customControls ?? const AdaptiveControls() + : const SizedBox(); + } + + Widget buildPlayerWithControls( + ChewieController chewieController, + BuildContext context, + ) { + return Stack( + children: [ + if (chewieController.placeholder != null) + chewieController.placeholder!, + InteractiveViewer( + transformationController: chewieController.transformationController, + maxScale: chewieController.maxScale, + panEnabled: chewieController.zoomAndPan, + scaleEnabled: chewieController.zoomAndPan, + child: Center( + child: AspectRatio( + aspectRatio: + chewieController.videoPlayerController.value.aspectRatio, + child: VideoPlayer(chewieController.videoPlayerController), + ), + ), + ), + if (chewieController.overlay != null) chewieController.overlay!, + if (Theme.of(context).platform != TargetPlatform.iOS) + Consumer( + builder: ( + BuildContext context, + PlayerNotifier notifier, + Widget? widget, + ) => + Visibility( + visible: !notifier.hideStuff, + child: AnimatedOpacity( + opacity: notifier.hideStuff ? 0.0 : 0.8, + duration: const Duration( + milliseconds: 250, + ), + child: const DecoratedBox( + decoration: BoxDecoration(color: Colors.black54), + child: SizedBox(), + ), + ), + ), + ), + if (!chewieController.isFullScreen) + buildControls(context, chewieController) + else + SafeArea( + bottom: false, + child: buildControls(context, chewieController), + ), + ], + ); + } + + return Center( + child: SizedBox( + height: MediaQuery.of(context).size.height, + width: MediaQuery.of(context).size.width, + child: AspectRatio( + aspectRatio: calculateAspectRatio(context), + child: buildPlayerWithControls(chewieController, context), + ), + ), + ); + } +} diff --git a/flutter_common/lib/utils/file/video/lib/src/progress_bar.dart b/flutter_common/lib/utils/file/video/lib/src/progress_bar.dart new file mode 100755 index 0000000..5c09bcb --- /dev/null +++ b/flutter_common/lib/utils/file/video/lib/src/progress_bar.dart @@ -0,0 +1,218 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:video_player/video_player.dart'; + +import '../chewie.dart'; + +class VideoProgressBar extends StatefulWidget { + VideoProgressBar( + this.controller, { + ChewieProgressColors? colors, + this.onDragEnd, + this.onDragStart, + this.onDragUpdate, + Key? key, + required this.barHeight, + required this.handleHeight, + required this.drawShadow, + }) : colors = colors ?? ChewieProgressColors(), + super(key: key); + + final VideoPlayerController controller; + final ChewieProgressColors colors; + final Function()? onDragStart; + final Function()? onDragEnd; + final Function()? onDragUpdate; + + final double barHeight; + final double handleHeight; + final bool drawShadow; + + @override + // ignore: library_private_types_in_public_api + _VideoProgressBarState createState() { + return _VideoProgressBarState(); + } +} + +class _VideoProgressBarState extends State { + void listener() { + if (!mounted) return; + setState(() {}); + } + + bool _controllerWasPlaying = false; + + VideoPlayerController get controller => widget.controller; + + @override + void initState() { + super.initState(); + controller.addListener(listener); + } + + @override + void deactivate() { + controller.removeListener(listener); + super.deactivate(); + } + + void _seekToRelativePosition(Offset globalPosition) { + final box = context.findRenderObject()! as RenderBox; + final Offset tapPos = box.globalToLocal(globalPosition); + final double relative = tapPos.dx / box.size.width; + final Duration position = controller.value.duration * relative; + controller.seekTo(position); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onHorizontalDragStart: (DragStartDetails details) { + if (!controller.value.isInitialized) { + return; + } + _controllerWasPlaying = controller.value.isPlaying; + if (_controllerWasPlaying) { + controller.pause(); + } + + widget.onDragStart?.call(); + }, + onHorizontalDragUpdate: (DragUpdateDetails details) { + if (!controller.value.isInitialized) { + return; + } + // Should only seek if it's not running on Android, or if it is, + // then the VideoPlayerController cannot be buffering. + // On Android, we need to let the player buffer when scrolling + // in order to let the player buffer. https://github.com/flutter/flutter/issues/101409 + final shouldSeekToRelativePosition = + !Platform.isAndroid || !controller.value.isBuffering; + if (shouldSeekToRelativePosition) { + _seekToRelativePosition(details.globalPosition); + } + + widget.onDragUpdate?.call(); + }, + onHorizontalDragEnd: (DragEndDetails details) { + if (_controllerWasPlaying) { + controller.play(); + } + + widget.onDragEnd?.call(); + }, + onTapDown: (TapDownDetails details) { + if (!controller.value.isInitialized) { + return; + } + _seekToRelativePosition(details.globalPosition); + }, + child: Center( + child: Container( + height: MediaQuery.of(context).size.height, + width: MediaQuery.of(context).size.width, + color: Colors.transparent, + child: CustomPaint( + painter: _ProgressBarPainter( + value: controller.value, + colors: widget.colors, + barHeight: widget.barHeight, + handleHeight: widget.handleHeight, + drawShadow: widget.drawShadow, + ), + ), + ), + ), + ); + } +} + +class _ProgressBarPainter extends CustomPainter { + _ProgressBarPainter({ + required this.value, + required this.colors, + required this.barHeight, + required this.handleHeight, + required this.drawShadow, + }); + + VideoPlayerValue value; + ChewieProgressColors colors; + + final double barHeight; + final double handleHeight; + final bool drawShadow; + + @override + bool shouldRepaint(CustomPainter painter) { + return true; + } + + @override + void paint(Canvas canvas, Size size) { + final baseOffset = size.height / 2 - barHeight / 2; + + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromPoints( + Offset(0.0, baseOffset), + Offset(size.width, baseOffset + barHeight), + ), + const Radius.circular(4.0), + ), + colors.backgroundPaint, + ); + if (!value.isInitialized) { + return; + } + final double playedPartPercent = + value.position.inMilliseconds / value.duration.inMilliseconds; + final double playedPart = + playedPartPercent > 1 ? size.width : playedPartPercent * size.width; + for (final DurationRange range in value.buffered) { + final double start = range.startFraction(value.duration) * size.width; + final double end = range.endFraction(value.duration) * size.width; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromPoints( + Offset(start, baseOffset), + Offset(end, baseOffset + barHeight), + ), + const Radius.circular(4.0), + ), + colors.bufferedPaint, + ); + } + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromPoints( + Offset(0.0, baseOffset), + Offset(playedPart, baseOffset + barHeight), + ), + const Radius.circular(4.0), + ), + colors.playedPaint, + ); + + if (drawShadow) { + final Path shadowPath = Path() + ..addOval( + Rect.fromCircle( + center: Offset(playedPart, baseOffset + barHeight / 2), + radius: handleHeight, + ), + ); + + canvas.drawShadow(shadowPath, Colors.black, 0.2, false); + } + + canvas.drawCircle( + Offset(playedPart, baseOffset + barHeight / 2), + handleHeight, + colors.handlePaint, + ); + } +} diff --git a/flutter_common/lib/utils/file/webview/customer_pdf.dart b/flutter_common/lib/utils/file/webview/customer_pdf.dart new file mode 100755 index 0000000..b4326fc --- /dev/null +++ b/flutter_common/lib/utils/file/webview/customer_pdf.dart @@ -0,0 +1,89 @@ +// import 'dart:async'; +// +// import 'package:flutter/material.dart'; +// import 'package:flutter_cached_pdfview/flutter_cached_pdfview.dart'; +// +// class CustomerPDFPage extends StatefulWidget { +// final String filePath; +// const CustomerPDFPage({ +// super.key, +// required this.filePath, +// }); +// +// @override +// State createState() => _CustomerPDFPageState(); +// } +// +// class _CustomerPDFPageState extends State { +// final StreamController _pageCountController = +// StreamController(); +// final Completer _pdfViewController = +// Completer(); +// +// @override +// Widget build(BuildContext context) { +// return Scaffold( +// body: SafeArea( +// child: Stack( +// children: [ +// PDF( +// onPageChanged: (int? current, int? total) => +// _pageCountController.add('${current! + 1} - $total'), +// onViewCreated: (PDFViewController pdfViewController) async { +// _pdfViewController.complete(pdfViewController); +// final int currentPage = +// await pdfViewController.getCurrentPage() ?? 0; +// final int? pageCount = await pdfViewController.getPageCount(); +// _pageCountController.add('${currentPage + 1} - $pageCount'); +// }, +// ).cachedFromUrl( +// widget.filePath, +// placeholder: (progress) => Center(child: Text('$progress %')), +// errorWidget: (error) => Center(child: Text(error.toString())), +// ), +// Row( +// mainAxisAlignment: MainAxisAlignment.spaceBetween, +// children: [ +// GestureDetector( +// onTap: () => Navigator.pop(context), +// child: Container( +// margin: const EdgeInsets.only(left: 16, top: 16), +// width: 44, +// height: 44, +// decoration: BoxDecoration( +// borderRadius: BorderRadius.circular(22), +// color: Colors.grey.withOpacity(0.3), +// ), +// child: const Icon( +// Icons.navigate_before, +// color: Colors.white, +// ), +// ), +// ), +// Container( +// margin: const EdgeInsets.only(right: 16, top: 16), +// width: 88, +// height: 44, +// decoration: BoxDecoration( +// borderRadius: BorderRadius.circular(22), +// color: Colors.grey.withOpacity(0.3), +// ), +// child: StreamBuilder( +// stream: _pageCountController.stream, +// builder: (_, AsyncSnapshot snapshot) { +// if (snapshot.hasData) { +// return Center( +// child: Text(snapshot.data!), +// ); +// } +// return const SizedBox(); +// }), +// ), +// ], +// ), +// ], +// ), +// ), +// ); +// } +// } diff --git a/flutter_common/lib/utils/file/webview/customer_webview.dart b/flutter_common/lib/utils/file/webview/customer_webview.dart new file mode 100755 index 0000000..9abe9d1 --- /dev/null +++ b/flutter_common/lib/utils/file/webview/customer_webview.dart @@ -0,0 +1,149 @@ +import 'dart:collection'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; + +class CustomerWebView extends StatefulWidget { + final String url; + final bool? hideBack; + + const CustomerWebView({ + super.key, + required this.url, + this.hideBack, + }); + + @override + State createState() => _CustomerWebViewState(); +} + +class _CustomerWebViewState extends State { + final GlobalKey webViewKey = GlobalKey(); + + InAppWebViewController? webViewController; + InAppWebViewSettings settings = InAppWebViewSettings( + isInspectable: kDebugMode, + mediaPlaybackRequiresUserGesture: false, + allowsInlineMediaPlayback: true, + iframeAllow: "camera; microphone", + iframeAllowFullscreen: true); + + @override + void initState() { + super.initState(); + webViewController?.loadUrl(urlRequest: URLRequest(url: WebUri(widget.url))); + } + + @override + Widget build(BuildContext context) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, + overlays: [SystemUiOverlay.bottom]); + return Stack( + alignment: Alignment.topLeft, + children: [ + InAppWebView( + key: webViewKey, + // webViewEnvironment: webViewEnvironment, + initialUrlRequest: URLRequest(url: WebUri(widget.url)), + // initialUrlRequest: + // URLRequest(url: WebUri(Uri.base.toString().replaceFirst("/#/", "/") + 'page.html')), + // initialFile: "assets/index.html", + initialUserScripts: UnmodifiableListView([]), + initialSettings: settings, + // contextMenu: contextMenu, + // pullToRefreshController: pullToRefreshController, + onWebViewCreated: (controller) async { + webViewController = controller; + }, + onLoadStart: (controller, url) { + setState(() { + // this.url = url.toString(); + // urlController.text = this.url; + }); + }, + // onPermissionRequest: (controller, request) { + // return PermissionResponse( + // resources: request.resources, + // action: PermissionResponseAction.GRANT); + // }, + // shouldOverrideUrlLoading: + // (controller, navigationAction) async { + // var uri = navigationAction.request.url!; + // + // if (![ + // "http", + // "https", + // "file", + // "chrome", + // "data", + // "javascript", + // "about" + // ].contains(uri.scheme)) { + // if (await canLaunchUrl(uri)) { + // // Launch the App + // await launchUrl( + // uri, + // ); + // // and cancel the request + // return NavigationActionPolicy.CANCEL; + // } + // } + // + // return NavigationActionPolicy.ALLOW; + // }, + // onLoadStop: (controller, url) { + // pullToRefreshController?.endRefreshing(); + // setState(() { + // this.url = url.toString(); + // urlController.text = this.url; + // }); + // }, + // onReceivedError: (controller, request, error) { + // pullToRefreshController?.endRefreshing(); + // }, + // onProgressChanged: (controller, progress) { + // if (progress == 100) { + // pullToRefreshController?.endRefreshing(); + // } + // setState(() { + // this.progress = progress / 100; + // urlController.text = this.url; + // }); + // }, + // onUpdateVisitedHistory: (controller, url, isReload) { + // setState(() { + // this.url = url.toString(); + // urlController.text = this.url; + // }); + // }, + onConsoleMessage: (controller, consoleMessage) { + print(consoleMessage); + }, + ), + Visibility( + visible: widget.hideBack != true, + child: GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + margin: const EdgeInsets.only(left: 16, top: 16), + width: 44, + height: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + color: Colors.grey.withOpacity(0.3), + ), + child: const Icon( + Icons.navigate_before, + color: Colors.white, + ), + ), + ), + ) + ], + ); + } +} diff --git a/flutter_common/lib/utils/frosted_glass_effect_widget.dart b/flutter_common/lib/utils/frosted_glass_effect_widget.dart new file mode 100644 index 0000000..a8ae398 --- /dev/null +++ b/flutter_common/lib/utils/frosted_glass_effect_widget.dart @@ -0,0 +1,53 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +class FrostedGlassEffectWidget extends StatelessWidget { + final Widget? child; + final double? width; + final double? height; + final double? borderRadius; + final Widget? backgroundChild; + final Color? backgroundColor; + + const FrostedGlassEffectWidget({ + super.key, + this.child, + this.width, + this.height, + this.borderRadius, + this.backgroundChild, + this.backgroundColor, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: Stack( + children: [ + // 背景内容(可以放图片或其他内容) + backgroundChild ?? SizedBox(), + // 毛玻璃效果 + ClipRRect( + borderRadius: BorderRadius.circular(borderRadius ?? 20.h), + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 10.0, + sigmaY: 10.0, + ), + child: Container( + decoration: BoxDecoration( + color: backgroundColor ?? Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(borderRadius ?? 20.h), + ), + child: child, + ), + ), + ), + ], + ), + ); + } +} diff --git a/flutter_common/lib/utils/launch_utils.dart b/flutter_common/lib/utils/launch_utils.dart new file mode 100755 index 0000000..1092ca1 --- /dev/null +++ b/flutter_common/lib/utils/launch_utils.dart @@ -0,0 +1,42 @@ +import 'package:url_launcher/url_launcher.dart'; + +enum LaunchType { tel, sms, email, link } + +const launchTypeValues = { + LaunchType.tel: "tel:", + LaunchType.sms: "sms:", + LaunchType.email: "mailto:", + LaunchType.link: '' +}; + +class LaunchUtils { + ///自定义Launch方法 + static Future customLaunch({ + required String urlString, + LaunchType launchType = LaunchType.link, + bool enableJavaScript = false, + bool enableDomStorage = false, + Map headers = const {}, + LaunchMode mode = LaunchMode.externalApplication, + String? webOnlyWindowName, + }) { + return launchUrl( + Uri.parse('${launchTypeValues[launchType]}$urlString'), + webViewConfiguration: WebViewConfiguration( + enableJavaScript: enableJavaScript, + enableDomStorage: enableDomStorage, + headers: headers, + ), + mode: mode, + webOnlyWindowName: webOnlyWindowName, + ); + } + + ///判断是否打开链接 + static Future customCanLaunch( + String urlString, { + LaunchType launchType = LaunchType.link, + }) async { + return canLaunchUrl(Uri.parse('${launchTypeValues[launchType]}$urlString')); + } +} diff --git a/flutter_common/lib/utils/pull_refresh_widget/pull_refresh_widget.dart b/flutter_common/lib/utils/pull_refresh_widget/pull_refresh_widget.dart new file mode 100644 index 0000000..1e5fdec --- /dev/null +++ b/flutter_common/lib/utils/pull_refresh_widget/pull_refresh_widget.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +///封装刷新加载pull_to_refresh控件 +class PullRefreshListWidget extends StatelessWidget { + final RefreshController controller; + + // final NullableIndexedWidgetBuilder itemBuilder; + // final int itemCount; + final bool enablePullUp; + final bool enablePullDown; + final bool shrinkWrap; + final VoidCallback? onRefresh; + final VoidCallback? onLoading; + + // final EdgeInsetsGeometry? padding; + final Widget? header; + final Widget? footer; + final Widget? placeholder; + + // final ScrollPhysics? physics; + final bool isWhiteTheme; + final Widget? child; + + const PullRefreshListWidget({ + Key? key, + required this.controller, + // required this.itemBuilder, + // required this.itemCount, + this.enablePullUp = false, + this.enablePullDown = true, + this.shrinkWrap = true, + this.onRefresh, + this.onLoading, + // this.padding, + this.header, + this.footer, + this.placeholder, + // this.physics, + this.isWhiteTheme = false, + this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SmartRefresher( + header: header ?? + ClassicHeader( + releaseText: '松开刷新', + refreshingText: '正在刷新...', + completeText: '刷新成功', + failedText: '刷新失败', + idleText: '下拉刷新', + textStyle: + TextStyle(color: isWhiteTheme ? Colors.white : Colors.grey), + idleIcon: Icon(Icons.arrow_downward, + color: isWhiteTheme ? Colors.white : Colors.grey), + failedIcon: Icon(Icons.error, + color: isWhiteTheme ? Colors.white : Colors.grey), + completeIcon: Icon(Icons.done, + color: isWhiteTheme ? Colors.white : Colors.grey), + releaseIcon: Icon(Icons.refresh, + color: isWhiteTheme ? Colors.white : Colors.grey), + ), + footer: footer ?? + ClassicFooter( + loadingText: '正在加载...', + noDataText: '没有更多数据', + idleText: '加载更多', + failedText: '加载失败', + canLoadingText: '松开加载更多', + textStyle: + TextStyle(color: isWhiteTheme ? Colors.white : Colors.grey), + idleIcon: Icon(Icons.arrow_downward, + color: isWhiteTheme ? Colors.white : Colors.grey), + failedIcon: Icon(Icons.error, + color: isWhiteTheme ? Colors.white : Colors.grey), + ), + enablePullDown: enablePullDown, + enablePullUp: enablePullUp, + controller: controller, + onRefresh: onRefresh, + onLoading: onLoading, + child: child ?? + Column( + children: [ + Expanded( + child: Container( + child: placeholder ?? const Text('暂无相关数据'), + ), + ), + ], + ), + ); + } +} diff --git a/flutter_common/lib/utils/string_utils.dart b/flutter_common/lib/utils/string_utils.dart new file mode 100755 index 0000000..f02d8cc --- /dev/null +++ b/flutter_common/lib/utils/string_utils.dart @@ -0,0 +1,134 @@ +// import 'package:intl/intl.dart'; + +extension StringUtils on String? { + String get hidePhone => StringUtils._phoneString(phone: this); + + String get hideUserName => StringUtils._userNameString(name: this); + + bool get isNumeric => StringUtils._isNumeric(str: this); + + String get toPrice => StringUtils._cutOutPrice(price: this); + + String get toSplMoney => StringUtils._splNumberInsyo(str: this); + + String get toThousandPrice => StringUtils._formatThousandPrice(price: this); + + double get toDouble => StringUtils._strToDouble(str: this); + + ///隐藏手机号中间4位数 + static String _phoneString({String? phone}) { + if (phone == null || phone == '') return ''; + return phone.replaceFirst(RegExp(r'\d{4}'), '****', 3); + } + + ///隐藏姓名(只显示姓 隐藏名字) + static String _userNameString({String? name}) { + if (name == null || name == '') return ''; + return name.replaceAll(name.substring(1, name.length), '**'); + } + + ///判断是否是纯数字 + static bool _isNumeric({String? str}) { + if (str == null) { + return false; + } + return double.tryParse(str) != null; + } + + ///将金额转换为小数点后两位 + static String _cutOutPrice({String? price}) { + String cutOutPrice = '0.00'; + if (price != null && price != '') { + var newPrice = double.tryParse(price); + if (newPrice != null) { + cutOutPrice = newPrice.toStringAsFixed(2); + } + } + return cutOutPrice; + } + + ///金额处理千分位金额 + static String _formatThousandPrice({String? price}) { + String priceStr = price ?? ''; + try { + num? money = num.tryParse(priceStr); + int truncateMoney = money?.truncate() ?? 0; + if (truncateMoney >= 1000) { + // NumberFormat format = NumberFormat('0,000'); + return _formatNum(truncateMoney); + } else { + List resultList = priceStr.split("."); + if (resultList.isNotEmpty) { + return priceStr.split(".").first; + } else { + return priceStr; + } + } + } catch (error) { + return ''; + } + } + + static String _formatNum(num, {point = 3}) { + if (num != null) { + String str = double.parse(num.toString()).toString(); + // 分开截取 + List sub = str.split('.'); + // 处理值 + List val = List.from(sub[0].split('')); + // 处理点 + List points = List.from(sub[1].split('')); + //处理分割符 + for (int index = 0, i = val.length - 1; i >= 0; index++, i--) { + // 除以三没有余数、不等于零并且不等于1 就加个逗号 + if (index % 3 == 0 && index != 0 && i != 1) val[i] = val[i] + ','; + } + // 处理小数点 + for (int i = 0; i <= point - points.length; i++) { + points.add('0'); + } + //如果大于长度就截取 + if (points.length > point) { + // 截取数组 + points = points.sublist(0, point); + } + // 判断是否有长度 + if (points.length > 0) { + return '${val.join('')}.${points.join('')}'; + } else { + return val.join(''); + } + } else { + return "0.0"; + } + } + + ///将数字转换位千分位隔开 + static String _splNumberInsyo({String? str}) { + if (str?.isEmpty == true) { + return ''; + } + // num amount = num.parse(str ?? '0'); + // final NumberFormat formatter = NumberFormat.currency( + // locale: 'en_US', // 设置为你的本地化Locale + // decimalDigits: 0, + // name: '', // 设置货币符号,例如 'USD' 或 'EUR' + // ); + // return formatter.format(amount); + return str ?? ''; + } + + ///将字符串转为double + static double _strToDouble({String? str}) { + String newStr = str ?? ''; + try { + double? doubleValue = double.tryParse(newStr); + if (doubleValue != null) { + return doubleValue; + } + return 0; + } catch (error) { + return 0; + } + } +} diff --git a/flutter_common/lib/utils/toast_utils.dart b/flutter_common/lib/utils/toast_utils.dart new file mode 100755 index 0000000..77dfc88 --- /dev/null +++ b/flutter_common/lib/utils/toast_utils.dart @@ -0,0 +1,220 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_common/utils/custom_dialog.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:get/get.dart'; + +// import 'package:get/get.dart'; + +class ToastUtils { + ///提示框 + static showToast({ + String? msg, + Color? backgroundColor, + TextStyle? textStyle = const TextStyle(color: Colors.white), + }) async { + EasyLoading.showToast(msg ?? ''); + // _cancelToast(); + } + + ///加载框 + static showLoading({String? text}) async { + await EasyLoading.show( + // status: 'loading...', + maskType: EasyLoadingMaskType.black, + ); + } + + ///成功弹窗提示 + static successToast({String? successText}) { + EasyLoading.showError( + successText ?? '成功', + duration: const Duration(seconds: 2), + ); + cancelToast(); + } + + ///失败弹窗提示 + static errorToast({String? errorText}) { + EasyLoading.showError( + errorText ?? '错误', + duration: const Duration(seconds: 2), + ); + cancelToast(); + } + + ///底部自适应高度弹窗 + ///底部弹窗 + static showBottomSheet({ + required BuildContext context, + bool isTime = false, + double? height, + Function? onConfirm, + String? title, + double? titleFontSize, + double? leftIconSize, + Widget? contentWidget, + Widget? header, + bool isShowConfirm = false, + Color? barrierColor, + EdgeInsetsGeometry? padding, + }) { + cancelToast(); + return showDialog( + context: context, + builder: (BuildContext ctx) { + return Container( + width: double.infinity, + height: MediaQuery.of(context).size.height / 2, + margin: EdgeInsets.only( + top: height == null + ? MediaQuery.of(context).size.height / 2 + : (MediaQuery.of(context).size.height - height), + ), + padding: padding ?? const EdgeInsets.only(bottom: 40), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + ), + child: Column( + children: [ + header ?? + Container( + padding: const EdgeInsets.only(bottom: 5), + decoration: const BoxDecoration( + border: Border( + bottom: + BorderSide(color: Color(0xffE1E1E1), width: 0.5), + ), + ), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + padding: + const EdgeInsets.only(left: 6, right: 10), + color: Colors.transparent, + child: Icon( + Icons.keyboard_arrow_down_rounded, + size: leftIconSize ?? 40, + ), + ), + ), + Expanded( + child: Container( + alignment: Alignment.center, + child: Text( + title ?? '头部', + style: TextStyle( + color: const Color(0xff333333), + fontSize: titleFontSize ?? 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + GestureDetector( + onTap: () { + if (isShowConfirm) { + if (onConfirm != null) { + onConfirm(); + Navigator.pop(context); + } + } + }, + child: Container( + padding: const EdgeInsets.only( + left: 10, + top: 8, + bottom: 8, + right: 18, + ), + alignment: Alignment.center, + color: Colors.transparent, + child: Text( + '确定', + style: TextStyle( + color: isShowConfirm + ? const Color(0xff4D6FD5) + : Colors.transparent, + fontSize: 16), + ), + ), + ) + ], + ), + ), + Expanded(child: contentWidget ?? const SizedBox()) + ], + ), + ); + }); + } + + static cancelToast() { + // 延时2秒 + EasyLoading.dismiss(); + } + + ///显示对话框 + static showAlterDialog({ + VoidCallback? confirmCallback, + VoidCallback? cancelCallback, + String? titleText, + String? contentText, + String? confirmText, + TextStyle? confirmStyle, + TextStyle? cancelStyle, + }) { + cancelToast(); + return Get.dialog( + CustomDialog( + title: titleText ?? '温馨提示', + content: contentText ?? '您确定要退出当前登录吗?', + cancelText: "取消", + confirmText: "确定", + cancelTextStyle: cancelStyle, + confirmTextStyle: confirmStyle, + cancelCall: () { + Get.back(); + Future.delayed(const Duration(milliseconds: 50)).then((value) { + if (cancelCallback != null) { + cancelCallback(); + } + }); + }, + confirmCall: () { + Get.back(); + if (confirmCallback != null) { + confirmCallback(); + } + }, + ), + ); + } + + ///错误信息弹窗 + static showExceptionToast({String? title, String? msg}) { + _cancelToast(); + return Get.snackbar( + title ?? '错误信息', + msg ?? '错误信息内容', + snackPosition: SnackPosition.BOTTOM, + colorText: Colors.white, + backgroundColor: Colors.red[800], + margin: const EdgeInsets.only(bottom: 10, left: 10, right: 10), + borderRadius: 4, + duration: const Duration(seconds: 3), + ); + } + + ///取消弹窗 + static _cancelToast() { + // Toast.dismissAllToast(); + } +} diff --git a/flutter_common/lib/value/string.dart b/flutter_common/lib/value/string.dart new file mode 100755 index 0000000..9f9a162 --- /dev/null +++ b/flutter_common/lib/value/string.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; +import 'dart:math'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:crypto/crypto.dart'; + + +class BaseStringValue { + static String chinaMoney = '¥'; + + ///md5 加密 32位 + static String generate_MD5(String data) { + var content = const Utf8Encoder().convert(data); + var digest = md5.convert(content); + // 这里其实就是 digest.toString() + return digest.toString(); + } + + ///cell key 随机数+时间 + static String cellKeyString({String? string}) { + var random = Random(); + int randomNumber = random.nextInt(10000); // 生成0到10000000000000之间的随机整数 + return '$randomNumber + $string + ${DateTime.now().toString()}'; + } +} +