diff --git a/mobile/lib/modules/home/ui/draggable_scrollbar.dart b/mobile/lib/modules/home/ui/draggable_scrollbar.dart new file mode 100644 index 000000000..2b8f1315f --- /dev/null +++ b/mobile/lib/modules/home/ui/draggable_scrollbar.dart @@ -0,0 +1,617 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// Build the Scroll Thumb and label using the current configuration +typedef ScrollThumbBuilder = Widget Function( + Color backgroundColor, + Animation thumbAnimation, + Animation labelAnimation, + double height, { + Text? labelText, + BoxConstraints? labelConstraints, +}); + +/// Build a Text widget using the current scroll offset +typedef LabelTextBuilder = Text Function(double offsetY); + +/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged +/// for quick navigation of the BoxScrollView. +class DraggableScrollbar extends StatefulWidget { + /// The view that will be scrolled with the scroll thumb + final CustomScrollView child; + + /// A function that builds a thumb using the current configuration + final ScrollThumbBuilder scrollThumbBuilder; + + /// The height of the scroll thumb + final double heightScrollThumb; + + /// The background color of the label and thumb + final Color backgroundColor; + + /// The amount of padding that should surround the thumb + final EdgeInsetsGeometry? padding; + + /// Determines how quickly the scrollbar will animate in and out + final Duration scrollbarAnimationDuration; + + /// How long should the thumb be visible before fading out + final Duration scrollbarTimeToFade; + + /// Build a Text widget from the current offset in the BoxScrollView + final LabelTextBuilder? labelTextBuilder; + + /// Determines box constraints for Container displaying label + final BoxConstraints? labelConstraints; + + /// The ScrollController for the BoxScrollView + final ScrollController controller; + + /// Determines scrollThumb displaying. If you draw own ScrollThumb and it is true you just don't need to use animation parameters in [scrollThumbBuilder] + final bool alwaysVisibleScrollThumb; + + DraggableScrollbar({ + Key? key, + this.alwaysVisibleScrollThumb = false, + required this.heightScrollThumb, + required this.backgroundColor, + required this.scrollThumbBuilder, + required this.child, + required this.controller, + this.padding, + this.scrollbarAnimationDuration = const Duration(milliseconds: 300), + this.scrollbarTimeToFade = const Duration(milliseconds: 600), + this.labelTextBuilder, + this.labelConstraints, + }) : assert(child.scrollDirection == Axis.vertical), + super(key: key); + + DraggableScrollbar.rrect({ + Key? key, + Key? scrollThumbKey, + this.alwaysVisibleScrollThumb = false, + required this.child, + required this.controller, + this.heightScrollThumb = 48.0, + this.backgroundColor = Colors.white, + this.padding, + this.scrollbarAnimationDuration = const Duration(milliseconds: 300), + this.scrollbarTimeToFade = const Duration(milliseconds: 600), + this.labelTextBuilder, + this.labelConstraints, + }) : assert(child.scrollDirection == Axis.vertical), + scrollThumbBuilder = _thumbRRectBuilder(scrollThumbKey, alwaysVisibleScrollThumb), + super(key: key); + + DraggableScrollbar.arrows({ + Key? key, + Key? scrollThumbKey, + this.alwaysVisibleScrollThumb = false, + required this.child, + required this.controller, + this.heightScrollThumb = 48.0, + this.backgroundColor = Colors.white, + this.padding, + this.scrollbarAnimationDuration = const Duration(milliseconds: 300), + this.scrollbarTimeToFade = const Duration(milliseconds: 600), + this.labelTextBuilder, + this.labelConstraints, + }) : assert(child.scrollDirection == Axis.vertical), + scrollThumbBuilder = _thumbArrowBuilder(scrollThumbKey, alwaysVisibleScrollThumb), + super(key: key); + + DraggableScrollbar.semicircle({ + Key? key, + Key? scrollThumbKey, + this.alwaysVisibleScrollThumb = false, + required this.child, + required this.controller, + this.heightScrollThumb = 48.0, + this.backgroundColor = Colors.white, + this.padding, + this.scrollbarAnimationDuration = const Duration(milliseconds: 300), + this.scrollbarTimeToFade = const Duration(milliseconds: 600), + this.labelTextBuilder, + this.labelConstraints, + }) : assert(child.scrollDirection == Axis.vertical), + scrollThumbBuilder = _thumbSemicircleBuilder(heightScrollThumb * 0.6, scrollThumbKey, alwaysVisibleScrollThumb), + super(key: key); + + @override + _DraggableScrollbarState createState() => _DraggableScrollbarState(); + + static buildScrollThumbAndLabel( + {required Widget scrollThumb, + required Color backgroundColor, + required Animation? thumbAnimation, + required Animation? labelAnimation, + required Text? labelText, + required BoxConstraints? labelConstraints, + required bool alwaysVisibleScrollThumb}) { + var scrollThumbAndLabel = labelText == null + ? scrollThumb + : Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ScrollLabel( + animation: labelAnimation, + child: labelText, + backgroundColor: backgroundColor, + constraints: labelConstraints, + ), + scrollThumb, + ], + ); + + if (alwaysVisibleScrollThumb) { + return scrollThumbAndLabel; + } + return SlideFadeTransition( + animation: thumbAnimation!, + child: scrollThumbAndLabel, + ); + } + + static ScrollThumbBuilder _thumbSemicircleBuilder(double width, Key? scrollThumbKey, bool alwaysVisibleScrollThumb) { + return ( + Color backgroundColor, + Animation thumbAnimation, + Animation labelAnimation, + double height, { + Text? labelText, + BoxConstraints? labelConstraints, + }) { + final scrollThumb = CustomPaint( + key: scrollThumbKey, + foregroundPainter: ArrowCustomPainter(Colors.grey), + child: Material( + elevation: 4.0, + child: Container( + constraints: BoxConstraints.tight(Size(width, height)), + ), + color: backgroundColor, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(height), + bottomLeft: Radius.circular(height), + topRight: const Radius.circular(4.0), + bottomRight: const Radius.circular(4.0), + ), + ), + ); + + return buildScrollThumbAndLabel( + scrollThumb: scrollThumb, + backgroundColor: backgroundColor, + thumbAnimation: thumbAnimation, + labelAnimation: labelAnimation, + labelText: labelText, + labelConstraints: labelConstraints, + alwaysVisibleScrollThumb: alwaysVisibleScrollThumb, + ); + }; + } + + static ScrollThumbBuilder _thumbArrowBuilder(Key? scrollThumbKey, bool alwaysVisibleScrollThumb) { + return ( + Color backgroundColor, + Animation thumbAnimation, + Animation labelAnimation, + double height, { + Text? labelText, + BoxConstraints? labelConstraints, + }) { + final scrollThumb = ClipPath( + child: Container( + height: height, + width: 20.0, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: const BorderRadius.all( + Radius.circular(12.0), + ), + ), + ), + clipper: ArrowClipper(), + ); + + return buildScrollThumbAndLabel( + scrollThumb: scrollThumb, + backgroundColor: backgroundColor, + thumbAnimation: thumbAnimation, + labelAnimation: labelAnimation, + labelText: labelText, + labelConstraints: labelConstraints, + alwaysVisibleScrollThumb: alwaysVisibleScrollThumb, + ); + }; + } + + static ScrollThumbBuilder _thumbRRectBuilder(Key? scrollThumbKey, bool alwaysVisibleScrollThumb) { + return ( + Color backgroundColor, + Animation thumbAnimation, + Animation labelAnimation, + double height, { + Text? labelText, + BoxConstraints? labelConstraints, + }) { + final scrollThumb = Material( + elevation: 4.0, + child: Container( + constraints: BoxConstraints.tight( + Size(16.0, height), + ), + ), + color: backgroundColor, + borderRadius: const BorderRadius.all(Radius.circular(7.0)), + ); + + return buildScrollThumbAndLabel( + scrollThumb: scrollThumb, + backgroundColor: backgroundColor, + thumbAnimation: thumbAnimation, + labelAnimation: labelAnimation, + labelText: labelText, + labelConstraints: labelConstraints, + alwaysVisibleScrollThumb: alwaysVisibleScrollThumb, + ); + }; + } +} + +class ScrollLabel extends StatelessWidget { + final Animation? animation; + final Color backgroundColor; + final Text child; + + final BoxConstraints? constraints; + static const BoxConstraints _defaultConstraints = BoxConstraints.tightFor(width: 72.0, height: 28.0); + + const ScrollLabel({ + Key? key, + required this.child, + required this.animation, + required this.backgroundColor, + this.constraints = _defaultConstraints, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: animation!, + child: Container( + margin: const EdgeInsets.only(right: 12.0), + child: Material( + elevation: 4.0, + color: backgroundColor, + borderRadius: const BorderRadius.all(Radius.circular(16.0)), + child: Container( + constraints: constraints ?? _defaultConstraints, + alignment: Alignment.center, + child: child, + ), + ), + ), + ); + } +} + +class _DraggableScrollbarState extends State with TickerProviderStateMixin { + late double _barOffset; + late double _viewOffset; + late bool _isDragInProcess; + + late AnimationController _thumbAnimationController; + late Animation _thumbAnimation; + late AnimationController _labelAnimationController; + late Animation _labelAnimation; + Timer? _fadeoutTimer; + + @override + void initState() { + super.initState(); + _barOffset = 0.0; + _viewOffset = 0.0; + _isDragInProcess = false; + + _thumbAnimationController = AnimationController( + vsync: this, + duration: widget.scrollbarAnimationDuration, + ); + + _thumbAnimation = CurvedAnimation( + parent: _thumbAnimationController, + curve: Curves.fastOutSlowIn, + ); + + _labelAnimationController = AnimationController( + vsync: this, + duration: widget.scrollbarAnimationDuration, + ); + + _labelAnimation = CurvedAnimation( + parent: _labelAnimationController, + curve: Curves.fastOutSlowIn, + ); + } + + @override + void dispose() { + _thumbAnimationController.dispose(); + _labelAnimationController.dispose(); + _fadeoutTimer?.cancel(); + super.dispose(); + } + + double get barMaxScrollExtent => context.size!.height - widget.heightScrollThumb; + + double get barMinScrollExtent => 0.0; + + double get viewMaxScrollExtent => widget.controller.position.maxScrollExtent; + + double get viewMinScrollExtent => widget.controller.position.minScrollExtent; + + @override + Widget build(BuildContext context) { + Text? labelText; + if (widget.labelTextBuilder != null && _isDragInProcess) { + labelText = widget.labelTextBuilder!( + _viewOffset + _barOffset + widget.heightScrollThumb / 2, + ); + } + + return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { + //print("LayoutBuilder constraints=$constraints"); + + return NotificationListener( + onNotification: (ScrollNotification notification) { + changePosition(notification); + return false; + }, + child: Stack( + children: [ + RepaintBoundary( + child: widget.child, + ), + RepaintBoundary( + child: GestureDetector( + onVerticalDragStart: _onVerticalDragStart, + onVerticalDragUpdate: _onVerticalDragUpdate, + onVerticalDragEnd: _onVerticalDragEnd, + child: Container( + alignment: Alignment.topRight, + margin: EdgeInsets.only(top: _barOffset), + padding: widget.padding, + child: widget.scrollThumbBuilder( + widget.backgroundColor, + _thumbAnimation, + _labelAnimation, + widget.heightScrollThumb, + labelText: labelText, + labelConstraints: widget.labelConstraints, + ), + ), + )), + ], + ), + ); + }); + } + + //scroll bar has received notification that it's view was scrolled + //so it should also changes his position + //but only if it isn't dragged + changePosition(ScrollNotification notification) { + if (_isDragInProcess) { + return; + } + + setState(() { + if (notification is ScrollUpdateNotification) { + _barOffset += getBarDelta( + notification.scrollDelta!, + barMaxScrollExtent, + viewMaxScrollExtent, + ); + + if (_barOffset < barMinScrollExtent) { + _barOffset = barMinScrollExtent; + } + if (_barOffset > barMaxScrollExtent) { + _barOffset = barMaxScrollExtent; + } + + _viewOffset += notification.scrollDelta!; + if (_viewOffset < widget.controller.position.minScrollExtent) { + _viewOffset = widget.controller.position.minScrollExtent; + } + if (_viewOffset > viewMaxScrollExtent) { + _viewOffset = viewMaxScrollExtent; + } + } + + if (notification is ScrollUpdateNotification || notification is OverscrollNotification) { + if (_thumbAnimationController.status != AnimationStatus.forward) { + _thumbAnimationController.forward(); + } + + _fadeoutTimer?.cancel(); + _fadeoutTimer = Timer(widget.scrollbarTimeToFade, () { + _thumbAnimationController.reverse(); + _labelAnimationController.reverse(); + _fadeoutTimer = null; + }); + } + }); + } + + double getBarDelta( + double scrollViewDelta, + double barMaxScrollExtent, + double viewMaxScrollExtent, + ) { + return scrollViewDelta * barMaxScrollExtent / viewMaxScrollExtent; + } + + double getScrollViewDelta( + double barDelta, + double barMaxScrollExtent, + double viewMaxScrollExtent, + ) { + return barDelta * viewMaxScrollExtent / barMaxScrollExtent; + } + + void _onVerticalDragStart(DragStartDetails details) { + setState(() { + _isDragInProcess = true; + _labelAnimationController.forward(); + _fadeoutTimer?.cancel(); + }); + } + + void _onVerticalDragUpdate(DragUpdateDetails details) { + setState(() { + if (_thumbAnimationController.status != AnimationStatus.forward) { + _thumbAnimationController.forward(); + } + if (_isDragInProcess) { + _barOffset += details.delta.dy; + + if (_barOffset < barMinScrollExtent) { + _barOffset = barMinScrollExtent; + } + if (_barOffset > barMaxScrollExtent) { + _barOffset = barMaxScrollExtent; + } + + double viewDelta = getScrollViewDelta(details.delta.dy, barMaxScrollExtent, viewMaxScrollExtent); + + _viewOffset = widget.controller.position.pixels + viewDelta; + if (_viewOffset < widget.controller.position.minScrollExtent) { + _viewOffset = widget.controller.position.minScrollExtent; + } + if (_viewOffset > viewMaxScrollExtent) { + _viewOffset = viewMaxScrollExtent; + } + widget.controller.jumpTo(_viewOffset); + } + }); + } + + void _onVerticalDragEnd(DragEndDetails details) { + _fadeoutTimer = Timer(widget.scrollbarTimeToFade, () { + _thumbAnimationController.reverse(); + _labelAnimationController.reverse(); + _fadeoutTimer = null; + }); + setState(() { + _isDragInProcess = false; + }); + } +} + +/// Draws 2 triangles like arrow up and arrow down +class ArrowCustomPainter extends CustomPainter { + Color color; + + ArrowCustomPainter(this.color); + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = color; + const width = 12.0; + const height = 8.0; + final baseX = size.width / 2; + final baseY = size.height / 2; + + canvas.drawPath( + _trianglePath(Offset(baseX, baseY - 2.0), width, height, true), + paint, + ); + canvas.drawPath( + _trianglePath(Offset(baseX, baseY + 2.0), width, height, false), + paint, + ); + } + + static Path _trianglePath(Offset o, double width, double height, bool isUp) { + return Path() + ..moveTo(o.dx, o.dy) + ..lineTo(o.dx + width, o.dy) + ..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height) + ..close(); + } +} + +///This cut 2 lines in arrow shape +class ArrowClipper extends CustomClipper { + @override + Path getClip(Size size) { + Path path = Path(); + path.lineTo(0.0, size.height); + path.lineTo(size.width, size.height); + path.lineTo(size.width, 0.0); + path.lineTo(0.0, 0.0); + path.close(); + + double arrowWidth = 8.0; + double startPointX = (size.width - arrowWidth) / 2; + double startPointY = size.height / 2 - arrowWidth / 2; + path.moveTo(startPointX, startPointY); + path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2); + path.lineTo(startPointX + arrowWidth, startPointY); + path.lineTo(startPointX + arrowWidth, startPointY + 1.0); + path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2 + 1.0); + path.lineTo(startPointX, startPointY + 1.0); + path.close(); + + startPointY = size.height / 2 + arrowWidth / 2; + path.moveTo(startPointX + arrowWidth, startPointY); + path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2); + path.lineTo(startPointX, startPointY); + path.lineTo(startPointX, startPointY - 1.0); + path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2 - 1.0); + path.lineTo(startPointX + arrowWidth, startPointY - 1.0); + path.close(); + + return path; + } + + @override + bool shouldReclip(CustomClipper oldClipper) => false; +} + +class SlideFadeTransition extends StatelessWidget { + final Animation animation; + final Widget child; + + const SlideFadeTransition({ + Key? key, + required this.animation, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: animation, + builder: (context, child) => animation.value == 0.0 ? Container() : child!, + child: SlideTransition( + position: Tween( + begin: const Offset(0.3, 0.0), + end: const Offset(0.0, 0.0), + ).animate(animation), + child: FadeTransition( + opacity: animation, + child: child, + ), + ), + ); + } +} diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 569b97d25..eea2d2ef8 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -1,15 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart'; import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart'; import 'package:immich_mobile/modules/home/ui/profile_drawer.dart'; -import 'package:immich_mobile/shared/models/backup_state.model.dart'; import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart'; import 'package:immich_mobile/modules/home/ui/image_grid.dart'; import 'package:immich_mobile/modules/home/providers/asset.provider.dart'; -import 'package:immich_mobile/shared/providers/backup.provider.dart'; -import 'package:visibility_detector/visibility_detector.dart'; import 'package:intl/intl.dart'; class HomePage extends HookConsumerWidget { @@ -21,8 +18,8 @@ class HomePage extends HookConsumerWidget { ScrollController _scrollController = useScrollController(); List assetGroup = ref.watch(assetProvider); List imageGridGroup = []; - List monthGroupKey = []; - final monthInView = useState(""); + final scrollLabelText = useState(""); + _scrollControllerCallback() { var endOfPage = _scrollController.position.maxScrollExtent; @@ -35,13 +32,6 @@ class HomePage extends HookConsumerWidget { } else { _showBackToTopBtn.value = false; } - - // Quick Scroll For Jumping to Month - if (_scrollController.position.userScrollDirection == ScrollDirection.forward) { - // Scroll UP - } else if (_scrollController.position.userScrollDirection == ScrollDirection.reverse) { - // SCroll Down - } } useEffect(() { @@ -55,53 +45,29 @@ class HomePage extends HookConsumerWidget { }; }, []); - SliverToBoxAdapter _buildMonthGroupTitle(String dateTitle, BuildContext context) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(left: 10.0, top: 32), - child: Text( - DateFormat('MMMM, y').format( - DateTime.parse(dateTitle), - ), - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).primaryColor, - ), - ), - ), - ); - } - SliverToBoxAdapter _buildDateGroupTitle(String dateTitle) { var currentYear = DateTime.now().year; var groupYear = DateTime.parse(dateTitle).year; var formatDateTemplate = currentYear == groupYear ? 'E, MMM dd' : 'E, MMM dd, yyyy'; var dateText = DateFormat(formatDateTemplate).format(DateTime.parse(dateTitle)); - var monthText = DateFormat('MMMM, y').format(DateTime.parse(dateTitle)); + return SliverToBoxAdapter( - child: VisibilityDetector( - key: Key(dateText), - onVisibilityChanged: (visibilityInfo) { - monthInView.value = monthText; - }, - child: Padding( - padding: const EdgeInsets.only(top: 24.0, bottom: 24.0, left: 3.0), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(left: 8.0, bottom: 5.0, top: 5.0), - child: Text( - dateText, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), + child: Padding( + padding: const EdgeInsets.only(top: 24.0, bottom: 24.0, left: 3.0), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0, bottom: 5.0, top: 5.0), + child: Text( + dateText, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.black87, ), ), - ], - ), + ), + ], ), ), ); @@ -121,58 +87,82 @@ class HomePage extends HookConsumerWidget { if ((currentMonth! - previousMonth!) != 0) { var monthTitleText = DateFormat('MMMM, y').format(DateTime.parse(dateTitle)); - imageGridGroup.add(_buildMonthGroupTitle(monthTitleText, context)); + imageGridGroup.add( + MonthlyTitleText(monthTitleText: monthTitleText), + ); } imageGridGroup.add( _buildDateGroupTitle(dateTitle), ); - imageGridGroup.add(ImageGrid(assetGroup: assetGroup)); + imageGridGroup.add( + ImageGrid(assetGroup: assetGroup), + ); lastGroupDate = dateTitle; } } return SafeArea( - child: Stack(children: [ - RawScrollbar( - minThumbLength: 50, - isAlwaysShown: false, - interactive: true, + child: DraggableScrollbar.semicircle( + // labelTextBuilder: (offset) { + // final int currentItem = _scrollController.hasClients + // ? (_scrollController.offset / _scrollController.position.maxScrollExtent * imageGridGroup.length) + // .floor() + // : 0; + + // if (imageGridGroup[currentItem] is MonthlyTitleText) { + // MonthlyTitleText item = imageGridGroup[currentItem] as MonthlyTitleText; + + // scrollLabelText.value = item.monthTitleText; + // } + + // return Text(scrollLabelText.value); + // }, + // labelConstraints: const BoxConstraints.tightFor(width: 200.0, height: 30.0), + controller: _scrollController, + heightScrollThumb: 40.0, + child: CustomScrollView( controller: _scrollController, - thickness: 50, - crossAxisMargin: -20, - mainAxisMargin: 70, - timeToFade: const Duration(seconds: 2), - thumbColor: Colors.blueGrey, - radius: const Radius.circular(30), - child: CustomScrollView( - controller: _scrollController, - slivers: [ - ImmichSliverAppBar(imageGridGroup: imageGridGroup), - ...imageGridGroup, - ], - ), + slivers: [ + ImmichSliverAppBar(imageGridGroup: imageGridGroup), + ...imageGridGroup, + ], ), - ]), + ), ); } return Scaffold( drawer: const ProfileDrawer(), body: _buildBody(), - floatingActionButton: _showBackToTopBtn.value - ? FloatingActionButton.small( - enableFeedback: true, - backgroundColor: Theme.of(context).secondaryHeaderColor, - foregroundColor: Theme.of(context).primaryColor, - onPressed: () { - _scrollController.animateTo(0, duration: const Duration(seconds: 1), curve: Curves.easeOutExpo); - }, - child: const Icon(Icons.keyboard_arrow_up_rounded), - ) - : null, + ); + } +} + +class MonthlyTitleText extends StatelessWidget { + const MonthlyTitleText({ + Key? key, + required this.monthTitleText, + }) : super(key: key); + + final String monthTitleText; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(left: 10.0, top: 32), + child: Text( + monthTitleText, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Theme.of(context).primaryColor, + ), + ), + ), ); } }