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 @@
+
+
+
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);
+ });
+ });
}