diff --git a/CHANGELOG.md b/CHANGELOG.md index aa0dec09..96a48273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # [Unreleased - 11 May 2026] +- Added `ResizableMonthView`, a new highly customizable month view with dynamic modes (Full, Compact, Minimal) and built-in event list. +- Added `ResizableMonthViewThemeData` and related theme configurations for styling the new view. - Fixed `MonthViewBuilder` to be generic for improved type safety in `MonthView`. [#524](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/524) - Added `DividerSettings` to customize the dividers in `WeekView` and `MultiDayView`. [#374](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/374), [#430](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/430), [#498](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/498) - Added `timeSlotColorBuilder` in `DayView` and `WeekView` to customize background color. [#470](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/issues/470) diff --git a/README.md b/README.md index 7766a85d..a30a1f77 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ dependencies: - Multiple calendar view options: - Month View + - Resizable Month View - Day View - Week View - Highly customisable UI components diff --git a/example/lib/enumerations.dart b/example/lib/enumerations.dart index 0cb3c76b..8d0a0e15 100644 --- a/example/lib/enumerations.dart +++ b/example/lib/enumerations.dart @@ -1 +1 @@ -enum CalendarView { month, day, week } +enum CalendarView { month, day, week, resizableMonth } diff --git a/example/lib/extension.dart b/example/lib/extension.dart index 2a2b8c4a..8a2b95cd 100644 --- a/example/lib/extension.dart +++ b/example/lib/extension.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'enumerations.dart'; import 'l10n/app_localizations.dart'; import 'theme/app_colors.dart'; import 'theme/app_theme_extension.dart'; @@ -124,10 +123,6 @@ extension StringExt on String { String get capitalized => toBeginningOfSentenceCase(this) ?? ""; } -extension ViewNameExt on CalendarView { - String get name => toString().split(".").last; -} - extension BuildContextExtension on BuildContext { AppThemeExtension get appColors => Theme.of(this).extension() ?? diff --git a/example/lib/l10n/app_ar.arb b/example/lib/l10n/app_ar.arb index ea9d685d..5207cbe3 100644 --- a/example/lib/l10n/app_ar.arb +++ b/example/lib/l10n/app_ar.arb @@ -6,6 +6,7 @@ "dayView": "عرض يومي", "weekView": "عرض أسبوعي", "multidayView": "عرض متعدد الأيام", + "resizableMonthView": "عرض شهري قابل للتغيير", "appTitle": "مثال صفحة تقويم فلاتر", "projectMeetingTitle": "اجتماع المشروع", "projectMeetingDesc": "اليوم هو اجتماع المشروع.", diff --git a/example/lib/l10n/app_en.arb b/example/lib/l10n/app_en.arb index 41ed55a8..334ae84c 100644 --- a/example/lib/l10n/app_en.arb +++ b/example/lib/l10n/app_en.arb @@ -6,6 +6,7 @@ "dayView": "Day View", "weekView": "Week View", "multidayView": "Multi-Day View", + "resizableMonthView": "Resizable Month View", "appTitle": "Flutter Calendar Page Demo", "projectMeetingTitle": "Project meeting", "projectMeetingDesc": "Today is project meeting.", diff --git a/example/lib/l10n/app_es.arb b/example/lib/l10n/app_es.arb index 1c2e354c..f25b6255 100644 --- a/example/lib/l10n/app_es.arb +++ b/example/lib/l10n/app_es.arb @@ -6,6 +6,7 @@ "dayView": "Vista Diaria", "weekView": "Vista Semanal", "multidayView": "Vista de Varios Días", + "resizableMonthView": "Vista Mensual Redimensionable", "appTitle": "Demo de Página de Calendario Flutter", "projectMeetingTitle": "Reunión del proyecto", "projectMeetingDesc": "Hoy hay reunión del proyecto.", diff --git a/example/lib/main.dart b/example/lib/main.dart index febc0474..cfd96844 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -55,6 +55,9 @@ class _MyAppState extends State { multiDayViewTheme: isDarkMode ? MultiDayViewThemeData.dark() : MultiDayViewThemeData.light(), + resizableMonthViewTheme: isDarkMode + ? ResizableMonthViewThemeData.dark() + : ResizableMonthViewThemeData.light(), ), child: CalendarControllerProvider( controller: EventController(), diff --git a/example/lib/pages/mobile/mobile_home_page.dart b/example/lib/pages/mobile/mobile_home_page.dart index 3f8e55fb..68a10315 100644 --- a/example/lib/pages/mobile/mobile_home_page.dart +++ b/example/lib/pages/mobile/mobile_home_page.dart @@ -5,6 +5,7 @@ import '../../localization/locale_controller.dart'; import '../day_view_page.dart'; import '../month_view_page.dart'; import '../multi_day_view_page.dart'; +import '../resizable_month_view_page.dart'; import '../week_view_page.dart'; class MobileHomePage extends StatefulWidget { @@ -107,6 +108,11 @@ class _MobileHomePageState extends State { child: Text(translate.monthView), ), SizedBox(height: 20), + ElevatedButton( + onPressed: () => context.pushRoute(ResizableMonthViewPageDemo()), + child: Text(translate.resizableMonthView), + ), + SizedBox(height: 20), ElevatedButton( onPressed: () => context.pushRoute(DayViewPageDemo()), child: Text(translate.dayView), diff --git a/example/lib/pages/resizable_month_view_page.dart b/example/lib/pages/resizable_month_view_page.dart new file mode 100644 index 00000000..18e95382 --- /dev/null +++ b/example/lib/pages/resizable_month_view_page.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import '../extension.dart'; +import '../widgets/resizable_month_view_widget.dart'; +import '../widgets/responsive_widget.dart'; +import 'create_event_page.dart'; +import 'web/web_home_page.dart'; +import '../enumerations.dart'; + +/// Full-screen demo page for [ResizableMonthViewWidget]. +/// +/// On mobile it renders a [Scaffold] with an AppBar and a FAB to create +/// events. On web/desktop it delegates to [WebHomePage] with the +/// [CalendarView.resizableMonth] view pre-selected. +class ResizableMonthViewPageDemo extends StatefulWidget { + const ResizableMonthViewPageDemo({super.key}); + + @override + State createState() => + _ResizableMonthViewPageDemoState(); +} + +class _ResizableMonthViewPageDemoState + extends State { + @override + Widget build(BuildContext context) { + final appColors = context.appColors; + + return ResponsiveWidget( + webWidget: WebHomePage(selectedView: CalendarView.resizableMonth), + mobileWidget: Scaffold( + primary: false, + appBar: AppBar(leading: const SizedBox.shrink()), + floatingActionButton: FloatingActionButton( + heroTag: 'add_event_resizable_month_view', + child: Icon(Icons.add, color: appColors.onPrimary), + elevation: 8, + onPressed: () => context.pushRoute(CreateEventPage()), + ), + body: ResizableMonthViewWidget(), + ), + ); + } +} diff --git a/example/lib/theme/app_theme.dart b/example/lib/theme/app_theme.dart index 001adc25..6a5fecee 100644 --- a/example/lib/theme/app_theme.dart +++ b/example/lib/theme/app_theme.dart @@ -30,6 +30,7 @@ class AppTheme { static final _dayViewTheme = DayViewThemeData.light(); static final _weekViewTheme = WeekViewThemeData.light(); static final _multiDayViewTheme = MultiDayViewThemeData.light(); + static final _resizableMonthViewTheme = ResizableMonthViewThemeData.light(); // Dark colors static final _appDarkTheme = AppThemeExtension.dark(); @@ -37,6 +38,8 @@ class AppTheme { static final _dayViewDarkTheme = DayViewThemeData.dark(); static final _weekViewDarkTheme = WeekViewThemeData.dark(); static final _multiDayViewDarkTheme = MultiDayViewThemeData.dark(); + static final _resizableMonthViewDarkTheme = + ResizableMonthViewThemeData.dark(); // Light theme static final light = ThemeData.light().copyWith( @@ -57,7 +60,12 @@ class AppTheme { radioTheme: RadioThemeData( fillColor: WidgetStateColor.resolveWith((_) => AppColors.primary), ), - extensions: [_dayViewTheme, _weekViewTheme, _multiDayViewTheme], + extensions: [ + _dayViewTheme, + _weekViewTheme, + _multiDayViewTheme, + _resizableMonthViewTheme, + ], ); // Dark theme @@ -95,13 +103,13 @@ class AppTheme { radioTheme: RadioThemeData( fillColor: WidgetStateColor.resolveWith((_) => DarkAppColors.primary), ), - // TODO(Shubham): Test dark theme update extensions: [ _appDarkTheme, _monthViewDarkTheme, _dayViewDarkTheme, _weekViewDarkTheme, _multiDayViewDarkTheme, + _resizableMonthViewDarkTheme, ], ); } diff --git a/example/lib/widgets/calendar_configs.dart b/example/lib/widgets/calendar_configs.dart index 7079de84..1d493c7c 100644 --- a/example/lib/widgets/calendar_configs.dart +++ b/example/lib/widgets/calendar_configs.dart @@ -126,6 +126,9 @@ class _CalendarConfigState extends State { case CalendarView.week: viewName = translate.weekView; break; + case CalendarView.resizableMonth: + viewName = translate.resizableMonthView; + break; } return GestureDetector( onTap: () => widget.onViewChange(view), diff --git a/example/lib/widgets/calendar_views.dart b/example/lib/widgets/calendar_views.dart index 7988893e..90a5a549 100644 --- a/example/lib/widgets/calendar_views.dart +++ b/example/lib/widgets/calendar_views.dart @@ -3,9 +3,9 @@ import 'dart:math'; import 'package:flutter/material.dart'; import '../enumerations.dart'; -import '../theme/app_colors.dart'; import 'day_view_widget.dart'; import 'month_view_widget.dart'; +import 'resizable_month_view_widget.dart'; import 'week_view_widget.dart'; class CalendarViews extends StatelessWidget { @@ -13,23 +13,25 @@ class CalendarViews extends StatelessWidget { const CalendarViews({super.key, this.view = CalendarView.month}); - final _breakPoint = 490.0; + /// Maximum width for the calendar preview on web. + static const _maxCalendarWidth = 600.0; @override Widget build(BuildContext context) { final availableWidth = MediaQuery.of(context).size.width; - final width = min(_breakPoint, availableWidth); + final width = min(_maxCalendarWidth, availableWidth); return Container( height: double.infinity, width: double.infinity, - color: AppColors.grey, + color: Theme.of(context).scaffoldBackgroundColor, child: Center( - child: view == CalendarView.month - ? MonthViewWidget(width: width) - : view == CalendarView.day - ? DayViewWidget(width: width) - : WeekViewWidget(width: width), + child: switch (view) { + CalendarView.month => MonthViewWidget(width: width), + CalendarView.day => DayViewWidget(width: width), + CalendarView.week => WeekViewWidget(width: width), + CalendarView.resizableMonth => ResizableMonthViewWidget(width: width), + }, ), ); } diff --git a/example/lib/widgets/resizable_month_view_widget.dart b/example/lib/widgets/resizable_month_view_widget.dart new file mode 100644 index 00000000..ce55f03b --- /dev/null +++ b/example/lib/widgets/resizable_month_view_widget.dart @@ -0,0 +1,210 @@ +import 'package:calendar_view/calendar_view.dart'; +import 'package:example/extension.dart'; +import 'package:flutter/material.dart'; + +import '../pages/event_details_page.dart'; + +/// Example widget showcasing [ResizableMonthView]. +/// +/// Demonstrates: +/// * All three display modes (Full, Compact, Minimal) via the built-in +/// mode-toggle pill in the header. +/// * Custom theme colours – a deep-purple/indigo accent matching the +/// existing example-app palette. +/// * Tapping a cell navigates to the event details page. +/// * A floating "create event" FAB is injected by the parent page. +class ResizableMonthViewWidget extends StatefulWidget { + final GlobalKey? state; + final double? width; + + const ResizableMonthViewWidget({super.key, this.state, this.width}); + + @override + State createState() => + _ResizableMonthViewWidgetState(); +} + +class _ResizableMonthViewWidgetState extends State { + late DateTime _selectedDate; + + @override + void initState() { + super.initState(); + _selectedDate = DateTime.now().withoutTime; + } + + @override + Widget build(BuildContext context) { + final translate = context.translate; + + return ResizableMonthView( + key: widget.state, + style: ResizableMonthViewStyle( + // ── Resizable-specific ────────────────────────────────────── + initialMode: ResizableMonthViewMode.monthly, + showModeToggle: true, + enableDragToSwitchMode: true, + modeToggleActiveColor: Colors.deepPurple, + modeToggleTextColor: Colors.white, + modeToggleBorderRadius: 18, + animationDuration: const Duration(milliseconds: 300), + animationCurve: Curves.easeInOutCubic, + eventListPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + eventListSeparatorHeight: 6, + // ── Shared month-view settings ────────────────────────────── + showBorder: true, + hideDaysNotInMonth: false, + cellAspectRatio: 1.0, + startDay: WeekDays.monday, + minMonth: DateTime(2020, 1, 1), + maxMonth: DateTime(2027, 12, 31), + ), + themeSettings: ResizableMonthViewThemeSettings( + // Highlight today's cell + cellsInMonthHighlightColor: Colors.indigo, + cellsInMonthHighlightedTitleColor: Colors.white, + // Selected date gets a deep-purple circle + selectedHighlightColor: Colors.deepPurple, + selectedTitleColor: Colors.white, + selectedHighlightRadius: 12, + // Cells outside the current month are faded + cellsNotInMonthHighlightedTitleColor: Colors.white, + ), + builders: ResizableMonthViewBuilders( + // ── Navigation boundary feedback ─────────────────────────── + onHasReachedEnd: (date, page) { + context.showSnackBarWithText(translate.reachedTheEndPage); + }, + onHasReachedStart: (date, page) { + context.showSnackBarWithText(translate.reachedTheStartPage); + }, + // ── Mode change feedback ──────────────────────────────────── + onModeChanged: (mode) { + final label = switch (mode) { + ResizableMonthViewMode.monthly => 'Monthly', + ResizableMonthViewMode.biWeekly => 'Bi-weekly', + ResizableMonthViewMode.weekly => 'Weekly', + ResizableMonthViewMode.monthlyScrollable => 'Monthly Scrollable', + }; + context.showSnackBarWithText('Mode: $label'); + }, + // ── Cell tap → update selection ───────────────────────────── + onCellTap: (events, date) { + setState(() => _selectedDate = date.withoutTime); + context.showSnackBarWithText( + 'Tapped ${date.dateToStringWithFormat(format: 'y-MM-dd')}', + ); + }, + // ── Event tap → open event details ────────────────────────── + onEventTap: (event, date) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DetailsPage(event: event, date: date), + ), + ); + }, + onEventLongTap: (event, date) => + context.showSnackBarWithText('Long tapped: ${event.title}'), + // ── Custom event list item ────────────────────────────────── + eventListItemBuilder: (event, date) => GestureDetector( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DetailsPage(event: event, date: date), + ), + ), + child: _EventListItem(event: event, date: date), + ), + ), + selectedDate: _selectedDate, + width: widget.width, + ); + } +} + +// ─── Custom event tile for the event list ──────────────────────────────────── + +class _EventListItem extends StatelessWidget { + const _EventListItem({required this.event, required this.date}); + + final CalendarEventData event; + final DateTime date; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + border: Border(left: BorderSide(color: event.color, width: 4)), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(10), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + // Color dot + CircleAvatar(backgroundColor: event.color, radius: 5), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + event.title, + style: + event.titleStyle ?? + const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (event.startTime != null) + Padding( + padding: const EdgeInsets.only(top: 3), + child: Text( + _timeRange(event), + style: TextStyle( + fontSize: 12, + color: Theme.of(context).hintColor, + ), + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + size: 18, + color: Theme.of(context).hintColor, + ), + ], + ), + ); + } + + String _timeRange(CalendarEventData event) { + String fmt(DateTime? dt) { + if (dt == null) return ''; + final h = dt.hour.toString().padLeft(2, '0'); + final m = dt.minute.toString().padLeft(2, '0'); + return '$h:$m'; + } + + final start = fmt(event.startTime); + final end = fmt(event.endTime); + if (start.isEmpty && end.isEmpty) return ''; + if (end.isEmpty) return start; + return '$start – $end'; + } +} diff --git a/lib/calendar_view.dart b/lib/calendar_view.dart index d6179243..e21370b2 100644 --- a/lib/calendar_view.dart +++ b/lib/calendar_view.dart @@ -27,6 +27,7 @@ export './src/theme/calendar_theme_data.dart'; export './src/theme/day_view_theme_data.dart'; export './src/theme/month_view_theme_data.dart'; export './src/theme/multi_day_view_theme_data.dart'; +export './src/theme/resizable_month_view_theme_data.dart'; export './src/theme/week_view_theme_data.dart'; export './src/typedefs.dart'; export './src/week_view/week_view.dart'; @@ -34,3 +35,7 @@ export './src/month_view/month_view_style.dart'; export './src/month_view/month_view_theme_settings.dart'; export './src/month_view/month_view_builders.dart'; export './src/multi_day_view/multi_day_view.dart'; +export './src/resizable_month_view/resizable_month_view.dart'; +export './src/resizable_month_view/resizable_month_view_style.dart'; +export './src/resizable_month_view/resizable_month_view_theme_settings.dart'; +export './src/resizable_month_view/resizable_month_view_builders.dart'; diff --git a/lib/src/enumerations.dart b/lib/src/enumerations.dart index b33c8d34..a18b814b 100644 --- a/lib/src/enumerations.dart +++ b/lib/src/enumerations.dart @@ -83,3 +83,24 @@ enum DeleteEvent { current, following, } + +/// Display modes for [ResizableMonthView]. +/// +/// Controls how many calendar rows are visible at any given time. +enum ResizableMonthViewMode { + /// Shows the entire month grid (up to 6 rows of weeks). + /// Events for the selected date appear in a scrollable list below. + monthly, + + /// Shows exactly two rows (14 days) with navigation arrows to move + /// forward or backward by one week-pair within the month. + biWeekly, + + /// Shows a single row (7 days – the current week) with navigation + /// arrows to advance or retreat week by week. + /// Events for the selected date appear below. + weekly, + + /// Shows the entire month grid, and events within cells are scrollable. + monthlyScrollable, +} diff --git a/lib/src/extensions.dart b/lib/src/extensions.dart index 4ca436d1..6d11fb42 100644 --- a/lib/src/extensions.dart +++ b/lib/src/extensions.dart @@ -346,6 +346,13 @@ extension BuildContextExtension on BuildContext { MultiDayViewThemeData get multiDayViewColors => Theme.of(this).extension() ?? MultiDayViewThemeData.light(); + + /// Get [ResizableMonthViewThemeData] from theme, if null returns light theme. + /// [ResizableMonthViewThemeData] needs to be added in [MaterialApp] theme + /// extensions to get theme data with this type. + ResizableMonthViewThemeData get resizableMonthViewColors => + Theme.of(this).extension() ?? + ResizableMonthViewThemeData.light(); } extension BuildContextMultiDayViewThemeExtension on BuildContext { diff --git a/lib/src/resizable_month_view/_calendar_drag_handle.dart b/lib/src/resizable_month_view/_calendar_drag_handle.dart new file mode 100644 index 00000000..84749ad8 --- /dev/null +++ b/lib/src/resizable_month_view/_calendar_drag_handle.dart @@ -0,0 +1,137 @@ +// Copyright (c) 2021 Simform Solutions. All rights reserved. +// Use of this source code is governed by a MIT-style license +// that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; + +/// A drag-handle widget rendered at the bottom of [ResizableMonthView]. +/// +/// Detects vertical drag gestures and fires [onDragUp] / [onDragDown] +/// whenever the cumulative drag delta exceeds [threshold] pixels. +/// The accumulator is reset after each crossing and on drag end, so +/// every [threshold]-sized segment triggers exactly one mode switch — +/// no mode steps are ever skipped. +/// +/// The visible handle is a small pill-shaped indicator centred inside a +/// taller invisible hit area so the minimum touch target size is always +/// at least [touchTargetHeight] pixels (≥ 44 px by default). +/// +/// This widget is private to the resizable_month_view directory and must +/// not be imported by any other package file. +class CalendarDragHandle extends StatefulWidget { + const CalendarDragHandle({ + Key? key, + required this.onDragUp, + required this.onDragDown, + this.threshold = 40.0, + this.handleColor, + this.handleWidth = 48.0, + this.handleHeight = 4.0, + this.touchTargetHeight = 44.0, + }) : super(key: key); + + /// Called when the user drags upward past [threshold] pixels. + final VoidCallback onDragUp; + + /// Called when the user drags downward past [threshold] pixels. + final VoidCallback onDragDown; + + /// Cumulative pixel delta required to fire a mode-switch callback. + /// + /// Defaults to 40.0 px. + final double threshold; + + /// Color of the visible pill indicator. + /// + /// If null, falls back to `Colors.grey.shade400` (or the inverse of + /// the current theme's canvas color for dark/light adaptation). + final Color? handleColor; + + /// Visual width of the pill bar in logical pixels. Defaults to 48. + final double handleWidth; + + /// Visual height (thickness) of the pill bar in logical pixels. Defaults to 4. + final double handleHeight; + + /// Height of the invisible touch-target wrapper. Must be ≥ 44 to meet + /// minimum accessibility requirements. Defaults to 44. + final double touchTargetHeight; + + @override + State createState() => _CalendarDragHandleState(); +} + +class _CalendarDragHandleState extends State { + /// Running sum of vertical drag deltas since the last threshold crossing + /// or the start of the current drag gesture. + double _accumulator = 0.0; + + /// Whether a drag gesture is currently in progress — drives a subtle + /// opacity animation on the pill to signal interactivity. + bool _isDragging = false; + + void _onDragUpdate(DragUpdateDetails details) { + if (!_isDragging) { + setState(() => _isDragging = true); + } + + _accumulator += details.delta.dy; + + if (_accumulator > widget.threshold) { + // Downward drag threshold crossed → move to the next lower mode. + _accumulator = 0.0; + widget.onDragDown(); + } else if (_accumulator < -widget.threshold) { + // Upward drag threshold crossed → move to the next higher mode. + _accumulator = 0.0; + widget.onDragUp(); + } + } + + void _onDragEnd(DragEndDetails details) { + // Reset both the accumulator and the dragging visual state. + setState(() { + _accumulator = 0.0; + _isDragging = false; + }); + } + + void _onDragCancel() { + setState(() { + _accumulator = 0.0; + _isDragging = false; + }); + } + + @override + Widget build(BuildContext context) { + final brightness = Theme.of(context).brightness; + final defaultHandleColor = brightness == Brightness.dark + ? Colors.grey.shade600 + : Colors.grey.shade400; + final pillColor = widget.handleColor ?? defaultHandleColor; + + return GestureDetector( + // Consume vertical drags so parent scroll views don't intercept them. + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: _onDragUpdate, + onVerticalDragEnd: _onDragEnd, + onVerticalDragCancel: _onDragCancel, + child: SizedBox( + width: double.infinity, + height: widget.touchTargetHeight, + child: Center( + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: widget.handleWidth, + height: widget.handleHeight, + decoration: BoxDecoration( + color: pillColor.withAlpha(_isDragging ? 255 : 178), + borderRadius: BorderRadius.circular(widget.handleHeight / 2), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/resizable_month_view/_event_list_panel.dart b/lib/src/resizable_month_view/_event_list_panel.dart new file mode 100644 index 00000000..83ac4607 --- /dev/null +++ b/lib/src/resizable_month_view/_event_list_panel.dart @@ -0,0 +1,366 @@ +// Copyright (c) 2021 Simform Solutions. All rights reserved. +// Use of this source code is governed by a MIT-style license +// that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; + +import '../../calendar_view.dart'; +import '../extensions.dart'; + +/// Internal event-list panel rendered below the calendar grid in +/// [ResizableMonthView]. +/// +/// Shows all events for [selectedDate] from [controller]. +/// If [builders.eventListBuilder] is provided it delegates entirely to that. +/// Otherwise a default scrollable card list is shown with an optional +/// per-item override via [builders.eventListItemBuilder]. +class EventListPanel extends StatelessWidget { + const EventListPanel({ + Key? key, + required this.selectedDate, + required this.controller, + required this.builders, + required this.padding, + required this.separatorHeight, + }) : super(key: key); + + /// The date whose events are displayed. + final DateTime selectedDate; + + /// Event controller. + final EventController controller; + + /// Builders config (may include [eventListBuilder] or + /// [eventListItemBuilder]). + final ResizableMonthViewBuilders builders; + + /// Padding applied around the entire list area. + final EdgeInsets padding; + + /// Height of the gap between the calendar grid and this panel. + final double separatorHeight; + + /// Builds the event list as a [Sliver] suitable for inclusion in a + /// [CustomScrollView]. + /// + /// **Layout structure** (top → bottom): + /// 1. A date-label header showing which date the events belong to + /// (Samsung calendar style). + /// 2. The event list itself, or a "No events" placeholder. + /// + /// **Delegation order**: + /// 1. If [builders.eventListBuilder] is provided, the entire list + /// rendering is delegated to it (full custom layout). + /// 2. If no events exist for [selectedDate], a lightweight + /// [_EmptyEventsPlaceholder] is shown. + /// 3. Otherwise a [SliverList] is built with interleaved 8-px spacing + /// gaps (odd indices) and event tiles (even indices). Per-item + /// rendering can be customised via [builders.eventListItemBuilder]; + /// without it, [_DefaultEventTile] is used. + @override + Widget build(BuildContext context) { + final events = controller.getEventsOnDay(selectedDate); + + // Custom whole-list builder takes priority. + final customList = builders.eventListBuilder?.call(events, selectedDate); + if (customList != null) { + return SliverPadding( + padding: padding.copyWith(top: padding.top + separatorHeight), + sliver: SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _DateLabelHeader(date: selectedDate), + customList, + ], + ), + ), + ); + } + + // Empty state: no events for the selected date. + if (events.isEmpty) { + return SliverPadding( + padding: padding.copyWith(top: padding.top + separatorHeight), + sliver: SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _DateLabelHeader(date: selectedDate), + _EmptyEventsPlaceholder(), + ], + ), + ), + ); + } + + // Default list: date header + alternating event tiles and spacing gaps. + // childCount = events.length * 2 accounts for the interleaved gaps + // (first item at index 0 is the date header). + return SliverPadding( + padding: padding.copyWith(top: padding.top + separatorHeight), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + // First item is the date header. + if (index == 0) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _DateLabelHeader(date: selectedDate), + ); + } + + // Adjust index to account for the header. + final adjustedIndex = index - 1; + + // Odd adjusted indices are spacing gaps between tiles. + if (adjustedIndex.isOdd) return const SizedBox(height: 8); + + final itemIndex = adjustedIndex ~/ 2; + final event = events[itemIndex]; + + // Per-item custom builder. + if (builders.eventListItemBuilder != null) { + return _maybeWrapDismissible( + event: event, + child: builders.eventListItemBuilder!( + event, + selectedDate, + ), + ); + } + + return _maybeWrapDismissible( + event: event, + child: _DefaultEventTile( + event: event, + date: selectedDate, + onTap: builders.onEventTap, + onLongTap: builders.onEventLongTap, + onDoubleTap: builders.onEventDoubleTap, + ), + ); + }, + // +1 for the date header at index 0 + childCount: events.length * 2, // header + (events + gaps) + ), + ), + ); + } + + /// Wraps [child] in a [Dismissible] if [builders.onEventDismissed] + /// is provided, enabling swipe-to-delete on event tiles. + Widget _maybeWrapDismissible({ + required CalendarEventData event, + required Widget child, + }) { + if (builders.onEventDismissed == null) return child; + + return Dismissible( + key: ValueKey('dismiss_${event.hashCode}_${selectedDate.hashCode}'), + direction: DismissDirection.horizontal, + onDismissed: (direction) { + builders.onEventDismissed!(event, selectedDate, direction); + }, + background: Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(left: 20), + decoration: BoxDecoration( + color: Colors.red.shade400, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.delete_outline, color: Colors.white), + ), + secondaryBackground: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + decoration: BoxDecoration( + color: Colors.red.shade400, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.delete_outline, color: Colors.white), + ), + child: child, + ); + } +} + +// ─── Date label header (Samsung-style) ──────────────────────────────────────── + +/// Shows the date whose events are currently displayed, giving context +/// when the user navigates to another month without selecting a date there. +/// +/// Format: "Wednesday, June 11" — matching the Samsung Calendar style. +class _DateLabelHeader extends StatelessWidget { + const _DateLabelHeader({required this.date}); + + final DateTime date; + + @override + Widget build(BuildContext context) { + final now = DateTime.now().withoutTime; + final isToday = date.compareWithoutTime(now); + final yesterday = now.subtract(const Duration(days: 1)); + final tomorrow = now.add(const Duration(days: 1)); + + String label; + if (isToday) { + label = 'Today'; + } else if (date.compareWithoutTime(yesterday)) { + label = 'Yesterday'; + } else if (date.compareWithoutTime(tomorrow)) { + label = 'Tomorrow'; + } else { + label = _formatDate(date); + } + + return Padding( + padding: const EdgeInsets.only(top: 4, bottom: 4), + child: Text( + label, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface.withAlpha(180), + ), + ), + ); + } + + /// Formats [date] as "Weekday, Month Day" (e.g. "Wednesday, June 11"). + static String _formatDate(DateTime date) { + const weekdays = [ + 'Monday', 'Tuesday', 'Wednesday', 'Thursday', + 'Friday', 'Saturday', 'Sunday', // + ]; + const months = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', // + ]; + return '${weekdays[date.weekday - 1]}, ' + '${months[date.month - 1]} ${date.day}'; + } +} + +// ─── Default tile ───────────────────────────────────────────────────────────── + +/// Fallback event tile rendered when no custom +/// [ResizableMonthViewBuilders.eventListItemBuilder] is supplied. +/// +/// Displays a colour-accented left border, a colour dot (matching +/// [CalendarEventData.color]) and the event title inside a rounded, +/// lightly shadowed container. When the event has a custom colour, a +/// subtle tint of that colour is blended into the tile background so +/// that events like "VIP Client Meeting" (Colors.black) render with +/// proper contrast. Supports tap, long-press, and double-tap gestures +/// via the optional [TileTapCallback]s. +class _DefaultEventTile extends StatelessWidget { + const _DefaultEventTile({ + required this.event, + required this.date, + this.onTap, + this.onLongTap, + this.onDoubleTap, + }); + + final CalendarEventData event; + final DateTime date; + final TileTapCallback? onTap; + final TileTapCallback? onLongTap; + final TileTapCallback? onDoubleTap; + + /// Builds the tile widget with a coloured-dot + title row inside a + /// themed rounded container, wired to gesture callbacks. + @override + Widget build(BuildContext context) { + final theme = context.resizableMonthViewColors; + + // Blend a subtle tint of the event's colour into the tile background + // so that custom-coloured events (e.g. black, dark colours) get a + // distinguishable tile background rather than the plain theme colour. + final tintedBackground = Color.alphaBlend( + event.color.withAlpha(25), + theme.eventListItemColor, + ); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap.safeVoidCall(event, date), + onLongPress: onLongTap.safeVoidCall(event, date), + onDoubleTap: onDoubleTap.safeVoidCall(event, date), + child: Container( + decoration: BoxDecoration( + color: tintedBackground, + borderRadius: BorderRadius.circular(10), + border: Border( + left: BorderSide(color: event.color, width: 4), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(10), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + // Colour indicator dot matching the event's colour. + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: event.color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + // Event title, truncated to 2 lines. + Expanded( + child: Text( + event.title, + style: event.titleStyle ?? + TextStyle( + color: theme.eventListItemTextColor, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } +} + +// ─── Empty placeholder ──────────────────────────────────────────────────────── + +/// Placeholder shown when no events exist for the currently selected date. +/// +/// Renders a centred "No events" label using the theme's `onSurface` colour +/// with reduced opacity to ensure visibility in both light and dark mode. +class _EmptyEventsPlaceholder extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + alignment: Alignment.topCenter, + padding: const EdgeInsets.only(top: 24), + child: Text( + 'No events', + style: TextStyle( + // Use the theme's onSurface colour with reduced opacity to + // guarantee visibility in dark mode (the previous theme-extension + // colour could be invisible against certain scaffold backgrounds). + color: Theme.of(context).colorScheme.onSurface.withAlpha(150), + fontSize: 14, + ), + ), + ); + } +} diff --git a/lib/src/resizable_month_view/_resizable_month_header.dart b/lib/src/resizable_month_view/_resizable_month_header.dart new file mode 100644 index 00000000..2b7be3c9 --- /dev/null +++ b/lib/src/resizable_month_view/_resizable_month_header.dart @@ -0,0 +1,248 @@ +// Copyright (c) 2021 Simform Solutions. All rights reserved. +// Use of this source code is governed by a MIT-style license +// that can be found in the LICENSE file. + +import 'package:flutter/foundation.dart' show AsyncCallback; +import 'package:flutter/material.dart'; + +import '../../calendar_view.dart'; +import '../extensions.dart'; + +/// Internal header widget for [ResizableMonthView]. +/// +/// Renders the navigation arrows, the month–year title, and the mode-toggle +/// pill button. Tapping the pill cycles through [ResizableMonthViewMode] +/// values: full → compact → minimal → full. +/// +/// This widget is private to the resizable_month_view directory and must not +/// be imported by any other package file. +class ResizableMonthViewHeader extends StatelessWidget { + const ResizableMonthViewHeader({ + Key? key, + required this.currentDate, + required this.currentMode, + required this.onPrevious, + required this.onNext, + required this.onModeTap, + required this.showPreviousIcon, + required this.showNextIcon, + required this.showModeToggle, + required this.headerStyle, + required this.modeToggleActiveColor, + required this.modeToggleTextColor, + required this.modeToggleBorderRadius, + this.dateStringBuilder, + this.onTitleTap, + }) : super(key: key); + + /// The date whose month/year is displayed in the title. + final DateTime currentDate; + + /// Currently active display mode. + final ResizableMonthViewMode currentMode; + + /// Called when the previous arrow is tapped. + final VoidCallback onPrevious; + + /// Called when the next arrow is tapped. + final VoidCallback onNext; + + /// Called when the mode-toggle pill is tapped. + final VoidCallback onModeTap; + + /// Whether to show the previous-navigation arrow. + final bool showPreviousIcon; + + /// Whether to show the next-navigation arrow. + final bool showNextIcon; + + /// Whether to render the mode-toggle pill. + final bool showModeToggle; + + /// Header styling (colors, padding, text style …). + final HeaderStyle headerStyle; + + /// Background color of the mode-toggle pill. + final Color modeToggleActiveColor; + + /// Text color inside the mode-toggle pill. + final Color modeToggleTextColor; + + /// Corner radius of the mode-toggle pill. + final double modeToggleBorderRadius; + + /// Custom string builder for the date shown in the header title. + final StringProvider? dateStringBuilder; + + /// Optional tap handler for the header title (e.g. open a date picker). + final AsyncCallback? onTitleTap; + + // ─── helpers ──────────────────────────────────────────────────────── + + /// Returns a human-readable label for the current mode, displayed + /// inside the toggle pill ("Full", "Compact", or "Minimal"). + String get _modeLabel { + switch (currentMode) { + case ResizableMonthViewMode.monthly: + return 'Monthly'; + case ResizableMonthViewMode.biWeekly: + return 'Bi-weekly'; + case ResizableMonthViewMode.weekly: + return 'Weekly'; + case ResizableMonthViewMode.monthlyScrollable: + return 'Monthly Scrollable'; + } + } + + /// Formats [date] for the header title. + /// + /// Delegates to the optional [dateStringBuilder]; otherwise falls back + /// to a simple "month - year" numeric format, localised via + /// [PackageStrings.localizeNumber]. + String _buildDateString(DateTime date) { + if (dateStringBuilder != null) return dateStringBuilder!(date); + return '${PackageStrings.localizeNumber(date.month)} - ' + '${PackageStrings.localizeNumber(date.year)}'; + } + + // ─── build ─────────────────────────────────────────────────────────── + + /// Builds the header row containing (left to right): + /// 1. Previous-navigation arrow (or equal-width spacer if hidden). + /// 2. Tappable month–year title. + /// 3. Mode-toggle pill (if [showModeToggle] is `true`). + /// 4. Next-navigation arrow (or equal-width spacer if hidden). + /// + /// The spacers ensure the title stays centred even when one or both + /// arrows are hidden at the date-range boundaries. + @override + Widget build(BuildContext context) { + final textColor = headerStyle.headerTextStyle?.color ?? Colors.white; + final arrowColor = headerStyle.leftIconConfig?.color ?? Colors.white; + + return Container( + decoration: headerStyle.decoration ?? + BoxDecoration( + color: context.resizableMonthViewColors.headerBackgroundColor, + ), + child: Padding( + padding: headerStyle.headerPadding, + child: Row( + mainAxisAlignment: headerStyle.mainAxisAlignment, + children: [ + // ── Previous arrow ─────────────────────────────────────── + if (showPreviousIcon) + _ArrowButton( + icon: Icons.chevron_left, + color: arrowColor, + padding: headerStyle.leftIconConfig?.padding ?? + const EdgeInsets.all(10), + size: headerStyle.leftIconConfig?.size ?? 30, + onTap: onPrevious, + ) + else + SizedBox( + width: (headerStyle.leftIconConfig?.size ?? 30) + + (headerStyle.leftIconConfig?.padding.horizontal ?? 20), + ), + + // ── Title ───────────────────────────────────────────────── + Expanded( + child: GestureDetector( + onTap: onTitleTap != null ? () => onTitleTap!() : null, + child: Text( + _buildDateString(currentDate), + textAlign: headerStyle.titleAlign, + style: headerStyle.headerTextStyle ?? + TextStyle( + color: textColor, + fontWeight: FontWeight.w500, + fontSize: 16, + ), + ), + ), + ), + + // ── Mode-toggle pill ───────────────────────────────────── + if (showModeToggle) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: GestureDetector( + onTap: onModeTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 6, + ), + decoration: BoxDecoration( + color: modeToggleActiveColor, + borderRadius: + BorderRadius.circular(modeToggleBorderRadius), + ), + child: Text( + _modeLabel, + style: TextStyle( + color: modeToggleTextColor, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ), + ), + ), + + // ── Next arrow ─────────────────────────────────────────── + if (showNextIcon) + _ArrowButton( + icon: Icons.chevron_right, + color: arrowColor, + padding: headerStyle.rightIconConfig?.padding ?? + const EdgeInsets.all(10), + size: headerStyle.rightIconConfig?.size ?? 30, + onTap: onNext, + ) + else + SizedBox( + width: (headerStyle.rightIconConfig?.size ?? 30) + + (headerStyle.rightIconConfig?.padding.horizontal ?? 20), + ), + ], + ), + ), + ); + } +} + +// ─── Private helper ─────────────────────────────────────────────────────── + +/// Minimal wrapper around [IconButton] used for the previous / next +/// navigation arrows in [ResizableMonthViewHeader]. +class _ArrowButton extends StatelessWidget { + const _ArrowButton({ + required this.icon, + required this.color, + required this.padding, + required this.size, + required this.onTap, + }); + + final IconData icon; + final Color color; + final EdgeInsets padding; + final double size; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onTap, + icon: Icon(icon), + color: color, + iconSize: size, + padding: padding, + splashRadius: size, + constraints: const BoxConstraints(), + ); + } +} diff --git a/lib/src/resizable_month_view/_resizable_month_page.dart b/lib/src/resizable_month_view/_resizable_month_page.dart new file mode 100644 index 00000000..90c8b0ec --- /dev/null +++ b/lib/src/resizable_month_view/_resizable_month_page.dart @@ -0,0 +1,328 @@ +// Copyright (c) 2021 Simform Solutions. All rights reserved. +// Use of this source code is governed by a MIT-style license +// that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; + +import '../../calendar_view.dart'; +import '../extensions.dart'; + +/// Internal grid widget for [ResizableMonthView]. +/// +/// Renders the weekday-name row (optionally) and the day-cell grid for a +/// given set of [dates]. The caller controls which dates are supplied +/// depending on the active [ResizableMonthViewMode]: +/// +/// * **Full** – all dates returned by [DateTime.datesOfMonths]. +/// * **Compact** – 14 dates (two consecutive weeks). +/// * **Minimal** – 7 dates (one week). +/// +/// When [showWeekDayRow] is `false` the weekday-label row is skipped +/// (because the parent already renders it once above the scrollable area). +class ResizableMonthPage extends StatefulWidget { + const ResizableMonthPage({ + Key? key, + required this.dates, + required this.monthDate, + required this.controller, + required this.cellBuilder, + required this.weekDayBuilder, + required this.selectedDate, + required this.style, + required this.themeSettings, + required this.builders, + required this.onCellTap, + required this.width, + required this.cellWidth, + required this.cellHeight, + this.showWeekDayRow = true, + }) : super(key: key); + + /// The date cells to render. Length drives the grid row count. + final List dates; + + /// The reference month used to determine if a cell is "in month". + final DateTime monthDate; + + /// Event controller. + final EventController controller; + + /// Cell builder (custom or default). + final CellBuilder cellBuilder; + + /// Week-day label builder. + final WeekDayBuilder weekDayBuilder; + + /// Currently selected date (may be null). + final DateTime? selectedDate; + + /// Style config. + final ResizableMonthViewStyle style; + + /// Theme settings. + final ResizableMonthViewThemeSettings themeSettings; + + /// Builders/callbacks. + final ResizableMonthViewBuilders builders; + + /// Tap handler wired from the parent state. + final CellTapCallback? onCellTap; + + /// Total widget width. + final double width; + + /// Width of a single cell column. + final double cellWidth; + + /// Height of a single cell row. + final double cellHeight; + + /// Whether to render the weekday-name header row inside this widget. + /// + /// Set to `false` when the parent renders the header row once above the + /// scrollable/paged area to avoid double rendering and height overflow. + final bool showWeekDayRow; + + @override + State> createState() => _ResizableMonthPageState(); +} + +/// State for [ResizableMonthPage]. +/// +/// Manages the long-press gesture pipeline (start → move → cancel) that +/// drives multi-date selection, and builds the cell grid + optional +/// weekday-label row. +class _ResizableMonthPageState + extends State> { + /// The last date reported by the long-press gesture, used to de-duplicate + /// callbacks when the finger stays over the same cell. + DateTime? _lastReportedDate; + + /// Whether a long-press sequence is currently active. Guards move-update + /// callbacks from firing after the gesture has been cancelled. + bool _isLongPressActive = false; + + int get _columnCount => widget.style.showWeekends ? 7 : 5; + + @override + void dispose() { + _cancelLongPress(); + super.dispose(); + } + + /// Builds the day-cell grid, optionally preceded by a weekday-label row. + /// + /// **Grid construction**: + /// A [GridView.builder] with non-scrollable physics and a fixed + /// cross-axis count is used so that the grid always matches the exact + /// height pre-computed by the parent ([widget.cellHeight] × row count). + /// + /// **Event resolution per cell**: + /// When [style.hideDaysNotInMonth] is `true` and the cell date falls + /// outside [widget.monthDate]'s month, an empty event list is used so + /// that event dots are suppressed for placeholder cells. + /// + /// **Long-press wrapping**: + /// The grid is wrapped in a [GestureDetector] only when at least one + /// long-press callback is registered, avoiding unnecessary hit-testing + /// overhead in the common case. + @override + Widget build(BuildContext context) { + final themeColors = context.resizableMonthViewColors; + + // ── Cell grid ──────────────────────────────────────────────────── + final rowCount = (widget.dates.length / _columnCount).ceil(); + final gridHeight = widget.cellHeight * rowCount; + + final grid = SizedBox( + width: widget.width, + height: gridHeight, + child: GridView.builder( + padding: EdgeInsets.zero, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _columnCount, + childAspectRatio: widget.style.cellAspectRatio, + ), + itemCount: widget.dates.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final date = widget.dates[index]; + final events = widget.style.hideDaysNotInMonth && + date.month != widget.monthDate.month + ? >[] + : widget.controller.getEventsOnDay(date); + final isSelected = + widget.selectedDate?.compareWithoutTime(date) ?? false; + final isInMonth = date.month == widget.monthDate.month; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => widget.onCellTap?.call(events, date), + onDoubleTap: widget.builders.onCellDoubleTap != null + ? () => widget.builders.onCellDoubleTap!.call(events, date) + : null, + child: Container( + decoration: BoxDecoration( + border: widget.style.showBorder + ? Border.all( + color: widget.style.borderColor ?? + themeColors.cellBorderColor, + width: widget.style.borderSize, + ) + : null, + ), + child: widget.cellBuilder( + date, + events, + date.compareWithoutTime(DateTime.now()), + isInMonth, + isSelected, + widget.style.hideDaysNotInMonth, + ), + ), + ); + }, + ), + ); + + // ── Weekday header row (optional) ───────────────────────────────── + // Only built when the parent has not already placed it above the + // scrollable area (showWeekDayRow == true). + if (!widget.showWeekDayRow) { + // Grid-only: the parent provided exact height for cells, no header. + final topAlignedGrid = Align(alignment: Alignment.topCenter, child: grid); + + if (!_hasLongPressCallbacks) return topAlignedGrid; + return GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPressStart: (d) => _onLongPressStart(d, rowCount), + onLongPressMoveUpdate: (d) => _onLongPressMoveUpdate(d, rowCount), + onLongPressEnd: (_) => _cancelLongPress(), + onLongPressCancel: _cancelLongPress, + child: topAlignedGrid, + ); + } + + final weekDayRow = SizedBox( + width: widget.width, + child: Row( + children: List.generate( + _columnCount, + (i) => Expanded( + child: SizedBox( + width: widget.cellWidth, + child: widget.weekDayBuilder( + widget.dates.isNotEmpty ? widget.dates[i].weekday - 1 : i, + ), + ), + ), + ), + ), + ); + + if (!_hasLongPressCallbacks) return Column(children: [weekDayRow, grid]); + + return Column( + children: [ + weekDayRow, + GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPressStart: (d) => _onLongPressStart(d, rowCount), + onLongPressMoveUpdate: (d) => _onLongPressMoveUpdate(d, rowCount), + onLongPressEnd: (_) => _cancelLongPress(), + onLongPressCancel: _cancelLongPress, + child: grid, + ), + ], + ); + } + + /// `true` when at least one long-press callback is registered, + /// signalling that the grid should be wrapped in a [GestureDetector]. + bool get _hasLongPressCallbacks => + widget.builders.onDateLongPress != null || + widget.builders.onDateLongPressMoveUpdate != null; + + /// Begins a long-press sequence: resets tracking state and reports the + /// initial cell under the finger. + void _onLongPressStart(LongPressStartDetails d, int rowCount) { + _isLongPressActive = true; + _lastReportedDate = null; + _reportDate(d.localPosition, d.globalPosition, rowCount, false, null); + } + + /// Continues a long-press sequence: reports each *new* cell the finger + /// moves over, de-duplicating calls when the finger stays on the same cell. + void _onLongPressMoveUpdate(LongPressMoveUpdateDetails d, int rowCount) { + if (!_isLongPressActive) return; + _reportDate(d.localPosition, d.globalPosition, rowCount, true, d); + } + + /// Core date-reporting logic shared by long-press start and move. + /// + /// Converts the pointer's [local] position to a grid cell date via + /// [_dateFromPosition]. If the resolved date is `null` (out of bounds) + /// or identical to [_lastReportedDate], the callback is skipped. + /// + /// * On initial press ([isMoveUpdate] == `false`): fires + /// [onDateLongPress]. + /// * On drag ([isMoveUpdate] == `true`): fires + /// [onDateLongPressMoveUpdate] with the original [moveDetails]. + void _reportDate( + Offset local, + Offset global, + int rowCount, + bool isMoveUpdate, + LongPressMoveUpdateDetails? moveDetails, + ) { + final date = _dateFromPosition(local, rowCount); + if (date == null || date == _lastReportedDate) return; + + if (!isMoveUpdate) { + widget.builders.onDateLongPress?.call(date); + } else if (widget.builders.onDateLongPressMoveUpdate != null) { + widget.builders.onDateLongPressMoveUpdate!( + date, + moveDetails ?? + LongPressMoveUpdateDetails( + globalPosition: global, + localPosition: local, + offsetFromOrigin: Offset.zero, + localOffsetFromOrigin: Offset.zero, + ), + ); + } + _lastReportedDate = date; + } + + /// Converts a local pixel position within the grid into the + /// corresponding [DateTime] from [widget.dates]. + /// + /// Returns `null` when the position falls outside the grid bounds or + /// maps to an index beyond the dates list (possible for partially-filled + /// last rows). + DateTime? _dateFromPosition(Offset local, int rowCount) { + final size = context.size; + if (size == null || size.width <= 0 || size.height <= 0) return null; + if (local.dx < 0 || + local.dy < 0 || + local.dx >= size.width || + local.dy >= size.height) { + return null; + } + + final col = (local.dx / (size.width / _columnCount)).floor(); + final row = (local.dy / (size.height / rowCount)).floor(); + final index = row * _columnCount + col; + if (index < 0 || index >= widget.dates.length) return null; + return widget.dates[index]; + } + + /// Resets long-press tracking state. Called on gesture end and cancel + /// to ensure stale state does not leak into the next gesture. + void _cancelLongPress() { + _lastReportedDate = null; + _isLongPressActive = false; + } +} diff --git a/lib/src/resizable_month_view/resizable_month_view.dart b/lib/src/resizable_month_view/resizable_month_view.dart new file mode 100644 index 00000000..012635ea --- /dev/null +++ b/lib/src/resizable_month_view/resizable_month_view.dart @@ -0,0 +1,1490 @@ +// Copyright (c) 2021 Simform Solutions. All rights reserved. +// Use of this source code is governed by a MIT-style license +// that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; + +import '../../calendar_view.dart'; +import '../extensions.dart'; +import '_calendar_drag_handle.dart'; +import '_event_list_panel.dart'; +import '_resizable_month_header.dart'; +import '_resizable_month_page.dart'; + +/// A resizable calendar widget that displays a month-based calendar with +/// three interchangeable display modes: +/// +/// * **Full** ([ResizableMonthViewMode.monthly]) – shows the entire month grid +/// (up to six rows). A scrollable event list for the selected date is +/// shown below the grid. +/// +/// * **Compact** ([ResizableMonthViewMode.biWeekly]) – shows exactly two rows +/// (bi-weekly view). The header arrows navigate one week-pair at a time. +/// +/// * **Minimal** ([ResizableMonthViewMode.weekly]) – shows a single row +/// (the current week). The header arrows navigate one week at a time. +/// +/// The user can cycle through modes by tapping the mode-toggle pill that is +/// embedded in the header row. The current mode can also be changed +/// programmatically via [ResizableMonthViewState.setMode]. +/// +/// This widget is a **completely separate implementation** from [MonthView]. +/// It shares only public types (typedefs, extensions, enums, theme data) and +/// does not extend or override any `MonthView` class. +class ResizableMonthView extends StatefulWidget { + /// Creates a [ResizableMonthView]. + const ResizableMonthView({ + Key? key, + this.style = const ResizableMonthViewStyle(), + this.builders = const ResizableMonthViewBuilders(), + this.themeSettings = const ResizableMonthViewThemeSettings(), + this.controller, + this.width, + this.height, + this.selectedDate, + this.multiDateSelectionRange = const {}, + this.multiDateSelectionColor, + }) : super(key: key); + + /// Event controller. Falls back to + /// [CalendarControllerProvider.controller] if null. + final EventController? controller; + + /// Style configuration for this view. + final ResizableMonthViewStyle style; + + /// Builder callbacks for this view. + final ResizableMonthViewBuilders builders; + + /// Theme-color settings for this view. + final ResizableMonthViewThemeSettings themeSettings; + + /// Fixed width of this widget. + /// + /// If null the width of the closest [MediaQuery] is used. + final double? width; + + /// Fixed height of the calendar grid in full mode. + /// + /// If null, the grid height will adjust dynamically to tightly fit the + /// required number of rows based on the cell size. + final double? height; + + /// Externally controlled selected date. + /// + /// When non-null the view does **not** update the selection on cell tap; + /// tapping only fires [ResizableMonthViewBuilders.onCellTap]. + final DateTime? selectedDate; + + /// Set of dates highlighted by a long-press drag. + final Set multiDateSelectionRange; + + /// Highlight color for multi-selected dates. + final Color? multiDateSelectionColor; + + @override + ResizableMonthViewState createState() => ResizableMonthViewState(); +} + +/// State for [ResizableMonthView]. +/// +/// Exposes: +/// * [nextPage] / [previousPage] / [jumpToMonth] / [animateToMonth] — same +/// as [MonthViewState] (only meaningful in Full mode). +/// * [setMode] — programmatically switch display modes. +/// * [currentDate] / [currentPage] — current visible month / page index. +class ResizableMonthViewState + extends State> { + // ── Date-range state ────────────────────────────────────────────────── + late DateTime _minDate; + late DateTime _maxDate; + late DateTime _currentDate; // currently visible month (Full mode) + late int _currentIndex; + int _totalMonths = 0; + + // ── Mode state ──────────────────────────────────────────────────────── + late ResizableMonthViewMode _currentMode; + + /// For Compact and Minimal modes: the first day (startDay-aligned Monday + /// equivalent) of the visible week strip. + late DateTime _currentWeekStart; + + // ── Selection ───────────────────────────────────────────────────────── + DateTime? _selectedDate; + + // ── Layout ──────────────────────────────────────────────────────────── + late double _width; + late double _cellWidth; + late double _cellHeight; + + // ── PageController (Full mode) ──────────────────────────────────────── + late PageController _pageController; + + // ── PageController (Weekly / BiWeekly mode) ────────────────────────── + /// Virtual midpoint page index for the week-strip [PageView]. + /// Using a large fixed pool (10 000 pages) avoids computing bounds up front. + static const int _weekPageMidpoint = 5000; + + /// Current page index inside [_weekPageController]. + int _weekPageIndex = _weekPageMidpoint; + + /// Controller for the week-strip [PageView] used in Compact / Minimal modes. + /// Created lazily when first entering a strip mode and disposed when leaving. + PageController? _weekPageController; + + // ── Controller / callback wiring ───────────────────────────────────── + EventController? _controller; + late VoidCallback _reloadCallback; + + // ── Builder cache ───────────────────────────────────────────────────── + late CellBuilder _cellBuilder; + late WeekDayBuilder _weekBuilder; + + // ── Layout measurement keys (monthlyScrollable grid height) ─────────── + /// Key attached to the rendered header so we can measure its height. + final GlobalKey _headerKey = GlobalKey(); + + /// Key attached to the rendered weekday-label row so we can measure its height. + final GlobalKey _weekDayRowKey = GlobalKey(); + + /// Last measured height of the header widget. Defaults to 60 px (a safe + /// approximation used before the first frame completes measurement). + double _measuredHeaderHeight = 60.0; + + /// Last measured height of the weekday-label row. Defaults to 37 px + /// (vertical: 10 padding × 2 + fontSize 17). + double _measuredWeekDayRowHeight = 37.0; + + // ── Computed style shortcuts ────────────────────────────────────────── + ResizableMonthViewStyle get _style => widget.style; + + ResizableMonthViewBuilders get _builders => widget.builders; + + ResizableMonthViewThemeSettings get _theme => widget.themeSettings; + + // ───────────────────────────────────────────────────────────────────── + // Lifecycle + // ───────────────────────────────────────────────────────────────────── + + /// Initialises all state fields from widget configuration. + /// + /// Order matters: + /// 1. Compute the valid date range (`_minDate` / `_maxDate`). + /// 2. Derive the initial visible month and clamp it to the range. + /// 3. Resolve the week-start anchor for Compact / Minimal strips. + /// 4. Seed the [PageController] at the correct page index. + /// 5. Cache builder callbacks so they are ready for the first frame. + @override + void initState() { + super.initState(); + _reloadCallback = _reload; + _currentMode = _style.initialMode; + + _setDateRange(); + + _currentDate = (_style.initialMonth ?? DateTime.now()).withoutTime; + _regulateCurrentDate(); + + _currentWeekStart = _currentDate.firstDayOfWeek(start: _style.startDay); + + _selectedDate = widget.selectedDate?.withoutTime; + + _pageController = PageController(initialPage: _currentIndex); + + // Initialise the week-strip controller only when the view starts in a + // strip mode so we don't create an unused controller in the common case. + if (_currentMode == ResizableMonthViewMode.weekly || + _currentMode == ResizableMonthViewMode.biWeekly) { + _initWeekPageController(); + } + + _assignBuilders(); + } + + /// Re-resolves the [EventController] from the widget tree whenever an + /// inherited widget changes, and recalculates layout dimensions. + /// + /// The controller may come from the widget property or from + /// [CalendarControllerProvider]; either way, stale listeners are + /// detached before attaching the new one. + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final newController = widget.controller ?? + CalendarControllerProvider.of(context).controller; + + if (newController != _controller) { + _controller?.removeListener(_reloadCallback); + _controller = newController; + _controller?.addListener(_reloadCallback); + } + + _updateDimensions(); + } + + /// Reacts to parent rebuilds by diffing the old and new widget. + /// + /// Key reconciliation steps: + /// * Re-wires the [EventController] if it changed. + /// * Recomputes the date range and jumps the [PageView] when + /// [ResizableMonthViewStyle.minMonth] or `maxMonth` changed. + /// * Preserves external selection: if [widget.selectedDate] becomes + /// null after being non-null, the last externally provided value + /// is kept so the UI doesn't lose the highlight. + @override + void didUpdateWidget(ResizableMonthView oldWidget) { + super.didUpdateWidget(oldWidget); + + final newController = widget.controller ?? + CalendarControllerProvider.of(context).controller; + + if (newController != _controller) { + _controller?.removeListener(_reloadCallback); + _controller = newController; + _controller?.addListener(_reloadCallback); + } + + if (_style.minMonth != oldWidget.style.minMonth || + _style.maxMonth != oldWidget.style.maxMonth) { + _setDateRange(); + _regulateCurrentDate(); + _pageController.jumpToPage(_currentIndex); + } + + _assignBuilders(); + + if (widget.selectedDate != null) { + _selectedDate = widget.selectedDate?.withoutTime; + } else if (oldWidget.selectedDate != null) { + _selectedDate = oldWidget.selectedDate?.withoutTime; + } + + _updateDimensions(); + } + + @override + void dispose() { + _controller?.removeListener(_reloadCallback); + _pageController.dispose(); + _weekPageController?.dispose(); + super.dispose(); + } + + // ───────────────────────────────────────────────────────────────────── + // Public API + // ───────────────────────────────────────────────────────────────────── + + /// Returns the [EventController] wired to this widget. + EventController get controller { + if (_controller == null) { + throw StateError('EventController is not initialized yet.'); + } + return _controller!; + } + + /// The currently displayed month. + DateTime get currentDate => DateTime(_currentDate.year, _currentDate.month); + + /// Current page index in the [PageView] (Full mode only). + int get currentPage => _currentIndex; + + /// Programmatically change the display mode. + void setMode(ResizableMonthViewMode mode) { + if (!mounted || _currentMode == mode) return; + setState(() => _applyModeChange(mode)); + } + + /// Navigate to the next month (Full mode) or the next week / week-pair + /// (Compact / Minimal mode). + void nextPage({Duration? duration, Curve? curve}) { + if (_currentMode == ResizableMonthViewMode.monthly || + _currentMode == ResizableMonthViewMode.monthlyScrollable) { + _pageController.nextPage( + duration: duration ?? _style.pageTransitionDuration, + curve: curve ?? _style.pageTransitionCurve, + ); + } else { + _weekPageController?.nextPage( + duration: duration ?? _style.pageTransitionDuration, + curve: curve ?? _style.pageTransitionCurve, + ); + } + } + + /// Navigate to the previous month (Full mode) or the previous week / + /// week-pair (Compact / Minimal mode). + void previousPage({Duration? duration, Curve? curve}) { + if (_currentMode == ResizableMonthViewMode.monthly || + _currentMode == ResizableMonthViewMode.monthlyScrollable) { + _pageController.previousPage( + duration: duration ?? _style.pageTransitionDuration, + curve: curve ?? _style.pageTransitionCurve, + ); + } else { + _weekPageController?.previousPage( + duration: duration ?? _style.pageTransitionDuration, + curve: curve ?? _style.pageTransitionCurve, + ); + } + } + + /// Jumps to the page for [month] without animation (Full mode). + void jumpToMonth(DateTime month) { + if (month.isBefore(_minDate) || month.isAfter(_maxDate)) { + throw ArgumentError( + 'Invalid date selected: $month. Must be between $_minDate and $_maxDate'); + } + _pageController.jumpToPage(_minDate.getMonthDifference(month) - 1); + } + + /// Animates to the page for [month] (Full mode). + Future animateToMonth( + DateTime month, { + Duration? duration, + Curve? curve, + }) async { + if (month.isBefore(_minDate) || month.isAfter(_maxDate)) { + throw ArgumentError( + 'Invalid date selected: $month. Must be between $_minDate and $_maxDate'); + } + await _pageController.animateToPage( + _minDate.getMonthDifference(month) - 1, + duration: duration ?? _style.pageTransitionDuration, + curve: curve ?? _style.pageTransitionCurve, + ); + } + + /// Jumps to [page] index without animation. + void jumpToPage(int page) => _pageController.jumpToPage(page); + + /// Animates to [page] index. + Future animateToPage( + int page, { + Duration? duration, + Curve? curve, + }) async { + await _pageController.animateToPage( + page, + duration: duration ?? _style.pageTransitionDuration, + curve: curve ?? _style.pageTransitionCurve, + ); + } + + // ───────────────────────────────────────────────────────────────────── + // Build + // ───────────────────────────────────────────────────────────────────── + + /// Builds the complete resizable month view. + /// + /// Layout structure: + /// 1. **Header** – navigation arrows, title, and mode-toggle pill. + /// 2. **Calendar area** – weekday labels + animated cell grid. + /// 3. **Event list panel** – sliver list for the selected date. + /// 4. **Drag handle** (optional) – overlaid at the bottom of the widget via + /// a [Stack] when [ResizableMonthViewStyle.enableDragToSwitchMode] is + /// `true`. A matching bottom spacer sliver keeps the last list item from + /// being hidden behind the handle. + @override + Widget build(BuildContext context) { + const double handleTouchTarget = 44.0; + final bool showHandle = _style.enableDragToSwitchMode; + + return SafeAreaWrapper( + option: _style.safeAreaOption, + child: LayoutBuilder( + builder: (context, constraints) { + final isBounded = constraints.hasBoundedHeight; + + final scrollView = CustomScrollView( + shrinkWrap: !isBounded, + physics: isBounded + ? const BouncingScrollPhysics() + : const NeverScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: _buildHeader(), + ), + SliverToBoxAdapter( + child: _buildCalendarArea(constraints), + ), + if (showHandle) + SliverToBoxAdapter( + child: SizedBox( + height: handleTouchTarget, + child: CalendarDragHandle( + onDragUp: _onDragHandleUp, + onDragDown: _onDragHandleDown, + threshold: _style.dragThreshold, + handleColor: _style.dragHandleColor, + ), + ), + ), + if (_currentMode != ResizableMonthViewMode.monthlyScrollable) + EventListPanel( + selectedDate: _selectedDate ?? DateTime.now().withoutTime, + controller: controller, + builders: _builders, + padding: _style.eventListPadding, + separatorHeight: _style.eventListSeparatorHeight, + ), + // Bottom spacer so the event list isn't hidden behind the + // handle overlay when the user has scrolled to the bottom. + ], + ); + + // No drag-to-switch → return the scroll view directly (no overhead). + if (!showHandle) return scrollView; + + // Stack the drag handle at the very bottom of the available area. + // Using Positioned.fill for the scroll view and Positioned(bottom:0) + // for the handle guarantees the pill is always visible, regardless + // of how much content the event list contains or which mode is active. + return scrollView; + }, + ), + ); + } + + // ───────────────────────────────────────────────────────────────────── + // Header + // ───────────────────────────────────────────────────────────────────── + + /// Builds the header bar. + /// + /// If a fully custom [ResizableMonthViewBuilders.headerBuilder] is + /// provided, the entire header is delegated to it. Otherwise a default + /// [ResizableMonthViewHeader] is composed by merging: + /// * The user-provided [ResizableMonthViewThemeSettings.headerStyle] + /// (falling back to theme-extension colours). + /// * Mode-toggle pill colours from [_style] or the theme extension. + /// * Navigation callbacks (`previousPage` / `nextPage`), visibility + /// flags (`_canGoPrevious` / `_canGoNext`), and an optional title-tap + /// handler that opens the platform date picker. + Widget _buildHeader() { + // Allow fully custom header builder. + if (_builders.headerBuilder != null) { + return SizedBox( + width: _width, + child: _builders.headerBuilder!(_displayDate), + ); + } + + final themeColors = context.resizableMonthViewColors; + + final effectiveHeaderStyle = _theme.headerStyle ?? + HeaderStyle( + decoration: BoxDecoration( + color: themeColors.headerBackgroundColor, + ), + leftIconConfig: IconDataConfig( + color: themeColors.headerIconColor, + ), + rightIconConfig: IconDataConfig( + color: themeColors.headerIconColor, + ), + headerTextStyle: TextStyle( + color: themeColors.headerTextColor, + fontWeight: FontWeight.w500, + ), + ); + + final toggleColor = + _style.modeToggleActiveColor ?? themeColors.modeToggleActiveColor; + final toggleTextColor = + _style.modeToggleTextColor ?? themeColors.modeToggleTextColor; + + return SizedBox( + key: _headerKey, + width: _width, + child: ResizableMonthViewHeader( + currentDate: _displayDate, + currentMode: _currentMode, + onPrevious: previousPage, + onNext: nextPage, + onModeTap: _onModeTap, + showPreviousIcon: _canGoPrevious, + showNextIcon: _canGoNext, + showModeToggle: _style.showModeToggle, + headerStyle: effectiveHeaderStyle, + modeToggleActiveColor: toggleColor, + modeToggleTextColor: toggleTextColor, + modeToggleBorderRadius: _style.modeToggleBorderRadius, + dateStringBuilder: _builders.headerStringBuilder, + onTitleTap: _builders.onHeaderTitleTap != null + ? () => _builders.onHeaderTitleTap!(_displayDate) + : (_builders.headerBuilder == null ? _defaultTitleTap : null), + ), + ); + } + + // ───────────────────────────────────────────────────────────────────── + // Calendar area + // ───────────────────────────────────────────────────────────────────── + + /// Assembles the calendar area: a static weekday-label row on top, and + /// the day-cell grid below it, wrapped in [AnimatedSize] so that height + /// transitions smoothly when the user toggles between Full / Compact / + /// Minimal modes. + /// + /// The weekday row is rendered *outside* the paged / strip area + /// intentionally — this avoids it being included in the height budget + /// for the cell grid and prevents the row from being duplicated on + /// every page of the [PageView]. + Widget _buildCalendarArea(BoxConstraints constraints) { + // Schedule a measurement after this frame so _getFullGridHeight has + // accurate values on the *next* build triggered by the setState below. + _scheduleMeasurement(); + + final weekDayRow = KeyedSubtree( + key: _weekDayRowKey, + child: _buildWeekDayRow(), + ); + + Widget gridArea; + Key gridKey; + switch (_currentMode) { + case ResizableMonthViewMode.monthly: + case ResizableMonthViewMode.monthlyScrollable: + gridArea = _buildFullGrid(constraints); + gridKey = const ValueKey('fullGrid'); + break; + case ResizableMonthViewMode.biWeekly: + gridArea = _buildWeekStripGrid(rowCount: 2); + gridKey = const ValueKey('biWeekly'); + break; + case ResizableMonthViewMode.weekly: + gridArea = _buildWeekStripGrid(rowCount: 1); + gridKey = const ValueKey('weekly'); + break; + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + weekDayRow, + AnimatedSize( + duration: _style.animationDuration, + curve: _style.animationCurve, + alignment: Alignment.topCenter, + child: AnimatedSwitcher( + duration: _style.animationDuration, + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.05), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animation, curve: Curves.easeOut)), + child: child, + ), + ); + }, + child: KeyedSubtree( + key: gridKey, + child: gridArea, + ), + ), + ), + ], + ); + } + + /// Builds the weekday-label row (Mon, Tue … Sun) using the active builder. + Widget _buildWeekDayRow() { + final weekDays = _currentWeekStart.datesOfWeek( + start: _style.startDay, + showWeekEnds: _style.showWeekends, + ); + + return SizedBox( + width: _width, + child: Row( + children: List.generate( + _columnCount, + (i) => Expanded( + child: SizedBox( + width: _cellWidth, + child: _weekBuilder(weekDays[i].weekday - 1), + ), + ), + ), + ), + ); + } + + /// Builds the **Full-mode** grid: a horizontally-paged [PageView] where + /// each page is one calendar month. + /// + /// For each page the dates list is generated via [DateTime.datesOfMonths] + /// and then trimmed: any trailing full week that belongs entirely to the + /// *next* month is removed so that the grid never displays an extra empty + /// row. The minimum guaranteed row count is 4 (28-day February starting + /// on the configured start day). + /// + /// The weekday-name row is *not* rendered here — it is placed once by + /// [_buildCalendarArea] above the paged area. + Widget _buildFullGrid(BoxConstraints constraints) { + final double gridHeight = _getFullGridHeight(constraints); + return GestureDetector( + onVerticalDragEnd: (details) { + final velocity = details.primaryVelocity ?? 0; + if (velocity > 300 && _currentMode == ResizableMonthViewMode.monthly) { + // Swipe down: switch to scrollable mode + setMode(ResizableMonthViewMode.monthlyScrollable); + } else if (velocity < -300 && + _currentMode == ResizableMonthViewMode.monthlyScrollable) { + // Swipe up: switch back to normal monthly mode + setMode(ResizableMonthViewMode.monthly); + } + }, + child: SizedBox( + width: _width, + height: gridHeight, + child: PageView.builder( + controller: _pageController, + physics: _style.pageViewPhysics, + onPageChanged: _onPageChange, + itemCount: _totalMonths, + itemBuilder: (_, index) { + final monthDate = DateTime(_minDate.year, _minDate.month + index); + var dates = monthDate + .datesOfMonths( + startDay: _style.startDay, + hideDaysNotInMonth: _style.hideDaysNotInMonth, + showWeekends: _style.showWeekends, + ) + .toList(); + + final columnCount = _style.showWeekends ? 7 : 5; + // Trim trailing rows that belong entirely to the next month so + // the grid does not show a redundant empty row. + while (dates.length > columnCount * 4) { + final lastWeek = dates.sublist(dates.length - columnCount); + if (lastWeek.every((d) => d.month != monthDate.month)) { + dates.removeRange(dates.length - columnCount, dates.length); + } else { + break; + } + } + return ResizableMonthPage( + key: ValueKey(monthDate.toIso8601String()), + dates: dates, + monthDate: monthDate, + controller: controller, + cellBuilder: + (date, events, isToday, isInMonth, isSelected, hideDays) { + final child = _currentMode == + ResizableMonthViewMode.monthlyScrollable + ? _scrollableCellBuilder( + date, events, isToday, isInMonth, isSelected, hideDays) + : _cellBuilder( + date, events, isToday, isInMonth, isSelected, hideDays); + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: KeyedSubtree( + key: ValueKey(_currentMode), + child: child, + ), + ); + }, + weekDayBuilder: _weekBuilder, + selectedDate: _selectedDate, + style: _style, + themeSettings: _theme, + builders: _builders, + onCellTap: _handleCellTap, + width: _width, + cellWidth: _cellWidth, + cellHeight: + _currentMode == ResizableMonthViewMode.monthlyScrollable + ? gridHeight / (dates.length / columnCount).ceil() + : _cellHeight, + showWeekDayRow: false, + ); + }, + ), + ), + ); + } + + /// Builds the **Compact** (2-row) or **Minimal** (1-row) grid. + /// + /// Uses a virtually-infinite [PageView.builder] (10 000 pages, centred at + /// [_weekPageMidpoint]) so that swiping produces the same native horizontal- + /// slide effect as the Full-mode month [PageView]. + /// + /// Dates for each page are computed on-the-fly by offsetting from the + /// current [_currentWeekStart] anchor: a page at index `i` shows the weeks + /// starting `(i − _weekPageIndex) × rowCount × 7` days from the anchor. + /// + /// Navigation callbacks (arrows) call [_weekPageController.nextPage] / + /// [previousPage], and page-change state is managed by [_onWeekPageChanged]. + /// + /// The weekday-name row is *not* rendered here — see [_buildCalendarArea]. + Widget _buildWeekStripGrid({required int rowCount}) { + final gridHeight = _cellHeight * rowCount; + + return SizedBox( + width: _width, + height: gridHeight, + child: PageView.builder( + controller: _weekPageController, + physics: _style.pageViewPhysics, + onPageChanged: (index) => _onWeekPageChanged(index, rowCount), + itemBuilder: (_, pageIndex) { + // Compute the week-start for this virtual page relative to the + // current anchor. Each page is `rowCount` weeks away from its + // neighbour (1 week for Minimal, 2 weeks for Compact). + final delta = pageIndex - _weekPageIndex; + final weekStart = _currentWeekStart.add( + Duration(days: delta * rowCount * 7), + ); + final dates = _stripDatesForStart(weekStart, rowCount); + return ResizableMonthPage( + key: ValueKey('${weekStart.toIso8601String()}_$rowCount'), + dates: dates, + monthDate: weekStart, + controller: controller, + cellBuilder: _cellBuilder, + weekDayBuilder: _weekBuilder, + selectedDate: _selectedDate, + style: _style, + themeSettings: _theme, + builders: _builders, + onCellTap: _handleCellTap, + width: _width, + cellWidth: _cellWidth, + cellHeight: _cellHeight, + showWeekDayRow: false, + ); + }, + ), + ); + } + + // ───────────────────────────────────────────────────────────────────── + // Private helpers + // ───────────────────────────────────────────────────────────────────── + + /// Creates (or re-creates) [_weekPageController] anchored at the virtual + /// midpoint so there is ample room to navigate in both directions. + /// + /// Always disposes the previous controller before creating a new one to + /// avoid memory leaks. + void _initWeekPageController() { + _weekPageController?.dispose(); + _weekPageIndex = _weekPageMidpoint; + _weekPageController = PageController(initialPage: _weekPageIndex); + } + + /// Called by the week-strip [PageView] when the user swipes to a new page. + /// + /// Computes the new [_currentWeekStart] from the page delta, enforces + /// min/max boundaries (bouncing back via [jumpToPage] when exceeded), and + /// updates the header month label if it has changed. + void _onWeekPageChanged(int newIndex, int rowCount) { + if (!mounted) return; + final delta = newIndex - _weekPageIndex; + final newStart = _currentWeekStart.add( + Duration(days: delta * rowCount * 7), + ); + + // ── Boundary checks ────────────────────────────────────────────── + if (newStart.isBefore(_minDate) && delta < 0) { + _builders.onHasReachedStart?.call(_currentWeekStart, _currentIndex); + // Snap back to the current anchor page without animation. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _weekPageController?.jumpToPage(_weekPageIndex); + }); + return; + } + final prospectiveDates = _stripDatesForStart(newStart, rowCount); + if (prospectiveDates.isNotEmpty && + prospectiveDates.last.isAfter(_maxDate) && + delta > 0) { + _builders.onHasReachedEnd?.call(_currentWeekStart, _currentIndex); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _weekPageController?.jumpToPage(_weekPageIndex); + }); + return; + } + + final oldMonth = _currentDate.month; + final oldYear = _currentDate.year; + + setState(() { + _weekPageIndex = newIndex; + _currentWeekStart = newStart; + + // Use a date 3 days into the strip as a heuristic for which month + // the strip "belongs to" so the header title stays correct. + final repr = newStart.add(const Duration(days: 3)); + if (repr.month != oldMonth || repr.year != oldYear) { + _currentDate = DateTime(repr.year, repr.month); + _regulateCurrentDate(); + } + }); + + if (_currentDate.month != oldMonth || _currentDate.year != oldYear) { + _builders.onPageChange?.call(_currentDate, _currentIndex); + } + } + + /// Returns the date label shown in the header. + DateTime get _displayDate { + if (_currentMode == ResizableMonthViewMode.monthly || + _currentMode == ResizableMonthViewMode.monthlyScrollable) { + return _currentDate; + } + // Compact / Minimal: show the month of the first visible cell. + return _currentWeekStart; + } + + /// Whether the "previous" arrow should be enabled. + bool get _canGoPrevious { + if (_currentMode == ResizableMonthViewMode.monthly || + _currentMode == ResizableMonthViewMode.monthlyScrollable) { + return _currentDate != _minDate; + } + return _currentWeekStart.isAfter(_minDate); + } + + /// Whether the "next" arrow should be enabled. + bool get _canGoNext { + if (_currentMode == ResizableMonthViewMode.monthly || + _currentMode == ResizableMonthViewMode.monthlyScrollable) { + return _currentDate != _maxDate; + } + final lastVisible = _stripDates(_stripRowCount).last; + return lastVisible.isBefore(_maxDate); + } + + int get _stripRowCount => + _currentMode == ResizableMonthViewMode.biWeekly ? 2 : 1; + + /// Dynamically calculated height of the full-month grid based on the + /// current month. + /// + /// For [ResizableMonthViewMode.monthlyScrollable] the grid height is computed + /// as: + /// `screenUsableHeight - measuredHeaderHeight - measuredWeekDayRowHeight + /// - dragHandleTouchTarget (if enabled)` + /// + /// The header and weekday row heights are measured via [GlobalKey] after the + /// first frame and kept updated — no hardcoded values. + /// + /// For all other modes the height is derived from the number of *visible* + /// rows multiplied by [_cellHeight]. + double _getFullGridHeight(BoxConstraints constraints) { + if (widget.height != null) return widget.height!; + + if (_currentMode == ResizableMonthViewMode.monthlyScrollable) { + final mq = MediaQuery.of(context); + // Total usable height = screen height minus system UI insets (status bar, + // bottom nav bar, home indicator, etc.). + final usable = mq.size.height - mq.padding.top - mq.padding.bottom; + + // Overhead = header + weekday row + drag handle (if shown). + const double handleHeight = 44.0; + final overhead = _measuredHeaderHeight + + _measuredWeekDayRowHeight + + (_style.enableDragToSwitchMode ? handleHeight : 0.0); + + // Clamp so the grid is never smaller than 4 rows of cells. + return (usable - overhead).clamp(_cellHeight * 4, double.infinity); + } + + final columnCount = _style.showWeekends ? 7 : 5; + var dates = _currentDate + .datesOfMonths( + startDay: _style.startDay, + hideDaysNotInMonth: _style.hideDaysNotInMonth, + showWeekends: _style.showWeekends, + ) + .toList(); + + // Same trailing-row trim logic as _buildFullGrid's itemBuilder. + while (dates.length > columnCount * 4) { + final lastWeek = dates.sublist(dates.length - columnCount); + if (lastWeek.every((d) => d.month != _currentDate.month)) { + dates.removeRange(dates.length - columnCount, dates.length); + } else { + break; + } + } + + final rowCount = (dates.length / columnCount).ceil(); + return _cellHeight * rowCount; + } + + int get _columnCount => _style.showWeekends ? 7 : 5; + + /// Generates the flat list of dates for a Compact (14 dates) or Minimal + /// (7 dates) strip starting from the given [start] anchor. + /// + /// Each row is one full week (respecting [_style.startDay] and + /// [_style.showWeekends]). Multiple rows are concatenated in order. + List _stripDatesForStart(DateTime start, int rowCount) { + return List.generate(rowCount, (index) { + final weekStart = start.add(Duration(days: index * 7)); + return weekStart.datesOfWeek( + start: _style.startDay, + showWeekEnds: _style.showWeekends, + ); + }).expand((element) => element).toList(); + } + + /// Convenience wrapper that returns strip dates anchored at the current + /// [_currentWeekStart]. + List _stripDates(int rowCount) { + return _stripDatesForStart(_currentWeekStart, rowCount); + } + + /// Handler for taps on the mode-toggle pill in the header. + /// + /// Cycles full → compact → minimal → full and notifies external + /// listeners via [ResizableMonthViewBuilders.onModeChanged]. + void _onModeTap() { + final nextMode = _nextMode(_currentMode); + setState(() => _applyModeChange(nextMode)); + _builders.onModeChanged?.call(nextMode); + } + + // ── Drag-handle mode ladder ────────────────────────────────────────── + + /// Advances one step UP the mode ladder when the user drags upward. + /// + /// Ladder (upward direction): + /// monthlyScrollable → monthly → biWeekly → weekly (terminates) + /// + /// No-ops when already at [ResizableMonthViewMode.weekly]. + /// Reuses [_applyModeChange] so transitions are animated and both + /// drag-based and button-based switching share the same state. + void _onDragHandleUp() { + final ResizableMonthViewMode? next; + switch (_currentMode) { + case ResizableMonthViewMode.weekly: + next = null; // already at the top + break; + case ResizableMonthViewMode.biWeekly: + next = ResizableMonthViewMode.weekly; + break; + case ResizableMonthViewMode.monthly: + next = ResizableMonthViewMode.biWeekly; + break; + case ResizableMonthViewMode.monthlyScrollable: + next = ResizableMonthViewMode.monthly; + break; + } + if (next == null || !mounted) return; + setState(() => _applyModeChange(next!)); + _builders.onModeChanged?.call(next); + } + + /// Advances one step DOWN the mode ladder when the user drags downward. + /// + /// Ladder (downward direction): + /// weekly → biWeekly → monthly → monthlyScrollable (terminates) + /// + /// No-ops when already at [ResizableMonthViewMode.monthlyScrollable]. + /// Reuses [_applyModeChange] so transitions are animated and both + /// drag-based and button-based switching share the same state. + void _onDragHandleDown() { + final ResizableMonthViewMode? next; + switch (_currentMode) { + case ResizableMonthViewMode.weekly: + next = ResizableMonthViewMode.biWeekly; + break; + case ResizableMonthViewMode.biWeekly: + next = ResizableMonthViewMode.monthly; + break; + case ResizableMonthViewMode.monthly: + next = ResizableMonthViewMode.monthlyScrollable; + break; + case ResizableMonthViewMode.monthlyScrollable: + next = null; // already at the bottom + break; + } + if (next == null || !mounted) return; + setState(() => _applyModeChange(next!)); + _builders.onModeChanged?.call(next); + } + + /// Applies a mode transition by reconciling the [PageView] state and the + /// week-strip anchor. + /// + /// **Full → (Compact | Minimal)**: + /// The strip is anchored to the week containing the currently selected + /// date (or, if none, the current month's first day). A post-frame + /// callback is *not* needed because the [PageView] is being replaced + /// by a static widget. + /// + /// **(Compact | Minimal) → Full**: + /// The [_currentDate] is set to the month of the current strip, and a + /// post-frame callback jumps the [PageController] to that month's page + /// — the jump must be deferred because the [PageView] has not been + /// laid out yet at the point [setState] runs. + /// + /// In both directions the representative-date heuristic is used to + /// decide whether the header month label has changed, firing + /// [onPageChange] if so. + void _applyModeChange(ResizableMonthViewMode newMode) { + _currentMode = newMode; + + if (newMode == ResizableMonthViewMode.monthly || + newMode == ResizableMonthViewMode.monthlyScrollable) { + // When returning to Full, jump the PageView to the month of the + // currently visible week strip. + _currentDate = DateTime( + _currentWeekStart.year, + _currentWeekStart.month, + ); + _regulateCurrentDate(); + // Dispose the week-strip controller — it is no longer needed. + _weekPageController?.dispose(); + _weekPageController = null; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _pageController.jumpToPage(_currentIndex); + }); + } else { + // Switching from Full or from the other strip mode: anchor the strip + // to the week containing the selected date — but only if the selected + // date is in the currently displayed month. When the user has + // navigated to a different month, we pick the 1st of that month as + // the new selection so the strip stays on the visible month. + DateTime anchor; + if (_selectedDate != null && + _selectedDate!.month == _currentDate.month && + _selectedDate!.year == _currentDate.year) { + anchor = _selectedDate!; + } else { + // Select the 1st of the currently displayed month (or today if + // the displayed month is the current calendar month). + final now = DateTime.now().withoutTime; + if (now.month == _currentDate.month && now.year == _currentDate.year) { + anchor = now; + } else { + anchor = DateTime(_currentDate.year, _currentDate.month); + } + _selectedDate = anchor; + } + _currentWeekStart = anchor.firstDayOfWeek(start: _style.startDay); + + final oldMonth = _currentDate.month; + final oldYear = _currentDate.year; + final representativeDate = _currentWeekStart.add(const Duration(days: 3)); + + if (representativeDate.month != oldMonth || + representativeDate.year != oldYear) { + _currentDate = + DateTime(representativeDate.year, representativeDate.month); + _regulateCurrentDate(); + } + + if (_currentDate.month != oldMonth || _currentDate.year != oldYear) { + _builders.onPageChange?.call(_currentDate, _currentIndex); + } + + // (Re-)initialise the week-strip PageController so it is ready when + // the new strip mode's PageView is built in the next frame. + _initWeekPageController(); + } + } + + /// Returns the next mode in the cycle: + /// full → compact → minimal → full. + static ResizableMonthViewMode _nextMode(ResizableMonthViewMode current) { + switch (current) { + case ResizableMonthViewMode.monthly: + return ResizableMonthViewMode.monthlyScrollable; + case ResizableMonthViewMode.monthlyScrollable: + return ResizableMonthViewMode.biWeekly; + case ResizableMonthViewMode.biWeekly: + return ResizableMonthViewMode.weekly; + case ResizableMonthViewMode.weekly: + return ResizableMonthViewMode.monthly; + } + } + + /// Recalculates layout dimensions from the available width. + /// + /// Cell width is derived by dividing the total width equally across + /// columns (5 when weekends are hidden, 7 otherwise). Cell height is + /// then determined by the configured [cellAspectRatio]. + void _updateDimensions() { + _width = widget.width ?? MediaQuery.of(context).size.width; + _cellWidth = _width / _columnCount; + _cellHeight = _cellWidth / _style.cellAspectRatio; + } + + /// Initialises or refreshes the navigable date range and page count. + /// + /// Falls back to [CalendarConstants.epochDate] / [CalendarConstants.maxDate] + /// when the user has not specified explicit bounds. + void _setDateRange() { + _minDate = (_style.minMonth ?? CalendarConstants.epochDate).withoutTime; + _maxDate = (_style.maxMonth ?? CalendarConstants.maxDate).withoutTime; + + assert( + _minDate.isBefore(_maxDate), + 'Minimum date should be less than maximum date.\n' + 'Provided minimum date: $_minDate, maximum date: $_maxDate', + ); + + _totalMonths = _maxDate.getMonthDifference(_minDate); + } + + /// Clamps [_currentDate] to `[_minDate, _maxDate]` and recomputes + /// [_currentIndex] so the [PageController] stays in sync. + void _regulateCurrentDate() { + if (_currentDate.isBefore(_minDate)) { + _currentDate = _minDate; + } else if (_currentDate.isAfter(_maxDate)) { + _currentDate = _maxDate; + } + _currentIndex = _minDate.getMonthDifference(_currentDate) - 1; + } + + /// Callback from the [PageView] when the visible page changes. + /// + /// Derives the new [_currentDate] from the page delta and fires + /// [onPageChange] so external listeners (e.g. a header widget in a + /// parent scaffold) stay synchronised. + void _onPageChange(int value) { + if (!mounted) return; + setState(() { + _currentDate = DateTime( + _currentDate.year, + _currentDate.month + (value - _currentIndex), + ); + _currentIndex = value; + }); + _builders.onPageChange?.call(_currentDate, _currentIndex); + } + + /// Handles a tap on a calendar day cell. + /// + /// When the widget is in **internally-managed selection mode** + /// (`widget.selectedDate == null`), the tapped date is persisted in + /// [_selectedDate] and the event-list panel updates automatically. + /// When selection is **externally controlled**, the tap only forwards + /// the event via [onCellTap] — the parent is responsible for updating + /// [widget.selectedDate]. + void _handleCellTap(List> events, DateTime date) { + if (widget.selectedDate == null && + !_isSameDate(_selectedDate, date.withoutTime) && + mounted) { + setState(() => _selectedDate = date.withoutTime); + } + _builders.onCellTap?.call(events, date); + } + + /// Null-safe date comparison that ignores the time component. + bool _isSameDate(DateTime? a, DateTime? b) { + if (a == null || b == null) return a == b; + return a.compareWithoutTime(b); + } + + /// Lightweight rebuild trigger called when the [EventController] + /// notifies that its event list has changed. + void _reload() { + if (mounted) setState(() {}); + } + + /// Schedules a single post-frame measurement of the header and weekday-row + /// heights. If either has changed since the last measurement, calls + /// [setState] so [_getFullGridHeight] recalculates with accurate values. + /// + /// Called at the start of every [_buildCalendarArea] invocation so that any + /// layout change (e.g. font-size override, orientation change) is detected + /// promptly. The guard on changed values prevents unnecessary rebuilds. + void _scheduleMeasurement() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + double? newHeader; + double? newWeekDay; + + final headerBox = + _headerKey.currentContext?.findRenderObject() as RenderBox?; + if (headerBox != null && headerBox.hasSize) { + newHeader = headerBox.size.height; + } + + final weekDayBox = + _weekDayRowKey.currentContext?.findRenderObject() as RenderBox?; + if (weekDayBox != null && weekDayBox.hasSize) { + newWeekDay = weekDayBox.size.height; + } + + // Only rebuild when a measurement actually changed. + final headerChanged = + newHeader != null && newHeader != _measuredHeaderHeight; + final weekDayChanged = + newWeekDay != null && newWeekDay != _measuredWeekDayRowHeight; + + if (headerChanged || weekDayChanged) { + setState(() { + if (headerChanged) _measuredHeaderHeight = newHeader!; + if (weekDayChanged) _measuredWeekDayRowHeight = newWeekDay!; + }); + } + }); + } + + // ── Builder assignment ───────────────────────────────────────────── + + /// Resolves builder callbacks, falling back to built-in implementations + /// when the user has not supplied custom ones. + void _assignBuilders() { + _cellBuilder = _builders.cellBuilder ?? _defaultCellBuilder; + _weekBuilder = _builders.weekDayBuilder ?? _defaultWeekDayBuilder; + } + + // ── Default builder implementations ──────────────────────────────── + + /// Default weekday-label builder (e.g. "Mon", "Tue"). + /// + /// Uses theme-extension colours and the optional + /// [weekDayStringBuilder] for localisation. + Widget _defaultWeekDayBuilder(int index) { + final themeColors = context.resizableMonthViewColors; + return WeekDayTile( + dayIndex: index, + weekDayStringBuilder: _builders.weekDayStringBuilder, + displayBorder: _style.showWeekTileBorder, + borderColor: _theme.weekDayBorderColor ?? themeColors.weekDayBorderColor, + backgroundColor: + _theme.weekDayBackgroundColor ?? themeColors.weekDayTileColor, + textStyle: _theme.weekDayTextStyle, + ); + } + + /// Default cell builder used when no custom [CellBuilder] is provided. + /// + /// Rendering rules: + /// * **Hidden out-of-month cells**: if [hideDaysNotInMonth] is `true` and + /// the cell does not belong to the displayed month, an empty coloured + /// box is returned immediately. + /// * **Highlight cascade** (selected → today → normal): the circle + /// avatar colour and text colour are resolved in priority order so + /// that `selected` always wins over `today`. + /// * **Event dots**: up to 4 small coloured dots are shown beneath the + /// date number, each matching the event's [CalendarEventData.color]. + Widget _defaultCellBuilder( + DateTime date, + List> events, + bool isToday, + bool isInMonth, + bool isSelected, + bool hideDaysNotInMonth, + ) { + final themeColor = context.resizableMonthViewColors; + final shouldHighlight = isSelected || isToday; + + // Resolve highlight colours with priority: selected > today > default. + final highlightedTitleColor = isSelected + ? _theme.selectedTitleColor + : hideDaysNotInMonth + ? _theme.cellsNotInMonthHighlightedTitleColor + : _theme.cellsInMonthHighlightedTitleColor; + + final highlightColor = isSelected + ? _theme.selectedHighlightColor + : hideDaysNotInMonth + ? themeColor.cellHighlightColor + : _theme.cellsInMonthHighlightColor; + + final highlightRadius = isSelected + ? _theme.selectedHighlightRadius + : hideDaysNotInMonth + ? _theme.cellsNotInMonthHighlightRadius + : _theme.cellsInMonthHighlightRadius; + + // Early return: blank placeholder for out-of-month dates when hidden. + if (hideDaysNotInMonth && !isInMonth) { + return ColoredBox( + color: themeColor.cellNotInMonthColor, + ); + } + + final backgroundColor = isInMonth + ? themeColor.cellInMonthColor + : themeColor.cellNotInMonthColor; + + final cellTitleColor = isInMonth + ? themeColor.cellTextColor + : themeColor.cellTextColor.withAlpha(150); + + return Container( + width: double.infinity, + height: double.infinity, + color: backgroundColor, + child: Column( + children: [ + const SizedBox(height: 5), + CircleAvatar( + radius: highlightRadius, + backgroundColor: + shouldHighlight ? highlightColor : Colors.transparent, + child: Text( + _builders.dateStringBuilder?.call(date) ?? + PackageStrings.localizeNumber(date.day), + style: TextStyle( + color: shouldHighlight ? highlightedTitleColor : cellTitleColor, + fontSize: 12, + ), + ), + ), + if (events.isNotEmpty) + Expanded( + child: Container( + padding: const EdgeInsets.only(top: 4.0), + alignment: Alignment.topCenter, + child: Wrap( + spacing: 3, + runSpacing: 3, + alignment: WrapAlignment.center, + children: events + .take(4) // Limit to 4 dots to prevent visual clutter. + .map((e) => CircleAvatar( + radius: 3.5, + backgroundColor: e.color, + )) + .toList(), + ), + ), + ), + ], + ), + ); + } + + Widget _scrollableCellBuilder( + DateTime date, + List> events, + bool isToday, + bool isInMonth, + bool isSelected, + bool hideDaysNotInMonth, + ) { + final themeColor = context.resizableMonthViewColors; + final shouldHighlight = isSelected || isToday; + + final highlightedTitleColor = isSelected + ? _theme.selectedTitleColor + : hideDaysNotInMonth + ? _theme.cellsNotInMonthHighlightedTitleColor + : _theme.cellsInMonthHighlightedTitleColor; + + final highlightColor = isSelected + ? _theme.selectedHighlightColor + : hideDaysNotInMonth + ? themeColor.cellHighlightColor + : _theme.cellsInMonthHighlightColor; + + final highlightRadius = isSelected + ? _theme.selectedHighlightRadius + : hideDaysNotInMonth + ? _theme.cellsNotInMonthHighlightRadius + : _theme.cellsInMonthHighlightRadius; + + if (hideDaysNotInMonth) { + return FilledCell( + date: date, + shouldHighlight: shouldHighlight, + backgroundColor: isInMonth + ? themeColor.cellInMonthColor + : themeColor.cellNotInMonthColor, + events: events, + isInMonth: isInMonth, + onTileTap: _builders.onEventTap, + onTileDoubleTap: _builders.onEventDoubleTap, + onTileLongTap: _builders.onEventLongTap, + onTileTapDetails: _builders.onEventTapDetails, + onTileDoubleTapDetails: _builders.onEventDoubleTapDetails, + onTileLongTapDetails: _builders.onEventLongTapDetails, + dateStringBuilder: _builders.dateStringBuilder, + hideDaysNotInMonth: hideDaysNotInMonth, + titleColor: themeColor.cellTextColor, + highlightColor: highlightColor, + tileColor: themeColor.cellHighlightColor, + highlightRadius: highlightRadius, + highlightedTitleColor: highlightedTitleColor, + ); + } + return FilledCell( + date: date, + shouldHighlight: shouldHighlight, + backgroundColor: isInMonth + ? themeColor.cellInMonthColor + : themeColor.cellNotInMonthColor, + events: events, + onTileTap: _builders.onEventTap, + onTileLongTap: _builders.onEventLongTap, + onTileTapDetails: _builders.onEventTapDetails, + onTileDoubleTapDetails: _builders.onEventDoubleTapDetails, + onTileLongTapDetails: _builders.onEventLongTapDetails, + dateStringBuilder: _builders.dateStringBuilder, + onTileDoubleTap: _builders.onEventDoubleTap, + hideDaysNotInMonth: hideDaysNotInMonth, + titleColor: isInMonth + ? themeColor.cellTextColor + : themeColor.cellTextColor.withAlpha(150), + highlightedTitleColor: highlightedTitleColor, + highlightRadius: highlightRadius, + tileColor: themeColor.cellInMonthColor, + highlightColor: highlightColor, + ); + } + + /// Opens the platform date picker and navigates to the selected month + /// or week, then selects the chosen date. + /// + /// In Full mode, the [PageView] jumps to the month containing the + /// chosen date. In Compact / Minimal mode, the strip anchor is + /// repositioned to the week containing the chosen date. + /// + /// In all modes, the picked date becomes the new [_selectedDate] and + /// [onCellTap] is fired so that external listeners stay in sync. + Future _defaultTitleTap() async { + final pickedDate = await showDatePicker( + context: context, + initialDate: _displayDate, + firstDate: _minDate, + lastDate: _maxDate, + locale: Locale(PackageStrings.selectedLocale), + ); + if (pickedDate == null || !mounted) return; + + final normalised = pickedDate.withoutTime; + + if (_currentMode == ResizableMonthViewMode.monthly || + _currentMode == ResizableMonthViewMode.monthlyScrollable) { + jumpToMonth(pickedDate); + } else { + // Re-anchor the strip to the week containing the picked date and + // reset the virtual PageView to its midpoint so the user can navigate + // freely in both directions from the new position. + setState(() { + _currentWeekStart = pickedDate.firstDayOfWeek(start: _style.startDay); + _weekPageIndex = _weekPageMidpoint; + }); + // Rebuild the week controller at the new anchor without animation. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _weekPageController?.jumpToPage(_weekPageMidpoint); + }); + } + + // Update selection and notify listeners. + if (widget.selectedDate == null) { + setState(() => _selectedDate = normalised); + } + _builders.onCellTap?.call( + controller.getEventsOnDay(normalised), + normalised, + ); + } +} diff --git a/lib/src/resizable_month_view/resizable_month_view_builders.dart b/lib/src/resizable_month_view/resizable_month_view_builders.dart new file mode 100644 index 00000000..f4bfed29 --- /dev/null +++ b/lib/src/resizable_month_view/resizable_month_view_builders.dart @@ -0,0 +1,151 @@ +// Copyright (c) 2021 Simform Solutions. All rights reserved. +// Use of this source code is governed by a MIT-style license +// that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; + +import '../../calendar_view.dart'; + +/// Collection of builder callbacks and interaction handlers used to +/// customise the appearance and behaviour of a [ResizableMonthView]. +/// +/// Mirrors [MonthViewBuilders] but adds resizable-specific callbacks: +/// * [eventListBuilder] — custom widget for the event-list area. +/// * [eventListItemBuilder] — custom per-event tile. +/// * [onModeChanged] — fired when the user switches display modes. +/// +/// Note: [onHeaderTitleTap] and [headerBuilder] are mutually exclusive. +@immutable +class ResizableMonthViewBuilders { + const ResizableMonthViewBuilders({ + this.cellBuilder, + this.headerBuilder, + this.headerStringBuilder, + this.dateStringBuilder, + this.weekDayStringBuilder, + this.onPageChange, + this.onCellTap, + this.onCellDoubleTap, + this.onEventTap, + this.onEventLongTap, + this.onEventDoubleTap, + this.weekDayBuilder, + this.onDateLongPress, + this.onDateLongPressMoveUpdate, + this.onHeaderTitleTap, + this.onEventTapDetails, + this.onEventLongTapDetails, + this.onEventDoubleTapDetails, + this.onHasReachedEnd, + this.onHasReachedStart, + // Resizable-specific + this.eventListBuilder, + this.eventListItemBuilder, + this.onModeChanged, + this.onEventDismissed, + }) : assert( + !(onHeaderTitleTap != null && headerBuilder != null), + "can't use [onHeaderTitleTap] & [headerBuilder] simultaneously", + ); + + // ── Standard month-view callbacks (mirrored from MonthViewBuilders) ── + + /// Builds each day cell. + final CellBuilder? cellBuilder; + + /// Builds the entire header row. + /// + /// When provided, [onHeaderTitleTap] must be null. + final DateWidgetBuilder? headerBuilder; + + /// Generates the date string shown in the header (e.g. for i18n). + final StringProvider? headerStringBuilder; + + /// Generates the day-number string inside each cell (e.g. for i18n). + final StringProvider? dateStringBuilder; + + /// Generates the weekday-label string (Mon, Tue … Sun) for i18n. + final String Function(int)? weekDayStringBuilder; + + /// Called when the displayed month page changes. + final CalendarPageChangeCallBack? onPageChange; + + /// Called when the user taps on a day cell. + final CellTapCallback? onCellTap; + + /// Called when the user double-taps on a day cell. + final CellTapCallback? onCellDoubleTap; + + /// Called when the user taps a single event tile. + /// + /// Only fires when [cellBuilder] is null (default cell is used). + final TileTapCallback? onEventTap; + + /// Called when the user long-presses a single event tile. + /// + /// Only fires when [cellBuilder] is null. + final TileTapCallback? onEventLongTap; + + /// Called when the user double-taps a single event tile. + /// + /// Only fires when [cellBuilder] is null. + final TileTapCallback? onEventDoubleTap; + + /// Builds the weekday-name row tiles (Mon, Tue …). + final WeekDayBuilder? weekDayBuilder; + + /// Called when the user long-presses on the calendar. + final DatePressCallback? onDateLongPress; + + /// Called when the user moves the pointer after a long-press on a cell. + final DateLongPressMoveUpdateCallback? onDateLongPressMoveUpdate; + + /// Callback for tapping the header title (e.g. to open a date-picker). + /// + /// Mutually exclusive with [headerBuilder]. + final HeaderTitleCallback? onHeaderTitleTap; + + /// Tap callback with additional [TapUpDetails]. + final TileTapDetailsCallback? onEventTapDetails; + + /// Long-press callback with additional [LongPressStartDetails]. + final TileLongTapDetailsCallback? onEventLongTapDetails; + + /// Double-tap callback with additional [TapDownDetails]. + final TileDoubleTapDetailsCallback? onEventDoubleTapDetails; + + /// Fired when the user drags the last page leftward (request next data). + final CalendarPageChangeCallBack? onHasReachedEnd; + + /// Fired when the user drags the first page rightward (request prior data). + final CalendarPageChangeCallBack? onHasReachedStart; + + // ── Resizable-specific callbacks ───────────────────────────────────── + + /// Builds the entire event-list area shown below the calendar grid. + /// + /// Receives the list of events for the currently selected date and the + /// selected [DateTime]. Return `null` to fall back to the default list. + final Widget? Function(List> events, DateTime date)? + eventListBuilder; + + /// Builds a single event tile inside the default event list. + /// + /// Only used when [eventListBuilder] is null. + final Widget Function(CalendarEventData event, DateTime date)? + eventListItemBuilder; + + /// Called whenever the user taps the mode-toggle pill and the display + /// mode changes. + final void Function(ResizableMonthViewMode mode)? onModeChanged; + + /// Called when the user swipes an event tile in the list to dismiss it. + /// + /// The [direction] indicates whether the user swiped left or right. + /// Use this to delete or archive the event. + final void Function( + CalendarEventData event, + DateTime date, + DismissDirection direction, + )? onEventDismissed; +} diff --git a/lib/src/resizable_month_view/resizable_month_view_style.dart b/lib/src/resizable_month_view/resizable_month_view_style.dart new file mode 100644 index 00000000..a74522be --- /dev/null +++ b/lib/src/resizable_month_view/resizable_month_view_style.dart @@ -0,0 +1,267 @@ +// Copyright (c) 2021 Simform Solutions. All rights reserved. +// Use of this source code is governed by a MIT-style license +// that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; + +import '../../calendar_view.dart'; + +/// Configures the visual appearance, layout, interaction behaviour, and +/// display mode of a [ResizableMonthView]. +/// +/// This is a standalone class modelled after [MonthViewStyle] but augmented +/// with resizable-specific fields such as [initialMode], mode-toggle styling, +/// event-list spacing, and height-transition animation settings. +/// +/// Instances are immutable and can be reused across widgets. +@immutable +class ResizableMonthViewStyle { + const ResizableMonthViewStyle({ + // ── Resizable-specific fields ───────────────────────────────────── + this.initialMode = ResizableMonthViewMode.monthly, + this.showModeToggle = true, + this.modeToggleActiveColor, + this.modeToggleTextColor, + this.modeToggleBorderRadius = 20, + this.eventListPadding = const EdgeInsets.symmetric(horizontal: 12), + this.eventListSeparatorHeight = 8, + this.animationDuration = const Duration(milliseconds: 350), + this.animationCurve = Curves.easeInOut, + // ── Drag-to-switch fields ───────────────────────────────────────── + this.enableDragToSwitchMode = false, + this.dragHandleColor, + this.dragThreshold = 40.0, + // ── Fields mirrored from MonthViewStyle ────────────────────────── + this.showBorder = true, + this.borderColor, + this.minMonth, + this.maxMonth, + this.initialMonth, + this.showWeekends = true, + this.borderSize = 1, + this.cellAspectRatio = 0.55, + this.pageTransitionDuration = const Duration(milliseconds: 300), + this.pageTransitionCurve = Curves.ease, + this.startDay = WeekDays.monday, + this.headerStyle, + this.safeAreaOption = const SafeAreaOption(), + this.pageViewPhysics, + this.showWeekTileBorder = true, + this.hideDaysNotInMonth = false, + }); + + // ── Resizable-specific ───────────────────────────────────────────── + + /// The display mode shown when the widget first renders. + /// + /// Defaults to [ResizableMonthViewMode.monthly]. + final ResizableMonthViewMode initialMode; + + /// Whether to render the mode-toggle pill button inside the header. + /// + /// Set to `false` to hide the button and lock the view to [initialMode]. + final bool showModeToggle; + + /// Background color of the active mode-toggle pill. + /// + /// If null, falls back to the theme's primary color. + final Color? modeToggleActiveColor; + + /// Text / icon color inside the mode-toggle pill. + /// + /// If null, falls back to the theme's onPrimary color. + final Color? modeToggleTextColor; + + /// Corner radius of the mode-toggle pill button. + /// + /// Defaults to 20. + final double modeToggleBorderRadius; + + /// Padding applied around the event-list area that appears below the grid. + final EdgeInsets eventListPadding; + + /// Vertical gap (in pixels) between the calendar grid and the event list. + final double eventListSeparatorHeight; + + /// Duration of the animated height transition when the user switches modes. + final Duration animationDuration; + + /// Easing curve used during the mode-switch height animation. + final Curve animationCurve; + + // ── Drag-to-switch ──────────────────────────────────────────────── + + /// Whether to show a drag handle at the bottom of the calendar that + /// lets users switch between display modes by dragging up or down. + /// + /// When `true`, a [CalendarDragHandle] is rendered below the calendar + /// grid. Dragging down advances through the mode ladder + /// (weekly → biWeekly → monthly → monthlyScrollable) and dragging up + /// reverses it. The feature is disabled by default. + final bool enableDragToSwitchMode; + + /// Color of the drag-handle pill indicator. + /// + /// If null, a neutral grey adapted to the current [Brightness] is used. + final Color? dragHandleColor; + + /// Minimum cumulative vertical drag delta (in logical pixels) required + /// to advance one step in the mode ladder. + /// + /// Smaller values feel more responsive; larger values require a more + /// deliberate gesture. Defaults to 40.0. + final double dragThreshold; + + // ── MonthViewStyle mirrors ───────────────────────────────────────── + + /// Show weekends in the grid. + final bool showWeekends; + + /// Lower boundary the user can scroll to (base date for page indexing). + final DateTime? minMonth; + + /// Upper boundary the user can scroll to. + final DateTime? maxMonth; + + /// Initial month displayed when the widget first renders. + final DateTime? initialMonth; + + /// Whether to show cell borders. + final bool showBorder; + + /// Whether to show borders on the weekday-name row tiles. + final bool showWeekTileBorder; + + /// Color of cell borders (only used when [showBorder] is true). + final Color? borderColor; + + /// Duration used for month-page transitions. + final Duration pageTransitionDuration; + + /// Curve used for month-page transitions. + final Curve pageTransitionCurve; + + /// Width of cell borders. + final double borderSize; + + /// Aspect ratio (width / height) for each day cell. + final double cellAspectRatio; + + /// Day of the week that starts each row. + final WeekDays startDay; + + /// Optional custom style for the header widget. + final HeaderStyle? headerStyle; + + /// Safe-area configuration. + final SafeAreaOption safeAreaOption; + + /// Scroll physics for the horizontal [PageView] (Full mode only). + final ScrollPhysics? pageViewPhysics; + + /// Whether to hide day cells that belong to the previous/next month. + final bool hideDaysNotInMonth; + + /// Creates a copy of this style with the given fields replaced. + ResizableMonthViewStyle copyWith({ + ResizableMonthViewMode? initialMode, + bool? showModeToggle, + Color? modeToggleActiveColor, + Color? modeToggleTextColor, + double? modeToggleBorderRadius, + EdgeInsets? eventListPadding, + double? eventListSeparatorHeight, + Duration? animationDuration, + Curve? animationCurve, + bool? enableDragToSwitchMode, + Color? dragHandleColor, + double? dragThreshold, + bool? showBorder, + Color? borderColor, + DateTime? minMonth, + DateTime? maxMonth, + DateTime? initialMonth, + bool? showWeekends, + double? borderSize, + double? cellAspectRatio, + Duration? pageTransitionDuration, + Curve? pageTransitionCurve, + WeekDays? startDay, + HeaderStyle? headerStyle, + SafeAreaOption? safeAreaOption, + ScrollPhysics? pageViewPhysics, + bool? showWeekTileBorder, + bool? hideDaysNotInMonth, + }) { + return ResizableMonthViewStyle( + initialMode: initialMode ?? this.initialMode, + showModeToggle: showModeToggle ?? this.showModeToggle, + modeToggleActiveColor: + modeToggleActiveColor ?? this.modeToggleActiveColor, + modeToggleTextColor: modeToggleTextColor ?? this.modeToggleTextColor, + modeToggleBorderRadius: + modeToggleBorderRadius ?? this.modeToggleBorderRadius, + eventListPadding: eventListPadding ?? this.eventListPadding, + eventListSeparatorHeight: + eventListSeparatorHeight ?? this.eventListSeparatorHeight, + animationDuration: animationDuration ?? this.animationDuration, + animationCurve: animationCurve ?? this.animationCurve, + enableDragToSwitchMode: + enableDragToSwitchMode ?? this.enableDragToSwitchMode, + dragHandleColor: dragHandleColor ?? this.dragHandleColor, + dragThreshold: dragThreshold ?? this.dragThreshold, + showBorder: showBorder ?? this.showBorder, + borderColor: borderColor ?? this.borderColor, + minMonth: minMonth ?? this.minMonth, + maxMonth: maxMonth ?? this.maxMonth, + initialMonth: initialMonth ?? this.initialMonth, + showWeekends: showWeekends ?? this.showWeekends, + borderSize: borderSize ?? this.borderSize, + cellAspectRatio: cellAspectRatio ?? this.cellAspectRatio, + pageTransitionDuration: + pageTransitionDuration ?? this.pageTransitionDuration, + pageTransitionCurve: pageTransitionCurve ?? this.pageTransitionCurve, + startDay: startDay ?? this.startDay, + headerStyle: headerStyle ?? this.headerStyle, + safeAreaOption: safeAreaOption ?? this.safeAreaOption, + pageViewPhysics: pageViewPhysics ?? this.pageViewPhysics, + showWeekTileBorder: showWeekTileBorder ?? this.showWeekTileBorder, + hideDaysNotInMonth: hideDaysNotInMonth ?? this.hideDaysNotInMonth, + ); + } + + /// Merges this style with [other], preferring non-null values from [other]. + ResizableMonthViewStyle merge(ResizableMonthViewStyle? other) { + if (other == null) return this; + return copyWith( + initialMode: other.initialMode, + showModeToggle: other.showModeToggle, + modeToggleActiveColor: other.modeToggleActiveColor, + modeToggleTextColor: other.modeToggleTextColor, + modeToggleBorderRadius: other.modeToggleBorderRadius, + eventListPadding: other.eventListPadding, + eventListSeparatorHeight: other.eventListSeparatorHeight, + animationDuration: other.animationDuration, + animationCurve: other.animationCurve, + enableDragToSwitchMode: other.enableDragToSwitchMode, + dragHandleColor: other.dragHandleColor, + dragThreshold: other.dragThreshold, + showBorder: other.showBorder, + borderColor: other.borderColor, + minMonth: other.minMonth, + maxMonth: other.maxMonth, + initialMonth: other.initialMonth, + showWeekends: other.showWeekends, + borderSize: other.borderSize, + cellAspectRatio: other.cellAspectRatio, + pageTransitionDuration: other.pageTransitionDuration, + pageTransitionCurve: other.pageTransitionCurve, + startDay: other.startDay, + headerStyle: other.headerStyle, + safeAreaOption: other.safeAreaOption, + pageViewPhysics: other.pageViewPhysics, + showWeekTileBorder: other.showWeekTileBorder, + hideDaysNotInMonth: other.hideDaysNotInMonth, + ); + } +} diff --git a/lib/src/resizable_month_view/resizable_month_view_theme_settings.dart b/lib/src/resizable_month_view/resizable_month_view_theme_settings.dart new file mode 100644 index 00000000..e6c09006 --- /dev/null +++ b/lib/src/resizable_month_view/resizable_month_view_theme_settings.dart @@ -0,0 +1,144 @@ +// Copyright (c) 2021 Simform Solutions. All rights reserved. +// Use of this source code is governed by a MIT-style license +// that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; + +import '../../calendar_view.dart'; +import '../constants.dart'; + +/// Theme settings for [ResizableMonthView]. +/// +/// Mirrors [MonthViewThemeSettings] exactly so that the two views can be +/// independently themed. No fields are shared by reference. +@immutable +class ResizableMonthViewThemeSettings { + /// Creates default theme settings for [ResizableMonthView]. + const ResizableMonthViewThemeSettings({ + this.weekDayBorderColor, + this.weekDayBackgroundColor, + this.weekDayTextStyle, + this.headerStyle, + this.textStyle, + this.cellsNotInMonthHighlightedTitleColor = Constants.white, + this.cellsNotInMonthHighlightRadius = 11, + this.cellsInMonthHighlightedTitleColor = Constants.white, + this.cellsInMonthHighlightRadius = 11, + this.cellsInMonthTileColor = Colors.blue, + this.cellsInMonthHighlightColor = Colors.blue, + this.selectedHighlightColor = Colors.blue, + this.selectedTitleColor = Constants.white, + this.selectedHighlightRadius = 11, + }); + + /// Default border color for week-day header tiles. + final Color? weekDayBorderColor; + + /// Default background color for week-day header tiles. + final Color? weekDayBackgroundColor; + + /// Text style for week-day header labels. + final TextStyle? weekDayTextStyle; + + /// Header style for the [ResizableMonthView] header row. + final HeaderStyle? headerStyle; + + /// General text style for the view. + final TextStyle? textStyle; + + /// Highlighted title color for cells **not** in the current month. + final Color cellsNotInMonthHighlightedTitleColor; + + /// Highlight circle radius for cells **not** in the current month. + final double cellsNotInMonthHighlightRadius; + + /// Highlighted title color for cells in the current month. + final Color cellsInMonthHighlightedTitleColor; + + /// Highlight circle radius for cells in the current month. + final double cellsInMonthHighlightRadius; + + /// Tile background color for cells in the current month. + final Color cellsInMonthTileColor; + + /// Highlight circle color for cells in the current month (today). + final Color cellsInMonthHighlightColor; + + /// Highlight circle color for the selected date. + final Color selectedHighlightColor; + + /// Title (date number) color for the selected date. + final Color selectedTitleColor; + + /// Highlight circle radius for the selected date. + final double selectedHighlightRadius; + + /// Creates a copy with the given fields replaced. + ResizableMonthViewThemeSettings copyWith({ + Color? weekDayBorderColor, + Color? weekDayBackgroundColor, + TextStyle? weekDayTextStyle, + HeaderStyle? headerStyle, + TextStyle? textStyle, + Color? cellsNotInMonthHighlightedTitleColor, + double? cellsNotInMonthHighlightRadius, + Color? cellsInMonthHighlightedTitleColor, + double? cellsInMonthHighlightRadius, + Color? cellsInMonthTileColor, + Color? cellsInMonthHighlightColor, + Color? selectedHighlightColor, + Color? selectedTitleColor, + double? selectedHighlightRadius, + }) { + return ResizableMonthViewThemeSettings( + weekDayBorderColor: weekDayBorderColor ?? this.weekDayBorderColor, + weekDayBackgroundColor: + weekDayBackgroundColor ?? this.weekDayBackgroundColor, + weekDayTextStyle: weekDayTextStyle ?? this.weekDayTextStyle, + headerStyle: headerStyle ?? this.headerStyle, + textStyle: textStyle ?? this.textStyle, + cellsNotInMonthHighlightedTitleColor: + cellsNotInMonthHighlightedTitleColor ?? + this.cellsNotInMonthHighlightedTitleColor, + cellsNotInMonthHighlightRadius: + cellsNotInMonthHighlightRadius ?? this.cellsNotInMonthHighlightRadius, + cellsInMonthHighlightedTitleColor: cellsInMonthHighlightedTitleColor ?? + this.cellsInMonthHighlightedTitleColor, + cellsInMonthHighlightRadius: + cellsInMonthHighlightRadius ?? this.cellsInMonthHighlightRadius, + cellsInMonthTileColor: + cellsInMonthTileColor ?? this.cellsInMonthTileColor, + cellsInMonthHighlightColor: + cellsInMonthHighlightColor ?? this.cellsInMonthHighlightColor, + selectedHighlightColor: + selectedHighlightColor ?? this.selectedHighlightColor, + selectedTitleColor: selectedTitleColor ?? this.selectedTitleColor, + selectedHighlightRadius: + selectedHighlightRadius ?? this.selectedHighlightRadius, + ); + } + + /// Merges this with [other], preferring non-null values from [other]. + ResizableMonthViewThemeSettings merge( + ResizableMonthViewThemeSettings? other) { + if (other == null) return this; + return copyWith( + weekDayBorderColor: other.weekDayBorderColor, + weekDayBackgroundColor: other.weekDayBackgroundColor, + weekDayTextStyle: other.weekDayTextStyle, + headerStyle: other.headerStyle, + textStyle: other.textStyle, + cellsNotInMonthHighlightedTitleColor: + other.cellsNotInMonthHighlightedTitleColor, + cellsNotInMonthHighlightRadius: other.cellsNotInMonthHighlightRadius, + cellsInMonthHighlightedTitleColor: + other.cellsInMonthHighlightedTitleColor, + cellsInMonthHighlightRadius: other.cellsInMonthHighlightRadius, + cellsInMonthTileColor: other.cellsInMonthTileColor, + cellsInMonthHighlightColor: other.cellsInMonthHighlightColor, + selectedHighlightColor: other.selectedHighlightColor, + selectedTitleColor: other.selectedTitleColor, + selectedHighlightRadius: other.selectedHighlightRadius, + ); + } +} diff --git a/lib/src/theme/calendar_theme_data.dart b/lib/src/theme/calendar_theme_data.dart index 943d8c33..ca95d2ad 100644 --- a/lib/src/theme/calendar_theme_data.dart +++ b/lib/src/theme/calendar_theme_data.dart @@ -1,17 +1,20 @@ import '../../calendar_view.dart'; class CalendarThemeData { - const CalendarThemeData({ + CalendarThemeData({ required this.monthViewTheme, required this.dayViewTheme, required this.weekViewTheme, required this.multiDayViewTheme, - }); + ResizableMonthViewThemeData? resizableMonthViewTheme, + }) : resizableMonthViewTheme = + resizableMonthViewTheme ?? ResizableMonthViewThemeData.light(); final MonthViewThemeData monthViewTheme; final DayViewThemeData dayViewTheme; final WeekViewThemeData weekViewTheme; final MultiDayViewThemeData multiDayViewTheme; + final ResizableMonthViewThemeData resizableMonthViewTheme; /// Creates a copy of this `CalendarThemeData` with optional overrides. CalendarThemeData copyWith({ @@ -19,12 +22,15 @@ class CalendarThemeData { DayViewThemeData? dayViewTheme, WeekViewThemeData? weekViewTheme, MultiDayViewThemeData? multiDayViewTheme, + ResizableMonthViewThemeData? resizableMonthViewTheme, }) { return CalendarThemeData( monthViewTheme: monthViewTheme ?? this.monthViewTheme, dayViewTheme: dayViewTheme ?? this.dayViewTheme, weekViewTheme: weekViewTheme ?? this.weekViewTheme, multiDayViewTheme: multiDayViewTheme ?? this.multiDayViewTheme, + resizableMonthViewTheme: + resizableMonthViewTheme ?? this.resizableMonthViewTheme, ); } @@ -37,6 +43,7 @@ class CalendarThemeData { dayViewTheme: other.dayViewTheme, weekViewTheme: other.weekViewTheme, multiDayViewTheme: other.multiDayViewTheme, + resizableMonthViewTheme: other.resizableMonthViewTheme, ); } } diff --git a/lib/src/theme/resizable_month_view_theme_data.dart b/lib/src/theme/resizable_month_view_theme_data.dart new file mode 100644 index 00000000..5989f80a --- /dev/null +++ b/lib/src/theme/resizable_month_view_theme_data.dart @@ -0,0 +1,314 @@ +// Copyright (c) 2021 Simform Solutions. All rights reserved. +// Use of this source code is governed by a MIT-style license +// that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; + +import 'dark_app_colors.dart'; +import 'light_app_colors.dart'; + +/// Theme extension that controls all colours used by [ResizableMonthView]. +/// +/// Register it in [MaterialApp.theme.extensions] to provide custom colours. +/// If not registered, [ResizableMonthViewThemeData.light] is used as fallback. +/// +/// ```dart +/// MaterialApp( +/// theme: ThemeData( +/// extensions: [ +/// ResizableMonthViewThemeData( +/// headerBackgroundColor: Colors.deepPurple, +/// headerTextColor: Colors.white, +/// // … +/// ), +/// ], +/// ), +/// ) +/// ``` +class ResizableMonthViewThemeData + extends ThemeExtension { + ResizableMonthViewThemeData({ + // ── Cell colours ─────────────────────────────────────────────────── + required this.cellInMonthColor, + required this.cellNotInMonthColor, + required this.cellTextColor, + required this.cellBorderColor, + required this.cellHighlightColor, + + // ── Weekday-header colours ───────────────────────────────────────── + required this.weekDayTileColor, + required this.weekDayTextColor, + required this.weekDayBorderColor, + + // ── Page-header colours ──────────────────────────────────────────── + required this.headerIconColor, + required this.headerTextColor, + required this.headerBackgroundColor, + + // ── Mode-toggle pill colours ─────────────────────────────────────── + required this.modeToggleActiveColor, + required this.modeToggleTextColor, + required this.modeToggleInactiveColor, + + // ── Event-list colours ───────────────────────────────────────────── + required this.eventListBackgroundColor, + required this.eventListItemColor, + required this.eventListItemTextColor, + required this.eventListItemSubtextColor, + required this.eventListSeparatorColor, + }); + + // ── Cell ─────────────────────────────────────────────────────────────── + /// Background colour for cells that belong to the currently displayed month. + final Color cellInMonthColor; + + /// Background colour for cells that fall outside the displayed month. + final Color cellNotInMonthColor; + + /// Day-number text colour inside each cell. + final Color cellTextColor; + + /// Grid border / cell border colour. + final Color cellBorderColor; + + /// Colour of the highlight circle shown on today's date. + final Color cellHighlightColor; + + // ── Weekday header ───────────────────────────────────────────────────── + /// Background of the weekday-label tiles (Mon, Tue, …). + final Color weekDayTileColor; + + /// Text colour of the weekday-label tiles. + final Color weekDayTextColor; + + /// Border colour of the weekday-label tiles. + final Color weekDayBorderColor; + + // ── Page header ──────────────────────────────────────────────────────── + /// Colour of the previous / next navigation arrow icons. + final Color headerIconColor; + + /// Colour of the month/year title text. + final Color headerTextColor; + + /// Background colour of the header bar. + final Color headerBackgroundColor; + + // ── Mode-toggle pill ─────────────────────────────────────────────────── + /// Background colour of the **active** mode pill segment. + final Color modeToggleActiveColor; + + /// Text / icon colour on the active mode pill segment. + final Color modeToggleTextColor; + + /// Background colour of the **inactive** mode pill segments. + final Color modeToggleInactiveColor; + + // ── Event list ───────────────────────────────────────────────────────── + /// Background of the entire event-list area below the calendar. + final Color eventListBackgroundColor; + + /// Background of individual event tiles in the list. + final Color eventListItemColor; + + /// Primary (title) text colour for event tiles. + final Color eventListItemTextColor; + + /// Secondary (time / sub-label) text colour for event tiles. + final Color eventListItemSubtextColor; + + /// Colour of the separator between event tiles. + final Color eventListSeparatorColor; + + // ── Named constructors ───────────────────────────────────────────────── + + /// Predefined colours for a light theme. + ResizableMonthViewThemeData.light() + : cellInMonthColor = LightAppColors.surfaceContainerLowest, + cellNotInMonthColor = LightAppColors.surfaceContainerLow, + cellTextColor = LightAppColors.onSurface, + cellBorderColor = LightAppColors.surfaceContainerHigh, + cellHighlightColor = LightAppColors.primary, + weekDayTileColor = LightAppColors.surfaceContainerHigh, + weekDayTextColor = LightAppColors.onSurface, + weekDayBorderColor = LightAppColors.outlineVariant, + headerIconColor = LightAppColors.onPrimary, + headerTextColor = LightAppColors.onPrimary, + headerBackgroundColor = LightAppColors.primary, + modeToggleActiveColor = LightAppColors.primary, + modeToggleTextColor = LightAppColors.onPrimary, + modeToggleInactiveColor = LightAppColors.surfaceContainerHigh, + eventListBackgroundColor = LightAppColors.surfaceContainerLowest, + eventListItemColor = LightAppColors.surfaceContainerLowest, + eventListItemTextColor = LightAppColors.onSurface, + eventListItemSubtextColor = LightAppColors.onSurface, + eventListSeparatorColor = LightAppColors.outlineVariant; + + /// Predefined colours for a dark theme. + ResizableMonthViewThemeData.dark() + : cellInMonthColor = DarkAppColors.surfaceContainerLowest, + cellNotInMonthColor = DarkAppColors.surfaceContainerLow, + cellTextColor = DarkAppColors.onSurface, + cellBorderColor = DarkAppColors.surfaceContainerHigh, + cellHighlightColor = DarkAppColors.primary, + weekDayTileColor = DarkAppColors.surfaceContainerHigh, + weekDayTextColor = DarkAppColors.onSurface, + weekDayBorderColor = DarkAppColors.outlineVariant, + headerIconColor = DarkAppColors.onPrimary, + headerTextColor = DarkAppColors.onPrimary, + headerBackgroundColor = DarkAppColors.primary, + modeToggleActiveColor = DarkAppColors.primary, + modeToggleTextColor = DarkAppColors.onPrimary, + modeToggleInactiveColor = DarkAppColors.surfaceContainerHigh, + eventListBackgroundColor = DarkAppColors.surfaceContainerLowest, + eventListItemColor = DarkAppColors.surfaceContainerHigh, + eventListItemTextColor = DarkAppColors.onSurface, + eventListItemSubtextColor = DarkAppColors.onSurface, + eventListSeparatorColor = DarkAppColors.outlineVariant; + + // ── ThemeExtension overrides ─────────────────────────────────────────── + + @override + ResizableMonthViewThemeData copyWith({ + Color? cellInMonthColor, + Color? cellNotInMonthColor, + Color? cellTextColor, + Color? cellBorderColor, + Color? cellHighlightColor, + Color? weekDayTileColor, + Color? weekDayTextColor, + Color? weekDayBorderColor, + Color? headerIconColor, + Color? headerTextColor, + Color? headerBackgroundColor, + Color? modeToggleActiveColor, + Color? modeToggleTextColor, + Color? modeToggleInactiveColor, + Color? eventListBackgroundColor, + Color? eventListItemColor, + Color? eventListItemTextColor, + Color? eventListItemSubtextColor, + Color? eventListSeparatorColor, + }) { + return ResizableMonthViewThemeData( + cellInMonthColor: cellInMonthColor ?? this.cellInMonthColor, + cellNotInMonthColor: cellNotInMonthColor ?? this.cellNotInMonthColor, + cellTextColor: cellTextColor ?? this.cellTextColor, + cellBorderColor: cellBorderColor ?? this.cellBorderColor, + cellHighlightColor: cellHighlightColor ?? this.cellHighlightColor, + weekDayTileColor: weekDayTileColor ?? this.weekDayTileColor, + weekDayTextColor: weekDayTextColor ?? this.weekDayTextColor, + weekDayBorderColor: weekDayBorderColor ?? this.weekDayBorderColor, + headerIconColor: headerIconColor ?? this.headerIconColor, + headerTextColor: headerTextColor ?? this.headerTextColor, + headerBackgroundColor: + headerBackgroundColor ?? this.headerBackgroundColor, + modeToggleActiveColor: + modeToggleActiveColor ?? this.modeToggleActiveColor, + modeToggleTextColor: modeToggleTextColor ?? this.modeToggleTextColor, + modeToggleInactiveColor: + modeToggleInactiveColor ?? this.modeToggleInactiveColor, + eventListBackgroundColor: + eventListBackgroundColor ?? this.eventListBackgroundColor, + eventListItemColor: eventListItemColor ?? this.eventListItemColor, + eventListItemTextColor: + eventListItemTextColor ?? this.eventListItemTextColor, + eventListItemSubtextColor: + eventListItemSubtextColor ?? this.eventListItemSubtextColor, + eventListSeparatorColor: + eventListSeparatorColor ?? this.eventListSeparatorColor, + ); + } + + @override + ResizableMonthViewThemeData lerp( + covariant ThemeExtension? other, + double t, + ) { + if (other is! ResizableMonthViewThemeData) return this; + return ResizableMonthViewThemeData( + cellInMonthColor: + Color.lerp(cellInMonthColor, other.cellInMonthColor, t) ?? + cellInMonthColor, + cellNotInMonthColor: + Color.lerp(cellNotInMonthColor, other.cellNotInMonthColor, t) ?? + cellNotInMonthColor, + cellTextColor: + Color.lerp(cellTextColor, other.cellTextColor, t) ?? cellTextColor, + cellBorderColor: Color.lerp(cellBorderColor, other.cellBorderColor, t) ?? + cellBorderColor, + cellHighlightColor: + Color.lerp(cellHighlightColor, other.cellHighlightColor, t) ?? + cellHighlightColor, + weekDayTileColor: + Color.lerp(weekDayTileColor, other.weekDayTileColor, t) ?? + weekDayTileColor, + weekDayTextColor: + Color.lerp(weekDayTextColor, other.weekDayTextColor, t) ?? + weekDayTextColor, + weekDayBorderColor: + Color.lerp(weekDayBorderColor, other.weekDayBorderColor, t) ?? + weekDayBorderColor, + headerIconColor: Color.lerp(headerIconColor, other.headerIconColor, t) ?? + headerIconColor, + headerTextColor: Color.lerp(headerTextColor, other.headerTextColor, t) ?? + headerTextColor, + headerBackgroundColor: + Color.lerp(headerBackgroundColor, other.headerBackgroundColor, t) ?? + headerBackgroundColor, + modeToggleActiveColor: + Color.lerp(modeToggleActiveColor, other.modeToggleActiveColor, t) ?? + modeToggleActiveColor, + modeToggleTextColor: + Color.lerp(modeToggleTextColor, other.modeToggleTextColor, t) ?? + modeToggleTextColor, + modeToggleInactiveColor: Color.lerp( + modeToggleInactiveColor, other.modeToggleInactiveColor, t) ?? + modeToggleInactiveColor, + eventListBackgroundColor: Color.lerp( + eventListBackgroundColor, other.eventListBackgroundColor, t) ?? + eventListBackgroundColor, + eventListItemColor: + Color.lerp(eventListItemColor, other.eventListItemColor, t) ?? + eventListItemColor, + eventListItemTextColor: + Color.lerp(eventListItemTextColor, other.eventListItemTextColor, t) ?? + eventListItemTextColor, + eventListItemSubtextColor: Color.lerp( + eventListItemSubtextColor, other.eventListItemSubtextColor, t) ?? + eventListItemSubtextColor, + eventListSeparatorColor: Color.lerp( + eventListSeparatorColor, other.eventListSeparatorColor, t) ?? + eventListSeparatorColor, + ); + } + + /// Merges another [ResizableMonthViewThemeData] into this one, + /// preferring values from [other]. + ResizableMonthViewThemeData merge( + ResizableMonthViewThemeData? other, + ) { + if (other == null) return this; + return copyWith( + cellInMonthColor: other.cellInMonthColor, + cellNotInMonthColor: other.cellNotInMonthColor, + cellTextColor: other.cellTextColor, + cellBorderColor: other.cellBorderColor, + cellHighlightColor: other.cellHighlightColor, + weekDayTileColor: other.weekDayTileColor, + weekDayTextColor: other.weekDayTextColor, + weekDayBorderColor: other.weekDayBorderColor, + headerIconColor: other.headerIconColor, + headerTextColor: other.headerTextColor, + headerBackgroundColor: other.headerBackgroundColor, + modeToggleActiveColor: other.modeToggleActiveColor, + modeToggleTextColor: other.modeToggleTextColor, + modeToggleInactiveColor: other.modeToggleInactiveColor, + eventListBackgroundColor: other.eventListBackgroundColor, + eventListItemColor: other.eventListItemColor, + eventListItemTextColor: other.eventListItemTextColor, + eventListItemSubtextColor: other.eventListItemSubtextColor, + eventListSeparatorColor: other.eventListSeparatorColor, + ); + } +} diff --git a/test/event_arranger_test/merge_event_arranger_multiday_test.dart b/test/event_arranger_test/merge_event_arranger_multiday_test.dart new file mode 100644 index 00000000..8c2b4021 --- /dev/null +++ b/test/event_arranger_test/merge_event_arranger_multiday_test.dart @@ -0,0 +1,418 @@ +import 'package:calendar_view/calendar_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('MergeEventArranger - Multi-Day and Midnight Edge Cases', () { + final now = DateTime(2026, 2, 9); + const height = 1000.0; + const width = 400.0; + const heightPerMinute = 1.0; + const startHour = 0; + + group('Multi-Day Event Tests', () { + test('should handle multi-day event on first day', () { + final startDate = now; + final endDate = now.add(Duration(days: 2)); + + final events = [ + CalendarEventData( + title: '3-Day Workshop', + date: startDate, + startTime: + DateTime(startDate.year, startDate.month, startDate.day, 9, 0), + endTime: + DateTime(startDate.year, startDate.month, startDate.day, 17, 0), + endDate: endDate, + color: Colors.blue, + ), + ]; + + final arranged = MergeEventArranger().arrange( + events: events, + height: height, + width: width, + heightPerMinute: heightPerMinute, + startHour: startHour, + calendarViewDate: startDate, // Viewing first day + ); + + expect(arranged.length, 1); + + // On first day, should start at 9:00 and extend to end of day + // endDuration will be 0 (representing 1440/end-of-day) + expect(arranged[0].startDuration.getTotalMinutes, equals(9 * 60)); + expect(arranged[0].endDuration.getTotalMinutes, + equals(0)); // 0 represents end-of-day (1440) + }); + + test('should handle multi-day event on middle day', () { + final startDate = now; + final middleDate = now.add(Duration(days: 1)); + final endDate = now.add(Duration(days: 2)); + + final events = [ + CalendarEventData( + title: '3-Day Workshop', + date: startDate, + startTime: + DateTime(startDate.year, startDate.month, startDate.day, 9, 0), + endTime: + DateTime(startDate.year, startDate.month, startDate.day, 17, 0), + endDate: endDate, + color: Colors.blue, + ), + ]; + + final arranged = MergeEventArranger().arrange( + events: events, + height: height, + width: width, + heightPerMinute: heightPerMinute, + startHour: startHour, + calendarViewDate: middleDate, // Viewing middle day + ); + + expect(arranged.length, 1); + + // On middle day, should span full day (0 to end-of-day) + // endDuration will be 0 (representing 1440/end-of-day) + expect(arranged[0].startDuration.getTotalMinutes, equals(0)); + expect(arranged[0].endDuration.getTotalMinutes, + equals(0)); // 0 represents end-of-day (1440) + }); + + test('should handle multi-day event on last day', () { + final startDate = now; + final endDate = now.add(Duration(days: 2)); + + final events = [ + CalendarEventData( + title: '3-Day Workshop', + date: startDate, + startTime: + DateTime(startDate.year, startDate.month, startDate.day, 9, 0), + endTime: + DateTime(startDate.year, startDate.month, startDate.day, 17, 0), + endDate: endDate, + color: Colors.blue, + ), + ]; + + final arranged = MergeEventArranger().arrange( + events: events, + height: height, + width: width, + heightPerMinute: heightPerMinute, + startHour: startHour, + calendarViewDate: endDate, // Viewing last day + ); + + expect(arranged.length, 1); + + // On last day, should start at beginning and end at 17:00 + expect(arranged[0].startDuration.getTotalMinutes, equals(0)); + expect(arranged[0].endDuration.getTotalMinutes, equals(17 * 60)); + }); + + test('should merge overlapping multi-day and single-day events', () { + final startDate = now; + final middleDate = now.add(Duration(days: 1)); + final endDate = now.add(Duration(days: 2)); + + final events = [ + CalendarEventData( + title: '3-Day Workshop', + date: startDate, + startTime: + DateTime(startDate.year, startDate.month, startDate.day, 9, 0), + endTime: + DateTime(startDate.year, startDate.month, startDate.day, 17, 0), + endDate: endDate, + color: Colors.blue, + ), + CalendarEventData( + title: 'Marathon Coding', + date: middleDate, + startTime: DateTime( + middleDate.year, middleDate.month, middleDate.day, 8, 0), + endTime: DateTime( + middleDate.year, middleDate.month, middleDate.day, 22, 0), + color: Colors.red, + ), + ]; + + final arranged = MergeEventArranger().arrange( + events: events, + height: height, + width: width, + heightPerMinute: heightPerMinute, + startHour: startHour, + calendarViewDate: middleDate, // Viewing middle day + ); + + // Should merge into 1 event since they overlap on middle day + expect(arranged.length, 1); + expect(arranged[0].events.length, 2); + + // Merged event should span from 0 (workshop start) to end-of-day (workshop end on middle day) + // Note: Marathon is 8:00-22:00, Workshop on middle day is 0:00-23:59 + // endDuration will be 0 (representing 1440/end-of-day) + expect(arranged[0].startDuration.getTotalMinutes, equals(0)); + expect(arranged[0].endDuration.getTotalMinutes, + equals(0)); // 0 represents end-of-day (1440) + }); + }); + + group('Midnight (endTime == 0) Edge Cases', () { + test('should treat endTime of 00:00 as end of day (1440)', () { + final events = [ + CalendarEventData( + title: 'Event Ending at Midnight', + date: now, + startTime: DateTime(now.year, now.month, now.day, 22, 0), + endTime: DateTime(now.year, now.month, now.day, 0, 0), // midnight + color: Colors.purple, + ), + ]; + + final arranged = MergeEventArranger().arrange( + events: events, + height: height, + width: width, + heightPerMinute: heightPerMinute, + startHour: startHour, + calendarViewDate: now, + ); + + expect(arranged.length, 1); + expect(arranged[0].startDuration.getTotalMinutes, equals(22 * 60)); + expect(arranged[0].endDuration.getTotalMinutes, + equals(0)); // 0 represents end-of-day (1440) + }); + + test('should merge events when one ends at midnight', () { + final events = [ + CalendarEventData( + title: 'Event 1', + date: now, + startTime: DateTime(now.year, now.month, now.day, 20, 0), + endTime: DateTime(now.year, now.month, now.day, 0, 0), // midnight + color: Colors.purple, + ), + CalendarEventData( + title: 'Event 2', + date: now, + startTime: DateTime(now.year, now.month, now.day, 23, 0), + endTime: DateTime(now.year, now.month, now.day, 23, 59), + color: Colors.orange, + ), + ]; + + final arranged = MergeEventArranger().arrange( + events: events, + height: height, + width: width, + heightPerMinute: heightPerMinute, + startHour: startHour, + calendarViewDate: now, + ); + + // Should merge because Event 1 (20:00-24:00) overlaps Event 2 (23:00-23:59) + expect(arranged.length, 1); + expect(arranged[0].events.length, 2); + expect(arranged[0].startDuration.getTotalMinutes, equals(20 * 60)); + // Merged end is 1440 (end of day), which getTotalMinutes returns as 0 + expect(arranged[0].endDuration.getTotalMinutes, equals(0)); + }); + + test('should handle multi-day event ending at midnight on last day', () { + final startDate = now; + final endDate = now.add(Duration(days: 2)); + + final events = [ + CalendarEventData( + title: '3-Day Event Ending at Midnight', + date: startDate, + startTime: + DateTime(startDate.year, startDate.month, startDate.day, 9, 0), + endTime: DateTime(startDate.year, startDate.month, startDate.day, 0, + 0), // midnight + endDate: endDate, + color: Colors.green, + ), + ]; + + final arranged = MergeEventArranger().arrange( + events: events, + height: height, + width: width, + heightPerMinute: heightPerMinute, + startHour: startHour, + calendarViewDate: endDate, // Viewing last day + ); + + expect(arranged.length, 1); + expect(arranged[0].startDuration.getTotalMinutes, equals(0)); + // Multi-day event ending at midnight: endTime=0 is treated as 1440, + // but copyFromMinutes(1440) creates 24:00 which wraps to 0:00 + expect(arranged[0].endDuration.getTotalMinutes, equals(0)); + }); + }); + + group('Non-Zero startHour Tests', () { + const startHourNonZero = 8; // Start at 8 AM + const startHourInMinutes = startHourNonZero * 60; + + test( + 'should handle multi-day event with non-zero startHour on middle day', + () { + final startDate = now; + final middleDate = now.add(Duration(days: 1)); + final endDate = now.add(Duration(days: 2)); + + final events = [ + CalendarEventData( + title: '3-Day Workshop', + date: startDate, + startTime: + DateTime(startDate.year, startDate.month, startDate.day, 9, 0), + endTime: + DateTime(startDate.year, startDate.month, startDate.day, 17, 0), + endDate: endDate, + color: Colors.blue, + ), + ]; + + final arranged = MergeEventArranger().arrange( + events: events, + height: height, + width: width, + heightPerMinute: heightPerMinute, + startHour: startHourNonZero, + calendarViewDate: middleDate, + ); + + expect(arranged.length, 1); + + // On middle day with startHour=8, visible range is 8:00-23:59 + // Multi-day event spans 0:00-23:59 on this day + // eventStart = 0 - 480 = -480, but gets clamped to 0 + // eventEnd = 1440 - 480 = 960 + expect(arranged[0].startDuration.getTotalMinutes, equals(0)); + expect(arranged[0].endDuration.getTotalMinutes, + equals(1440 - startHourInMinutes)); + }); + + test('should handle event ending at midnight with non-zero startHour', + () { + final events = [ + CalendarEventData( + title: 'Event Ending at Midnight', + date: now, + startTime: DateTime(now.year, now.month, now.day, 22, 0), + endTime: DateTime(now.year, now.month, now.day, 0, 0), // midnight + color: Colors.purple, + ), + ]; + + final arranged = MergeEventArranger().arrange( + events: events, + height: height, + width: width, + heightPerMinute: heightPerMinute, + startHour: startHourNonZero, + calendarViewDate: now, + ); + + expect(arranged.length, 1); + // 22:00 - 8:00 = 14:00 = 840 minutes from start + expect(arranged[0].startDuration.getTotalMinutes, + equals(22 * 60 - startHourInMinutes)); + // midnight (1440) - 8:00 = 16:00 = 960 minutes from start + expect(arranged[0].endDuration.getTotalMinutes, + equals(1440 - startHourInMinutes)); + }); + + test( + 'should handle event starting before visible hours with non-zero startHour', + () { + final events = [ + CalendarEventData( + title: 'Early Morning Event', + date: now, + startTime: DateTime(now.year, now.month, now.day, 6, 0), + endTime: DateTime(now.year, now.month, now.day, 10, 0), + color: Colors.yellow, + ), + ]; + + final arranged = MergeEventArranger().arrange( + events: events, + height: height, + width: width, + heightPerMinute: heightPerMinute, + startHour: startHourNonZero, + calendarViewDate: now, + ); + + expect(arranged.length, 1); + // Event starts at 6:00 but view starts at 8:00, so it should be clamped to 0 + expect(arranged[0].startDuration.getTotalMinutes, equals(0)); + // Event ends at 10:00, which is 2 hours after startHour (8:00) + expect(arranged[0].endDuration.getTotalMinutes, + equals(10 * 60 - startHourInMinutes)); + }); + }); + + group('Complex Overlap Scenarios', () { + test('should merge multiple overlapping events including multi-day', () { + final startDate = now.subtract(Duration(days: 1)); + final endDate = now.add(Duration(days: 1)); + + final events = [ + CalendarEventData( + title: '3-Day Workshop', + date: startDate, + startTime: + DateTime(startDate.year, startDate.month, startDate.day, 9, 0), + endTime: + DateTime(startDate.year, startDate.month, startDate.day, 17, 0), + endDate: endDate, + color: Colors.blue, + ), + CalendarEventData( + title: 'Morning Meeting', + date: now, + startTime: DateTime(now.year, now.month, now.day, 10, 0), + endTime: DateTime(now.year, now.month, now.day, 11, 0), + color: Colors.green, + ), + CalendarEventData( + title: 'Afternoon Session', + date: now, + startTime: DateTime(now.year, now.month, now.day, 14, 0), + endTime: DateTime(now.year, now.month, now.day, 16, 0), + color: Colors.red, + ), + ]; + + final arranged = MergeEventArranger().arrange( + events: events, + height: height, + width: width, + heightPerMinute: heightPerMinute, + startHour: startHour, + calendarViewDate: now, // Viewing middle day of workshop + ); + + // All should merge because workshop spans full day and overlaps both events + expect(arranged.length, 1); + expect(arranged[0].events.length, 3); + expect(arranged[0].startDuration.getTotalMinutes, equals(0)); + expect(arranged[0].endDuration.getTotalMinutes, + equals(0)); // 0 represents end-of-day (1440) + }); + }); + }); +}