Files
nixos-config/modules/user/lucid/ClockCalendar.qml
T

536 lines
18 KiB
QML
Raw Normal View History

2026-11-03 19:54:26 +03:00
import QtQuick
import qs
import qs.lucidui
// the month on the left, the picked day's reminders and a way to add one on the right
Item {
id: page
property var host: null
property var selected: {
const n = Loc.now();
return { "year": n.getFullYear(), "month": n.getMonth(), "day": n.getDate() };
}
readonly property date selectedDate: new Date(page.selected.year, page.selected.month, page.selected.day)
readonly property var dayItems: {
Agenda.checkedDay;
return Agenda.forDay(page.selected.year, page.selected.month, page.selected.day);
}
property int newHour: 9
property int newMinute: 0
// the hour is freshTime's next-hour guess, not one anybody chose
property bool hourGuessed: false
// am/pm was set by hand, so a typed hour keeps to it
property bool halfPicked: false
property string newRepeat: ""
readonly property bool pm: page.newHour >= 12
readonly property int shownHour: Prefs.clock24h ? page.newHour : (page.newHour % 12 === 0 ? 12 : page.newHour % 12)
// a one-off set before now would never ring
readonly property bool past: {
Agenda.minuteKey;
return page.newRepeat === "" && page.gone(page.newHour, page.newMinute);
}
readonly property bool shown: page.visible && (!page.host || page.host.expanded)
onShownChanged: if (page.shown)
page.freshTime()
function selectToday() {
const n = Loc.now();
page.selected = { "year": n.getFullYear(), "month": n.getMonth(), "day": n.getDate() };
month.goToday();
page.freshTime();
}
// earlier than the minute we are in, on the picked day
function gone(hour, minute) {
const n = Loc.now();
return new Date(page.selected.year, page.selected.month, page.selected.day, hour, minute) < new Date(n.getFullYear(), n.getMonth(), n.getDate(), n.getHours(), n.getMinutes());
}
function isToday() {
return page.gone(0, 0) && !page.gone(23, 59);
}
// every opening of the clock starts on today, unless a reminder is half written
function startVisit() {
if (nameField.text !== "")
return ;
const n = Loc.now();
page.selected = { "year": n.getFullYear(), "month": n.getMonth(), "day": n.getDate() };
if (!month.isCurrent)
month.goToday();
page.freshTime();
}
// 9:00, or the next hour once 9:00 has gone today; a time already set for a name stays
function freshTime() {
if (nameField.text !== "")
return ;
page.newHour = 9;
page.newMinute = 0;
page.hourGuessed = false;
page.halfPicked = false;
const next = Loc.now().getHours() + 1;
if (page.gone(9, 0) && next < 24 && !page.gone(next, 0)) {
page.newHour = next;
page.hourGuessed = true;
}
}
// a typed 12-hour time means the half still to come: 12:04 at noon is pm
function preferUpcoming() {
if (Prefs.clock24h || !page.past)
return ;
const flipped = (page.newHour + 12) % 24;
if (!page.gone(flipped, page.newMinute))
page.newHour = flipped;
}
function focusName() {
nameField.input.forceActiveFocus();
}
function submit() {
const name = nameField.text.trim();
if (name === "" || page.past)
return ;
Agenda.add(page.selected.year, page.selected.month, page.selected.day, page.newHour, page.newMinute, name, page.newRepeat);
nameField.text = "";
page.newRepeat = "";
}
function step(minutes) {
page.hourGuessed = false;
let t = (page.newHour * 60 + page.newMinute + minutes + 1440) % 1440;
page.newHour = Math.floor(t / 60);
page.newMinute = t % 60;
}
// typed hours are 0-23, or 1-12 next to the am/pm toggle; on today a
// 12-hour one is its next turn, not the half the guessed hour left behind
function typeHour(h) {
page.hourGuessed = false;
if (Prefs.clock24h) {
page.newHour = Math.min(23, h);
} else if (page.isToday() && !page.halfPicked) {
// the minutes are still to come, so only an hour wholly gone is past
const h12 = Math.min(12, h) % 12;
page.newHour = page.gone(h12, 59) ? h12 + 12 : h12;
} else {
page.newHour = Math.min(12, h) % 12 + (page.pm ? 12 : 0);
page.preferUpcoming();
}
}
// minutes typed under the guessed hour mean the next time the clock shows
// them: at 12:19, 20 is 12:20 rather than 1:20
function typeMinute(m) {
page.newMinute = Math.min(59, m);
if (page.hourGuessed && page.isToday()) {
const h = Loc.now().getHours();
if (!page.gone(h, page.newMinute))
page.newHour = h;
else if (h + 1 < 24)
page.newHour = h + 1;
}
page.preferUpcoming();
}
function setPm(wantPm) {
page.hourGuessed = false;
page.halfPicked = true;
page.newHour = page.newHour % 12 + (wantPm ? 12 : 0);
}
// two digits you type into, each holding its own value until focus leaves
component TimeDigit: Item {
id: digit
property int value: 0
property int span: 60
property Item nextDigit: null
readonly property bool typing: field.activeFocus
signal typed(int v)
signal meridiem(bool wantPm)
signal accepted()
function show() {
field.text = String(digit.value).padStart(2, "0");
}
function take() {
field.forceActiveFocus();
field.selectAll();
}
function nudge(by) {
digit.typed((digit.value + by + digit.span) % digit.span);
digit.show();
}
width: Theme.dp(30)
height: Theme.dp(30)
onValueChanged: if (!field.activeFocus)
digit.show()
Component.onCompleted: digit.show()
TextInput {
id: field
anchors.fill: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
maximumLength: 2
inputMethodHints: Qt.ImhDigitsOnly
selectByMouse: true
color: Theme.text
selectionColor: Theme.alpha(Theme.primary, 0.35)
selectedTextColor: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.typeSize("labelLarge")
font.variableAxes: Theme.axes(Theme.typeSize("labelLarge"), 640, 0)
font.features: ({
"tnum": 1
})
onTextEdited: {
if (field.text !== "")
digit.typed(parseInt(field.text, 10));
if (field.text.length === 2 && digit.nextDigit)
digit.nextDigit.take();
}
onActiveFocusChanged: {
if (field.activeFocus)
Qt.callLater(field.selectAll);
else
digit.show();
}
onAccepted: digit.accepted()
Keys.onUpPressed: digit.nudge(1)
Keys.onDownPressed: digit.nudge(-1)
Keys.onTabPressed: digit.nextDigit ? digit.nextDigit.take() : digit.show()
Keys.onPressed: (e) => {
const c = e.text.toLowerCase();
if (c === "a" || c === "p")
digit.meridiem(c === "p");
}
validator: RegularExpressionValidator {
regularExpression: /[0-9]{0,2}/
}
}
Rectangle {
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - Theme.dp(6)
height: Theme.dp(2)
radius: 1
color: digit.typing ? Theme.primary : Theme.alpha(Theme.subtext, 0.4)
Behavior on color {
ColorAnimation {
duration: Theme.durFastEffects
}
}
}
}
implicitHeight: Theme.dp(404)
Connections {
function onExpandedChanged() {
if (page.host.expanded)
page.startVisit();
}
target: page.host
}
Rectangle {
id: monthCard
width: Theme.dp(312)
height: parent.height
radius: Theme.shapeXl
color: Theme.withBlur(Theme.surfaceHigh)
MonthView {
id: month
anchors.centerIn: parent
cell: Theme.dp(40)
selected: page.selected
onPicked: (y, m, d) => {
page.selected = { "year": y, "month": m, "day": d };
if (m !== month.monthIndex)
month.page(y * 12 + m > month.year * 12 + month.monthIndex ? 1 : -1);
}
}
}
Rectangle {
anchors.left: monthCard.right
anchors.leftMargin: Theme.dp(12)
anchors.right: parent.right
height: parent.height
radius: Theme.shapeXl
color: Theme.withBlur(Theme.surfaceHigh)
Item {
anchors.fill: parent
anchors.margins: Theme.dp(18)
Column {
id: dayHead
width: parent.width
spacing: 0
LText {
role: "titleLarge"
weight: 560
text: page.selectedDate.toLocaleDateString(Qt.locale(), "dddd")
}
LText {
role: "bodyMedium"
color: Theme.subtext
text: page.selectedDate.toLocaleDateString(Qt.locale(), "d MMMM yyyy") + (page.dayItems.length ? " · " + page.dayItems.length + (page.dayItems.length === 1 ? " reminder" : " reminders") : "")
}
}
Flickable {
id: list
anchors.top: dayHead.bottom
anchors.topMargin: Theme.dp(12)
anchors.bottom: form.top
anchors.bottomMargin: Theme.dp(12)
width: parent.width
contentHeight: items.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
Column {
id: items
width: list.width
spacing: Theme.dp(2)
Repeater {
model: page.dayItems
ListItem {
id: row
required property var modelData
required property int index
width: items.width
first: row.index === 0
last: row.index === page.dayItems.length - 1
containerColor: Theme.withBlur(Theme.surfaceHighest)
clickable: false
headline: row.modelData.name
supporting: Agenda.timeText(row.modelData) + (row.modelData.repeat ? " · " + Agenda.repeatText(row.modelData) : "") + (row.modelData.source === "thunderbird" ? " · " + row.modelData.calendar : "")
icon: row.modelData.source === "thunderbird" ? "event" : (row.modelData.repeat ? "repeat" : "notifications")
iconColor: Theme.primary
trailing: [
IconButton {
visible: row.modelData.source !== "thunderbird"
icon: "delete"
size: "xs"
onClicked: Agenda.remove(row.modelData.id)
}
]
}
}
}
Column {
visible: page.dayItems.length === 0
anchors.horizontalCenter: parent.horizontalCenter
y: Theme.dp(24)
spacing: Theme.dp(8)
Icon {
anchors.horizontalCenter: parent.horizontalCenter
name: "event_available"
size: Theme.dp(32)
color: Theme.subtextDim
}
LText {
anchors.horizontalCenter: parent.horizontalCenter
role: "bodyMedium"
color: Theme.subtext
text: "A free day"
}
}
}
Column {
id: form
anchors.bottom: parent.bottom
width: parent.width
spacing: Theme.dp(10)
TextField {
id: nameField
width: parent.width
label: "New reminder"
placeholder: "What should it say?"
supporting: page.past && nameField.text.trim() !== "" ? (page.gone(0, 0) && !page.gone(23, 59) ? Agenda.timeText({ "hour": page.newHour, "minute": page.newMinute }) + " has already gone by today" : "That day has already gone by") : ""
containerColor: Theme.withBlur(Theme.surfaceHighest)
onAccepted: page.submit()
}
ButtonGroup {
width: parent.width
size: "xs"
options: Agenda.repeatOptions
current: page.newRepeat
onPicked: (k) => {
return page.newRepeat = k;
}
}
Row {
width: parent.width
spacing: Theme.dp(8)
Rectangle {
id: timeBox
width: timeRow.implicitWidth + Theme.dp(20)
height: Theme.dp(36)
radius: Theme.dp(18)
color: Theme.withBlur(Theme.surfaceHighest)
Row {
id: timeRow
anchors.centerIn: parent
spacing: Theme.dp(2)
TimeDigit {
id: hourDigit
anchors.verticalCenter: parent.verticalCenter
value: page.shownHour
span: Prefs.clock24h ? 24 : 12
nextDigit: minuteDigit
onTyped: (v) => {
return page.typeHour(v);
}
onMeridiem: (wantPm) => {
return page.setPm(wantPm);
}
onAccepted: page.submit()
}
LText {
anchors.verticalCenter: parent.verticalCenter
role: "labelLarge"
weight: 640
color: Theme.subtext
text: ":"
}
TimeDigit {
id: minuteDigit
anchors.verticalCenter: parent.verticalCenter
value: page.newMinute
span: 60
onTyped: (v) => {
return page.typeMinute(v);
}
onMeridiem: (wantPm) => {
return page.setPm(wantPm);
}
onAccepted: page.submit()
}
Item {
visible: !Prefs.clock24h
anchors.verticalCenter: parent.verticalCenter
width: Theme.dp(32)
height: Theme.dp(30)
LText {
anchors.centerIn: parent
role: "labelMedium"
weight: 640
color: Theme.primary
text: page.pm ? "PM" : "AM"
}
StateLayer {
radius: Theme.rad(8)
onClicked: page.setPm(!page.pm)
}
}
}
// the wheel moves a minute at a time
WheelHandler {
onWheel: (e) => {
return page.step(e.angleDelta.y > 0 ? 1 : -1);
}
}
}
Item {
width: parent.width - timeBox.width - addBtn.width - Theme.dp(16)
height: 1
}
Button {
id: addBtn
variant: "filled"
size: "xs"
icon: "add"
text: "Add"
disabled: nameField.text.trim() === "" || page.past
onClicked: page.submit()
}
}
}
}
}
}