From e8b78b026944581f7c480813f172cba78a63b2c9 Mon Sep 17 00:00:00 2001 From: Parth Baraiya Date: Thu, 27 May 2021 13:38:29 +0530 Subject: [PATCH] feature: Added month and day view - Added month view - Added day view - Added example to demonstrate month view and day view. --- example/.gitignore | 1 + example/lib/constants.dart | 8 + example/lib/create_event_page.dart | 209 ++++++++ example/lib/date_time_selector.dart | 147 ++++++ example/lib/day_view_page.dart | 61 +++ example/lib/event.dart | 14 + example/lib/event_details_page.dart | 94 ++++ example/lib/extension.dart | 114 +++++ example/lib/main.dart | 125 ++--- example/lib/month_view_page.dart | 56 +++ example/pubspec.yaml | 5 +- lib/flutter_calendar_page.dart | 8 + lib/src/calendar_controller.dart | 140 ++++++ lib/src/calendar_event_data.dart | 50 +- lib/src/components/common_components.dart | 87 ++++ lib/src/components/components.dart | 2 + lib/src/components/day_view_components.dart | 109 +++++ lib/src/components/month_view_components.dart | 183 +++++++ lib/src/constants.dart | 21 + lib/src/day_view/_internal_day_view_page.dart | 223 +++++++++ lib/src/day_view/day_view.dart | 446 ++++++++++++++++++ lib/src/day_view/day_view_page.dart | 73 +++ lib/src/day_view/modals.dart | 20 + lib/src/event_arrangers/event_arrangers.dart | 70 +++ .../event_arrangers/merge_event_arranger.dart | 111 +++++ .../event_arrangers/side_event_arranger.dart | 157 ++++++ .../event_arrangers/stack_event_arranger.dart | 25 + lib/src/extensions.dart | 67 ++- lib/src/month_view/month_view.dart | 431 ++++++++++++++++- lib/src/painters.dart | 87 ++++ lib/src/typedefs.dart | 35 ++ pubspec.yaml | 2 +- 32 files changed, 3080 insertions(+), 101 deletions(-) create mode 100644 example/lib/constants.dart create mode 100644 example/lib/create_event_page.dart create mode 100644 example/lib/date_time_selector.dart create mode 100644 example/lib/day_view_page.dart create mode 100644 example/lib/event.dart create mode 100644 example/lib/event_details_page.dart create mode 100644 example/lib/extension.dart create mode 100644 example/lib/month_view_page.dart create mode 100644 lib/src/calendar_controller.dart create mode 100644 lib/src/components/common_components.dart create mode 100644 lib/src/components/components.dart create mode 100644 lib/src/components/day_view_components.dart create mode 100644 lib/src/components/month_view_components.dart create mode 100644 lib/src/constants.dart create mode 100644 lib/src/day_view/_internal_day_view_page.dart create mode 100644 lib/src/day_view/day_view.dart create mode 100644 lib/src/day_view/day_view_page.dart create mode 100644 lib/src/day_view/modals.dart create mode 100644 lib/src/event_arrangers/event_arrangers.dart create mode 100644 lib/src/event_arrangers/merge_event_arranger.dart create mode 100644 lib/src/event_arrangers/side_event_arranger.dart create mode 100644 lib/src/event_arrangers/stack_event_arranger.dart create mode 100644 lib/src/painters.dart create mode 100644 lib/src/typedefs.dart diff --git a/example/.gitignore b/example/.gitignore index 51dab37f..8e70c252 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -46,3 +46,4 @@ app.*.map.json /android/app/release pubspec.lock +.vscode/ \ No newline at end of file diff --git a/example/lib/constants.dart b/example/lib/constants.dart new file mode 100644 index 00000000..3326e2b5 --- /dev/null +++ b/example/lib/constants.dart @@ -0,0 +1,8 @@ +import 'dart:ui'; + +class AppConstants { + AppConstants._(); + + static const Color black = Color(0xff444444); + static const Color white = Color(0xffefefef); +} diff --git a/example/lib/create_event_page.dart b/example/lib/create_event_page.dart new file mode 100644 index 00000000..908aa27e --- /dev/null +++ b/example/lib/create_event_page.dart @@ -0,0 +1,209 @@ +import 'package:example/constants.dart'; +import 'package:example/date_time_selector.dart'; +import 'package:example/event.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_calendar_page/flutter_calendar_page.dart'; + +import 'extension.dart'; + +class CreateEventPage extends StatefulWidget { + final bool withDuration; + + const CreateEventPage({Key? key, this.withDuration = false}) + : super(key: key); + + @override + _CreateEventPageState createState() => _CreateEventPageState(); +} + +class _CreateEventPageState extends State { + GlobalKey _formKey = GlobalKey(); + + late DateTime _date; + DateTime? _startTime; + DateTime? _endTime; + String _title = ""; + String _description = ""; + + late FocusNode _titleNode; + late FocusNode _descriptionNode; + late FocusNode _dateNode; + + @override + void initState() { + super.initState(); + + _titleNode = FocusNode(); + _descriptionNode = FocusNode(); + _dateNode = FocusNode(); + } + + @override + void dispose() { + _titleNode.dispose(); + _descriptionNode.dispose(); + _dateNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + elevation: 0, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + centerTitle: false, + leading: IconButton( + onPressed: context.popRoute, + icon: Icon( + Icons.arrow_back, + color: AppConstants.black, + ), + ), + title: Text( + "Create New Event", + style: TextStyle( + color: AppConstants.black, + fontSize: 20.0, + fontWeight: FontWeight.bold, + ), + ), + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 30.0, horizontal: 10.0), + shrinkWrap: true, + scrollDirection: Axis.vertical, + children: [ + TextFormField( + focusNode: _titleNode, + style: TextStyle( + color: AppConstants.black, + fontSize: 17.0, + ), + decoration: InputDecoration( + labelText: "Title", + ), + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.trim() == "") return "Invalid Title"; + return null; + }, + onSaved: (value) => _title = value ?? "", + ), + SizedBox( + height: 15.0, + ), + DateTimeSelectorFormField( + decoration: InputDecoration( + labelText: "Select Date", + ), + onSave: (date) => _date = date, + textStyle: TextStyle( + color: AppConstants.black, + fontSize: 17.0, + ), + type: DateTimeSelectionType.date, + ), + SizedBox( + height: 15.0, + ), + if (widget.withDuration) ...[ + Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: DateTimeSelectorFormField( + decoration: InputDecoration( + labelText: "Start Time", + ), + textStyle: TextStyle( + color: AppConstants.black, + fontSize: 17.0, + ), + onSave: (date) => _startTime = date, + type: DateTimeSelectionType.time, + ), + ), + SizedBox(width: 20.0), + Expanded( + child: DateTimeSelectorFormField( + decoration: InputDecoration( + labelText: "End Time", + ), + textStyle: TextStyle( + color: AppConstants.black, + fontSize: 17.0, + ), + onSave: (date) => _endTime = date, + type: DateTimeSelectionType.time, + ), + ), + ], + ), + SizedBox( + height: 15.0, + ), + ], + TextFormField( + focusNode: _descriptionNode, + style: TextStyle( + color: AppConstants.black, + fontSize: 17.0, + ), + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + selectionControls: MaterialTextSelectionControls(), + minLines: 1, + maxLines: 10, + maxLength: 1000, + validator: (value) { + if (value == null || value.trim() == "") + return "Invalid Description"; + return null; + }, + decoration: InputDecoration( + labelText: "Description", + ), + onSaved: (value) => _description = value ?? "", + ), + SizedBox( + height: 50.0, + ), + ElevatedButton( + onPressed: () { + if (_formKey.currentState?.validate() ?? false) { + _formKey.currentState?.save(); + CalendarEventData event = CalendarEventData( + date: _date, + event: Event(title: _title), + title: _title, + description: _description, + startTime: _startTime, + endTime: _endTime, + ); + context.popRoute(event); + } + }, + child: Text( + "Create", + style: Theme.of(context) + .textTheme + .subtitle1! + .copyWith(color: AppConstants.white), + ), + style: ButtonStyle( + elevation: MaterialStateProperty.all(8), + padding: MaterialStateProperty.all( + EdgeInsets.symmetric(vertical: 10.0)), + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/date_time_selector.dart b/example/lib/date_time_selector.dart new file mode 100644 index 00000000..11f1527f --- /dev/null +++ b/example/lib/date_time_selector.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_calendar_page/flutter_calendar_page.dart'; + +import 'extension.dart'; + +typedef Validator = String? Function(String? value); + +enum DateTimeSelectionType { date, time } + +class DateTimeSelectorFormField extends StatefulWidget { + final Function(DateTime?)? onSelect; + final DateTimeSelectionType? type; + final FocusNode? focusNode; + final DateTime? minimumDateTime; + final Validator? validator; + final bool displayDefault; + final TextStyle? textStyle; + final void Function(DateTime date)? onSave; + final InputDecoration? decoration; + + const DateTimeSelectorFormField({ + this.onSelect, + this.type, + this.onSave, + this.decoration, + this.focusNode, + this.minimumDateTime, + this.validator, + this.displayDefault = false, + this.textStyle, + }); + + @override + _DateTimeSelectorFormFieldState createState() => + _DateTimeSelectorFormFieldState(); +} + +class _DateTimeSelectorFormFieldState extends State { + late TextEditingController _textEditingController; + late FocusNode _focusNode; + + late DateTime _selectedDate; + + @override + void initState() { + super.initState(); + + _textEditingController = TextEditingController(); + _focusNode = FocusNode(); + + _selectedDate = widget.minimumDateTime ?? DateTime.now(); + + if (widget.displayDefault && widget.minimumDateTime != null) { + if (widget.type == DateTimeSelectionType.date) { + _textEditingController.text = widget.minimumDateTime + ?.dateToStringWithFormat(format: "dd/MM/yyyy") ?? + ""; + } else { + _textEditingController.text = + widget.minimumDateTime?.getTimeInFormat(TimeStampFormat.parse_12) ?? + ""; + } + } + } + + @override + void dispose() { + _focusNode.dispose(); + _textEditingController.dispose(); + super.dispose(); + } + + Future _showSelector() async { + DateTime? date; + + if (widget.type == DateTimeSelectionType.date) { + date = await _showDateSelector(); + _textEditingController.text = + (date ?? _selectedDate).dateToStringWithFormat(format: "dd/MM/yyyy"); + } else { + date = await _showTimeSelector(); + _textEditingController.text = + (date ?? _selectedDate).getTimeInFormat(TimeStampFormat.parse_12); + } + + _selectedDate = date ?? DateTime.now(); + + if (mounted) { + setState(() {}); + } + + widget.onSelect?.call(date); + } + + Future _showDateSelector() async { + DateTime now = widget.minimumDateTime ?? DateTime.now(); + + DateTime? date = await showDatePicker( + context: context, + initialDate: now, + firstDate: widget.minimumDateTime ?? now, + lastDate: Constants.maxDate, + ); + + if (date == null) return null; + + return date; + } + + Future _showTimeSelector() async { + DateTime now = widget.minimumDateTime ?? DateTime.now(); + TimeOfDay? time = await showTimePicker( + context: context, + builder: (context, widget) { + return widget ?? Container(); + }, + initialTime: TimeOfDay(hour: now.hour, minute: now.minute), + ); + + if (time == null) return null; + + DateTime? date = now.copyWith( + hour: time.hour, + minute: time.minute, + ); + + if (widget.minimumDateTime == null) return date; + + return date; + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _showSelector, + child: TextFormField( + style: widget.textStyle, + controller: _textEditingController, + validator: widget.validator, + minLines: 1, + onSaved: (value) => widget.onSave?.call(_selectedDate), + enabled: false, + decoration: widget.decoration, + ), + ); + } +} diff --git a/example/lib/day_view_page.dart b/example/lib/day_view_page.dart new file mode 100644 index 00000000..ae3186ca --- /dev/null +++ b/example/lib/day_view_page.dart @@ -0,0 +1,61 @@ +import 'package:example/event_details_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_calendar_page/flutter_calendar_page.dart'; + +import 'create_event_page.dart'; +import 'event.dart'; +import 'extension.dart'; + +class DayViewPageDemo extends StatefulWidget { + @override + _DayViewPageDemoState createState() => _DayViewPageDemoState(); +} + +class _DayViewPageDemoState extends State { + CalendarController _controller = CalendarController(); + GlobalKey _dayViewKey = GlobalKey(); + + DateTime date = DateTime(2021, 5, 31); + + @override + Widget build(BuildContext context) { + return Scaffold( + floatingActionButton: FloatingActionButton( + child: Icon(Icons.add), + elevation: 8, + onPressed: () async { + CalendarEventData? event = + await context.pushRoute>(CreateEventPage( + withDuration: true, + )); + if (event == null) return; + _controller.addEvent(event); + }, + ), + body: DayView( + key: _dayViewKey, + eventTileBuilder: (date, events, area, startDuration, endDuration) { + if (events.isEmpty) return Container(); + + return RoundedEventTile( + borderRadius: BorderRadius.circular(10.0), + title: events[0]?.event.title ?? "", + extraEvents: events.length - 1, + onTap: () => context.pushRoute(DetailsPage( + event: events[0] ?? + CalendarEventData(date: DateTime.now(), event: []))), + description: events[0]?.description ?? "", + padding: EdgeInsets.all(10.0), + margin: EdgeInsets.all(2.0), + ); + }, + pageTransitionDuration: Duration(milliseconds: 300), + pageTransitionCurve: Curves.ease, + controller: _controller, + timeLineOffset: 0, + heightPerMinute: 0.7, + showLiveTimeLineInAllDays: false, + ), + ); + } +} diff --git a/example/lib/event.dart b/example/lib/event.dart new file mode 100644 index 00000000..50c4bc40 --- /dev/null +++ b/example/lib/event.dart @@ -0,0 +1,14 @@ +class Event { + final String title; + + Event({this.title = "Title"}); + + @override + bool operator ==(Object other) => other is Event && this.title == other.title; + + @override + int get hashCode => super.hashCode; + + @override + String toString() => this.title; +} diff --git a/example/lib/event_details_page.dart b/example/lib/event_details_page.dart new file mode 100644 index 00000000..a0d661b7 --- /dev/null +++ b/example/lib/event_details_page.dart @@ -0,0 +1,94 @@ +import 'package:example/constants.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_calendar_page/flutter_calendar_page.dart'; + +import 'extension.dart'; + +class DetailsPage extends StatelessWidget { + final CalendarEventData event; + + const DetailsPage({Key? key, required this.event}) : super(key: key); + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + elevation: 0, + centerTitle: false, + title: Text( + event.title, + style: TextStyle( + color: AppConstants.black, + fontSize: 20.0, + fontWeight: FontWeight.bold, + ), + ), + leading: IconButton( + onPressed: context.popRoute, + icon: Icon( + Icons.arrow_back, + color: AppConstants.black, + ), + ), + ), + body: ListView( + padding: const EdgeInsets.all(20.0), + children: [ + Text( + "Date: ${event.date.dateToStringWithFormat(format: "dd/MM/yyyy")}"), + SizedBox( + height: 15.0, + ), + if (event.startTime != null && event.endTime != null) ...[ + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("From"), + Text( + event.startTime + ?.getTimeInFormat(TimeStampFormat.parse_12) ?? + "", + ), + ], + ), + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("To"), + Text( + event.endTime + ?.getTimeInFormat(TimeStampFormat.parse_12) ?? + "", + ), + ], + ), + ), + ], + ), + SizedBox( + height: 30.0, + ), + ], + if (event.description != "") ...[ + Divider(), + Text("Description"), + SizedBox( + height: 10.0, + ), + Text(event.description), + ] + ], + ), + ); + } +} diff --git a/example/lib/extension.dart b/example/lib/extension.dart new file mode 100644 index 00000000..6efb22b2 --- /dev/null +++ b/example/lib/extension.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +enum TimeStampFormat { parse_12, parse_24 } + +extension NavigationExtension on State { + void pushRoute(Widget page) => + Navigator.of(context).push(MaterialPageRoute(builder: (context) => page)); +} + +extension NavigatorExtention on BuildContext { + Future pushRoute(Widget page) async => await Navigator.of(this) + .push(MaterialPageRoute(builder: (context) => page)); + + void popRoute([dynamic value]) => Navigator.of(this).pop(value); + + void showSnackBarWithText(String text) => ScaffoldMessenger.of(this) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(text))); +} + +extension DateUtils on DateTime { + String get weekdayToFullString { + switch (this.weekday) { + case DateTime.monday: + return "Monday"; + case DateTime.tuesday: + return "Tuesday"; + case DateTime.wednesday: + return "Wednesday"; + case DateTime.thursday: + return "Thursday"; + case DateTime.friday: + return "Friday"; + case DateTime.saturday: + return "Saturday"; + case DateTime.sunday: + return "Sunday"; + default: + return "Error"; + } + } + + String get weekdayToAbbreviatedString { + switch (this.weekday) { + case DateTime.monday: + return "M"; + case DateTime.tuesday: + return "T"; + case DateTime.wednesday: + return "W"; + case DateTime.thursday: + return "T"; + case DateTime.friday: + return "F"; + case DateTime.saturday: + return "S"; + case DateTime.sunday: + return "S"; + default: + return "Err"; + } + } + + int get totalMinutes => this.hour * 60 + this.minute; + + TimeOfDay get timeOfDay => TimeOfDay(hour: this.hour, minute: this.minute); + + DateTime copyWith({ + int? year, + int? month, + int? day, + int? hour, + int? minute, + int? second, + int? millisecond, + int? microsecond, + }) => + DateTime( + year ?? this.year, + month ?? this.month, + day ?? this.day, + hour ?? this.hour, + minute ?? this.minute, + second ?? this.second, + millisecond ?? this.millisecond, + microsecond ?? this.microsecond, + ); + + String dateToStringWithFormat({String format = 'y-M-d'}) { + return DateFormat(format).format(this); + } + + DateTime stringToDateWithFormat({ + required String format, + required String dateString, + }) => + DateFormat(format).parse(dateString); + + String getTimeInFormat(TimeStampFormat format) => + DateFormat('h:mm${format == TimeStampFormat.parse_12 ? " a" : ""}') + .format(this) + .toUpperCase(); + + bool compareWithoutTime(DateTime date) => + this.day == date.day && + this.month == date.month && + this.year == date.year; + + bool compareTime(DateTime date) => + this.hour == date.hour && + this.minute == date.minute && + this.second == date.second; +} diff --git a/example/lib/main.dart b/example/lib/main.dart index d8a05265..1faae201 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,5 +1,10 @@ +import 'package:example/constants.dart'; +import 'package:example/month_view_page.dart'; import 'package:flutter/material.dart'; +import 'day_view_page.dart'; +import 'extension.dart'; + void main() { runApp(MyApp()); } @@ -10,104 +15,62 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // Try running your application with "flutter run". You'll see the - // application has a blue toolbar. Then, without quitting the app, try - // changing the primarySwatch below to Colors.green and then invoke - // "hot reload" (press "r" in the console where you ran "flutter run", - // or simply save your changes to "hot reload" in a Flutter IDE). - // Notice that the counter didn't reset back to zero; the application - // is not restarted. - primarySwatch: Colors.blue, + theme: ThemeData.light().copyWith( + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(7.0), + borderSide: BorderSide( + color: AppConstants.black, + style: BorderStyle.solid, + width: 1.5, + ), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(7.0), + borderSide: BorderSide( + color: AppConstants.black, + style: BorderStyle.solid, + width: 0.8, + ), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 10.0, vertical: 10.0), + ), ), - home: MyHomePage(title: 'Flutter Demo Home Page'), + home: HomePage(), ); } } -class MyHomePage extends StatefulWidget { - MyHomePage({Key key, this.title}) : super(key: key); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - _MyHomePageState createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - +class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. return Scaffold( appBar: AppBar( - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), + title: Text("Flutter Calendar Page"), + centerTitle: true, ), body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Invoke "debug painting" (press "p" in the console, choose the - // "Toggle Debug Paint" action from the Flutter Inspector in Android - // Studio, or the "Toggle Debug Paint" command in Visual Studio Code) - // to see the wireframe for each widget. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'You have pushed the button this many times:', + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => context.pushRoute(MonthViewPageDemo()), + child: Text("Month View"), + ), + ElevatedButton( + onPressed: () => context.pushRoute(DayViewPageDemo()), + child: Text("Day View"), ), - Text( - '$_counter', - style: Theme.of(context).textTheme.headline4, + ElevatedButton( + onPressed: () => + context.showSnackBarWithText("Not Implemented..."), + child: Text("Week View"), ), ], ), ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: Icon(Icons.add), - ), // This trailing comma makes auto-formatting nicer for build methods. ); } } diff --git a/example/lib/month_view_page.dart b/example/lib/month_view_page.dart new file mode 100644 index 00000000..8bb26a85 --- /dev/null +++ b/example/lib/month_view_page.dart @@ -0,0 +1,56 @@ +import 'package:example/create_event_page.dart'; +import 'package:example/event_details_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_calendar_page/flutter_calendar_page.dart'; + +import 'event.dart'; +import 'extension.dart'; + +class MonthViewPageDemo extends StatefulWidget { + @override + _MonthViewPageDemoState createState() => _MonthViewPageDemoState(); +} + +class _MonthViewPageDemoState extends State { + CalendarController _controller = CalendarController(); + GlobalKey _monthViewState = GlobalKey(); + + DateTime date = DateTime(2021, 6, 28); + + @override + Widget build(BuildContext context) { + return Scaffold( + floatingActionButton: FloatingActionButton( + child: Icon(Icons.add), + elevation: 8, + onPressed: () async { + CalendarEventData? event = await context + .pushRoute>(CreateEventPage()); + if (event == null) return; + _controller.addEvent(event); + }, + ), + body: MonthView( + key: _monthViewState, + controller: _controller, + borderSize: 0.5, + showBorder: true, + borderColor: Colors.blueGrey, + cellAspectRatio: 0.55, + pageTransitionDuration: Duration(milliseconds: 300), + pageTransitionCurve: Curves.ease, + cellBuilder: + (date, List> events, isToday, isInMonth) { + return FilledCell( + date: date, + shouldHighlight: isToday, + onTileTap: (event, _) => + context.pushRoute(DetailsPage(event: event)), + backgroundColor: isInMonth ? Color(0xffffffff) : Color(0xffdedede), + events: events, + ); + }, + ), + ); + } +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 8dfe25eb..0cc5ca3d 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -18,7 +18,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: ">=2.7.0 <3.0.0" + sdk: ">=2.12.0 <3.0.0" dependencies: flutter: @@ -28,6 +28,9 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 + intl: + flutter_calendar_page: + path: ../ dev_dependencies: flutter_test: diff --git a/lib/flutter_calendar_page.dart b/lib/flutter_calendar_page.dart index 5ffa7444..9e9eb6b5 100644 --- a/lib/flutter_calendar_page.dart +++ b/lib/flutter_calendar_page.dart @@ -1,3 +1,11 @@ library flutter_calendar_page; +export './src/calendar_controller.dart'; export './src/calendar_event_data.dart'; +export './src/components/components.dart'; +export './src/constants.dart'; +export './src/day_view/day_view.dart'; +export './src/event_arrangers/event_arrangers.dart'; +export './src/extensions.dart'; +export './src/month_view/month_view.dart'; +export './src/typedefs.dart'; diff --git a/lib/src/calendar_controller.dart b/lib/src/calendar_controller.dart new file mode 100644 index 00000000..5aad065e --- /dev/null +++ b/lib/src/calendar_controller.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; + +import '../flutter_calendar_page.dart'; + +class CalendarController extends ChangeNotifier { + CalendarController(); + + List<_YearEvent> _events = []; + + List> get events { + List> totalEvents = []; + + for (int i = 0; i < _events.length; i++) { + totalEvents.addAll(_events[i].getAllEvents()); + } + + return totalEvents; + } + + /// Add all the events in the list + /// If there is an event with same date then + void addAllEvents(List> events) { + for (CalendarEventData event in events) { + _addEvent(event); + } + + notifyListeners(); + } + + /// Adds a single event in [_events] + void addEvent(CalendarEventData event) { + _addEvent(event); + + notifyListeners(); + } + + void _addEvent(CalendarEventData event) { + for (int i = 0; i < _events.length; i++) { + if (_events[i].year == event.date.year) { + _events[i].addEvent(event); + return; + } + } + + _YearEvent newEvent = _YearEvent(year: event.date.year); + newEvent.addEvent(event); + _events.add(newEvent); + } + + /// Returns event on given day + List> getEventsOnDay(DateTime date) { + List> events = []; + + // Iterate through all year events + for (int i = 0; i < _events.length; i++) { + // If year is matched. + if (_events[i].year == date.year) { + // Get list of months in year + List<_MonthEvent> monthEvents = _events[i]._months; + + // Iterate through all months + for (int j = 0; j < monthEvents.length; j++) { + // If month is matched + if (monthEvents[j].month == date.month) { + // Get list of events in month + List> calendarEvents = monthEvents[j]._events; + + // Iterate through all events + for (int k = 0; k < calendarEvents.length; k++) { + // If day of event is matched + if (calendarEvents[k].date.day == date.day) + // return event + events.add(calendarEvents[k]); + } + } + } + } + } + + return events; + } +} + +class _YearEvent { + int year; + List<_MonthEvent> _months = []; + + List<_MonthEvent> get months => _months.toList(growable: false); + + _YearEvent({required this.year}); + + int hasMonth(int month) { + for (int i = 0; i < _months.length; i++) { + if (_months[i].month == month) return i; + } + return -1; + } + + void addEvent(CalendarEventData event) { + for (int i = 0; i < _months.length; i++) { + if (_months[i].month == event.date.month) { + _months[i].addEvent(event); + return; + } + } + _MonthEvent newEvent = _MonthEvent(month: event.date.month); + newEvent.addEvent(event); + _months.add(newEvent); + } + + List> getAllEvents() { + List> totalEvents = []; + for (int i = 0; i < _months.length; i++) { + totalEvents.addAll(_months[i].events); + } + return totalEvents; + } +} + +class _MonthEvent { + int month; + List> _events = []; + + List> get events => _events.toList(growable: false); + + _MonthEvent({required this.month}); + + int hasDay(int day) { + for (int i = 0; i < _events.length; i++) { + if (_events[i].date.day == day) return i; + } + return -1; + } + + void addEvent(CalendarEventData event) { + if (!_events.contains(event)) { + _events.add(event); + } + } +} diff --git a/lib/src/calendar_event_data.dart b/lib/src/calendar_event_data.dart index c08e1263..c4909b02 100644 --- a/lib/src/calendar_event_data.dart +++ b/lib/src/calendar_event_data.dart @@ -8,28 +8,54 @@ class CalendarEventData { /// Specifies date on which all these events are. final DateTime date; - /// List of events on [CalendarEventData.date]. + /// Defines the start time of the event. + /// [endTime] and [startTime] will defines time on same day. + /// This is required when you are using [CalendarEventData] for [DayView] + final DateTime? startTime; + + /// Defines the end time of the event. + /// [endTime] and [startTime] defines time on same day. + /// This is required when you are using [CalendarEventData] for [DayView] + final DateTime? endTime; + + /// Title of the event. + final String title; + + /// Description of the event. + final String description; + + /// List of events on [date]. final T event; /// Stores all the events on [date] CalendarEventData({ - @required this.date, - @required this.event, + required this.date, + required this.event, + this.title = "Title", + this.description = "Description", + this.startTime, + this.endTime, }); + Map toJson() => { + "date": date, + "startTime": startTime, + "endTime": endTime, + "event": event, + "title": title, + "description": description, + }; + @override - String toString() { - return { - "date": date, - "events": event, - }.toString(); - } + String toString() => this.toJson().toString(); @override bool operator ==(Object other) { - if (this.runtimeType != other.runtimeType) return false; - CalendarEventData obj = other; - return this.date.compareWithoutTime(obj.date) && this.event == obj.event; + return other is CalendarEventData && + this.date.compareWithoutTime(other.date) && + this.event == other.event && + this.title == other.title && + this.description == other.description; } @override diff --git a/lib/src/components/common_components.dart b/lib/src/components/common_components.dart new file mode 100644 index 00000000..023df8c4 --- /dev/null +++ b/lib/src/components/common_components.dart @@ -0,0 +1,87 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../extensions.dart'; + +class CalendarPageHeader extends StatelessWidget { + /// When user taps on right arrow. + final VoidCallback? onNextDay; + + /// When user taps on left arrow. + final VoidCallback? onPreviousDay; + + /// When user taps on title. + final AsyncCallback? onTitleTapped; + + /// Date of month/day. + final DateTime date; + + /// Provides string to display as title. + final StringProvider dateStringBuilder; + + /// Common header for month and day view In this header user can define format + /// in which date will be displayed by providing [dateStringBuilder] function. + /// + const CalendarPageHeader({ + Key? key, + required this.date, + required this.dateStringBuilder, + this.onNextDay, + this.onTitleTapped, + this.onPreviousDay, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Color(0xFFDCF0FF), + ), + clipBehavior: Clip.antiAlias, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + IconButton( + onPressed: onPreviousDay, + enableFeedback: true, + splashColor: Colors.transparent, + focusColor: Colors.transparent, + hoverColor: Colors.transparent, + highlightColor: Colors.transparent, + padding: + const EdgeInsets.symmetric(horizontal: 10.0, vertical: 10.0), + icon: Icon( + Icons.chevron_left, + size: 30, + ), + ), + Expanded( + child: InkWell( + onTap: onTitleTapped, + child: Text( + dateStringBuilder(date), + textAlign: TextAlign.center, + ), + ), + ), + IconButton( + onPressed: onNextDay, + enableFeedback: true, + splashColor: Colors.transparent, + focusColor: Colors.transparent, + hoverColor: Colors.transparent, + highlightColor: Colors.transparent, + padding: + const EdgeInsets.symmetric(horizontal: 20.0, vertical: 10.0), + icon: Icon( + Icons.chevron_right, + size: 30, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/components/components.dart b/lib/src/components/components.dart new file mode 100644 index 00000000..90dabafa --- /dev/null +++ b/lib/src/components/components.dart @@ -0,0 +1,2 @@ +export 'day_view_components.dart'; +export 'month_view_components.dart'; diff --git a/lib/src/components/day_view_components.dart b/lib/src/components/day_view_components.dart new file mode 100644 index 00000000..6c435e1f --- /dev/null +++ b/lib/src/components/day_view_components.dart @@ -0,0 +1,109 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'common_components.dart'; + +/// This class defines default tile to display in day view. +/// +class RoundedEventTile extends StatelessWidget { + /// Title of the tile. + final String title; + + /// Description of the tile. + final String description; + + /// Background color of tile. + /// Default color is [Colors.blue] + final Color backgroundColor; + + /// Called when user taps on tile. + final VoidCallback? onTap; + + /// If same tile can have multiple events. + /// In most cases this value will be 1 less than total events. + final int extraEvents; + + /// Padding of the tile. Default padding is [EdgeInsets.zero] + final EdgeInsets padding; + + /// Margin of the tile. Default margin is [EdgeInsets.zero] + final EdgeInsets margin; + + /// Border radius of tile. + final BorderRadius borderRadius; + + /// This is default tile to display in day view. + /// + const RoundedEventTile({ + Key? key, + required this.title, + this.padding = const EdgeInsets.all(0), + this.margin = const EdgeInsets.all(0), + this.description = "", + this.borderRadius = BorderRadius.zero, + this.onTap, + this.extraEvents = 0, + this.backgroundColor = Colors.blue, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: this.onTap, + child: Container( + padding: padding, + margin: margin, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: borderRadius, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (title != "") + Text( + title, + style: TextStyle( + fontSize: 20, + color: Colors.white, + ), + ), + if (description != "") ...[ + Text( + description, + style: TextStyle( + fontSize: 17, + color: Colors.white.withAlpha(200), + ), + ), + SizedBox(height: 15.0), + ], + if (extraEvents > 0) Text("+$extraEvents more"), + ], + ), + ), + ); + } +} + +class DayPageHeader extends CalendarPageHeader { + /// A header widget to display on day view. + const DayPageHeader({ + Key? key, + VoidCallback? onNextDay, + AsyncCallback? onTitleTapped, + VoidCallback? onPreviousDay, + required DateTime date, + }) : super( + key: key, + date: date, + onNextDay: onNextDay, + onPreviousDay: onPreviousDay, + onTitleTapped: onTitleTapped, + dateStringBuilder: DayPageHeader._dayStringBuilder, + ); + static String _dayStringBuilder(DateTime date) => + "${date.day} - ${date.month} - ${date.year}"; +} diff --git a/lib/src/components/month_view_components.dart b/lib/src/components/month_view_components.dart new file mode 100644 index 00000000..c0c1a04c --- /dev/null +++ b/lib/src/components/month_view_components.dart @@ -0,0 +1,183 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_calendar_page/flutter_calendar_page.dart'; +import 'package:flutter_calendar_page/src/calendar_event_data.dart'; + +import 'common_components.dart'; + +class CircularCell extends StatelessWidget { + /// Date of cell. + final DateTime date; + + /// List of Events for current date. + final List events; + + /// Defines if [date] is [DateTime.now] or not. + final bool shouldHighlight; + + /// Called when user taps on cell. + final VoidCallback? onTap; + + /// Background color of circle around date title. + final Color backgroundColor; + + /// This class will defines how cell will be displayed. + /// To get proper view user [CircularCell] with 1 [MonthView.cellAspectRatio]. + const CircularCell({ + Key? key, + required this.date, + this.events = const [], + this.onTap, + this.shouldHighlight = false, + this.backgroundColor = Colors.blue, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Center( + child: GestureDetector( + onTap: onTap, + child: CircleAvatar( + backgroundColor: + shouldHighlight ? backgroundColor : Colors.transparent, + child: Text( + "${date.day}", + style: TextStyle( + fontSize: 20, + color: shouldHighlight ? Colors.white : Colors.black, + ), + ), + ), + ), + ); + } +} + +class FilledCell extends StatelessWidget { + /// Date of current cell. + final DateTime date; + + /// List of events on for current date. + final List events; + + /// Defines if cell should be highlighted or not. + /// If true it will display date title in a circle. + final bool shouldHighlight; + + /// Defines background color of cell. + final Color backgroundColor; + + /// Defines highlight color. + final Color highlightColor; + + /// Called when user taps on any event tile. + final void Function(CalendarEventData event, DateTime date)? onTileTap; + + /// This class will defines how cell will be displayed. + /// This widget will display all the events as tile below date title. + /// + const FilledCell({ + Key? key, + required this.date, + required this.events, + this.shouldHighlight = false, + this.backgroundColor = Colors.blue, + this.highlightColor = Colors.blue, + this.onTileTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + SizedBox( + height: 5.0, + ), + CircleAvatar( + radius: 15, + backgroundColor: + shouldHighlight ? highlightColor : Colors.transparent, + child: Text( + "${date.day}", + style: TextStyle( + color: shouldHighlight ? Colors.white : Colors.black), + ), + ), + if (events.isNotEmpty) + Expanded( + child: Container( + margin: EdgeInsets.only(top: 5.0), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration(), + child: SingleChildScrollView( + physics: BouncingScrollPhysics(), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: List.generate( + events.length, + (index) => GestureDetector( + onTap: () => + onTileTap?.call(events[index], events[index].date), + child: Container( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(4.0), + ), + margin: EdgeInsets.symmetric( + vertical: 2.0, horizontal: 3.0), + padding: const EdgeInsets.all(2.0), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Text( + events[index].title, + overflow: TextOverflow.clip, + maxLines: 1, + style: TextStyle( + color: Colors.white.withAlpha(240), + fontSize: 12, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class MonthPageHeader extends CalendarPageHeader { + /// A header widget to display on month view. + const MonthPageHeader({ + Key? key, + VoidCallback? onNextMonth, + AsyncCallback? onTitleTapped, + VoidCallback? onPreviousMonth, + required DateTime date, + }) : super( + key: key, + date: date, + onNextDay: onNextMonth, + onPreviousDay: onPreviousMonth, + onTitleTapped: onTitleTapped, + dateStringBuilder: MonthPageHeader._monthStringBuilder, + ); + static String _monthStringBuilder(DateTime date) => + "${date.month} - ${date.year}"; +} diff --git a/lib/src/constants.dart b/lib/src/constants.dart new file mode 100644 index 00000000..9c09ad86 --- /dev/null +++ b/lib/src/constants.dart @@ -0,0 +1,21 @@ +import 'dart:math'; +import 'dart:ui'; + +class Constants { + Constants._(); + + static final Random _random = Random(); + static final int _maxColor = 256; + + /// minimum and maximum dates are 100,000,000 days before and after epochDate + static final DateTime epochDate = DateTime(1970, 1, 1); + static final DateTime maxDate = DateTime(275760, 1, 1); + static final DateTime minDate = DateTime(-271820, 1, 1); + + static final List weekTitles = ["M", "T", "W", "T", "F", "S", "S"]; + + static Color get randomColor { + return Color.fromRGBO(_random.nextInt(_maxColor), + _random.nextInt(_maxColor), _random.nextInt(_maxColor), 1); + } +} diff --git a/lib/src/day_view/_internal_day_view_page.dart b/lib/src/day_view/_internal_day_view_page.dart new file mode 100644 index 00000000..4183d619 --- /dev/null +++ b/lib/src/day_view/_internal_day_view_page.dart @@ -0,0 +1,223 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_calendar_page/flutter_calendar_page.dart'; +import 'package:flutter_calendar_page/src/extensions.dart'; + +import '../painters.dart'; +import 'modals.dart'; + +/// Defines a single day page. +class InternalDayViewPage extends StatelessWidget { + final double width; + final double height; + final DateTime date; + final EventTileBuilder eventTileBuilder; + final CalendarController controller; + final DateWidgetBuilder timeLineBuilder; + final HourIndicatorSettings hourIndicatorSettings; + final bool showLiveLine; + final HourIndicatorSettings liveTimeIndicatorSettings; + final double heightPerMinute; + final double timeLineWidth; + final double timeLineOffset; + final double hourHeight; + final EventArranger eventArranger; + final bool showVerticalLine; + final double verticalLineOffset; + + /// Defines a single day page. + const InternalDayViewPage({ + Key? key, + required this.showVerticalLine, + required this.width, + required this.date, + required this.eventTileBuilder, + required this.controller, + required this.timeLineBuilder, + required this.hourIndicatorSettings, + required this.showLiveLine, + required this.liveTimeIndicatorSettings, + required this.heightPerMinute, + required this.timeLineWidth, + required this.timeLineOffset, + required this.height, + required this.hourHeight, + required this.eventArranger, + required this.verticalLineOffset, + }) : super(key: key); + + Widget _buildTimeLine() { + return ConstrainedBox( + key: ValueKey(this.heightPerMinute), + constraints: BoxConstraints( + maxWidth: timeLineWidth, + minWidth: timeLineWidth, + maxHeight: height, + minHeight: height, + ), + child: Stack( + children: [ + for (int i = 1; i < 24; i++) + Positioned( + top: hourHeight * i - timeLineOffset, + left: 0, + right: 0, + bottom: height - (hourHeight * (i + 1)) + timeLineOffset, + child: Container( + height: hourHeight, + width: timeLineWidth, + child: timeLineBuilder.call(DateTime( + date.year, + date.month, + date.day, + i, + 0, + 0, + )), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Container( + height: height, + width: width, + child: Stack( + children: [ + if (showLiveLine && liveTimeIndicatorSettings.height > 0) + _LiveTimeIndicator( + liveTimeIndicatorSettings: liveTimeIndicatorSettings, + width: width, + height: height, + heightPerMinute: heightPerMinute, + timeLineWidth: timeLineWidth, + ), + CustomPaint( + size: Size(width, height), + painter: HourLinePainter( + lineColor: hourIndicatorSettings.color, + lineHeight: hourIndicatorSettings.height, + offset: timeLineWidth + hourIndicatorSettings.offset, + minuteHeight: heightPerMinute, + verticalLineOffset: verticalLineOffset, + showVerticalLine: showVerticalLine, + ), + ), + Align( + alignment: Alignment.centerRight, + child: Container( + height: height, + width: width - timeLineWidth - hourIndicatorSettings.offset, + child: Stack( + children: _generateEvents(), + ), + ), + ), + Align( + key: ValueKey(heightPerMinute), + child: _buildTimeLine(), + alignment: Alignment.centerLeft, + ), + ], + ), + ); + } + + List _generateEvents() { + List> events = eventArranger.arrange( + events: controller.getEventsOnDay(date), + height: height, + width: width - timeLineWidth - hourIndicatorSettings.offset, + heightPerMinute: heightPerMinute, + ); + + return List.generate(events.length, (index) { + return Positioned( + top: events[index].top, + bottom: events[index].bottom, + left: events[index].left, + right: events[index].right, + child: eventTileBuilder( + date, + events[index].events, + Rect.fromLTWH( + events[index].left, + events[index].top, + width - events[index].right - events[index].left, + height - events[index].bottom - events[index].top), + events[index].startDuration ?? DateTime.now(), + events[index].endDuration ?? DateTime.now(), + ), + ); + }); + } +} + +/// This widget displays time line on day view page based on current time. +class _LiveTimeIndicator extends StatefulWidget { + final double width; + final double height; + final double timeLineWidth; + final HourIndicatorSettings liveTimeIndicatorSettings; + final double heightPerMinute; + + /// This widget displays time line on day view page based on current time. + const _LiveTimeIndicator( + {Key? key, + required this.width, + required this.height, + required this.timeLineWidth, + required this.liveTimeIndicatorSettings, + required this.heightPerMinute}) + : super(key: key); + + @override + _LiveTimeIndicatorState createState() => _LiveTimeIndicatorState(); +} + +class _LiveTimeIndicatorState extends State<_LiveTimeIndicator> { + late Timer _timer; + late DateTime _currentDate; + + @override + void initState() { + super.initState(); + + _currentDate = DateTime.now(); + _timer = Timer(Duration(minutes: 1), setTimer); + } + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } + + void setTimer() { + if (mounted) + setState(() { + _currentDate = DateTime.now(); + _timer = Timer(Duration(seconds: 1), setTimer); + }); + } + + @override + Widget build(BuildContext context) { + return CustomPaint( + size: Size(widget.width, widget.height), + painter: CurrentTimeLinePainter( + color: widget.liveTimeIndicatorSettings.color, + height: widget.liveTimeIndicatorSettings.height, + offset: Offset( + widget.timeLineWidth + widget.liveTimeIndicatorSettings.offset, + _currentDate.getTotalMinutes * widget.heightPerMinute, + ), + ), + ); + } +} diff --git a/lib/src/day_view/day_view.dart b/lib/src/day_view/day_view.dart new file mode 100644 index 00000000..7f641442 --- /dev/null +++ b/lib/src/day_view/day_view.dart @@ -0,0 +1,446 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_calendar_page/src/calendar_controller.dart'; +import 'package:flutter_calendar_page/src/components/day_view_components.dart'; +import 'package:flutter_calendar_page/src/constants.dart'; +import 'package:flutter_calendar_page/src/extensions.dart'; + +import '../event_arrangers/event_arrangers.dart'; +import '_internal_day_view_page.dart'; +import 'modals.dart'; + +export 'day_view_page.dart'; +export 'modals.dart'; + +class DayView extends StatefulWidget { + /// A function that returns a [Widget] that determines appearance of each cell in day calendar. + /// + final EventTileBuilder? eventTileBuilder; + + /// A function that returns a [Widget] that will be displayed left side of day view. + /// + /// If null is provided then no time line will be visible. + /// + final DateWidgetBuilder? timeLineBuilder; + + /// Builds day title bar. + /// + final DateWidgetBuilder? dayTitleBuilder; + + /// Defines how events are arranged in day view. + /// User can define custom event arranger by implementing [EventArranger] class + /// and pass object of that class as argument. + /// + final EventArranger? eventArranger; + + /// This callback will run whenever page will change. + /// + final CalendarPageChangeCallBack? onPageChange; + + /// Determines the lower boundary user can scroll. + /// + /// If not provided [Constants.epochDate] is default. + /// + final DateTime? minDay; + + /// Determines upper boundary user can scroll. + /// + /// If not provided [Constants.maxDate] is default. + /// + final DateTime? maxDay; + + /// Defines initial display day. + /// + /// If not provided current date is default date. + /// + final DateTime? initialDay; + + /// Defines settings for hour indication lines. + /// + /// If null or [HourIndicatorSettings.none()] provided no lines will be displayed or provide to remove lines. + /// + final HourIndicatorSettings? hourIndicatorSettings; + + /// Defines settings for live time indicator. + /// + /// If null or [HourIndicatorSettings.none()] provided no lines will be displayed or provide to remove lines. + /// + final HourIndicatorSettings? liveTimeIndicatorSettings; + + /// Page transition duration used when user try to change page using [DayView.nextPage] or [DayView.previousPage] + /// + final Duration pageTransitionDuration; + + /// Page transition curve used when user try to change page using [DayView.nextPage] or [DayView.previousPage] + /// + final Curve pageTransitionCurve; + + /// A required parameters that controls events for day view. + /// + /// This will auto update day view when user adds events in controller. + /// This controller will store all the events. And returns events for particular day. + /// + final CalendarController controller; + + /// Defines aspect ratio of day cells in day calendar page. + /// + final double heightPerMinute; + + /// Defines the width of timeline. + /// + final double? timeLineWidth; + + /// if parsed true then live time line will be displayed in all days. + /// else it will be displayed in [DateTime.now] only. + /// + /// Parse [HourIndicatorSettings.none()] as argument in [DayView.liveTimeIndicatorSettings] + /// to remove time line completely. + /// + final bool showLiveTimeLineInAllDays; + + /// Defines offset for timeline. + /// + /// This will translate all the widgets returned by [DayView.timeLineBuilder] by provided offset. + /// + /// If offset is positive all the widgets will be translated up. + /// + /// If offset is negative all the widgets will be translated down. + /// + final double? timeLineOffset; + + /// Width of day page. + /// + /// if null provided then device width will be considered. + /// + final double? width; + + /// If true this will display vertical line in day view. + final bool showVerticalLine; + + /// Defines offset of vertical line from hour line starts. + final double verticalLineOffset; + + /// Main widget for day view. + const DayView({ + Key? key, + required this.eventTileBuilder, + required this.controller, + this.showVerticalLine = true, + this.pageTransitionDuration: const Duration(milliseconds: 300), + this.pageTransitionCurve: Curves.ease, + this.width, + this.minDay, + this.maxDay, + this.initialDay, + this.hourIndicatorSettings, + this.heightPerMinute = 1, + this.timeLineBuilder, + this.timeLineWidth, + this.timeLineOffset, + this.showLiveTimeLineInAllDays = false, + this.liveTimeIndicatorSettings, + this.onPageChange, + this.dayTitleBuilder, + this.eventArranger, + this.verticalLineOffset = 10, + }) : assert((timeLineOffset ?? 0) >= 0, + "timeLineOffset must be greater than or equal to 0"), + super(key: key); + + @override + DayViewState createState() => DayViewState(); +} + +class DayViewState extends State> { + late double _width; + late double _height; + late double _timeLineWidth; + late double _hourHeight; + late double _timeLineOffset; + late DateTime _currentDate; + late DateTime _maxDate; + late DateTime _minDate; + late DateTime _initialDay; + late int _totalDays; + late int _currentIndex; + + late EventArranger _eventArranger; + + late HourIndicatorSettings _hourIndicatorSettings; + late HourIndicatorSettings _liveTimeIndicatorSettings; + + late PageController _pageController; + + late DateWidgetBuilder _timeLineBuilder; + + late EventTileBuilder _eventTileBuilder; + + late DateWidgetBuilder _dayTitleBuilder; + + @override + void initState() { + super.initState(); + + _minDate = widget.minDay ?? Constants.epochDate; + _maxDate = widget.maxDay ?? Constants.maxDate; + + _initialDay = widget.initialDay ?? DateTime.now(); + _currentDate = _initialDay; + + if (_currentDate.isBefore(_minDate)) { + _currentDate = _minDate; + } else if (_currentDate.isAfter(_maxDate)) { + _currentDate = _maxDate; + } + _totalDays = _maxDate.getDayDifference(_minDate) + 1; + widget.controller.addListener(_reload); + _currentIndex = _currentDate.getDayDifference(_minDate); + _hourHeight = widget.heightPerMinute * 60; + _height = _hourHeight * 24; + _timeLineOffset = widget.timeLineOffset ?? widget.heightPerMinute * 10; + _pageController = PageController(initialPage: _currentIndex); + _eventArranger = widget.eventArranger ?? SideEventArranger(); + _timeLineBuilder = widget.timeLineBuilder ?? _defaultTimeLineBuilder; + _eventTileBuilder = widget.eventTileBuilder ?? _defaultEventTileBuilder; + _dayTitleBuilder = widget.dayTitleBuilder ?? _defaultDayBuilder; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _width = widget.width ?? MediaQuery.of(context).size.width; + assert(_width != 0, "Calendar width can not be 0."); + + _timeLineWidth = widget.timeLineWidth ?? _width * 0.13; + assert(_timeLineWidth != 0, "Time line width can not be 0."); + + _liveTimeIndicatorSettings = widget.liveTimeIndicatorSettings ?? + HourIndicatorSettings( + color: Theme.of(context).errorColor, + height: widget.heightPerMinute, + offset: 5 + widget.verticalLineOffset, + ); + + assert(_liveTimeIndicatorSettings.height < _hourHeight, + "liveTimeIndicator height must be less than minuteHeight * 60"); + + _hourIndicatorSettings = widget.hourIndicatorSettings ?? + HourIndicatorSettings( + height: widget.heightPerMinute, + color: Theme.of(context).primaryColor, + offset: 5, + ); + + assert(_hourIndicatorSettings.height < _hourHeight, + "hourIndicator height must be less than minuteHeight * 60"); + } + + @override + void dispose() { + widget.controller.removeListener(_reload); + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _dayTitleBuilder(_currentDate), + Expanded( + child: SingleChildScrollView( + child: SizedBox( + height: _height, + width: _width, + child: PageView.builder( + itemCount: _totalDays, + controller: _pageController, + onPageChanged: _onPageChange, + itemBuilder: (_, index) { + DateTime date = DateTime( + _minDate.year, _minDate.month, _minDate.day + index); + + return InternalDayViewPage( + width: _width, + liveTimeIndicatorSettings: _liveTimeIndicatorSettings, + timeLineBuilder: _timeLineBuilder, + eventTileBuilder: _eventTileBuilder, + heightPerMinute: widget.heightPerMinute, + hourIndicatorSettings: _hourIndicatorSettings, + date: date, + showLiveLine: widget.showLiveTimeLineInAllDays || + date.compareWithoutTime(DateTime.now()), + timeLineOffset: _timeLineOffset, + timeLineWidth: _timeLineWidth, + verticalLineOffset: widget.verticalLineOffset, + showVerticalLine: widget.showVerticalLine, + height: _height, + controller: widget.controller, + hourHeight: _hourHeight, + eventArranger: _eventArranger, + ); + }, + ), + ), + ), + ), + ], + ), + ); + } + + /// Reloads page. + /// + void _reload() { + if (mounted) { + setState(() {}); + } + } + + /// Default timeline builder this builder will be used if [widget.eventTileBuilder] is null + /// + Widget _defaultTimeLineBuilder(date) => Transform.translate( + offset: Offset(0, -7.5), + child: Padding( + padding: const EdgeInsets.only(right: 7.0), + child: Text( + "${((date.hour - 1) % 12) + 1} ${date.hour ~/ 12 == 0 ? "am" : "pm"}", + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 15.0, + ), + ), + ), + ); + + /// Default timeline builder. This builder will be used if [widget.eventTileBuilder] is null + /// + Widget _defaultEventTileBuilder( + date, events, boundary, startDuration, endDuration) { + if (events.isNotEmpty) + return RoundedEventTile( + title: events[0].title, + description: events[0].description, + ); + else + return Container(); + } + + /// Default view header builder. This builder will be used if [widget.dayTitleBuilder] is null. + /// + Widget _defaultDayBuilder(DateTime date) { + return DayPageHeader( + date: _currentDate, + onNextDay: nextPage, + onPreviousDay: previousPage, + onTitleTapped: () async { + DateTime? selectedDate = await showDatePicker( + context: context, + initialDate: date, + firstDate: Constants.minDate, + lastDate: Constants.maxDate, + ); + + if (selectedDate == null) return; + this.jumpToDate(selectedDate); + }, + ); + } + + /// Called when user change page using any gesture or inbuilt functions. + /// + void _onPageChange(int index) { + if (mounted) { + setState(() { + _currentDate = DateTime( + _currentDate.year, + _currentDate.month, + _currentDate.day + (index - _currentIndex), + ); + _currentIndex = index; + }); + } + widget.onPageChange?.call(_currentDate, _currentIndex); + } + + /// Animate to next page + /// + /// Arguments [duration] and [curve] will override default values provided + /// as [DayView.pageTransitionDuration] and [DayView.pageTransitionCurve] respectively. + /// + /// + void nextPage({Duration? duration, Curve? curve}) => _pageController.nextPage( + duration: duration ?? widget.pageTransitionDuration, + curve: curve ?? widget.pageTransitionCurve, + ); + + /// Animate to previous page + /// + /// Arguments [duration] and [curve] will override default values provided + /// as [DayView.pageTransitionDuration] and [DayView.pageTransitionCurve] respectively. + /// + /// + void previousPage({Duration? duration, Curve? curve}) => + _pageController.previousPage( + duration: duration ?? widget.pageTransitionDuration, + curve: curve ?? widget.pageTransitionCurve, + ); + + /// Jumps to page number [page] + /// + /// + void jumpToPage(int page) => _pageController.jumpToPage(page); + + /// Animate to page number [page]. + /// + /// Arguments [duration] and [curve] will override default values provided + /// as [DayView.pageTransitionDuration] and [DayView.pageTransitionCurve] respectively. + /// + /// + Future animateToPage(int page, + {Duration? duration, Curve? curve}) async => + await _pageController.animateToPage(page, + duration: duration ?? widget.pageTransitionDuration, + curve: curve ?? widget.pageTransitionCurve); + + /// Returns current page number. + /// + /// + int get currentPage => _currentIndex; + + /// Jumps to page which gives day calendar for [date] + /// + /// + void jumpToDate(DateTime date) { + if (date.isBefore(_minDate) || date.isAfter(_maxDate)) { + throw "Invalid date selected."; + } + _pageController.jumpToPage(_minDate.getDayDifference(date)); + } + + /// Animate to page which gives day calendar for [date]. + /// + /// Arguments [duration] and [curve] will override default values provided + /// as [DayView.pageTransitionDuration] and [DayView.pageTransitionCurve] respectively. + /// + /// + Future animateToDate(DateTime date, + {Duration? duration, Curve? curve}) async { + if (date.isBefore(_minDate) || date.isAfter(_maxDate)) { + throw "Invalid date selected."; + } + await _pageController.animateToPage( + _minDate.getDayDifference(date), + duration: duration ?? widget.pageTransitionDuration, + curve: curve ?? widget.pageTransitionCurve, + ); + } + + /// Returns the current visible date in day view. + DateTime get currentDate => + DateTime(_currentDate.year, _currentDate.month, _currentDate.day); +} diff --git a/lib/src/day_view/day_view_page.dart b/lib/src/day_view/day_view_page.dart new file mode 100644 index 00000000..d8434783 --- /dev/null +++ b/lib/src/day_view/day_view_page.dart @@ -0,0 +1,73 @@ +// Note: this is implementation for single page of day view. +// As we are not going in include it in initial release of plugin this is marked as commented. +// TODO: We will continue work on this once week view is completely implemented and we have more time else remove this file before releasing plugin. + +// import 'package:flutter/material.dart'; +// import 'package:flutter_calendar_page/flutter_calendar_page.dart'; +// import 'package:flutter_calendar_page/src/day_view/_internal_day_view_page.dart'; +// import 'package:flutter_calendar_page/src/extensions.dart'; +// +// import 'modals.dart'; +// +// export 'modals.dart'; +// +// class DayViewPage extends StatelessWidget { +// final double? width; +// final DateTime date; +// final EventTileBuilder eventTileBuilder; +// final EventArranger? eventArranger; +// final CalendarController controller; +// final DateWidgetBuilder? timeLineBuilder; +// final HourIndicatorSettings? hourIndicatorSettings; +// final bool showLiveLine; +// final HourIndicatorSettings? liveTimeIndicatorSettings; +// final double heightPerMinute; +// final double? timeLineWidth; +// final double timeLineOffset; +// final bool showVerticalLine; +// final double verticalLineOffset; +// +// const DayViewPage({ +// Key? key, +// this.width, +// required this.date, +// required this.controller, +// required this.eventTileBuilder, +// this.timeLineBuilder, +// this.hourIndicatorSettings, +// this.showLiveLine = true, +// this.liveTimeIndicatorSettings, +// this.heightPerMinute = 1, +// this.timeLineWidth, +// this.timeLineOffset = 0, +// this.eventArranger, +// this.showVerticalLine = true, +// this.verticalLineOffset = 10, +// }) : super(key: key); +// +// @override +// Widget build(BuildContext context) { +// double hourHeight = heightPerMinute * 60; +// double height = hourHeight * 24; +// double width = this.width ?? MediaQuery.of(context).size.width; +// +// return InternalDayViewPage( +// timeLineWidth: timeLineWidth??0, +// timeLineOffset: timeLineOffset, +// showLiveLine: showLiveLine, +// date: date, +// hourIndicatorSettings: hourIndicatorSettings??HourIndicatorSettings.none(), +// heightPerMinute: heightPerMinute, +// eventTileBuilder: eventTileBuilder, +// timeLineBuilder: timeLineBuilder, +// liveTimeIndicatorSettings: liveTimeIndicatorSettings, +// height: height, +// width: width, +// controller: controller, +// hourHeight: hourHeight, +// eventArranger: SideEventArranger(), +// showVerticalLine: showVerticalLine, +// verticalLineOffset: verticalLineOffset, +// ); +// } +// } diff --git a/lib/src/day_view/modals.dart b/lib/src/day_view/modals.dart new file mode 100644 index 00000000..d20db1aa --- /dev/null +++ b/lib/src/day_view/modals.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +/// Settings for hour lines +class HourIndicatorSettings { + final double height; + final Color color; + final double offset; + + /// Settings for hour lines + const HourIndicatorSettings({ + this.height = 1.0, + this.offset = 0.0, + this.color = Colors.grey, + }) : assert(height >= 0, "Height must be greater than or equal to 0."); + + factory HourIndicatorSettings.none() => HourIndicatorSettings( + color: Colors.transparent, + height: 0.0, + ); +} diff --git a/lib/src/event_arrangers/event_arrangers.dart b/lib/src/event_arrangers/event_arrangers.dart new file mode 100644 index 00000000..f3e6d8ed --- /dev/null +++ b/lib/src/event_arrangers/event_arrangers.dart @@ -0,0 +1,70 @@ +import 'dart:math' as math; + +import '../calendar_event_data.dart'; +import '../extensions.dart'; + +part 'merge_event_arranger.dart'; +part 'side_event_arranger.dart'; +part 'stack_event_arranger.dart'; + +abstract class EventArranger { + /// [EventArranger] defines how simultaneous events will be arranged. + /// Implement [arrange] method to define how events will be arranged. + /// + /// There are three predefined class that implements of [EventArranger]. + /// + /// [StackEventArranger], [SideEventArranger] and [MergeEventArranger]. + /// + const EventArranger(); + + /// This method will arrange all the events in and return List of [OrganizedCalendarEventData]. + /// + List> arrange({ + required List> events, + required double height, + required double width, + required double heightPerMinute, + }); +} + +/// Provides event data with its [left], [right], [top], and [bottom] boundary. +class OrganizedCalendarEventData { + final double top; + final double bottom; + final double left; + final double right; + final List?> events; + final DateTime? startDuration; + final DateTime? endDuration; + + /// Provides event data with its [left], [right], [top], and [bottom] boundary. + OrganizedCalendarEventData({ + this.startDuration, + this.endDuration, + required this.top, + required this.bottom, + required this.left, + required this.right, + required this.events, + }); + + OrganizedCalendarEventData.empty() + : this.startDuration = DateTime.now(), + this.endDuration = DateTime.now(), + this.right = 0, + this.left = 0, + this.events = const [], + this.top = 0, + this.bottom = 0; + + OrganizedCalendarEventData getWithUpdatedRight(double right) => + OrganizedCalendarEventData( + top: top, + bottom: bottom, + endDuration: endDuration, + events: events, + left: left, + right: right, + startDuration: startDuration, + ); +} diff --git a/lib/src/event_arrangers/merge_event_arranger.dart b/lib/src/event_arrangers/merge_event_arranger.dart new file mode 100644 index 00000000..eaf73372 --- /dev/null +++ b/lib/src/event_arrangers/merge_event_arranger.dart @@ -0,0 +1,111 @@ +part of 'event_arrangers.dart'; + +class MergeEventArranger extends EventArranger { + /// This class will provide method that will merge all the simultaneous events. and that will act like one single event. + /// [OrganizedCalendarEventData.events] will gives list of all the combined events. + /// + /// + const MergeEventArranger(); + + @override + List> arrange({ + required List> events, + required double height, + required double width, + required double heightPerMinute, + }) { + List> arrangedEvents = []; + + List> skippedEvents = []; + + for (CalendarEventData event in events) { + DateTime startTime = event.startTime ?? DateTime.now(); + DateTime endTime = event.endTime ?? startTime; + // If event has null start time or null end time or end time is earlier than start time or end time and tart time is same. + // Skip that event. + // + if (endTime.getTotalMinutes <= startTime.getTotalMinutes) { + skippedEvents.add(event); + continue; + } + int eventStart = startTime.getTotalMinutes; + int eventEnd = endTime.getTotalMinutes; + + int arrangeEventLen = arrangedEvents.length; + + int eventIndex = -1; + + for (int i = 0; i < arrangeEventLen; i++) { + int arrangedEventStart = + arrangedEvents[i].startDuration?.getTotalMinutes ?? 0; + int arrangedEventEnd = + arrangedEvents[i].endDuration?.getTotalMinutes ?? 0; + + if ((arrangedEventStart >= eventStart && + arrangedEventStart <= eventEnd) || + (arrangedEventEnd >= eventStart && arrangedEventEnd <= eventEnd) || + (eventStart >= arrangedEventStart && + eventStart <= arrangedEventEnd) || + (eventEnd >= arrangedEventStart && eventEnd <= arrangedEventEnd)) { + eventIndex = i; + break; + } + } + + if (eventIndex == -1) { + double top = eventStart * heightPerMinute; + double left = 0; + double right = 0; + double bottom = height - eventEnd * heightPerMinute; + + OrganizedCalendarEventData newEvent = OrganizedCalendarEventData( + top: top, + bottom: bottom, + left: left, + right: right, + startDuration: startTime.copyFromMinutes(eventStart), + endDuration: endTime.copyFromMinutes(eventEnd), + events: [event], + ); + + arrangedEvents.add(newEvent); + } else { + OrganizedCalendarEventData arrangedEventData = + arrangedEvents[eventIndex]; + + int arrangedEventStart = + arrangedEventData.startDuration?.getTotalMinutes ?? 0; + int arrangedEventEnd = + arrangedEventData.endDuration?.getTotalMinutes ?? 0; + + int startDuration = math.min(eventStart, arrangedEventStart); + int endDuration = math.max(eventEnd, arrangedEventEnd); + + double top = startDuration * heightPerMinute; + double left = 0; + double right = 0; + double bottom = height - endDuration * heightPerMinute; + + OrganizedCalendarEventData newEvent = OrganizedCalendarEventData( + top: top, + bottom: bottom, + left: left, + right: right, + startDuration: + arrangedEventData.startDuration?.copyFromMinutes(startDuration), + endDuration: + arrangedEventData.endDuration?.copyFromMinutes(endDuration), + events: arrangedEventData.events..add(event), + ); + + arrangedEvents[eventIndex] = newEvent; + } + } + + print("Skipped Event... Total: ${skippedEvents.length}"); + print(skippedEvents); + print("End Skipped Event...."); + + return arrangedEvents; + } +} diff --git a/lib/src/event_arrangers/side_event_arranger.dart b/lib/src/event_arrangers/side_event_arranger.dart new file mode 100644 index 00000000..bd25e374 --- /dev/null +++ b/lib/src/event_arrangers/side_event_arranger.dart @@ -0,0 +1,157 @@ +part of 'event_arrangers.dart'; + +class SideEventArranger extends EventArranger { + /// This class will provide method that will arrange all the events side by side. + /// + const SideEventArranger(); + + @override + List> arrange({ + required List> events, + required double height, + required double width, + required double heightPerMinute, + }) { + List durations = _getEventsDuration(events); + List> tempEvents = [...events]; + tempEvents.sort((e1, e2) => + (e1.startTime?.getTotalMinutes ?? 0) - + (e2.startTime?.getTotalMinutes ?? 0)); + + List?>> table = List.generate( + events.length, + (index) => List.generate(durations.length, (index) => null), + ); + + int eventCounter = 0; + int rowCounter = 0; + + while (tempEvents.isNotEmpty && rowCounter < events.length) { + eventCounter = 0; + + int end = tempEvents[0].endTime?.getTotalMinutes ?? 0; + + _insertIntoTable(table, durations, rowCounter, tempEvents[0]); + tempEvents.removeAt(0); + + while (tempEvents.isNotEmpty && eventCounter < tempEvents.length) { + if ((tempEvents[eventCounter].startTime?.getTotalMinutes ?? 0) > end) { + _insertIntoTable( + table, durations, rowCounter, tempEvents[eventCounter]); + end = tempEvents[eventCounter].endTime?.getTotalMinutes ?? 0; + tempEvents.removeAt(eventCounter); + } else { + eventCounter++; + } + } + rowCounter++; + } + + List> arrangedEvent = []; + + double widthPerCol = width / rowCounter; + + // TODO: Rearrange events in table to fill empty space once table is created. + // TODO: Solve scenario when start time of a event is same as end time of other event. + + for (int i = 0; i < rowCounter; i++) { + CalendarEventData? event; + for (int j = 0; j < durations.length; j++) { + if (table[i][j] != null && (event == null || table[i][j] != event)) { + event = table[i][j]; + + double top = + (event?.startTime?.getTotalMinutes ?? 0) * heightPerMinute; + double bottom = height - + ((event?.endTime?.getTotalMinutes ?? 0) * heightPerMinute); + double left = widthPerCol * (i); + double right = width - (left + widthPerCol); + + int index = _containsEvent(arrangedEvent, event); + + if (index == -1) { + OrganizedCalendarEventData eventData = + OrganizedCalendarEventData( + top: top, + bottom: bottom, + events: [event], + left: left, + right: right, + endDuration: event?.startTime ?? DateTime.now(), + startDuration: event?.endTime ?? DateTime.now(), + ); + arrangedEvent.add(eventData); + } else { + arrangedEvent[index] = arrangedEvent[index] + .getWithUpdatedRight(arrangedEvent[index].right - widthPerCol); + } + } else { + continue; + } + } + } + return arrangedEvent; + } + + /// Prints the table. + void _printTable( + List?>> table, int row, int column) { + for (int i = 0; i < row; i++) { + String data = "$i. "; + for (int j = 0; j < column; j++) { + if (table[i][j] == null) + data += "null "; + else + data += "${table[i][j]?.event?.toString().split(" ").last} "; + } + print(data); + } + } + + int _containsEvent( + List> events, CalendarEventData? event) { + for (int i = 0; i < events.length; i++) { + if (events[i].events.length > 0 && events[i].events[0] == event) return i; + } + return -1; + } + + void _insertIntoTable(List?>> table, + List durations, int row, CalendarEventData event) { + int i = 0; + + int start = event.startTime?.getTotalMinutes ?? 0; + int end = event.endTime?.getTotalMinutes ?? 0; + + while (i < durations.length && durations[i] != start) i++; + + while (i < durations.length && durations[i] <= end) { + table[row][i++] = event; + } + } + + /// This method returns list of all durations (start and end) in ascending order. + List _getEventsDuration(List> events) { + List durations = []; + for (CalendarEventData event in events) { + int start = event.startTime?.getTotalMinutes ?? 0; + int end = event.endTime?.getTotalMinutes ?? 0; + int i; + + /// Get position where we can add start duration + for (i = 0; i < durations.length && durations[i] < start; i++) {} + + /// Check if start duration is not repeating or if i is equal to length of durations list then add duration because duration will not be repeating if there is no element at i index. + if (i == durations.length || durations[i] != start) + durations.insert(i, start); + + /// Get position where we can add end duration. + for (i = i + 1; i < durations.length && durations[i] < end; i++) {} + + /// Check if end duration is not repeating or if i is equal to length of durations list then add duration because duration will not be repeating if there is no element at i index. + if (i == durations.length || durations[i] != end) + durations.insert(i, end); + } + return durations; + } +} diff --git a/lib/src/event_arrangers/stack_event_arranger.dart b/lib/src/event_arrangers/stack_event_arranger.dart new file mode 100644 index 00000000..0c61bfa2 --- /dev/null +++ b/lib/src/event_arrangers/stack_event_arranger.dart @@ -0,0 +1,25 @@ +part of 'event_arrangers.dart'; + +class StackEventArranger extends EventArranger { + final double leftOffset; + final double topOffset; + + /// This class will provide method that wil arrange all the events on each other. + /// + const StackEventArranger({this.leftOffset = 5, this.topOffset = 10}) + : assert(topOffset > 0, "Top offset must be grater than 0."); + + @override + List> arrange({ + required List> events, + required double height, + required double width, + required double heightPerMinute, + }) { + List> arrangedEvents = []; + + // TODO: Add logic to arrange events + + return arrangedEvents; + } +} diff --git a/lib/src/extensions.dart b/lib/src/extensions.dart index 5d45a99a..dfd1f037 100644 --- a/lib/src/extensions.dart +++ b/lib/src/extensions.dart @@ -1,9 +1,72 @@ +export 'typedefs.dart'; + extension DateTimeExtensions on DateTime { bool compareWithoutTime(DateTime date) { - if (date == null) throw "Null value provided."; - return this.day == date.day && this.month == date.month && this.year == date.year; } + + bool hasSameTimeAs(DateTime date) => + this.getTotalMinutes == date.getTotalMinutes; + + int getMonthDifference(DateTime date) { + if (this.year == date.year) return ((date.month - this.month).abs() + 1); + + int months = ((date.year - this.year).abs() - 1) * 12; + + if (date.year >= this.year) { + months += date.month + (13 - this.month); + } else { + months += this.month + (13 - date.month); + } + + return months; + } + + int getDayDifference(DateTime date) => this.difference(date).inDays.abs(); + int getWeekDifference(DateTime date) => + (this.difference(date).inDays.abs() / 7).ceil(); + + /// Returns The List of date of Current Week + /// Day will start from Monday to Sunday. + /// + /// ex: if Current Date instance is 8th and day is wednesday then weekDates will return dates + /// [6,7,8,9,10,11,12] + /// Where on 6th there will be monday and on 12th there will be Sunday + List get datesOfWeek { + int day = this.weekday; + DateTime start = this.subtract(Duration(days: day - 1)); + + return [ + start, + start.add(Duration(days: 1)), + start.add(Duration(days: 2)), + start.add(Duration(days: 3)), + start.add(Duration(days: 4)), + start.add(Duration(days: 5)), + start.add(Duration(days: 6)), + ]; + } + + List get datesOfMonths { + List monthDays = []; + for (int i = 1, start = 1; i < 7; i++, start += 7) { + monthDays.addAll(DateTime(this.year, this.month, start).datesOfWeek); + } + return monthDays; + } + + String get formatted => "${this.month}-${this.year}"; + + int get getTotalMinutes => this.hour * 60 + this.minute; + + DateTime copyFromMinutes([int totalMinutes = 0]) => DateTime( + this.year, + this.month, + this.day, + totalMinutes ~/ 60, + totalMinutes % 60, + 0, + ); } diff --git a/lib/src/month_view/month_view.dart b/lib/src/month_view/month_view.dart index d31c589b..cc215d2f 100644 --- a/lib/src/month_view/month_view.dart +++ b/lib/src/month_view/month_view.dart @@ -1,13 +1,436 @@ import 'package:flutter/material.dart'; +import 'package:flutter_calendar_page/flutter_calendar_page.dart'; +import 'package:flutter_calendar_page/src/extensions.dart'; + +class MonthView extends StatefulWidget { + /// A function that returns a [Widget] that determines appearance of each cell in month calendar. + final CellBuilder? cellBuilder; + + /// Builds month page title. + /// + /// Used default title builder if null. + final DateWidgetBuilder? headerBuilder; + + final CalendarPageChangeCallBack? onPageChange; + + /// Builds the name of the weeks. + /// + /// Used default week builder if null. + /// + /// Here day will range from 0 to 6 starting from Monday to Sunday. + final WeekDayBuilder? weekDayBuilder; + + /// Determines the lower boundary user can scroll. + /// + /// If not provided [Constants.epochDate] is default. + final DateTime? minMonth; + + /// Determines upper boundary user can scroll. + /// + /// If not provided [Constants.maxDate] is default. + final DateTime? maxMonth; + + /// Defines initial display month. + /// + /// If not provided current date is default date. + final DateTime? initialMonth; + + /// Defines whether to show default borders or not. + /// + /// Default value is true + /// + /// Use [borderSize] to define width of the border and + /// [borderColor] to define color of the border. + final bool showBorder; + + /// Defines width of default border + /// + /// Default value is [Colors.blue] + /// + /// It will take affect only if [showBorder] is set. + final Color borderColor; + + /// Page transition duration used when user try to change page using [MonthView.nextPage] or [MonthView.previousPage] + /// + final Duration pageTransitionDuration; + + /// Page transition curve used when user try to change page using [MonthView.nextPage] or [MonthView.previousPage] + /// + final Curve pageTransitionCurve; + + /// A required parameters that controls events for month view. + /// + /// This will auto update month view when user adds events in controller. + /// This controller will store all the events. And returns events for particular day. + /// + final CalendarController controller; + + /// Defines width of default border + /// + /// Default value is 1 + /// + /// It will take affect only if [showBorder] is set. + final double borderSize; + + /// Defines aspect ratio of day cells in month calendar page. + final double cellAspectRatio; + + /// Defines aspect ratio of week cells in month calendar page. + /// This ratio is for week titles. + final double weekCellAspectRatio; + + final double? width; + + /// Here T determines Type of your event class. You can specify Type of class in which you are storing your event data. + const MonthView({ + Key? key, + this.weekCellAspectRatio = 1.5, + this.showBorder = true, + this.borderColor = Colors.blue, + this.cellBuilder, + this.minMonth, + this.maxMonth, + required this.controller, + this.initialMonth, + this.borderSize = 1, + this.cellAspectRatio = 0.55, + this.headerBuilder, + this.weekDayBuilder, + this.pageTransitionDuration = const Duration(milliseconds: 300), + this.pageTransitionCurve = Curves.ease, + this.width, + this.onPageChange, + }) : super(key: key); -class MonthView extends StatefulWidget { @override - _MonthViewState createState() => _MonthViewState(); + MonthViewState createState() => MonthViewState(); } -class _MonthViewState extends State { +class MonthViewState extends State> { + late DateTime _minDate; + late DateTime _maxDate; + late DateTime _currentDate; + late int _currentIndex; + + int _totalMonths = 0; + + late PageController _pageController; + + late double _width; + late double _cellWidth; + late double _cellHeight; + late double _height; + + late CellBuilder _cellBuilder; + late WeekDayBuilder _weekBuilder; + + late DateWidgetBuilder _headerBuilder; + + @override + void initState() { + super.initState(); + + _minDate = widget.minMonth ?? Constants.epochDate; + _maxDate = widget.maxMonth ?? Constants.maxDate; + + _currentDate = widget.initialMonth ?? DateTime.now(); + + if (_currentDate.isBefore(_minDate)) { + _currentDate = _minDate; + } else if (_currentDate.isAfter(_maxDate)) { + _currentDate = _maxDate; + } + + _totalMonths = _maxDate.getMonthDifference(_minDate); + + widget.controller.addListener(_reload); + + _currentIndex = _minDate.getMonthDifference(_currentDate) - 1; + + _pageController = PageController(initialPage: _currentIndex); + + _cellBuilder = widget.cellBuilder ?? _defaultCellBuilder; + _weekBuilder = widget.weekDayBuilder ?? _defaultWeekDayBuilder; + _headerBuilder = widget.headerBuilder ?? _defaultHeaderBuilder; + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _width = widget.width ?? MediaQuery.of(context).size.width; + _cellWidth = _width / 7; + _cellHeight = _cellWidth / widget.cellAspectRatio; + _height = _cellHeight * 6 + (_cellWidth / widget.weekCellAspectRatio); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: _width, + child: _headerBuilder(_currentDate), + ), + Expanded( + child: SingleChildScrollView( + child: SizedBox( + width: _width, + height: _height, + child: PageView.builder( + scrollDirection: Axis.horizontal, + controller: _pageController, + onPageChanged: _onPageChange, + itemBuilder: (_, index) { + DateTime date = + DateTime(_minDate.year, _minDate.month + index, 1); + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: _width, + child: GridView.builder( + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + childAspectRatio: widget.weekCellAspectRatio, + ), + shrinkWrap: true, + itemCount: 7, + itemBuilder: (_, index) { + return _weekBuilder(index); + }, + ), + ), + Expanded( + child: _MonthPageBuilder( + key: ValueKey(date.toIso8601String()), + width: _width, + height: _height, + controller: widget.controller, + borderColor: widget.borderColor, + borderSize: widget.borderSize, + cellBuilder: _cellBuilder, + cellRatio: widget.cellAspectRatio, + date: date, + showBorder: widget.showBorder, + ), + ), + ], + ); + }, + itemCount: _totalMonths, + ), + ), + ), + ), + ], + ), + ); + } + + void _reload() { + if (mounted) { + setState(() {}); + } + } + + /// Calls when user changes page using gesture or inbuilt methods. + void _onPageChange(int value) { + if (mounted) { + setState(() { + _currentDate = DateTime( + _currentDate.year, + _currentDate.month + (value - _currentIndex), + _currentDate.day, + ); + _currentIndex = value; + }); + } + widget.onPageChange?.call(_currentDate, _currentIndex); + } + + /// Default month view header builder + Widget _defaultHeaderBuilder(DateTime date) { + return MonthPageHeader( + onTitleTapped: () async { + DateTime? selectedDate = await showDatePicker( + context: context, + initialDate: date, + firstDate: Constants.minDate, + lastDate: Constants.maxDate, + ); + + if (selectedDate == null) return; + this.jumpToDate(selectedDate); + }, + onPreviousMonth: previousPage, + date: date, + onNextMonth: nextPage, + ); + } + + /// Default builder for week line. + Widget _defaultWeekDayBuilder(int index) { + return Container( + alignment: Alignment.center, + child: Text(Constants.weekTitles[index]), + ); + } + + /// Default cell builder. Used when [widget.cellBuilder] is null + /// + Widget _defaultCellBuilder( + date, List> events, isToday, isInMonth) { + return FilledCell( + date: date, + shouldHighlight: isToday, + backgroundColor: isInMonth ? Color(0xffffffff) : Color(0xffdedede), + events: events, + ); + } + + /// Animate to next page + /// + /// Arguments [duration] and [curve] will override default values provided + /// as [MonthView.pageTransitionDuration] and [MonthView.pageTransitionCurve] respectively. + void nextPage({Duration? duration, Curve? curve}) { + _pageController.nextPage( + duration: duration ?? widget.pageTransitionDuration, + curve: curve ?? widget.pageTransitionCurve, + ); + } + + /// Animate to previous page + /// + /// Arguments [duration] and [curve] will override default values provided + /// as [MonthView.pageTransitionDuration] and [MonthView.pageTransitionCurve] respectively. + void previousPage({Duration? duration, Curve? curve}) { + _pageController.previousPage( + duration: duration ?? widget.pageTransitionDuration, + curve: curve ?? widget.pageTransitionCurve, + ); + } + + /// Jumps to page number [page] + void jumpToPage(int page) { + _pageController.jumpToPage(page); + } + + /// Animate to page number [page]. + /// + /// Arguments [duration] and [curve] will override default values provided + /// as [MonthView.pageTransitionDuration] and [MonthView.pageTransitionCurve] respectively. + Future animateToPage(int page, + {Duration? duration, Curve? curve}) async { + await _pageController.animateToPage(page, + duration: duration ?? widget.pageTransitionDuration, + curve: curve ?? widget.pageTransitionCurve); + } + + /// Returns current page number. + int get currentPage => _currentIndex; + + /// Jumps to page which gives month calendar for [date] + void jumpToDate(DateTime date) { + if (date.isBefore(_minDate) || date.isAfter(_maxDate)) { + throw "Invalid date selected."; + } + _pageController.jumpToPage(_minDate.getMonthDifference(date) - 1); + } + + /// Animate to page which gives month calendar for [date]. + /// + /// Arguments [duration] and [curve] will override default values provided + /// as [MonthView.pageTransitionDuration] and [MonthView.pageTransitionCurve] respectively. + Future animateToDate(DateTime date, + {Duration? duration, Curve? curve}) async { + if (date.isBefore(_minDate) || date.isAfter(_maxDate)) { + throw "Invalid date selected."; + } + await _pageController.animateToPage( + _minDate.getMonthDifference(date) - 1, + duration: duration ?? widget.pageTransitionDuration, + curve: curve ?? widget.pageTransitionCurve, + ); + } + + /// Returns the current visible date in month view. + DateTime get currentDate => + DateTime(_currentDate.year, _currentDate.month, _currentDate.day); +} + +/// A single month page. +class _MonthPageBuilder extends StatelessWidget { + final double cellRatio; + final bool showBorder; + final double borderSize; + final Color borderColor; + final CellBuilder cellBuilder; + final DateTime date; + final CalendarController controller; + final double width; + final double height; + + const _MonthPageBuilder({ + Key? key, + required this.cellRatio, + required this.showBorder, + required this.borderSize, + required this.borderColor, + required this.cellBuilder, + required this.date, + required this.controller, + this.width = 0, + this.height = 0, + }) : super(key: key); + @override Widget build(BuildContext context) { - return Container(); + List monthDays = date.datesOfMonths; + return Container( + width: width, + height: height, + child: GridView.builder( + physics: ClampingScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + childAspectRatio: cellRatio, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + itemCount: 42, + shrinkWrap: true, + itemBuilder: (context, index) { + return Container( + decoration: BoxDecoration( + border: showBorder + ? Border.all( + color: borderColor, + width: borderSize, + ) + : null, + ), + child: cellBuilder( + monthDays[index], + controller.getEventsOnDay(monthDays[index]), + monthDays[index].compareWithoutTime(DateTime.now()), + monthDays[index].month == date.month, + ), + ); + }, + ), + ); } } diff --git a/lib/src/painters.dart b/lib/src/painters.dart new file mode 100644 index 00000000..26465768 --- /dev/null +++ b/lib/src/painters.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +/// Paints 24 hour lines. +class HourLinePainter extends CustomPainter { + final Color lineColor; + final double lineHeight; + final double offset; + final double minuteHeight; + final bool showVerticalLine; + final double verticalLineOffset; + + /// Paints 24 hour lines. + HourLinePainter({ + required this.lineColor, + required this.lineHeight, + required this.minuteHeight, + required this.offset, + required this.showVerticalLine, + this.verticalLineOffset = 10, + }); + + @override + void paint(Canvas canvas, Size size) { + Paint paint = Paint() + ..color = lineColor + ..strokeWidth = lineHeight; + + for (int i = 1; i < 24; i++) { + double dy = i * minuteHeight * 60; + canvas.drawLine(Offset(offset, dy), Offset(size.width, dy), paint); + } + + if (showVerticalLine) + canvas.drawLine(Offset(offset + verticalLineOffset, 0), + Offset(offset + 10, size.height), paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return oldDelegate is HourLinePainter && + (oldDelegate.lineColor != this.lineColor || + oldDelegate.offset != this.offset || + this.lineHeight != oldDelegate.lineHeight || + this.minuteHeight != oldDelegate.minuteHeight || + this.showVerticalLine != oldDelegate.showVerticalLine); + } +} + +/// Paints a single horizontal line at [offset]. +class CurrentTimeLinePainter extends CustomPainter { + final Color color; + final double height; + final Offset offset; + final bool showBullet; + final double bulletRadius; + + /// Paints a single horizontal line at [offset]. + CurrentTimeLinePainter({ + this.showBullet = true, + required this.color, + required this.height, + required this.offset, + this.bulletRadius = 5, + }); + + @override + void paint(Canvas canvas, Size size) { + canvas.drawLine( + Offset(offset.dx, offset.dy), + Offset(size.width, offset.dy), + Paint() + ..color = color + ..strokeWidth = height, + ); + + if (showBullet) + canvas.drawCircle( + Offset(offset.dx, offset.dy), bulletRadius, Paint()..color = color); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => + oldDelegate is CurrentTimeLinePainter && + (this.color != oldDelegate.color || + this.height != oldDelegate.height || + this.offset != oldDelegate.offset); +} diff --git a/lib/src/typedefs.dart b/lib/src/typedefs.dart new file mode 100644 index 00000000..26f4a3a3 --- /dev/null +++ b/lib/src/typedefs.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import '../flutter_calendar_page.dart'; + +typedef CellBuilder = Widget Function( + DateTime date, + List> event, + bool isToday, + bool isInMonth, +); + +typedef EventTileBuilder = Widget Function( + DateTime date, + List?> events, + Rect boundary, + DateTime startDuration, + DateTime endDuration, +); + +typedef WeekDayBuilder = Widget Function( + int day, +); + +typedef DateWidgetBuilder = Widget Function( + DateTime date, +); + +typedef CalendarPageChangeCallBack = void Function(DateTime date, int page); + +typedef PageChangeCallback = void Function( + DateTime date, + CalendarEventData event, +); + +typedef StringProvider = String Function(DateTime date); diff --git a/pubspec.yaml b/pubspec.yaml index bab7cd8a..50459ae3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,7 +5,7 @@ author: homepage: environment: - sdk: ">=2.7.0 <3.0.0" + sdk: ">=2.12.0 <3.0.0" flutter: ">=1.17.0" dependencies: