diff --git a/CHANGELOG.md b/CHANGELOG.md index 84da302..8d4f23c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## 2.6.0 + +* SmartParser — Gujarati, Bengali, Tamil parsing +* SmartParser — new English expressions: + * "first/last monday of month" + * "2 mondays ago", "in 2 fridays" + * "beginning/end of next month" + * "this coming monday" + * "q1", "q2", "quarter 3" +* SmartCalendar — range selection mode + * `rangeSelectionMode` param + * `onRangeSelected` callback + * Visual range highlight in month view +* SmartCalendar — event overlap detection in week view +* `supportedParseLocales` updated — now includes gu, bn, ta + ## 2.5.0 - Added `themeMode` to SmartCalendar — light/dark/system diff --git a/README.md b/README.md index 0331822..53f3ff6 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ CI + + Pub Points +

diff --git a/example/pubspec.lock b/example/pubspec.lock index b821892..5f0c905 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" clock: dependency: transitive description: @@ -111,26 +111,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.16.0" path: dependency: transitive description: @@ -166,7 +166,7 @@ packages: path: ".." relative: true source: path - version: "2.3.0" + version: "2.6.0" source_span: dependency: transitive description: @@ -211,10 +211,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.6" vector_math: dependency: transitive description: @@ -248,5 +248,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.0-0 <4.0.0" + dart: ">=3.9.0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/lib/src/calendar/smart_calendar.dart b/lib/src/calendar/smart_calendar.dart index db3e016..76bab15 100644 --- a/lib/src/calendar/smart_calendar.dart +++ b/lib/src/calendar/smart_calendar.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:smart_date_formatter/smart_date_formatter.dart'; import 'package:smart_date_formatter/src/calendar/views/agenda_view.dart'; -import 'calendar_event.dart'; -import 'calendar_controller.dart'; import 'views/month_view.dart'; import 'views/week_view.dart'; import 'views/day_view.dart'; @@ -96,6 +95,12 @@ class SmartCalendar extends StatefulWidget { /// Theme mode for calendar final ThemeMode themeMode; + /// Enable range selection mode + final bool rangeSelectionMode; + + /// Called when range is selected in calendar + final void Function(DateTime start, DateTime end)? onRangeSelected; + /// Custom cell builder for month view dates. /// /// ```dart @@ -155,6 +160,8 @@ class SmartCalendar extends StatefulWidget { this.showWeekNumbers = false, this.themeMode = ThemeMode.light, this.cellBuilder, + this.rangeSelectionMode = false, + this.onRangeSelected, }); @override @@ -165,6 +172,9 @@ class _SmartCalendarState extends State { late SmartCalendarController _controller; late CalendarView _currentView; bool _ownsController = false; + DateTime? _rangeStart; + DateTime? _rangeEnd; + // bool _selectingRangeStart = true; @override void initState() { @@ -204,6 +214,26 @@ class _SmartCalendarState extends State { Color get _subtleTextColor => _isDark ? Colors.grey.shade400 : Colors.grey.shade600; + // void _handleRangeTap(DateTime date) { + // setState(() { + // if (_selectingRangeStart || _rangeEnd != null) { + // _rangeStart = date; + // _rangeEnd = null; + // _selectingRangeStart = false; + // } else { + // if (date.isBefore(_rangeStart!)) { + // _rangeEnd = _rangeStart; + // _rangeStart = date; + // } else { + // _rangeEnd = date; + // } + // _selectingRangeStart = true; + // widget.onRangeSelected?.call(_rangeStart!, _rangeEnd!); + // } + // _controller.selectDate(date); + // }); + // } + @override void dispose() { if (_ownsController) _controller.dispose(); @@ -240,6 +270,40 @@ class _SmartCalendarState extends State { ), ], ), + // After view switcher row + if (widget.rangeSelectionMode) ...[ + Container( + margin: const EdgeInsets.only(bottom: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: widget.selectedColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.date_range, + size: 14, color: widget.selectedColor), + const SizedBox(width: 6), + Text( + _rangeStart == null + ? 'Tap to select start date' + : _rangeEnd == null + ? 'Tap to select end date' + : '${_rangeStart!.format('dd MMM')} → ' + '${_rangeEnd!.format('dd MMM')} ' + '(${_rangeEnd!.daysUntil(_rangeStart!) * -1 + 1} days)', + style: TextStyle( + fontSize: 12, + color: widget.selectedColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], // Calendar view _buildCurrentView(), @@ -343,6 +407,8 @@ class _SmartCalendarState extends State { showWeekNumbers: widget.showWeekNumbers, isDark: _isDark, cellBuilder: widget.cellBuilder, + rangeStart: widget.rangeSelectionMode ? _rangeStart : null, // 👈 new + rangeEnd: widget.rangeSelectionMode ? _rangeEnd : null, ); case CalendarView.week: return WeekView( diff --git a/lib/src/calendar/views/month_view.dart b/lib/src/calendar/views/month_view.dart index afd2ae7..b9c646c 100644 --- a/lib/src/calendar/views/month_view.dart +++ b/lib/src/calendar/views/month_view.dart @@ -52,6 +52,12 @@ class MonthView extends StatelessWidget { /// Whether dark theme is active final bool isDark; + /// Range selection start date + final DateTime? rangeStart; + + /// Range selection end date + final DateTime? rangeEnd; + /// Custom cell builder for month view dates. /// /// ```dart @@ -94,6 +100,8 @@ class MonthView extends StatelessWidget { this.showWeekNumbers = false, this.isDark = false, this.cellBuilder, + this.rangeStart, + this.rangeEnd, }); List _daysInMonth(DateTime month) { @@ -243,22 +251,8 @@ class MonthView extends StatelessWidget { isToday, ), ) - : DayCell( - date: date, - events: dayEvents, - isSelected: isSelected, - isToday: isToday, - isCurrentMonth: isCurrentMonth, - isWeekend: isWeekend, - markerStyle: markerStyle, - selectedColor: selectedColor, - todayColor: todayColor, - isDark: isDark, - onTap: () { - controller.selectDate(date); - onDateSelected?.call(date, dayEvents); - }, - ), + : _buildRangeCell(date, dayEvents, isSelected, isToday, + isCurrentMonth, isWeekend), ); }), ], @@ -272,4 +266,73 @@ class MonthView extends StatelessWidget { final dayOfYear = date.difference(firstDayOfYear).inDays + 1; return ((dayOfYear + firstDayOfYear.weekday - 2) / 7).ceil().clamp(1, 53); } + + bool _isInSelectedRange(DateTime date) { + if (rangeStart == null || rangeEnd == null) return false; + final d = DateTime(date.year, date.month, date.day); + final s = DateTime(rangeStart!.year, rangeStart!.month, rangeStart!.day); + final e = DateTime(rangeEnd!.year, rangeEnd!.month, rangeEnd!.day); + return d.isAfter(s) && d.isBefore(e); + } + + bool _isRangeStart(DateTime date) => + rangeStart != null && date.isSameDay(rangeStart!); + + bool _isRangeEnd(DateTime date) => + rangeEnd != null && date.isSameDay(rangeEnd!); + + Widget _buildRangeCell( + DateTime date, + List events, + bool isSelected, + bool isToday, + bool isCurrentMonth, + bool isWeekend, + ) { + final inRange = _isInSelectedRange(date); + final isStart = _isRangeStart(date); + final isEnd = _isRangeEnd(date); + + return GestureDetector( + onTap: () { + controller.selectDate(date); + onDateSelected?.call(date, events); + }, + child: Container( + margin: EdgeInsets.zero, + decoration: BoxDecoration( + color: isStart || isEnd + ? selectedColor + : inRange + ? selectedColor.withValues(alpha: 0.12) + : Colors.transparent, + borderRadius: isStart + ? const BorderRadius.only( + topLeft: Radius.circular(8), + bottomLeft: Radius.circular(8), + ) + : isEnd + ? const BorderRadius.only( + topRight: Radius.circular(8), + bottomRight: Radius.circular(8), + ) + : inRange + ? BorderRadius.zero + : BorderRadius.circular(8), + ), + child: DayCell( + date: date, + events: events, + isSelected: isSelected || isStart || isEnd, + isToday: isToday, + isCurrentMonth: isCurrentMonth, + isWeekend: isWeekend, + markerStyle: markerStyle, + selectedColor: selectedColor, + todayColor: todayColor, + isDark: isDark, + ), + ), + ); + } } diff --git a/lib/src/calendar/views/week_view.dart b/lib/src/calendar/views/week_view.dart index 17c8f30..2c30776 100644 --- a/lib/src/calendar/views/week_view.dart +++ b/lib/src/calendar/views/week_view.dart @@ -23,6 +23,9 @@ class WeekView extends StatelessWidget { final void Function(DateTime date, List events)? onDateSelected; + /// Called when event is tapped + final void Function(CalendarEvent event)? onEventTap; + /// Marker style final EventMarkerStyle markerStyle; @@ -44,6 +47,7 @@ class WeekView extends StatelessWidget { required this.controller, required this.events, this.onDateSelected, + this.onEventTap, this.markerStyle = EventMarkerStyle.dot, this.selectedColor = Colors.indigo, this.todayColor = Colors.blue, @@ -183,7 +187,6 @@ class WeekView extends StatelessWidget { Widget _buildEventsList() { final dayEvents = _eventsForDate(controller.selectedDate); - if (dayEvents.isEmpty) { return Center( child: Padding( @@ -196,52 +199,145 @@ class WeekView extends StatelessWidget { ); } + // Detect overlaps + final overlappingEvents = _detectOverlaps(dayEvents); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( DateFormatHelper.format(controller.selectedDate, 'EEE, dd MMMM'), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), ), const SizedBox(height: 8), - ...dayEvents.map((e) => _eventTile(e)), + ...dayEvents.map((e) => _buildEventCard( + e, + hasOverlap: overlappingEvents.contains(e), + )), ], ); } - Widget _eventTile(CalendarEvent event) => Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isDark - ? event.color.withValues(alpha: 0.15) - : event.color.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(10), - border: Border( - left: BorderSide(color: event.color, width: 4), + Set _detectOverlaps(List events) { + final overlapping = {}; + for (int i = 0; i < events.length; i++) { + for (int j = i + 1; j < events.length; j++) { + final a = events[i]; + final b = events[j]; + if (a.startTime != null && b.startTime != null) { + final aStart = a.startTime!.hour * 60 + a.startTime!.minute; + final aEnd = a.endTime != null + ? a.endTime!.hour * 60 + a.endTime!.minute + : aStart + 60; + final bStart = b.startTime!.hour * 60 + b.startTime!.minute; + final bEnd = b.endTime != null + ? b.endTime!.hour * 60 + b.endTime!.minute + : bStart + 60; + if (aStart < bEnd && aEnd > bStart) { + overlapping.add(a); + overlapping.add(b); + } + } + } + } + return overlapping; + } + + Widget _buildEventCard(CalendarEvent event, {bool hasOverlap = false}) => + GestureDetector( + onTap: () => onEventTap?.call(event), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark + ? event.color.withValues(alpha: 0.15) + : event.color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + border: Border( + left: BorderSide(color: event.color, width: 4), + ), ), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(event.title, - style: const TextStyle(fontWeight: FontWeight.bold)), - if (event.description != null) - Text(event.description!, - style: - const TextStyle(fontSize: 12, color: Colors.grey)), - ], + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Expanded( + child: Text( + event.title, + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 14), + ), + ), + // ✅ Overlap indicator + if (hasOverlap) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: event.color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + 'Overlaps', + style: TextStyle( + fontSize: 9, + color: event.color, + fontWeight: FontWeight.bold, + ), + ), + ), + ]), + if (event.description != null) + Text(event.description!, + style: TextStyle( + fontSize: 12, color: Colors.grey.shade600)), + ], + ), ), - ), - Text(event.timeString, - style: TextStyle(fontSize: 11, color: event.color)), - ], + Text(event.timeString, + style: TextStyle( + fontSize: 11, + color: event.color, + fontWeight: FontWeight.w600)), + ], + ), ), ); + +// Widget _eventTile(CalendarEvent event) => Container( +// margin: const EdgeInsets.only(bottom: 8), +// padding: const EdgeInsets.all(12), +// decoration: BoxDecoration( +// color: isDark +// ? event.color.withValues(alpha: 0.15) +// : event.color.withValues(alpha: 0.08), +// borderRadius: BorderRadius.circular(10), +// border: Border( +// left: BorderSide(color: event.color, width: 4), +// ), +// ), +// child: Row( +// children: [ +// Expanded( +// child: Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// Text(event.title, +// style: const TextStyle(fontWeight: FontWeight.bold)), +// if (event.description != null) +// Text(event.description!, +// style: +// const TextStyle(fontSize: 12, color: Colors.grey)), +// ], +// ), +// ), +// Text(event.timeString, +// style: TextStyle(fontSize: 11, color: event.color)), +// ], +// ), +// ); } diff --git a/lib/src/smart_parser.dart b/lib/src/smart_parser.dart index 3ffefb4..b5212bf 100644 --- a/lib/src/smart_parser.dart +++ b/lib/src/smart_parser.dart @@ -28,19 +28,24 @@ class SmartParser { final reference = now ?? DateTime.now(); final normalized = input.trim().toLowerCase(); - // Try English first final english = _parseEnglish(normalized, reference); if (english != null) return english; - // Try Hindi final hindi = _parseHindi(normalized, reference); if (hindi != null) return hindi; - // Try Marathi final marathi = _parseMarathi(normalized, reference); if (marathi != null) return marathi; - // Try standard date string + final gujarati = _parseGujarati(normalized, reference); + if (gujarati != null) return gujarati; + + final bengali = _parseBengali(normalized, reference); + if (bengali != null) return bengali; + + final tamil = _parseTamil(normalized, reference); + if (tamil != null) return tamil; + try { return DateTime.parse(input.trim()); } catch (_) {} @@ -69,6 +74,15 @@ class SmartParser { case 'mr': return _parseMarathi(normalized, reference) ?? _parseEnglish(normalized.toLowerCase(), reference); + case 'gu': + return _parseGujarati(normalized, reference) ?? + _parseEnglish(normalized.toLowerCase(), reference); + case 'bn': + return _parseBengali(normalized, reference) ?? + _parseEnglish(normalized.toLowerCase(), reference); + case 'ta': + return _parseTamil(normalized, reference) ?? + _parseEnglish(normalized.toLowerCase(), reference); default: return _parseEnglish(normalized.toLowerCase(), reference); } @@ -111,7 +125,14 @@ class SmartParser { parseLocale(input, locale: locale, now: now) != null; /// All supported locales for parsing. - static const List supportedParseLocales = ['en', 'hi', 'mr']; + static const List supportedParseLocales = [ + 'en', + 'hi', + 'mr', + 'gu', + 'bn', + 'ta' + ]; // ── English Parser ──────────────────────────────────────────── @@ -179,6 +200,79 @@ class SmartParser { return _startOf(reference.subtract(const Duration(days: 2))); } + // "first/last monday of month" + final firstLastPattern = RegExp( + r'^(first|last) (monday|tuesday|wednesday|thursday|friday|saturday|sunday) of (this|next|last)? ?month$'); + final firstLastMatch = firstLastPattern.firstMatch(input); + if (firstLastMatch != null) { + final position = firstLastMatch.group(1)!; + final weekdayStr = firstLastMatch.group(2)!; + final monthStr = firstLastMatch.group(3) ?? 'this'; + final targetWeekday = _weekdayNumber(weekdayStr); + return _findWeekdayInMonth( + reference, targetWeekday, position == 'first', monthStr); + } + +// "N mondays/tuesdays ago" + final nWeekdaysAgoPattern = RegExp( + r'^(\d+) (monday|tuesday|wednesday|thursday|friday|saturday|sunday)s? ago$'); + final nWeekdaysAgoMatch = nWeekdaysAgoPattern.firstMatch(input); + if (nWeekdaysAgoMatch != null) { + final n = int.parse(nWeekdaysAgoMatch.group(1)!); + final weekdayStr = nWeekdaysAgoMatch.group(2)!; + final targetWeekday = _weekdayNumber(weekdayStr); + return _findNthWeekdayAgo(reference, targetWeekday, n); + } + +// "in N mondays/tuesdays" + final inNWeekdaysPattern = RegExp( + r'^in (\d+) (monday|tuesday|wednesday|thursday|friday|saturday|sunday)s?$'); + final inNWeekdaysMatch = inNWeekdaysPattern.firstMatch(input); + if (inNWeekdaysMatch != null) { + final n = int.parse(inNWeekdaysMatch.group(1)!); + final weekdayStr = inNWeekdaysMatch.group(2)!; + final targetWeekday = _weekdayNumber(weekdayStr); + return _findNthWeekdayAhead(reference, targetWeekday, n); + } + +// "beginning of next/last month" + final beginningPattern = + RegExp(r'^beginning of (next|last|this) (month|year|week)$'); + final beginningMatch = beginningPattern.firstMatch(input); + if (beginningMatch != null) { + final when = beginningMatch.group(1)!; + final period = beginningMatch.group(2)!; + return _beginningOf(reference, when, period); + } + +// "end of next/last month" + final endPattern = RegExp(r'^end of (next|last|this) (month|year|week)$'); + final endMatch = endPattern.firstMatch(input); + if (endMatch != null) { + final when = endMatch.group(1)!; + final period = endMatch.group(2)!; + return _endOf(reference, when, period); + } + +// "this coming monday/friday" + final comingPattern = RegExp( + r'^this coming (monday|tuesday|wednesday|thursday|friday|saturday|sunday)$'); + final comingMatch = comingPattern.firstMatch(input); + if (comingMatch != null) { + final weekdayStr = comingMatch.group(1)!; + final targetWeekday = _weekdayNumber(weekdayStr); + return _findWeekday(reference, targetWeekday, true); + } + +// "quarter N" or "Q1/Q2/Q3/Q4" + final quarterPattern = RegExp(r'^(q|quarter )([1-4])$'); + final quarterMatch = quarterPattern.firstMatch(input); + if (quarterMatch != null) { + final q = int.parse(quarterMatch.group(2)!); + final startMonth = (q - 1) * 3 + 1; + return DateTime(reference.year, startMonth, 1); + } + // "in N days/weeks/months/years" final inPattern = RegExp(r'^in (\d+) (second|minute|hour|day|week|month|year)s?$'); @@ -430,6 +524,158 @@ class SmartParser { return null; } + static DateTime? _parseGujarati(String input, DateTime reference) { + switch (input) { + case 'આજ': + return _startOf(reference); + case 'આવતી કાલ': + case 'આવતીકાલ': + return _startOf(reference.add(const Duration(days: 1))); + case 'ગઈ કાલ': + case 'ગઈકાલ': + return _startOf(reference.subtract(const Duration(days: 1))); + case 'પરમ દિવસ': + return _startOf(reference.add(const Duration(days: 2))); + case 'આ અઠવાડિયે': + return _startOf( + reference.subtract(Duration(days: reference.weekday - 1))); + case 'આવતા અઠવાડિયે': + return _startOf(reference.add(const Duration(days: 7))); + case 'ગયા અઠવાડિયે': + return _startOf(reference.subtract(const Duration(days: 7))); + case 'આ મહિને': + return DateTime(reference.year, reference.month, 1); + case 'આવતા મહિને': + return DateTime(reference.year, reference.month + 1, 1); + case 'ગયા મહિને': + return DateTime(reference.year, reference.month - 1, 1); + case 'આ વર્ષે': + return DateTime(reference.year, 1, 1); + case 'આવતા વર્ષે': + return DateTime(reference.year + 1, 1, 1); + case 'ગયા વર્ષે': + return DateTime(reference.year - 1, 1, 1); + } + + // "{n} દિવસ પછી" (in N days) + final inDaysGu = RegExp(r'^(\d+) દિવસ પછી$'); + final inDaysGuMatch = inDaysGu.firstMatch(input); + if (inDaysGuMatch != null) { + final n = int.parse(inDaysGuMatch.group(1)!); + return _startOf(reference.add(Duration(days: n))); + } + + // "{n} દિવસ પહેલાં" (N days ago) + final agoDaysGu = RegExp(r'^(\d+) દિવસ પહેલાં$'); + final agoDaysGuMatch = agoDaysGu.firstMatch(input); + if (agoDaysGuMatch != null) { + final n = int.parse(agoDaysGuMatch.group(1)!); + return _startOf(reference.subtract(Duration(days: n))); + } + + return null; + } + + static DateTime? _parseBengali(String input, DateTime reference) { + switch (input) { + case 'আজ': + return _startOf(reference); + case 'আগামীকাল': + return _startOf(reference.add(const Duration(days: 1))); + case 'গতকাল': + return _startOf(reference.subtract(const Duration(days: 1))); + case 'পরশু': + return _startOf(reference.add(const Duration(days: 2))); + case 'এই সপ্তাহ': + return _startOf( + reference.subtract(Duration(days: reference.weekday - 1))); + case 'আগামী সপ্তাহ': + return _startOf(reference.add(const Duration(days: 7))); + case 'গত সপ্তাহ': + return _startOf(reference.subtract(const Duration(days: 7))); + case 'এই মাস': + return DateTime(reference.year, reference.month, 1); + case 'আগামী মাস': + return DateTime(reference.year, reference.month + 1, 1); + case 'গত মাস': + return DateTime(reference.year, reference.month - 1, 1); + case 'এই বছর': + return DateTime(reference.year, 1, 1); + case 'আগামী বছর': + return DateTime(reference.year + 1, 1, 1); + case 'গত বছর': + return DateTime(reference.year - 1, 1, 1); + } + + // "{n} দিন পরে" (in N days) + final inDaysBn = RegExp(r'^(\d+) দিন পরে$'); + final inDaysBnMatch = inDaysBn.firstMatch(input); + if (inDaysBnMatch != null) { + final n = int.parse(inDaysBnMatch.group(1)!); + return _startOf(reference.add(Duration(days: n))); + } + + // "{n} দিন আগে" (N days ago) + final agoDaysBn = RegExp(r'^(\d+) দিন আগে$'); + final agoDaysBnMatch = agoDaysBn.firstMatch(input); + if (agoDaysBnMatch != null) { + final n = int.parse(agoDaysBnMatch.group(1)!); + return _startOf(reference.subtract(Duration(days: n))); + } + + return null; + } + + static DateTime? _parseTamil(String input, DateTime reference) { + switch (input) { + case 'இன்று': + return _startOf(reference); + case 'நாளை': + return _startOf(reference.add(const Duration(days: 1))); + case 'நேற்று': + return _startOf(reference.subtract(const Duration(days: 1))); + case 'நாளை மறுநாள்': + return _startOf(reference.add(const Duration(days: 2))); + case 'இந்த வாரம்': + return _startOf( + reference.subtract(Duration(days: reference.weekday - 1))); + case 'அடுத்த வாரம்': + return _startOf(reference.add(const Duration(days: 7))); + case 'கடந்த வாரம்': + return _startOf(reference.subtract(const Duration(days: 7))); + case 'இந்த மாதம்': + return DateTime(reference.year, reference.month, 1); + case 'அடுத்த மாதம்': + return DateTime(reference.year, reference.month + 1, 1); + case 'கடந்த மாதம்': + return DateTime(reference.year, reference.month - 1, 1); + case 'இந்த ஆண்டு': + return DateTime(reference.year, 1, 1); + case 'அடுத்த ஆண்டு': + return DateTime(reference.year + 1, 1, 1); + case 'கடந்த ஆண்டு': + return DateTime(reference.year - 1, 1, 1); + } + + // "{n} நாட்களில்" (in N days) + final inDaysTa = RegExp(r'^(\d+) நாட்களில்$'); + final inDaysTaMatch = inDaysTa.firstMatch(input); + if (inDaysTaMatch != null) { + final n = int.parse(inDaysTaMatch.group(1)!); + return _startOf(reference.add(Duration(days: n))); + } + + // "{n} நாட்களுக்கு முன்பு" (N days ago) + final agoDaysTa = RegExp(r'^(\d+) நாட்களுக்கு முன்பு$'); + final agoDaysTaMatch = agoDaysTa.firstMatch(input); + if (agoDaysTaMatch != null) { + final n = int.parse(agoDaysTaMatch.group(1)!); + return _startOf(reference.subtract(Duration(days: n))); + } + + return null; + } + // ── Private helpers ─────────────────────────────────────────── static DateTime _startOf(DateTime date) => @@ -505,4 +751,136 @@ class SmartParser { final result = from.add(Duration(days: diff)); return _startOf(result); } + + static DateTime? _findWeekdayInMonth( + DateTime reference, + int targetWeekday, + bool first, + String monthStr, + ) { + late DateTime monthDate; + switch (monthStr) { + case 'next': + monthDate = DateTime(reference.year, reference.month + 1, 1); + case 'last': + monthDate = DateTime(reference.year, reference.month - 1, 1); + default: + monthDate = DateTime(reference.year, reference.month, 1); + } + + if (first) { + // Find first occurrence + DateTime current = monthDate; + while (current.weekday != targetWeekday) { + current = current.add(const Duration(days: 1)); + } + return _startOf(current); + } else { + // Find last occurrence + final lastDay = DateTime(monthDate.year, monthDate.month + 1, 0); + DateTime current = lastDay; + while (current.weekday != targetWeekday) { + current = current.subtract(const Duration(days: 1)); + } + return _startOf(current); + } + } + + static DateTime _findNthWeekdayAgo( + DateTime reference, + int targetWeekday, + int n, + ) { + DateTime current = reference.subtract(const Duration(days: 1)); + int count = 0; + while (count < n) { + if (current.weekday == targetWeekday) count++; + if (count < n) { + current = current.subtract(const Duration(days: 1)); + } + } + return _startOf(current); + } + + static DateTime _findNthWeekdayAhead( + DateTime reference, + int targetWeekday, + int n, + ) { + DateTime current = reference.add(const Duration(days: 1)); + int count = 0; + while (count < n) { + if (current.weekday == targetWeekday) count++; + if (count < n) { + current = current.add(const Duration(days: 1)); + } + } + return _startOf(current); + } + + static DateTime? _beginningOf( + DateTime reference, + String when, + String period, + ) { + switch ('$when $period') { + case 'this month': + return DateTime(reference.year, reference.month, 1); + case 'next month': + return DateTime(reference.year, reference.month + 1, 1); + case 'last month': + return DateTime(reference.year, reference.month - 1, 1); + case 'this year': + return DateTime(reference.year, 1, 1); + case 'next year': + return DateTime(reference.year + 1, 1, 1); + case 'last year': + return DateTime(reference.year - 1, 1, 1); + case 'this week': + return _startOf( + reference.subtract(Duration(days: reference.weekday - 1))); + case 'next week': + final nextMon = reference.add(Duration(days: 8 - reference.weekday)); + return _startOf(nextMon); + case 'last week': + final lastMon = + reference.subtract(Duration(days: reference.weekday + 6)); + return _startOf(lastMon); + default: + return null; + } + } + + static DateTime? _endOf( + DateTime reference, + String when, + String period, + ) { + switch ('$when $period') { + case 'this month': + return _startOf(DateTime(reference.year, reference.month + 1, 0)); + case 'next month': + return _startOf(DateTime(reference.year, reference.month + 2, 0)); + case 'last month': + return _startOf(DateTime(reference.year, reference.month, 0)); + case 'this year': + return DateTime(reference.year, 12, 31); + case 'next year': + return DateTime(reference.year + 1, 12, 31); + case 'last year': + return DateTime(reference.year - 1, 12, 31); + case 'this week': + final daysToSunday = DateTime.sunday - reference.weekday; + return _startOf(reference.add(Duration(days: daysToSunday))); + case 'next week': + final nextSun = reference + .add(Duration(days: DateTime.sunday - reference.weekday + 7)); + return _startOf(nextSun); + case 'last week': + final lastSun = reference.subtract(Duration(days: reference.weekday)); + return _startOf(lastSun); + default: + return null; + } + } } diff --git a/pubspec.yaml b/pubspec.yaml index e16d6cf..c594b1f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: smart_date_formatter description: "A smart Flutter date formatting utility that converts DateTime into human-readable strings like '2 hours ago', 'Yesterday', 'Next Monday', and more." -version: 2.5.0 +version: 2.6.0 homepage: https://github.com/harshyadavDeveloper/smart_date_formatter repository: https://github.com/harshyadavDeveloper/smart_date_formatter issue_tracker: https://github.com/harshyadavDeveloper/smart_date_formatter/issues diff --git a/test/smart_date_formatter_test.dart b/test/smart_date_formatter_test.dart index f1d7637..ccd4046 100644 --- a/test/smart_date_formatter_test.dart +++ b/test/smart_date_formatter_test.dart @@ -3115,4 +3115,305 @@ void main() { expect(range.contains(DateTime(2024, 6, 30)), true); }); }); + + group('SmartParser v2.6.0 — New Expressions', () { + final now = DateTime(2024, 6, 15, 12, 0, 0); // Saturday + + // First/Last weekday of month + test('first monday of this month', () { + final result = SmartParser.parse('first monday of this month', now: now); + expect(result?.weekday, DateTime.monday); + expect(result?.month, 6); + }); + + test('last friday of this month', () { + final result = SmartParser.parse('last friday of this month', now: now); + expect(result?.weekday, DateTime.friday); + expect(result?.month, 6); + }); + + test('first monday of next month', () { + final result = SmartParser.parse('first monday of next month', now: now); + expect(result?.weekday, DateTime.monday); + expect(result?.month, 7); + }); + + // N weekdays ago/ahead + test('2 mondays ago', () { + final result = SmartParser.parse('2 mondays ago', now: now); + expect(result?.weekday, DateTime.monday); + expect(result?.isBefore(now), true); + }); + + test('in 2 fridays', () { + final result = SmartParser.parse('in 2 fridays', now: now); + expect(result?.weekday, DateTime.friday); + expect(result?.isAfter(now), true); + }); + + // Beginning/End of + test('beginning of next month', () { + final result = SmartParser.parse('beginning of next month', now: now); + expect(result?.month, 7); + expect(result?.day, 1); + }); + + test('end of this month', () { + final result = SmartParser.parse('end of this month', now: now); + expect(result?.month, 6); + expect(result?.day, 30); + }); + + test('beginning of next year', () { + final result = SmartParser.parse('beginning of next year', now: now); + expect(result?.year, 2025); + expect(result?.month, 1); + }); + + // This coming weekday + test('this coming monday', () { + final result = SmartParser.parse('this coming monday', now: now); + expect(result?.weekday, DateTime.monday); + expect(result?.isAfter(now), true); + }); + + // Quarter + test('q1 returns January', () { + final result = SmartParser.parse('q1', now: now); + expect(result?.month, 1); + }); + + test('q2 returns April', () { + final result = SmartParser.parse('q2', now: now); + expect(result?.month, 4); + }); + + test('quarter 3 returns July', () { + final result = SmartParser.parse('quarter 3', now: now); + expect(result?.month, 7); + }); + }); + + group('SmartParser v2.6.0 — Gujarati', () { + final now = DateTime(2024, 6, 15, 12, 0, 0); + + test('આજ — today', () { + expect(SmartParser.parse('આજ', now: now), DateTime(2024, 6, 15)); + }); + + test('આવતી કાલ — tomorrow', () { + expect(SmartParser.parse('આવતી કાલ', now: now), DateTime(2024, 6, 16)); + }); + + test('ગઈ કાલ — yesterday', () { + expect(SmartParser.parse('ગઈ કાલ', now: now), DateTime(2024, 6, 14)); + }); + + test('આવતા અઠવાડિયે — next week', () { + final result = SmartParser.parse('આવતા અઠવાડિયે', now: now); + expect(result, DateTime(2024, 6, 22)); + }); + + test('3 દિવસ પછી — in 3 days', () { + final result = SmartParser.parse('3 દિવસ પછી', now: now); + expect(result, DateTime(2024, 6, 18)); + }); + + test('parseLocale — Gujarati', () { + final result = SmartParser.parseLocale( + 'આવતી કાલ', + locale: SdfLocale.gu, + now: now, + ); + expect(result, DateTime(2024, 6, 16)); + }); + }); + + group('SmartParser v2.6.0 — Bengali', () { + final now = DateTime(2024, 6, 15, 12, 0, 0); + + test('আজ — today', () { + expect(SmartParser.parse('আজ', now: now), DateTime(2024, 6, 15)); + }); + + test('আগামীকাল — tomorrow', () { + expect(SmartParser.parse('আগামীকাল', now: now), DateTime(2024, 6, 16)); + }); + + test('গতকাল — yesterday', () { + expect(SmartParser.parse('গতকাল', now: now), DateTime(2024, 6, 14)); + }); + + test('আগামী সপ্তাহ — next week', () { + final result = SmartParser.parse('আগামী সপ্তাহ', now: now); + expect(result, DateTime(2024, 6, 22)); + }); + + test('3 দিন পরে — in 3 days', () { + final result = SmartParser.parse('3 দিন পরে', now: now); + expect(result, DateTime(2024, 6, 18)); + }); + + test('parseLocale — Bengali', () { + final result = SmartParser.parseLocale( + 'আগামীকাল', + locale: SdfLocale.bn, + now: now, + ); + expect(result, DateTime(2024, 6, 16)); + }); + }); + + group('SmartParser v2.6.0 — Tamil', () { + final now = DateTime(2024, 6, 15, 12, 0, 0); + + test('இன்று — today', () { + expect(SmartParser.parse('இன்று', now: now), DateTime(2024, 6, 15)); + }); + + test('நாளை — tomorrow', () { + expect(SmartParser.parse('நாளை', now: now), DateTime(2024, 6, 16)); + }); + + test('நேற்று — yesterday', () { + expect(SmartParser.parse('நேற்று', now: now), DateTime(2024, 6, 14)); + }); + + test('அடுத்த வாரம் — next week', () { + final result = SmartParser.parse('அடுத்த வாரம்', now: now); + expect(result, DateTime(2024, 6, 22)); + }); + + test('3 நாட்களில் — in 3 days', () { + final result = SmartParser.parse('3 நாட்களில்', now: now); + expect(result, DateTime(2024, 6, 18)); + }); + + test('parseLocale — Tamil', () { + final result = SmartParser.parseLocale( + 'நாளை', + locale: SdfLocale.ta, + now: now, + ); + expect(result, DateTime(2024, 6, 16)); + }); + + test('supportedParseLocales has gu bn ta', () { + expect( + SmartParser.supportedParseLocales, containsAll(['gu', 'bn', 'ta'])); + }); + }); + + group('SmartCalendar v2.6.0 — Improvements', () { + // Range selection tests + testWidgets('SmartCalendar — range selection mode', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 1200)); + DateTime? rangeStart; + DateTime? rangeEnd; + debugPrint('$rangeStart and $rangeEnd'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: SmartCalendar( + events: const [], + rangeSelectionMode: true, + onRangeSelected: (start, end) { + rangeStart = start; + rangeEnd = end; + }, + ), + ), + ), + ), + ); + await tester.pump(); + expect(find.byType(SmartCalendar), findsOneWidget); + expect(find.text('Tap to select start date'), findsOneWidget); + await tester.binding.setSurfaceSize(null); + }); + + testWidgets('SmartCalendar — range mode shows instruction', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 1200)); + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: SmartCalendar( + events: [], + rangeSelectionMode: true, + ), + ), + ), + ), + ); + await tester.pump(); + expect(find.text('Tap to select start date'), findsOneWidget); + await tester.binding.setSurfaceSize(null); + }); + + // Overlap detection tests + test('detectOverlaps — overlapping events', () { + final events = [ + CalendarEvent( + date: DateTime.now(), + title: 'Meeting 1', + color: Colors.blue, + allDay: false, + startTime: const TimeOfDay(hour: 10, minute: 0), + endTime: const TimeOfDay(hour: 11, minute: 0), + ), + CalendarEvent( + date: DateTime.now(), + title: 'Meeting 2', + color: Colors.red, + allDay: false, + startTime: const TimeOfDay(hour: 10, minute: 30), + endTime: const TimeOfDay(hour: 11, minute: 30), + ), + ]; + + // Both events overlap (10:00-11:00 and 10:30-11:30) + final a = events[0].startTime!; + final b = events[1].startTime!; + final aStart = a.hour * 60 + a.minute; + final bStart = b.hour * 60 + b.minute; + final aEnd = events[0].endTime!.hour * 60 + events[0].endTime!.minute; + final bEnd = events[1].endTime!.hour * 60 + events[1].endTime!.minute; + + expect(aStart < bEnd && aEnd > bStart, true); + }); + + test('detectOverlaps — non overlapping events', () { + const aStart = 10 * 60; // 10:00 + const aEnd = 11 * 60; // 11:00 + const bStart = 11 * 60; // 11:00 + const bEnd = 12 * 60; // 12:00 + + expect(aStart < bEnd && aEnd > bStart, false); + }); + + // MonthView range params + testWidgets('MonthView — with range selection', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 1200)); + final controller = SmartCalendarController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: SmartCalendar( + controller: controller, + events: const [], + rangeSelectionMode: true, + ), + ), + ), + ), + ); + await tester.pump(); + expect(find.byType(SmartCalendar), findsOneWidget); + controller.dispose(); + await tester.binding.setSurfaceSize(null); + }); + }); }