Kirigami-addons

DatePicker.qml
1// SPDX-FileCopyrightText: 2021 Claudio Cambra <claudio.cambra@gmail.com>
2// SPDX-FileCopyrightText: 2023 Carl Schwan <carl@carlschwan.eu>
3// SPDX-License-Identifier: LGPL-2.1-or-later
4
5import QtQuick
6import QtQuick.Controls as QQC2
7import QtQuick.Layouts
8import org.kde.kirigami as Kirigami
9import org.kde.kirigamiaddons.dateandtime
10import org.kde.kirigamiaddons.components as Components
11import org.kde.kirigamiaddons.delegates as Delegates
12
13QQC2.Control {
14 id: root
15
16 signal datePicked(date pickedDate)
17
18 property date selectedDate: new Date() // Decides calendar span
19 readonly property int year: selectedDate.getFullYear()
20 readonly property int month: selectedDate.getMonth()
21 readonly property int day: selectedDate.getDate()
22 property bool showDays: true
23 property bool showControlHeader: true
24
25 /**
26 * This property holds the minimum date (inclusive) that the user can select.
27 *
28 * By default, no limit is applied to the date selection.
29 */
30 property date minimumDate
31
32 /**
33 * This property holds the maximum date (inclusive) that the user can select.
34 *
35 * By default, no limit is applied to the date selection.
36 */
37 property date maximumDate
38
39 topPadding: Kirigami.Units.largeSpacing
40 rightPadding: Kirigami.Units.largeSpacing
41 bottomPadding: Kirigami.Units.largeSpacing
42 leftPadding: Kirigami.Units.largeSpacing
43
44 onActiveFocusChanged: if (activeFocus) {
45 dateSegmentedButton.forceActiveFocus();
46 }
47
48 property bool _completed: false
49 property bool _runSetDate: false
50
51 onSelectedDateChanged: if (selectedDate !== null && _completed) {
52 setToDate(selectedDate)
53 }
54
55 Component.onCompleted: {
56 _completed = true;
57 if (selectedDate) {
58 setToDate(selectedDate);
59 }
60 }
61 onShowDaysChanged: if (!showDays) pickerView.currentIndex = 1;
62
63 function setToDate(date) {
64 if (_runSetDate) {
65 return;
66 }
67 _runSetDate = true;
68
69 if (root.minimumDate.valueOf() && date.valueOf() < minimumDate.valueOf()) {
70 date = minimumDate;
71 }
72
73 if (root.maximumDate.valueOf() && date.valueOf() > maximumDate.valueOf()) {
74 date = maximumDate;
75 }
76
77 if (yearPathView.currentItem !== null) {
78 const yearDiff = date.getFullYear() - yearPathView.currentItem.startDate.getFullYear();
79 let newYearIndex = yearPathView.currentIndex + yearDiff;
80 let firstYearItemDate = yearPathView.model.data(yearPathView.model.index(1,0), InfiniteCalendarViewModel.StartDateRole);
81 let lastYearItemDate = yearPathView.model.data(yearPathView.model.index(yearPathView.model.rowCount() - 2,0), InfiniteCalendarViewModel.StartDateRole);
82
83 // Set to index and create dates if needed for year view
84 while (firstYearItemDate >= date) {
85 yearPathView.model.addDates(false)
86 firstYearItemDate = yearPathView.model.data(yearPathView.model.index(1,0), InfiniteCalendarViewModel.StartDateRole);
87 newYearIndex = 0;
88 }
89 if (firstYearItemDate < date && newYearIndex === 0) {
90 newYearIndex = date.getFullYear() - firstYearItemDate.getFullYear() + 1;
91 }
92
93 while (lastYearItemDate <= date) {
94 yearPathView.model.addDates(true)
95 lastYearItemDate = yearPathView.model.data(yearPathView.model.index(yearPathView.model.rowCount() - 1,0), InfiniteCalendarViewModel.StartDateRole);
96 }
97
98 yearPathView.currentIndex = newYearIndex;
99 }
100
101 if (decadePathView.currentItem !== null) {
102 // For the decadeDiff we add one to the input date year so that we use e.g. 2021, making the pathview move to the grid that contains the 2020 decade
103 // instead of staying within the 2010 decade, which contains a 2020 cell at the very end
104 const decadeDiff = Math.floor((date.getFullYear() + 1 - decadePathView.currentItem.startDate.getFullYear()) / 12); // 12 years in one decade grid
105 let newDecadeIndex = decadePathView.currentIndex + decadeDiff;
106 let firstDecadeItemDate = decadePathView.model.data(decadePathView.model.index(1,0), InfiniteCalendarViewModel.StartDateRole);
107 let lastDecadeItemDate = decadePathView.model.data(decadePathView.model.index(decadePathView.model.rowCount() - 1,0), InfiniteCalendarViewModel.StartDateRole);
108
109 // Set to index and create dates if needed for decade view
110 while (firstDecadeItemDate >= date) {
111 decadePathView.model.addDates(false)
112 firstDecadeItemDate = decadePathView.model.data(decadePathView.model.index(1,0), InfiniteCalendarViewModel.StartDateRole);
113 newDecadeIndex = 0;
114 }
115 if (firstDecadeItemDate < date && newDecadeIndex === 0) {
116 newDecadeIndex = date.getFullYear() - firstDecadeItemDate.getFullYear() + 1;
117 }
118
119 while (lastDecadeItemDate.getFullYear() <= date.getFullYear()) {
120 decadePathView.model.addDates(true)
121 lastDecadeItemDate = decadePathView.model.data(decadePathView.model.index(decadePathView.model.rowCount() - 1,0), InfiniteCalendarViewModel.StartDateRole);
122 }
123
124 decadePathView.currentIndex = newDecadeIndex;
125 }
126
127 if (showDays && monthPathView.currentItem !== null) { // Set to correct index, including creating new dates in model if needed, for the month view
128 const monthDiff = date.getMonth() - monthPathView.currentItem.firstDayOfMonth.getMonth() + (12 * (date.getFullYear() - monthPathView.currentItem.firstDayOfMonth.getFullYear()));
129 let newMonthIndex = monthPathView.currentIndex + monthDiff;
130 let firstMonthItemDate = monthPathView.model.data(monthPathView.model.index(1,0), InfiniteCalendarViewModel.FirstDayOfMonthRole);
131 let lastMonthItemDate = monthPathView.model.data(monthPathView.model.index(monthPathView.model.rowCount() - 1,0), InfiniteCalendarViewModel.FirstDayOfMonthRole);
132
133 while(firstMonthItemDate >= date) {
134 monthPathView.model.addDates(false)
135 firstMonthItemDate = monthPathView.model.data(monthPathView.model.index(1,0), InfiniteCalendarViewModel.FirstDayOfMonthRole);
136 newMonthIndex = 0;
137 }
138 if(firstMonthItemDate < date && newMonthIndex === 0) {
139 newMonthIndex = date.getMonth() - firstMonthItemDate.getMonth() + (12 * (date.getFullYear() - firstMonthItemDate.getFullYear())) + 1;
140 }
141
142 while(lastMonthItemDate <= date) {
143 monthPathView.model.addDates(true)
144 lastMonthItemDate = monthPathView.model.data(monthPathView.model.index(monthPathView.model.rowCount() - 1,0), InfiniteCalendarViewModel.FirstDayOfMonthRole);
145 }
146
147 monthPathView.currentIndex = newMonthIndex;
148 }
149
150 _runSetDate = false;
151 }
152
153 function goToday() {
154 selectedDate = new Date()
155 }
156
157 function prevMonth() {
158 const newDate = new Date(selectedDate.getFullYear(), selectedDate.getMonth() - 1, selectedDate.getDate());
159 if (root.minimumDate.valueOf() && newDate.valueOf() < minimumDate.valueOf()) {
160 if (selectedDate == minimumDate) {
161 return;
162 }
163 selectedDate = minimumDate;
164 } else {
165 selectedDate = newDate;
166 }
167 }
168
169 function nextMonth() {
170 const newDate = new Date(selectedDate.getFullYear(), selectedDate.getMonth() + 1, selectedDate.getDate());
171 if (root.maximumDate.valueOf() && newDate.valueOf() > maximumDate.valueOf()) {
172 if (selectedDate == maximumDate) {
173 return;
174 }
175 selectedDate = maximumDate;
176 return;
177 } else {
178 selectedDate = newDate;
179 }
180 }
181
182 function prevYear() {
183 const newDate = new Date(selectedDate.getFullYear() - 1, selectedDate.getMonth(), selectedDate.getDate())
184 if (root.minimumDate.valueOf() && newDate.valueOf() < minimumDate.valueOf()) {
185 if (selectedDate == minimumDate) {
186 return;
187 }
188 selectedDate = minimumDate;
189 } else {
190 selectedDate = newDate;
191 }
192 }
193
194 function nextYear() {
195 const newDate = new Date(selectedDate.getFullYear() + 1, selectedDate.getMonth(), selectedDate.getDate());
196 if (root.maximumDate && newDate.valueOf() > maximumDate.valueOf()) {
197 if (selectedDate == maximumDate) {
198 return;
199 }
200 selectedDate = maximumDate;
201 } else {
202 selectedDate = newDate;
203 }
204 }
205
206 function prevDecade() {
207 const newDate = new Date(selectedDate.getFullYear() - 10, selectedDate.getMonth(), selectedDate.getDate());
208 if (root.minimumDate.valueOf() && newDate.valueOf() < minimumDate.valueOf()) {
209 if (selectedDate == minimumDate) {
210 return;
211 }
212 selectedDate = minimumDate;
213 } else {
214 selectedDate = newDate;
215 }
216 }
217
218 function nextDecade() {
219 const newDate = new Date(selectedDate.getFullYear() + 10, selectedDate.getMonth(), selectedDate.getDate())
220 if (root.maximumDate && newDate.valueOf() > maximumDate.valueOf()) {
221 if (selectedDate == maximumDate) {
222 return;
223 }
224 selectedDate = maximumDate;
225 } else {
226 selectedDate = newDate;
227 }
228 }
229
230 contentItem: ColumnLayout {
231 id: pickerLayout
232
233 RowLayout {
234 id: headingRow
235 Layout.fillWidth: true
236 Layout.bottomMargin: Kirigami.Units.smallSpacing
237
238 Components.SegmentedButton {
239 id: dateSegmentedButton
240
241 actions: [
242 Kirigami.Action {
243 id: dayAction
244 text: root.selectedDate.getDate()
245 onTriggered: pickerView.currentIndex = 0 // dayGrid is first item in pickerView
246 checked: pickerView.currentIndex === 0
247 },
248 Kirigami.Action {
249 id: monthAction
250 text: root.selectedDate.toLocaleDateString(Qt.locale(), "MMMM")
251 onTriggered: pickerView.currentIndex = 1
252 checked: pickerView.currentIndex === 1
253 },
254 Kirigami.Action {
255 id: yearsViewCheck
256 text: root.selectedDate.getFullYear()
257 onTriggered: pickerView.currentIndex = 2
258 checked: pickerView.currentIndex === 2
259 }
260 ]
261 }
262
263 Instantiator {
264 model:dateSegmentedButton.children
265 Item {
266 required property Item modelData
267 parent: modelData
268 anchors.fill: parent
269 Accessible.ignored: !modelData.action
270 Accessible.role: Accessible.Dial
271 Accessible.focusable: true
272 Accessible.focused: parent.activeFocus
273 Accessible.name: {
274 if (modelData.action === dayAction) {
275 return i18nd("kirigami-addons6", "Day")
276 }
277 if (modelData.action === monthAction) {
278 return i18nd("kirigami-addons6", "Month")
279 }
280 if (modelData.action === yearsViewCheck) {
281 return i18nd("kirigami-addons6", "Year")
282 }
283 return ""
284 }
285 property int maximumValue: {
286 if (modelData.action === dayAction) {
287 if (maximumDate.valueOf() && root.year === maximumDate.getYear() && root.month === maximumDate.getMonth()) {
288 return maximumDate.getDate()
289 }
290 return 31
291 }
292 if (modelData.action === monthAction) {
293 if (maximumDate.valueOf() && root.year === maximumDate.getYear() ) {
294 return maximumDate.month() + 1
295 }
296 return 12
297 }
298 if (modelData.action === yearsViewCheck) {
299 if (maximumDate.valueOf()) {
300 return maximumDate.getYear()
301 }
302 return 9999
303 }
304 return 0
305 }
306 property int minimumValue: {
307 if (modelData.action === dayAction) {
308 if (minimumDate.valueOf() && root.year === minimumDate.getYear() && root.month === minimumDate.getMonth()) {
309 return minimumDate.getDate()
310 }
311 return 1
312 }
313 if (modelData.action === monthAction) {
314 if (minimumDate.valueOf() && root.year === minimumDate.getYear() ) {
315 return minimumDate.month() + 1
316 }
317 return 1
318 }
319 if (modelData.action === yearsViewCheck) {
320 if (minimumDate.valueOf()) {
321 return minimumDate.getYear()
322 }
323 return -9999
324 }
325 return 0
326 }
327 property int stepSize: 1
328 property int value: {
329 if (modelData.action === dayAction) {
330 return root.day
331 }
332 if (modelData.action === monthAction) {
333 return root.month + 1
334 }
335 if (modelData.action === yearsViewCheck) {
336 return root.year
337 }
338 return 0
339 }
340 onValueChanged: {
341 if (modelData.action === dayAction) {
342 selectedDate.setDate(value)
343 }
344 if (modelData.action === monthAction) {
345 selectedDate.setMonth(value - 1)
346 }
347 if (modelData.action === yearsViewCheck) {
348 selectedDate.setFullYear(value)
349 }
350 }
351 }
352 onObjectAdded: (index, object) => {
353 object.modelData.Accessible.ignored = true
354 }
355 }
356
357 Item {
358 Layout.fillWidth: true
359 }
360
361 Components.SegmentedButton {
362 actions: [
363 Kirigami.Action {
364 id: goPreviousAction
365 icon.name: 'go-previous-view'
366 text: i18ndc("kirigami-addons6", "@action:button", "Go Previous")
367 displayHint: Kirigami.DisplayHint.IconOnly
368 onTriggered: {
369 if (pickerView.currentIndex === 1) { // monthGrid index
370 prevYear();
371 } else if (pickerView.currentIndex === 2) { // yearGrid index
372 prevDecade();
373 } else { // dayGrid index
374 prevMonth();
375 }
376 }
377 },
378 Kirigami.Action {
379 text: i18ndc("kirigami-addons6", "@action:button", "Jump to today")
380 displayHint: Kirigami.DisplayHint.IconOnly
381 icon.name: 'go-jump-today'
382 onTriggered: goToday()
383 },
385 id: goNextAction
386 text: i18ndc("kirigami-addons6", "@action:button", "Go Next")
387 icon.name: 'go-next-view'
388 displayHint: Kirigami.DisplayHint.IconOnly
389 onTriggered: {
390 if (pickerView.currentIndex === 1) { // monthGrid index
391 nextYear();
392 } else if (pickerView.currentIndex === 2) { // yearGrid index
393 nextDecade();
394 } else { // dayGrid index
395 nextMonth();
396 }
397 }
398 }
399 ]
400 }
401 }
402
403 QQC2.SwipeView {
404 id: pickerView
405
406 clip: true
407 interactive: false
408 padding: 0
409
410 Layout.fillWidth: true
411 Layout.fillHeight: true
412
413 DatePathView {
414 id: monthPathView
415
416 mainView: pickerView
417 enabled: QQC2.SwipeView.isCurrentItem
418
419 model: InfiniteCalendarViewModel {
420 scale: InfiniteCalendarViewModel.MonthScale
421 currentDate: root.selectedDate
422 minimumDate: root.minimumDate
423 maximumDate: root.maximumDate
424 datesToAdd: 10
425 }
426
427
428 delegate: Loader {
429 id: monthViewLoader
430 property date firstDayOfMonth: model.firstDay
431 property bool isNextOrCurrentItem: index >= monthPathView.currentIndex -1 && index <= monthPathView.currentIndex + 1
432
433 active: isNextOrCurrentItem && root.showDays
434
435 sourceComponent: GridLayout {
436 id: dayGrid
437 columns: 7
438 rows: 7
439 width: monthPathView.width
440 height: monthPathView.height
441 Layout.topMargin: Kirigami.Units.smallSpacing
442
443 property var modelLoader: Loader {
444 asynchronous: true
445 sourceComponent: MonthModel {
446 year: monthViewLoader.firstDayOfMonth.getFullYear()
447 month: monthViewLoader.firstDayOfMonth.getMonth() + 1 // From pathview model
448 }
449 }
450
451 QQC2.ButtonGroup {
452 id: monthGroup
453 }
454
455 Repeater {
456 model: dayGrid.modelLoader.item?.weekDays
457 delegate: QQC2.Label {
458 Layout.fillWidth: true
459 Layout.fillHeight: true
460 horizontalAlignment: Text.AlignHCenter
461 rightPadding: Kirigami.Units.mediumSpacing
462 leftPadding: Kirigami.Units.mediumSpacing
463 opacity: 0.7
464 text: modelData
465 Accessible.ignored: true
466 }
467 }
468
469 Repeater {
470 id: dayRepeater
471
472 model: dayGrid.modelLoader.item
473
474 delegate: DatePickerDelegate {
475 id: dayDelegate
476
477 required property bool isToday
478 required property bool sameMonth
479 required property int dayNumber
480
481 repeater: dayRepeater
482 minimumDate: root.minimumDate
483 maximumDate: root.maximumDate
484 previousAction: goPreviousAction
485 nextAction: goNextAction
486
487 horizontalPadding: 0
488
489 Accessible.name: date.toLocaleDateString(locale, Locale.ShortFormat)
490 Accessible.ignored: !monthPathView.QQC2.SwipeView.isCurrentItem || !monthViewLoader.PathView.isCurrentItem
491
492 QQC2.ButtonGroup.group: monthGroup
493
494 background {
495 visible: sameMonth
496 }
497
498 highlighted: isToday
499 checkable: true
500 checked: date.getDate() === selectedDate.getDate() &&
501 date.getMonth() === selectedDate.getMonth() &&
502 date.getFullYear() === selectedDate.getFullYear()
503 opacity: sameMonth && inScope ? 1 : 0.6
504 text: dayNumber
505 onClicked: {
506 selectedDate = date;
507 datePicked(date);
508 }
509 }
510 }
511 }
512 }
513
514 onCurrentIndexChanged: {
515 if (pickerView.currentIndex === 0) {
516 root.selectedDate = new Date(currentItem.firstDayOfMonth.getFullYear(), currentItem.firstDayOfMonth.getMonth(), root.selectedDate.getDate());
517 }
518
519 if (currentIndex >= count - 2) {
520 model.addDates(true);
521 } else if (currentIndex <= 1) {
522 model.addDates(false);
523 startIndex += model.datesToAdd;
524 }
525 }
526 }
527
528 DatePathView {
529 id: yearPathView
530
531 mainView: pickerView
532
533 model: InfiniteCalendarViewModel {
534 scale: InfiniteCalendarViewModel.YearScale
535 currentDate: root.selectedDate
536 }
537
538 delegate: Loader {
539 id: yearViewLoader
540
541 required property int index
542 required property date startDate
543
544 property bool isNextOrCurrentItem: index >= yearPathView.currentIndex -1 && index <= yearPathView.currentIndex + 1
545
546 width: parent.width
547 height: parent.height
548
549 active: isNextOrCurrentItem
550
551 sourceComponent: GridLayout {
552 id: yearGrid
553 columns: 3
554 rows: 4
555
556 QQC2.ButtonGroup {
557 id: yearGroup
558 }
559
560 Repeater {
561 id: monthRepeater
562
563 model: yearGrid.columns * yearGrid.rows
564
565 delegate: DatePickerDelegate {
566 id: monthDelegate
567
568 date: new Date(yearViewLoader.startDate.getFullYear(), index)
569
570 minimumDate: root.minimumDate.valueOf() ? new Date(root.minimumDate).setDate(0) : new Date("invalid")
571 maximumDate: root.maximumDate.valueOf() ? new Date(root.maximumDate.getFullYear(), root.maximumDate.getMonth() + 1, 0) : new Date("invalid")
572 repeater: monthRepeater
573 previousAction: goPreviousAction
574 nextAction: goNextAction
575
576 Accessible.ignored: !yearPathView.QQC2.SwipeView.isCurrentItem || !yearViewLoader.PathView.isCurrentItem
577 Accessible.name: date.toLocaleDateString(Qt.locale(), "MMMM yyyy")
578
579 QQC2.ButtonGroup.group: yearGroup
580
581 horizontalPadding: padding * 2
582 rightPadding: undefined
583 leftPadding: undefined
584 highlighted: date.getMonth() === new Date().getMonth() &&
585 date.getFullYear() === new Date().getFullYear()
586 checkable: true
587 checked: date.getMonth() === selectedDate.getMonth() &&
588 date.getFullYear() === selectedDate.getFullYear()
589 text: Qt.locale().standaloneMonthName(date.getMonth())
590 onClicked: {
591 selectedDate = new Date(date);
592 root.datePicked(date);
593 if(root.showDays) pickerView.currentIndex = 0;
594 }
595 }
596 }
597 }
598 }
599
600 onCurrentIndexChanged: {
601 if (pickerView.currentIndex === 1) {
602 root.selectedDate = new Date(currentItem.startDate.getFullYear(), root.selectedDate.getMonth(), root.selectedDate.getDate());
603 }
604
605 if (currentIndex >= count - 2) {
606 model.addDates(true);
607 } else if (currentIndex <= 1) {
608 model.addDates(false);
609 startIndex += model.datesToAdd;
610 }
611 }
612
613 }
614
615 DatePathView {
616 id: decadePathView
617
618 mainView: pickerView
619
620 model: InfiniteCalendarViewModel {
621 scale: InfiniteCalendarViewModel.DecadeScale
622 currentDate: root.selectedDate
623 }
624
625 delegate: Loader {
626 id: decadeViewLoader
627
628 required property int index
629 required property date startDate
630
631 property bool isNextOrCurrentItem: index >= decadePathView.currentIndex -1 && index <= decadePathView.currentIndex + 1
632
633 width: parent.width
634 height: parent.height
635
636 active: isNextOrCurrentItem
637
638 sourceComponent: GridLayout {
639 id: decadeGrid
640
641 columns: 3
642 rows: 4
643
644 QQC2.ButtonGroup {
645 id: decadeGroup
646 }
647
648 Repeater {
649 id: decadeRepeater
650
651 model: decadeGrid.columns * decadeGrid.rows
652
653 delegate: DatePickerDelegate {
654 id: yearDelegate
655
656 readonly property bool sameDecade: Math.floor(date.getFullYear() / 10) == Math.floor(year / 10)
657
658 Accessible.ignored: !decadePathView.QQC2.SwipeView.isCurrentItem || !decadeViewLoader.PathView.isCurrentItem
659 QQC2.ButtonGroup.group: decadeGroup
660
661 date: new Date(startDate.getFullYear() + index, 0)
662 minimumDate: root.minimumDate.valueOf() ? new Date(root.minimumDate.getFullYear(), 0, 0) : new Date("invalid")
663 maximumDate: root.maximumDate.valueOf() ? new Date(root.maximumDate.getFullYear(), 12, 0) : new Date("invalid")
664 repeater: decadeRepeater
665 previousAction: goPreviousAction
666 nextAction: goNextAction
667
668 highlighted: date.getFullYear() === new Date().getFullYear()
669
670 horizontalPadding: padding * 2
671 rightPadding: undefined
672 leftPadding: undefined
673 checkable: true
674 checked: date.getFullYear() === selectedDate.getFullYear()
675 opacity: sameDecade ? 1 : 0.7
676 text: date.getFullYear()
677 onClicked: {
678 selectedDate = new Date(date);
679 root.datePicked(date);
680 pickerView.currentIndex = 1;
681 }
682 }
683 }
684 }
685 }
686
687 onCurrentIndexChanged: {
688 if (pickerView.currentIndex === 2) {
689 // getFullYear + 1 because the startDate is e.g. 2019, but we want the 2020 decade to be selected
690 root.selectedDate = new Date(currentItem.startDate.getFullYear() + 1, root.selectedDate.getMonth(), root.selectedDate.getDate());
691 }
692
693 if (currentIndex >= count - 2) {
694 model.addDates(true);
695 } else if (currentIndex <= 1) {
696 model.addDates(false);
697 startIndex += model.datesToAdd;
698 }
699 }
700
701 }
702 }
703 }
704}
QString i18ndc(const char *domain, const char *context, const char *text, const TYPE &arg...)
QString i18nd(const char *domain, const char *text, const TYPE &arg...)
int yearDiff(QDate start, QDate end)
This file is part of the KDE documentation.
Documentation copyright © 1996-2024 The KDE developers.
Generated on Sat Dec 21 2024 17:03:50 by doxygen 1.12.0 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.