From 55ac08bf195b39ef04f17c24aca9dd50b2d34180 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:23:11 +0200 Subject: [PATCH 01/19] Scheduling: schedule and project models, working calendars, critical path and resource leveling The UI-free layer the Scheduler and Gantt components are built on. Co-Authored-By: Claude Fable 5.2 --- .../scheduling/AbstractScheduleModel.java | 75 +++ .../scheduling/CalendarScheduleModel.java | 489 ++++++++++++++++++ .../scheduling/DefaultScheduleModel.java | 152 ++++++ .../com/codename1/scheduling/GanttTask.java | 372 +++++++++++++ .../codename1/scheduling/LevelingResult.java | 98 ++++ .../scheduling/ProjectChangeListener.java | 29 ++ .../codename1/scheduling/ProjectModel.java | 373 +++++++++++++ .../scheduling/ProjectScheduler.java | 427 +++++++++++++++ .../scheduling/ResourceAssignment.java | 49 ++ .../codename1/scheduling/ResourceLeveler.java | 308 +++++++++++ .../scheduling/ResourceOverallocation.java | 63 +++ .../scheduling/ScheduleChangeListener.java | 29 ++ .../codename1/scheduling/ScheduleItem.java | 202 ++++++++ .../codename1/scheduling/ScheduleModel.java | 73 +++ .../scheduling/ScheduleResource.java | 126 +++++ .../codename1/scheduling/TaskDependency.java | 91 ++++ .../com/codename1/scheduling/TimeMath.java | 114 ++++ .../codename1/scheduling/WorkCalendar.java | 290 +++++++++++ .../codename1/scheduling/package-info.java | 41 ++ .../scheduling/ProjectSchedulerTest.java | 269 ++++++++++ .../scheduling/ResourceLevelerTest.java | 143 +++++ .../scheduling/WorkCalendarTest.java | 133 +++++ 22 files changed, 3946 insertions(+) create mode 100644 CodenameOne/src/com/codename1/scheduling/AbstractScheduleModel.java create mode 100644 CodenameOne/src/com/codename1/scheduling/CalendarScheduleModel.java create mode 100644 CodenameOne/src/com/codename1/scheduling/DefaultScheduleModel.java create mode 100644 CodenameOne/src/com/codename1/scheduling/GanttTask.java create mode 100644 CodenameOne/src/com/codename1/scheduling/LevelingResult.java create mode 100644 CodenameOne/src/com/codename1/scheduling/ProjectChangeListener.java create mode 100644 CodenameOne/src/com/codename1/scheduling/ProjectModel.java create mode 100644 CodenameOne/src/com/codename1/scheduling/ProjectScheduler.java create mode 100644 CodenameOne/src/com/codename1/scheduling/ResourceAssignment.java create mode 100644 CodenameOne/src/com/codename1/scheduling/ResourceLeveler.java create mode 100644 CodenameOne/src/com/codename1/scheduling/ResourceOverallocation.java create mode 100644 CodenameOne/src/com/codename1/scheduling/ScheduleChangeListener.java create mode 100644 CodenameOne/src/com/codename1/scheduling/ScheduleItem.java create mode 100644 CodenameOne/src/com/codename1/scheduling/ScheduleModel.java create mode 100644 CodenameOne/src/com/codename1/scheduling/ScheduleResource.java create mode 100644 CodenameOne/src/com/codename1/scheduling/TaskDependency.java create mode 100644 CodenameOne/src/com/codename1/scheduling/TimeMath.java create mode 100644 CodenameOne/src/com/codename1/scheduling/WorkCalendar.java create mode 100644 CodenameOne/src/com/codename1/scheduling/package-info.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/scheduling/ProjectSchedulerTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/scheduling/ResourceLevelerTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/scheduling/WorkCalendarTest.java diff --git a/CodenameOne/src/com/codename1/scheduling/AbstractScheduleModel.java b/CodenameOne/src/com/codename1/scheduling/AbstractScheduleModel.java new file mode 100644 index 00000000000..a9d5fe0583f --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/AbstractScheduleModel.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +import java.util.ArrayList; +import java.util.List; + +/// The listener list and the refusals every read-only model shares. +/// +/// A subclass has to supply `getResources` and `getItems`. Every edit is +/// refused until the matching method is overridden. +public abstract class AbstractScheduleModel implements ScheduleModel { + private final List listeners = new ArrayList(); + + @Override + public void setVisibleRange(long from, long to) { + } + + @Override + public boolean updateItem(ScheduleItem item, long start, long end, String resourceId) { + return false; + } + + @Override + public ScheduleItem createItem(long start, long end, String resourceId) { + return null; + } + + @Override + public boolean removeItem(ScheduleItem item) { + return false; + } + + @Override + public void addChangeListener(ScheduleChangeListener l) { + if (l != null && !listeners.contains(l)) { + listeners.add(l); + } + } + + @Override + public void removeChangeListener(ScheduleChangeListener l) { + listeners.remove(l); + } + + /// Tells every listener the model changed. + protected void fireChanged() { + // A listener may remove itself while it is told. + for (int i = listeners.size() - 1; i >= 0; i--) { + if (i < listeners.size()) { + listeners.get(i).scheduleChanged(this); + } + } + } +} diff --git a/CodenameOne/src/com/codename1/scheduling/CalendarScheduleModel.java b/CodenameOne/src/com/codename1/scheduling/CalendarScheduleModel.java new file mode 100644 index 00000000000..85e20c1314d --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/CalendarScheduleModel.java @@ -0,0 +1,489 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +import com.codename1.calendar.CalendarChange; +import com.codename1.calendar.CalendarChangeListener; +import com.codename1.calendar.CalendarDateTime; +import com.codename1.calendar.CalendarEvent; +import com.codename1.calendar.CalendarInfo; +import com.codename1.calendar.CalendarMutationScope; +import com.codename1.calendar.CalendarPage; +import com.codename1.calendar.CalendarQuery; +import com.codename1.calendar.CalendarSource; +import com.codename1.calendar.FreeBusyInterval; +import com.codename1.io.Log; +import com.codename1.ui.Display; +import com.codename1.util.SuccessCallback; + +import java.time.Instant; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// A schedule read from a `CalendarSource`: the device's calendars, CalDAV, +/// Google or Microsoft. +/// +/// Every calendar added is a resource, so a resource view shows calendars +/// side by side and dragging an event to another column moves it to that +/// calendar. The model loads the events around the range a view shows, +/// with recurring events expanded into their occurrences, and loads again +/// when the view moves outside what it has or the source reports a change. +/// +/// ```java +/// CalendarScheduleModel model = new CalendarScheduleModel(source, ZoneId.systemDefault()); +/// model.addCalendar(calendarInfo); +/// scheduler.setModel(model); +/// ``` +/// +/// An edit is shown at once and saved in the background; if the source +/// refuses it, the item goes back and `getLastError` says why. +public class CalendarScheduleModel extends AbstractScheduleModel { + private final CalendarSource source; + private final ZoneId zone; + private final List resources = new ArrayList(); + private final Map calendars = new HashMap(); + private final Map> items = new HashMap>(); + private final Map> busy = new HashMap>(); + private final List freeBusyOnly = new ArrayList(); + private CalendarMutationScope mutationScope = CalendarMutationScope.THIS_INSTANCE; + private boolean creationEnabled; + private String newEventTitle = ""; + private long loadedFrom; + private long loadedTo; + private long wantedFrom; + private long wantedTo; + // Raised by every reload, so an answer to an older question is dropped. + private int generation; + private Throwable lastError; + private final CalendarChangeListener sourceListener = new CalendarChangeListener() { + @Override + public void calendarChanged(CalendarChange change) { + if (Display.getInstance().isEdt()) { + reload(); + } else { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + reload(); + } + }); + } + } + }; + + /// A model over `source`. `zone` decides where an all-day event's days + /// start; use the zone of the view. + public CalendarScheduleModel(CalendarSource source, ZoneId zone) { + if (source == null || zone == null) { + throw new IllegalArgumentException("source and zone are required"); + } + this.source = source; + this.zone = zone; + source.addChangeListener(sourceListener); + } + + /// Stops listening to the source. Call it when the model is discarded. + public void dispose() { + source.removeChangeListener(sourceListener); + generation++; + } + + public CalendarSource getSource() { + return source; + } + + /// Shows a calendar's events, as a resource of its own. + public CalendarScheduleModel addCalendar(CalendarInfo info) { + return add(info, false); + } + + /// Shows only when a calendar is busy, hatched, without any detail: the + /// way somebody else's calendar is shown. + public CalendarScheduleModel addFreeBusyCalendar(CalendarInfo info) { + return add(info, true); + } + + private CalendarScheduleModel add(CalendarInfo info, boolean freeBusy) { + if (info == null || info.getId() == null || calendars.containsKey(info.getId())) { + return this; + } + calendars.put(info.getId(), info); + ScheduleResource r = new ScheduleResource(info.getId(), info.getName()); + r.setDetail(info.getOwner()); + if (info.getColor() != 0) { + r.setColor(info.getColor() & 0xffffff); + } + r.setUserObject(info); + resources.add(r); + if (freeBusy) { + freeBusyOnly.add(info.getId()); + } + reload(); + return this; + } + + public CalendarScheduleModel removeCalendar(String calendarId) { + if (calendars.remove(calendarId) != null) { + for (int i = resources.size() - 1; i >= 0; i--) { + if (resources.get(i).getId().equals(calendarId)) { + resources.remove(i); + } + } + items.remove(calendarId); + busy.remove(calendarId); + freeBusyOnly.remove(calendarId); + fireChanged(); + } + return this; + } + + /// Which occurrences an edit of a recurring event changes. A view asks the + /// user and sets this before it commits the edit. One occurrence by default. + public CalendarScheduleModel setMutationScope(CalendarMutationScope scope) { + mutationScope = scope == null ? CalendarMutationScope.THIS_INSTANCE : scope; + return this; + } + + public CalendarMutationScope getMutationScope() { + return mutationScope; + } + + /// Whether dragging on empty space saves a new event with `title`. Off by + /// default: an application usually opens its own editor from the + /// scheduler's range listener instead. + public CalendarScheduleModel setCreationEnabled(boolean enabled, String title) { + creationEnabled = enabled; + newEventTitle = title == null ? "" : title; + return this; + } + + /// The failure of the last load or save, or null when it worked. + public Throwable getLastError() { + return lastError; + } + + /// The calendar event an item was made from, or null for busy time. + public static CalendarEvent eventOf(ScheduleItem item) { + Object o = item == null ? null : item.getUserObject(); + return o instanceof CalendarEvent ? (CalendarEvent) o : null; + } + + @Override + public List getResources() { + return Collections.unmodifiableList(resources); + } + + @Override + public void getItems(long from, long to, List out) { + collect(items, from, to, out); + collect(busy, from, to, out); + } + + private void collect(Map> map, long from, long to, List out) { + for (int r = 0; r < resources.size(); r++) { + List list = map.get(resources.get(r).getId()); + if (list == null) { + continue; + } + for (int i = 0; i < list.size(); i++) { + if (list.get(i).intersects(from, to)) { + out.add(list.get(i)); + } + } + } + } + + @Override + public void setVisibleRange(long from, long to) { + wantedFrom = from; + wantedTo = to; + if (from < loadedFrom || to > loadedTo) { + reload(); + } + } + + /// Loads the events around the visible range again. + public void reload() { + if (wantedTo <= wantedFrom || calendars.isEmpty()) { + return; + } + // A window three times the range, so scrolling one screen either way + // asks for nothing. + long span = wantedTo - wantedFrom; + final long from = wantedFrom - span; + final long to = wantedTo + span; + loadedFrom = from; + loadedTo = to; + final int asked = ++generation; + for (int r = 0; r < resources.size(); r++) { + String id = resources.get(r).getId(); + if (freeBusyOnly.contains(id)) { + loadBusy(id, from, to, asked); + } else { + loadPage(id, from, to, null, new ArrayList(), asked); + } + } + } + + private void loadPage(final String calendarId, final long from, final long to, String pageToken, + final List into, final int asked) { + CalendarQuery q = new CalendarQuery().setCalendarId(calendarId) + .setStartTime(Instant.ofEpochMilli(from)).setEndTime(Instant.ofEpochMilli(to)) + .setExpandRecurrences(true); + if (pageToken != null) { + q.setPageToken(pageToken); + } + source.queryEvents(q).ready(new SuccessCallback>() { + @Override + public void onSucess(CalendarPage page) { + if (asked != generation) { + return; + } + List events = page.getItems(); + for (int i = 0; i < events.size(); i++) { + ScheduleItem item = toItem(events.get(i), calendarId); + if (item != null) { + into.add(item); + } + } + String next = page.getNextPageToken(); + if (next != null && next.length() > 0) { + loadPage(calendarId, from, to, next, into, asked); + return; + } + lastError = null; + items.put(calendarId, into); + fireChanged(); + } + }).except(failed(asked)); + } + + private void loadBusy(final String calendarId, long from, long to, final int asked) { + List ids = new ArrayList(); + ids.add(calendarId); + source.queryFreeBusy(ids, Instant.ofEpochMilli(from), Instant.ofEpochMilli(to)) + .ready(new SuccessCallback>() { + @Override + public void onSucess(List intervals) { + if (asked != generation) { + return; + } + List list = new ArrayList(); + for (int i = 0; i < intervals.size(); i++) { + FreeBusyInterval f = intervals.get(i); + if (f.getAvailability() == CalendarEvent.Availability.FREE) { + continue; + } + long s = f.getStartTime().toEpochMilli(); + list.add(new ScheduleItem(calendarId + "-busy-" + s, "", s, f.getEndTime().toEpochMilli()) + .setKind(ScheduleItem.Kind.BLOCKED).setResourceId(calendarId)); + } + busy.put(calendarId, list); + fireChanged(); + } + }).except(failed(asked)); + } + + private SuccessCallback failed(final int asked) { + return new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + if (asked == generation) { + lastError = err; + Log.e(err); + fireChanged(); + } + } + }; + } + + private ScheduleItem toItem(CalendarEvent e, String calendarId) { + CalendarDateTime s = e.getStart(); + if (s == null || e.getStatus() == CalendarEvent.Status.CANCELED) { + return null; + } + CalendarDateTime en = e.getEnd(); + long start; + long end; + if (s.isAllDay()) { + long first = s.getDate().toEpochDay(); + // An all-day end is the day after the last one; a source that + // leaves it out or repeats the start means a single day. + long after = en != null && en.isAllDay() ? en.getDate().toEpochDay() : first + 1; + if (after <= first) { + after = first + 1; + } + start = TimeMath.startOfEpochDay(first, zone); + end = TimeMath.startOfEpochDay(after, zone); + } else { + start = s.getDateTime().toInstant().toEpochMilli(); + end = en == null || en.isAllDay() ? start : en.getDateTime().toInstant().toEpochMilli(); + if (end < start) { + end = start; + } + } + boolean series = e.getRecurrence() != null || e.getRecurringEventId() != null; + // Every occurrence of an expanded series carries the series' id. + String id = series ? e.getId() + "@" + start : e.getId(); + CalendarInfo info = calendars.get(calendarId); + boolean editable = info == null || !info.isReadOnly(); + return new ScheduleItem(id == null ? calendarId + "@" + start : id, e.getTitle(), start, end) + .setAllDay(s.isAllDay()).setDetail(e.getLocation()).setResourceId(calendarId) + .setRecurring(series).setMovable(editable).setResizable(editable).setUserObject(e); + } + + @Override + public boolean updateItem(final ScheduleItem item, long start, long end, String resourceId) { + final CalendarEvent e = eventOf(item); + if (e == null || !item.isMovable()) { + return false; + } + final long oldStart = item.getStart(); + final long oldEnd = item.getEnd(); + final String oldResource = item.getResourceId(); + final CalendarDateTime oldS = e.getStart(); + final CalendarDateTime oldE = e.getEnd(); + final String target = resourceId == null ? oldResource : resourceId; + if (item.isAllDay()) { + e.setStart(CalendarDateTime.allDay(java.time.LocalDate.ofEpochDay(TimeMath.epochDay(start, zone)))); + e.setEnd(CalendarDateTime.allDay(java.time.LocalDate.ofEpochDay(TimeMath.epochDay(end, zone)))); + } else { + ZoneId z = oldS.getDateTime().getZone(); + e.setStart(CalendarDateTime.instant(Instant.ofEpochMilli(start), z)); + e.setEnd(CalendarDateTime.instant(Instant.ofEpochMilli(end), z)); + } + e.setCalendarId(target); + item.setTime(start, end); + move(item, oldResource, target); + fireChanged(); + source.saveEvent(e, mutationScope).ready(new SuccessCallback() { + @Override + public void onSucess(CalendarEvent saved) { + lastError = null; + // Changing a series moves occurrences that are not this item. + if (item.isRecurring() && mutationScope != CalendarMutationScope.THIS_INSTANCE) { + reload(); + } + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + lastError = err; + Log.e(err); + e.setStart(oldS).setEnd(oldE).setCalendarId(oldResource); + item.setTime(oldStart, oldEnd); + move(item, target, oldResource); + fireChanged(); + } + }); + return true; + } + + private void move(ScheduleItem item, String from, String to) { + item.setResourceId(to); + if (from == null ? to == null : from.equals(to)) { + return; + } + List a = items.get(from); + if (a != null) { + a.remove(item); + } + List b = items.get(to); + if (b == null) { + b = new ArrayList(); + items.put(to, b); + } + b.add(item); + } + + @Override + public ScheduleItem createItem(long start, long end, String resourceId) { + String calendarId = resourceId; + if (calendarId == null && !resources.isEmpty()) { + calendarId = resources.get(0).getId(); + } + CalendarInfo info = calendars.get(calendarId); + if (!creationEnabled || info == null || info.isReadOnly() || freeBusyOnly.contains(calendarId) || end <= start) { + return null; + } + final CalendarEvent e = new CalendarEvent().setCalendarId(calendarId).setTitle(newEventTitle) + .setStart(CalendarDateTime.instant(Instant.ofEpochMilli(start), zone)) + .setEnd(CalendarDateTime.instant(Instant.ofEpochMilli(end), zone)); + final ScheduleItem item = new ScheduleItem("new-" + start + "-" + generation, newEventTitle, start, end) + .setResourceId(calendarId).setUserObject(e); + move(item, null, calendarId); + fireChanged(); + final String cal = calendarId; + source.saveEvent(e, CalendarMutationScope.ALL).ready(new SuccessCallback() { + @Override + public void onSucess(CalendarEvent saved) { + lastError = null; + if (saved != null) { + item.setUserObject(saved); + } + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + lastError = err; + Log.e(err); + List list = items.get(cal); + if (list != null) { + list.remove(item); + } + fireChanged(); + } + }); + return item; + } + + @Override + public boolean removeItem(final ScheduleItem item) { + final CalendarEvent e = eventOf(item); + if (e == null || !item.isMovable()) { + return false; + } + final String cal = item.getResourceId(); + final List list = items.get(cal); + if (list != null) { + list.remove(item); + } + fireChanged(); + source.deleteEvent(cal, e.getId(), mutationScope, e.getVersion()).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + lastError = err; + Log.e(err); + if (list != null) { + list.add(item); + } + fireChanged(); + } + }); + return true; + } +} diff --git a/CodenameOne/src/com/codename1/scheduling/DefaultScheduleModel.java b/CodenameOne/src/com/codename1/scheduling/DefaultScheduleModel.java new file mode 100644 index 00000000000..f7d23456a67 --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/DefaultScheduleModel.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// A schedule held in memory, which accepts every edit. +/// +/// Override `updateItem`, `createItem` or `removeItem` to validate a change +/// or to send it somewhere before it is accepted. +public class DefaultScheduleModel extends AbstractScheduleModel { + private final List resources = new ArrayList(); + private final List items = new ArrayList(); + private long defaultDuration = TimeMath.HOUR; + private int created; + private boolean creationEnabled = true; + + public DefaultScheduleModel addResource(ScheduleResource r) { + if (r != null) { + resources.add(r); + fireChanged(); + } + return this; + } + + public DefaultScheduleModel removeResource(ScheduleResource r) { + if (resources.remove(r)) { + fireChanged(); + } + return this; + } + + public DefaultScheduleModel addItem(ScheduleItem item) { + if (item != null) { + items.add(item); + fireChanged(); + } + return this; + } + + /// Adds several items and fires one change. + public DefaultScheduleModel addItems(List all) { + items.addAll(all); + fireChanged(); + return this; + } + + /// Removes every item and keeps the resources. + public DefaultScheduleModel clearItems() { + items.clear(); + fireChanged(); + return this; + } + + public List getAllItems() { + return Collections.unmodifiableList(items); + } + + public ScheduleItem getItem(String id) { + for (int i = 0; i < items.size(); i++) { + if (items.get(i).getId().equals(id)) { + return items.get(i); + } + } + return null; + } + + /// Whether dragging on empty space makes an item. On by default. + public DefaultScheduleModel setCreationEnabled(boolean v) { + creationEnabled = v; + return this; + } + + public boolean isCreationEnabled() { + return creationEnabled; + } + + /// The length of an item created by a tap, where there is no range. + public DefaultScheduleModel setDefaultDuration(long millis) { + defaultDuration = millis; + return this; + } + + @Override + public List getResources() { + return Collections.unmodifiableList(resources); + } + + @Override + public void getItems(long from, long to, List out) { + for (int i = 0; i < items.size(); i++) { + ScheduleItem it = items.get(i); + if (it.intersects(from, to)) { + out.add(it); + } + } + } + + @Override + public boolean updateItem(ScheduleItem item, long start, long end, String resourceId) { + item.setTime(start, end); + item.setResourceId(resourceId); + fireChanged(); + return true; + } + + @Override + public ScheduleItem createItem(long start, long end, String resourceId) { + if (!creationEnabled) { + return null; + } + if (end <= start) { + end = start + defaultDuration; + } + created++; + ScheduleItem item = new ScheduleItem("new-" + created, "", start, end).setResourceId(resourceId); + items.add(item); + fireChanged(); + return item; + } + + @Override + public boolean removeItem(ScheduleItem item) { + boolean removed = items.remove(item); + if (removed) { + fireChanged(); + } + return removed; + } +} diff --git a/CodenameOne/src/com/codename1/scheduling/GanttTask.java b/CodenameOne/src/com/codename1/scheduling/GanttTask.java new file mode 100644 index 00000000000..5cf0b98e6c3 --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/GanttTask.java @@ -0,0 +1,372 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// A task in a project: a piece of work, a milestone, or a summary of the +/// tasks under it. +/// +/// A task with children is a summary, and its dates are the span of its +/// children. A task with no children and no duration is a milestone. For any +/// other, `duration` is working time in the project's `WorkCalendar`, and +/// `ProjectScheduler` works out `start` and `end` from the dependencies, the +/// constraint and the leveling delay. A task that is scheduled manually keeps +/// the dates it was given. +/// +/// Add a task to a `ProjectModel` before using it; the model owns the tree. +public final class GanttTask { + /// A limit on when a task may be scheduled, on top of its dependencies. + public enum Constraint { + /// No limit: as early as the dependencies allow. + AS_SOON_AS_POSSIBLE, + START_NO_EARLIER_THAN, + START_NO_LATER_THAN, + FINISH_NO_EARLIER_THAN, + FINISH_NO_LATER_THAN, + /// Starts on the date whatever the dependencies say. + MUST_START_ON, + /// Finishes on the date whatever the dependencies say. + MUST_FINISH_ON + } + + private final String id; + private String name; + GanttTask parent; + final List children = new ArrayList(); + final List assignments = new ArrayList(); + private long duration; + private long start; + private long end; + private int percentComplete; + private Constraint constraint = Constraint.AS_SOON_AS_POSSIBLE; + private long constraintDate; + private boolean manuallyScheduled; + private long levelingDelay; + private int priority = 500; + private boolean hasBaseline; + private long baselineStart; + private long baselineEnd; + private int color = -1; + private String category; + private Object userObject; + + // What the scheduler worked out. + long earlyStart; + long earlyFinish; + long lateStart; + long lateFinish; + long totalSlack; + long freeSlack; + boolean critical; + boolean constraintViolated; + // The task's position in the scheduler's arrays during a pass. + int index; + + public GanttTask(String id, String name) { + if (id == null) { + throw new IllegalArgumentException("id"); + } + this.id = id; + this.name = name == null ? "" : name; + } + + /// A task of `duration` milliseconds of working time. + public GanttTask(String id, String name, long duration) { + this(id, name); + setDuration(duration); + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public GanttTask setName(String v) { + name = v == null ? "" : v; + return this; + } + + public GanttTask getParent() { + return parent; + } + + public List getChildren() { + return Collections.unmodifiableList(children); + } + + public boolean isSummary() { + return !children.isEmpty(); + } + + public boolean isMilestone() { + return children.isEmpty() && duration == 0 && !manuallyScheduled; + } + + /// How deep the task is in the tree; a top level task is at 0. + public int getLevel() { + int level = 0; + for (GanttTask p = parent; p != null; p = p.parent) { + level++; + } + return level; + } + + /// Whether `other` is this task's parent, or its parent's, and so on. + public boolean isDescendantOf(GanttTask other) { + for (GanttTask p = parent; p != null; p = p.parent) { + if (p == other) { //NOPMD CompareObjectsWithEquals - identity is the point + return true; + } + } + return false; + } + + /// Working time the task takes, in milliseconds. Ignored for a summary. + public long getDuration() { + return duration; + } + + public GanttTask setDuration(long millis) { + if (millis < 0) { + throw new IllegalArgumentException("duration"); + } + duration = millis; + return this; + } + + public long getStart() { + return start; + } + + public long getEnd() { + return end; + } + + /// Sets the dates directly. The scheduler overwrites them unless the task + /// is scheduled manually. + public GanttTask setDates(long start, long end) { + if (end < start) { + throw new IllegalArgumentException("end before start"); + } + this.start = start; + this.end = end; + return this; + } + + /// 0 to 100. + public int getPercentComplete() { + return percentComplete; + } + + public GanttTask setPercentComplete(int v) { + percentComplete = Math.max(0, Math.min(100, v)); + return this; + } + + public Constraint getConstraint() { + return constraint; + } + + public long getConstraintDate() { + return constraintDate; + } + + public GanttTask setConstraint(Constraint type, long date) { + constraint = type == null ? Constraint.AS_SOON_AS_POSSIBLE : type; + constraintDate = date; + return this; + } + + /// Whether the task keeps the dates it was given instead of being moved by + /// its dependencies. Its successors still follow it. + public boolean isManuallyScheduled() { + return manuallyScheduled; + } + + public GanttTask setManuallyScheduled(boolean v) { + manuallyScheduled = v; + return this; + } + + /// Working time the task is held back by after its dependencies would let + /// it start. `ResourceLeveler` sets it; zero undoes leveling. + public long getLevelingDelay() { + return levelingDelay; + } + + public GanttTask setLevelingDelay(long millis) { + levelingDelay = Math.max(0, millis); + return this; + } + + /// 0 to 1000; when two tasks want the same resource the higher keeps its + /// dates. 1000 is never delayed by leveling. + public int getPriority() { + return priority; + } + + public GanttTask setPriority(int v) { + priority = Math.max(0, Math.min(1000, v)); + return this; + } + + public boolean hasBaseline() { + return hasBaseline; + } + + public long getBaselineStart() { + return baselineStart; + } + + public long getBaselineEnd() { + return baselineEnd; + } + + /// Records the dates the task was planned at, to compare with later. + public GanttTask setBaseline(long start, long end) { + hasBaseline = true; + baselineStart = start; + baselineEnd = end; + return this; + } + + public GanttTask clearBaseline() { + hasBaseline = false; + return this; + } + + public List getAssignments() { + return Collections.unmodifiableList(assignments); + } + + /// Assigns a resource full time. + public GanttTask assign(String resourceId) { + return assign(resourceId, 1f); + } + + /// Assigns `units` of a resource: `0.5f` is half of somebody's time. + public GanttTask assign(String resourceId, float units) { + unassign(resourceId); + assignments.add(new ResourceAssignment(resourceId, units)); + return this; + } + + public GanttTask unassign(String resourceId) { + for (int i = assignments.size() - 1; i >= 0; i--) { + if (assignments.get(i).getResourceId().equals(resourceId)) { + assignments.remove(i); + } + } + return this; + } + + /// The units of a resource the task uses, 0 when it is not assigned. + public float getUnits(String resourceId) { + for (int i = 0; i < assignments.size(); i++) { + if (assignments.get(i).getResourceId().equals(resourceId)) { + return assignments.get(i).getUnits(); + } + } + return 0f; + } + + /// An RGB colour for the bar, or -1 for the theme's. + public int getColor() { + return color; + } + + public GanttTask setColor(int v) { + color = v; + return this; + } + + /// A name a theme can style: `GanttBar` plus the category. + public String getCategory() { + return category; + } + + public GanttTask setCategory(String v) { + category = v; + return this; + } + + public Object getUserObject() { + return userObject; + } + + public GanttTask setUserObject(Object v) { + userObject = v; + return this; + } + + /// The earliest the task can start, from the last scheduling pass. + public long getEarlyStart() { + return earlyStart; + } + + public long getEarlyFinish() { + return earlyFinish; + } + + /// The latest the task can start without delaying the project. + public long getLateStart() { + return lateStart; + } + + public long getLateFinish() { + return lateFinish; + } + + /// Working time the task can slip by without delaying the project. + /// Negative when a constraint or a deadline cannot be met. + public long getTotalSlack() { + return totalSlack; + } + + /// Working time the task can slip by without delaying any successor. + public long getFreeSlack() { + return freeSlack; + } + + /// Whether the task is on the critical path: any slip delays the project. + public boolean isCritical() { + return critical; + } + + /// Whether the dependencies pushed the task past a "no later than" + /// constraint, or a "must" constraint pulled it ahead of a predecessor. + public boolean isConstraintViolated() { + return constraintViolated; + } + + @Override + public String toString() { + return name; + } +} diff --git a/CodenameOne/src/com/codename1/scheduling/LevelingResult.java b/CodenameOne/src/com/codename1/scheduling/LevelingResult.java new file mode 100644 index 00000000000..14829785d9e --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/LevelingResult.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// What `ResourceLeveler.level` proposes: a delay for some tasks, and what +/// the project would look like with them. +/// +/// Nothing has changed in the project until `apply` is called, so the +/// proposal can be shown and turned down. +public final class LevelingResult { + private final ProjectModel model; + private final List tasks = new ArrayList(); + private final List delays = new ArrayList(); + private final List delayed = new ArrayList(); + private final List unresolved; + private final long finishBefore; + private final long finishAfter; + + LevelingResult(ProjectModel model, List all, List unresolved, + long finishBefore, long finishAfter, long[] before) { + this.model = model; + this.unresolved = unresolved; + this.finishBefore = finishBefore; + this.finishAfter = finishAfter; + for (int i = 0; i < all.size(); i++) { + GanttTask t = all.get(i); + tasks.add(t); + delays.add(Long.valueOf(t.getLevelingDelay())); + if (t.getLevelingDelay() != before[i]) { + delayed.add(t); + } + } + } + + /// The tasks whose delay the proposal changes. + public List getChangedTasks() { + return Collections.unmodifiableList(delayed); + } + + /// Whether applying the proposal would change anything. + public boolean isChanged() { + return !delayed.isEmpty(); + } + + /// The leveling delay the proposal gives a task, in working milliseconds. + public long getDelay(GanttTask task) { + int i = tasks.indexOf(task); + return i < 0 ? 0 : delays.get(i).longValue(); + } + + /// What could not be solved by delaying: a task that alone needs more of + /// a resource than exists, or tasks that may not be moved. + public List getUnresolved() { + return Collections.unmodifiableList(unresolved); + } + + /// The project's finish as it is now. + public long getFinishBefore() { + return finishBefore; + } + + /// The project's finish with the proposal applied. + public long getFinishAfter() { + return finishAfter; + } + + /// Writes the delays into the project and schedules it. + public void apply() { + for (int i = 0; i < tasks.size(); i++) { + tasks.get(i).setLevelingDelay(delays.get(i).longValue()); + } + model.update(); + } +} diff --git a/CodenameOne/src/com/codename1/scheduling/ProjectChangeListener.java b/CodenameOne/src/com/codename1/scheduling/ProjectChangeListener.java new file mode 100644 index 00000000000..b6b92b9da4b --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/ProjectChangeListener.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +/// Told when a `ProjectModel` changes: a task, a link, or the dates a +/// scheduling pass produced. +public interface ProjectChangeListener { + void projectChanged(ProjectModel model); +} diff --git a/CodenameOne/src/com/codename1/scheduling/ProjectModel.java b/CodenameOne/src/com/codename1/scheduling/ProjectModel.java new file mode 100644 index 00000000000..84b5bac2988 --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/ProjectModel.java @@ -0,0 +1,373 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// A project: a tree of tasks, the links between them, the resources they +/// use and the calendar their durations are measured in. +/// +/// Building or editing a project is two steps. The methods that change it +/// (`addTask`, `link`, a setter on a task) only change it; `update` then +/// schedules the whole project and tells the listeners, so a hundred edits +/// cost one pass: +/// +/// ```java +/// ProjectModel p = new ProjectModel(); +/// GanttTask design = new GanttTask("design", "Design", 3 * p.getCalendar().getMillisPerDay()); +/// GanttTask build = new GanttTask("build", "Build", 5 * p.getCalendar().getMillisPerDay()); +/// p.addTask(design).addTask(build); +/// p.link(design, build); +/// p.update(); +/// ``` +public class ProjectModel { + private final List roots = new ArrayList(); + private final List dependencies = new ArrayList(); + private final List resources = new ArrayList(); + private final List listeners = new ArrayList(); + private WorkCalendar calendar; + private long start; + long finish; + private long criticalSlack; + private List flat; + + /// A project that starts today, on the default working week. + public ProjectModel() { + this(new WorkCalendar(), System.currentTimeMillis()); + } + + public ProjectModel(WorkCalendar calendar, long start) { + if (calendar == null) { + throw new IllegalArgumentException("calendar"); + } + this.calendar = calendar; + this.start = TimeMath.startOfDay(start, calendar.getZone()); + this.finish = this.start; + } + + public WorkCalendar getCalendar() { + return calendar; + } + + public ProjectModel setCalendar(WorkCalendar v) { + if (v == null) { + throw new IllegalArgumentException("calendar"); + } + calendar = v; + return this; + } + + /// The earliest any task is scheduled. + public long getStart() { + return start; + } + + public ProjectModel setStart(long millis) { + start = millis; + return this; + } + + /// When the last task finishes, from the last scheduling pass. + public long getFinish() { + return finish; + } + + /// A task with this much total slack or less is critical. Zero by + /// default; a day makes the tasks that are nearly critical stand out too. + public long getCriticalSlack() { + return criticalSlack; + } + + public ProjectModel setCriticalSlack(long millis) { + criticalSlack = Math.max(0, millis); + return this; + } + + /// Adds a top level task. + public ProjectModel addTask(GanttTask task) { + return addTask(null, -1, task); + } + + /// Adds a task under `parent`, which becomes a summary. + public ProjectModel addTask(GanttTask parent, GanttTask task) { + return addTask(parent, -1, task); + } + + /// Adds a task at a position among `parent`'s children; -1 is the end, + /// and a null parent is the top level. + public ProjectModel addTask(GanttTask parent, int index, GanttTask task) { + if (task == null || task == parent) { //NOPMD CompareObjectsWithEquals + throw new IllegalArgumentException("task"); + } + if (parent != null && parent.isDescendantOf(task)) { + throw new IllegalArgumentException("A task cannot go under one of its own children"); + } + detach(task); + List into = parent == null ? roots : parent.children; + if (index < 0 || index > into.size()) { + into.add(task); + } else { + into.add(index, task); + } + task.parent = parent; + flat = null; + return this; + } + + /// Moves a task, with everything under it, to another place in the tree. + /// + /// #### Returns + /// + /// false when the move would put the task under itself or would close a + /// loop of links through a summary + public boolean moveTask(GanttTask task, GanttTask newParent, int index) { + if (task == newParent || (newParent != null && newParent.isDescendantOf(task))) { //NOPMD CompareObjectsWithEquals + return false; + } + GanttTask oldParent = task.parent; + List from = oldParent == null ? roots : oldParent.children; + int oldIndex = from.indexOf(task); + addTask(newParent, index, task); + if (ProjectScheduler.order(this) == null) { + addTask(oldParent, oldIndex, task); + return false; + } + return true; + } + + /// Removes a task, everything under it, and every link to any of them. + public ProjectModel removeTask(GanttTask task) { + for (int i = dependencies.size() - 1; i >= 0; i--) { + TaskDependency d = dependencies.get(i); + if (within(d.getPredecessor(), task) || within(d.getSuccessor(), task)) { + dependencies.remove(i); + } + } + detach(task); + task.parent = null; + flat = null; + return this; + } + + private static boolean within(GanttTask t, GanttTask root) { + return t == root || t.isDescendantOf(root); //NOPMD CompareObjectsWithEquals + } + + private void detach(GanttTask task) { + if (task.parent != null) { + task.parent.children.remove(task); + } else { + roots.remove(task); + } + } + + public List getRootTasks() { + return Collections.unmodifiableList(roots); + } + + /// Every task, a parent before its children, in the order of the outline. + public List getTasks() { + if (flat == null) { + List out = new ArrayList(); + flatten(roots, out); + flat = Collections.unmodifiableList(out); + } + return flat; + } + + private static void flatten(List level, List out) { + for (int i = 0; i < level.size(); i++) { + GanttTask t = level.get(i); + out.add(t); + if (!t.children.isEmpty()) { + flatten(t.children, out); + } + } + } + + public GanttTask getTask(String id) { + List all = getTasks(); + for (int i = 0; i < all.size(); i++) { + if (all.get(i).getId().equals(id)) { + return all.get(i); + } + } + return null; + } + + /// Links two tasks finish to start. + public TaskDependency link(GanttTask predecessor, GanttTask successor) { + return link(predecessor, successor, TaskDependency.Type.FINISH_TO_START, 0); + } + + /// Links two tasks. + /// + /// A link whose successor is a summary holds the start of every task + /// under it, whatever its type: a summary's finish is the result of its + /// children and cannot be held by itself. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when `canLink` says no + public TaskDependency link(GanttTask predecessor, GanttTask successor, TaskDependency.Type type, long lag) { + if (!canLink(predecessor, successor)) { + throw new IllegalArgumentException("Cannot link " + predecessor + " to " + successor); + } + TaskDependency d = new TaskDependency(predecessor, successor, type, lag); + dependencies.add(d); + return d; + } + + /// Whether a link from `predecessor` to `successor` is possible: they are + /// two tasks, neither is inside the other, they are not linked already, + /// and the link would not close a loop. + public boolean canLink(GanttTask predecessor, GanttTask successor) { + if (predecessor == null || successor == null || predecessor == successor //NOPMD CompareObjectsWithEquals + || predecessor.isDescendantOf(successor) || successor.isDescendantOf(predecessor)) { + return false; + } + if (getDependency(predecessor, successor) != null) { + return false; + } + TaskDependency probe = new TaskDependency(predecessor, successor, TaskDependency.Type.FINISH_TO_START, 0); + dependencies.add(probe); + boolean ok = ProjectScheduler.order(this) != null; + dependencies.remove(dependencies.size() - 1); + return ok; + } + + public ProjectModel unlink(TaskDependency d) { + dependencies.remove(d); + return this; + } + + public List getDependencies() { + return Collections.unmodifiableList(dependencies); + } + + /// The link from one task to another, or null. + public TaskDependency getDependency(GanttTask predecessor, GanttTask successor) { + for (int i = 0; i < dependencies.size(); i++) { + TaskDependency d = dependencies.get(i); + if (d.getPredecessor() == predecessor && d.getSuccessor() == successor) { //NOPMD CompareObjectsWithEquals + return d; + } + } + return null; + } + + /// The links that end at `task`. + public List getPredecessors(GanttTask task) { + List out = new ArrayList(); + for (int i = 0; i < dependencies.size(); i++) { + if (dependencies.get(i).getSuccessor() == task) { //NOPMD CompareObjectsWithEquals + out.add(dependencies.get(i)); + } + } + return out; + } + + /// The links that start at `task`. + public List getSuccessors(GanttTask task) { + List out = new ArrayList(); + for (int i = 0; i < dependencies.size(); i++) { + if (dependencies.get(i).getPredecessor() == task) { //NOPMD CompareObjectsWithEquals + out.add(dependencies.get(i)); + } + } + return out; + } + + public ProjectModel addResource(ScheduleResource r) { + if (r != null) { + resources.add(r); + } + return this; + } + + public ProjectModel removeResource(ScheduleResource r) { + resources.remove(r); + return this; + } + + public List getResources() { + return Collections.unmodifiableList(resources); + } + + public ScheduleResource getResource(String id) { + for (int i = 0; i < resources.size(); i++) { + if (resources.get(i).getId().equals(id)) { + return resources.get(i); + } + } + return null; + } + + /// Records every task's current dates as its baseline. + public ProjectModel captureBaseline() { + List all = getTasks(); + for (int i = 0; i < all.size(); i++) { + GanttTask t = all.get(i); + t.setBaseline(t.getStart(), t.getEnd()); + } + return this; + } + + /// Removes every leveling delay. Call `update` after it. + public ProjectModel clearLeveling() { + List all = getTasks(); + for (int i = 0; i < all.size(); i++) { + all.get(i).setLevelingDelay(0); + } + return this; + } + + /// Schedules the project and tells the listeners. Call it after a change, + /// or after a group of them. + public void update() { + ProjectScheduler.schedule(this); + fireChanged(); + } + + public void addChangeListener(ProjectChangeListener l) { + if (l != null && !listeners.contains(l)) { + listeners.add(l); + } + } + + public void removeChangeListener(ProjectChangeListener l) { + listeners.remove(l); + } + + /// Tells the listeners without scheduling, for a change that moves no + /// date, such as a name. + public void fireChanged() { + for (int i = listeners.size() - 1; i >= 0; i--) { + if (i < listeners.size()) { + listeners.get(i).projectChanged(this); + } + } + } +} diff --git a/CodenameOne/src/com/codename1/scheduling/ProjectScheduler.java b/CodenameOne/src/com/codename1/scheduling/ProjectScheduler.java new file mode 100644 index 00000000000..42eb60fe38a --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/ProjectScheduler.java @@ -0,0 +1,427 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +import java.util.ArrayList; +import java.util.List; + +/// Works out when every task of a project happens. +/// +/// A forward pass gives each task the earliest dates its predecessors, its +/// constraint and its leveling delay allow, in the project's working time. A +/// backward pass from the project's finish gives the latest dates that delay +/// nothing, and the difference is slack: a task with none is critical. +/// +/// `ProjectModel.update` runs it. The whole project is scheduled every time; +/// a pass is linear in tasks and links, and a partial one would have to prove +/// what it may skip. +public final class ProjectScheduler { + private ProjectScheduler() { + } + + /// Schedules `model`, writing dates and slack into its tasks. + /// + /// #### Throws + /// + /// - `IllegalStateException`: when the links form a loop, which + /// `ProjectModel.link` and `moveTask` do not allow + public static void schedule(ProjectModel model) { + List tasks = model.getTasks(); + int[] order = order(model); + if (order == null) { + throw new IllegalStateException("The project's links form a loop"); + } + int n = tasks.size(); + WorkCalendar cal = model.getCalendar(); + List deps = model.getDependencies(); + List> incoming = new ArrayList>(n); + List> outgoing = new ArrayList>(n); + for (int i = 0; i < n; i++) { + incoming.add(null); + outgoing.add(null); + } + for (int i = 0; i < deps.size(); i++) { + TaskDependency d = deps.get(i); + put(incoming, d.getSuccessor().index, d); + put(outgoing, d.getPredecessor().index, d); + } + + long projectStart = cal.nextWorkingTime(model.getStart()); + long projectFinish = projectStart; + for (int o = 0; o < n; o++) { + GanttTask t = tasks.get(order[o]); + if (t.isSummary()) { + long es = Long.MAX_VALUE; + long ef = Long.MIN_VALUE; + for (int c = 0; c < t.children.size(); c++) { + GanttTask child = t.children.get(c); + es = Math.min(es, child.earlyStart); + ef = Math.max(ef, child.earlyFinish); + } + t.earlyStart = es; + t.earlyFinish = ef; + continue; + } + forward(t, cal, incoming, projectStart); + projectFinish = Math.max(projectFinish, t.earlyFinish); + } + model.finish = projectFinish; + + List leaves = new ArrayList(); + for (int o = n - 1; o >= 0; o--) { + GanttTask t = tasks.get(order[o]); + if (!t.isSummary()) { + backward(t, cal, outgoing, projectFinish, leaves); + } + } + long threshold = model.getCriticalSlack(); + for (int i = 0; i < n; i++) { + GanttTask t = tasks.get(i); + if (t.isSummary()) { + continue; + } + t.totalSlack = cal.workingTimeBetween(t.earlyStart, t.lateStart); + t.critical = t.totalSlack <= threshold; + t.freeSlack = freeSlack(t, cal, outgoing, projectFinish, leaves); + if (!t.isManuallyScheduled()) { + t.setDates(t.earlyStart, t.earlyFinish); + } + } + // A parent comes before its children in the outline, so backwards is + // children first. + for (int i = n - 1; i >= 0; i--) { + GanttTask t = tasks.get(i); + if (t.isSummary()) { + rollUp(t, cal); + } + } + } + + /// The tasks' indices in an order in which every task comes after what it + /// waits for, or null when the links form a loop. Also numbers the tasks. + static int[] order(ProjectModel model) { + List tasks = model.getTasks(); + int n = tasks.size(); + for (int i = 0; i < n; i++) { + tasks.get(i).index = i; + } + List deps = model.getDependencies(); + // Edges as two parallel lists: a summary waits for its children, a + // successor for its predecessor, and every task under a summary that + // is a successor waits for that predecessor too. + IntList from = new IntList(); + IntList to = new IntList(); + for (int i = 0; i < n; i++) { + GanttTask t = tasks.get(i); + if (t.parent != null) { + from.add(i); + to.add(t.parent.index); + } + } + List leaves = new ArrayList(); + for (int i = 0; i < deps.size(); i++) { + TaskDependency d = deps.get(i); + GanttTask s = d.getSuccessor(); + if (s.isSummary()) { + leaves.clear(); + leavesOf(s, leaves); + for (int l = 0; l < leaves.size(); l++) { + from.add(d.getPredecessor().index); + to.add(leaves.get(l).index); + } + } else { + from.add(d.getPredecessor().index); + to.add(s.index); + } + } + int edges = from.size; + int[] degree = new int[n]; + int[] firstEdge = new int[n + 1]; + for (int e = 0; e < edges; e++) { + degree[to.data[e]]++; + firstEdge[from.data[e] + 1]++; + } + for (int i = 0; i < n; i++) { + firstEdge[i + 1] += firstEdge[i]; + } + int[] fill = new int[n]; + int[] target = new int[edges]; + for (int e = 0; e < edges; e++) { + int f = from.data[e]; + target[firstEdge[f] + fill[f]++] = to.data[e]; + } + int[] out = new int[n]; + int head = 0; + int tail = 0; + for (int i = 0; i < n; i++) { + if (degree[i] == 0) { + out[tail++] = i; + } + } + while (head < tail) { + int t = out[head++]; + for (int e = firstEdge[t]; e < firstEdge[t + 1]; e++) { + if (--degree[target[e]] == 0) { + out[tail++] = target[e]; + } + } + } + return tail == n ? out : null; + } + + private static void forward(GanttTask t, WorkCalendar cal, List> incoming, long projectStart) { + t.constraintViolated = false; + if (t.isManuallyScheduled()) { + t.earlyStart = t.getStart(); + t.earlyFinish = t.getEnd(); + return; + } + long dur = t.getDuration(); + long s = projectStart; + for (GanttTask a = t; a != null; a = a.parent) { + List in = incoming.get(a.index); + if (in == null) { + continue; + } + for (int i = 0; i < in.size(); i++) { + TaskDependency d = in.get(i); + GanttTask p = d.getPredecessor(); + // Without a lag the bound is the predecessor's own instant: + // adding zero working time would carry the close of one day + // to the opening of the next, and a milestone belongs to the + // day it closes. + long bound = d.fromFinish() ? p.earlyFinish : p.earlyStart; + if (d.getLag() != 0) { + bound = cal.addWorkingTime(bound, d.getLag()); + } + // A link to a summary holds the start of what is under it. + if (a == t && !d.constrainsStart()) { //NOPMD CompareObjectsWithEquals + bound = cal.subtractWorkingTime(bound, dur); + } + s = Math.max(s, bound); + } + } + long date = t.getConstraintDate(); + switch (t.getConstraint()) { + case START_NO_EARLIER_THAN: + s = Math.max(s, date); + break; + case FINISH_NO_EARLIER_THAN: + s = Math.max(s, cal.subtractWorkingTime(date, dur)); + break; + case MUST_START_ON: + t.constraintViolated = date < s; + s = date; + break; + case MUST_FINISH_ON: + long must = cal.subtractWorkingTime(date, dur); + t.constraintViolated = must < s; + s = must; + break; + default: + break; + } + if (t.getLevelingDelay() > 0) { + s = cal.addWorkingTime(cal.nextWorkingTime(s), t.getLevelingDelay()); + } + if (dur == 0) { + // A milestone may sit where a working period closes: the end of + // Friday, not the start of Monday. + t.earlyStart = cal.previousWorkingTime(s) == s ? s : cal.nextWorkingTime(s); + t.earlyFinish = t.earlyStart; + } else { + t.earlyStart = cal.nextWorkingTime(s); + t.earlyFinish = cal.addWorkingTime(t.earlyStart, dur); + } + if (t.getConstraint() == GanttTask.Constraint.START_NO_LATER_THAN) { + t.constraintViolated = t.earlyStart > date; + } else if (t.getConstraint() == GanttTask.Constraint.FINISH_NO_LATER_THAN) { + t.constraintViolated = t.earlyFinish > date; + } + } + + private static boolean pinned(GanttTask t) { + return t.isManuallyScheduled() || t.getConstraint() == GanttTask.Constraint.MUST_START_ON + || t.getConstraint() == GanttTask.Constraint.MUST_FINISH_ON; + } + + private static void backward(GanttTask t, WorkCalendar cal, List> outgoing, + long projectFinish, List leaves) { + if (pinned(t)) { + t.lateStart = t.earlyStart; + t.lateFinish = t.earlyFinish; + return; + } + long dur = t.getDuration(); + long lf = projectFinish; + for (GanttTask a = t; a != null; a = a.parent) { + List out = outgoing.get(a.index); + if (out == null) { + continue; + } + for (int i = 0; i < out.size(); i++) { + TaskDependency d = out.get(i); + // A summary's finish is the latest of its children, so a link + // from its finish holds each of them. Its start holds none. + if (a != t && !d.fromFinish()) { //NOPMD CompareObjectsWithEquals + continue; + } + GanttTask s = d.getSuccessor(); + if (s.isSummary()) { + leaves.clear(); + leavesOf(s, leaves); + for (int l = 0; l < leaves.size(); l++) { + lf = Math.min(lf, lateBound(d, leaves.get(l).lateStart, cal, dur)); + } + } else { + lf = Math.min(lf, lateBound(d, d.constrainsStart() ? s.lateStart : s.lateFinish, cal, dur)); + } + } + } + if (t.getConstraint() == GanttTask.Constraint.FINISH_NO_LATER_THAN) { + lf = Math.min(lf, t.getConstraintDate()); + } else if (t.getConstraint() == GanttTask.Constraint.START_NO_LATER_THAN) { + lf = Math.min(lf, cal.addWorkingTime(t.getConstraintDate(), dur)); + } + if (dur == 0) { + t.lateFinish = lf; + t.lateStart = lf; + } else { + t.lateFinish = cal.previousWorkingTime(lf); + t.lateStart = cal.subtractWorkingTime(t.lateFinish, dur); + } + } + + // The latest finish of a predecessor that lets the successor's end be at + // `target`. + private static long lateBound(TaskDependency d, long target, WorkCalendar cal, long dur) { + long b = d.getLag() == 0 ? target : cal.subtractWorkingTime(target, d.getLag()); + if (!d.fromFinish()) { + b = cal.addWorkingTime(b, dur); + } + return b; + } + + private static long freeSlack(GanttTask t, WorkCalendar cal, List> outgoing, + long projectFinish, List leaves) { + long best = Long.MAX_VALUE; + for (GanttTask a = t; a != null; a = a.parent) { + List out = outgoing.get(a.index); + if (out == null) { + continue; + } + for (int i = 0; i < out.size(); i++) { + TaskDependency d = out.get(i); + if (a != t && !d.fromFinish()) { //NOPMD CompareObjectsWithEquals + continue; + } + long mine = d.fromFinish() ? t.earlyFinish : t.earlyStart; + GanttTask s = d.getSuccessor(); + if (s.isSummary()) { + leaves.clear(); + leavesOf(s, leaves); + for (int l = 0; l < leaves.size(); l++) { + long allowed = cal.subtractWorkingTime(leaves.get(l).earlyStart, d.getLag()); + best = Math.min(best, cal.workingTimeBetween(mine, allowed)); + } + } else { + long allowed = cal.subtractWorkingTime(d.constrainsStart() ? s.earlyStart : s.earlyFinish, d.getLag()); + best = Math.min(best, cal.workingTimeBetween(mine, allowed)); + } + } + } + if (best == Long.MAX_VALUE) { + best = cal.workingTimeBetween(t.earlyFinish, projectFinish); + } + return Math.max(0, best); + } + + private static void rollUp(GanttTask t, WorkCalendar cal) { + long ls = Long.MAX_VALUE; + long lf = Long.MIN_VALUE; + long slack = Long.MAX_VALUE; + long free = Long.MAX_VALUE; + boolean critical = false; + boolean violated = false; + long work = 0; + long done = 0; + for (int c = 0; c < t.children.size(); c++) { + GanttTask child = t.children.get(c); + ls = Math.min(ls, child.lateStart); + lf = Math.max(lf, child.lateFinish); + slack = Math.min(slack, child.totalSlack); + free = Math.min(free, child.freeSlack); + critical |= child.critical; + violated |= child.constraintViolated; + long d = child.getDuration(); + work += d; + done += d * child.getPercentComplete(); + } + t.lateStart = ls; + t.lateFinish = lf; + t.totalSlack = slack; + t.freeSlack = free; + t.critical = critical; + t.constraintViolated = violated; + t.setDates(t.earlyStart, t.earlyFinish); + t.setDuration(Math.max(0, cal.workingTimeBetween(t.earlyStart, t.earlyFinish))); + if (work > 0) { + t.setPercentComplete((int) (done / work)); + } + } + + /// Adds every task under `t` that has no children of its own to `out`. + static void leavesOf(GanttTask t, List out) { + for (int c = 0; c < t.children.size(); c++) { + GanttTask child = t.children.get(c); + if (child.children.isEmpty()) { + out.add(child); + } else { + leavesOf(child, out); + } + } + } + + private static void put(List> lists, int index, TaskDependency d) { + List l = lists.get(index); + if (l == null) { + l = new ArrayList(2); + lists.set(index, l); + } + l.add(d); + } + + private static final class IntList { + int[] data = new int[32]; + int size; + + void add(int v) { + if (size == data.length) { + int[] bigger = new int[size * 2]; + System.arraycopy(data, 0, bigger, 0, size); + data = bigger; + } + data[size++] = v; + } + } +} diff --git a/CodenameOne/src/com/codename1/scheduling/ResourceAssignment.java b/CodenameOne/src/com/codename1/scheduling/ResourceAssignment.java new file mode 100644 index 00000000000..38622d647cf --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/ResourceAssignment.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +/// How much of a resource a task uses while it runs. +public final class ResourceAssignment { + private final String resourceId; + private final float units; + + /// `units` of 1 is the whole of one resource for the length of the task. + public ResourceAssignment(String resourceId, float units) { + if (resourceId == null) { + throw new IllegalArgumentException("resourceId"); + } + if (units <= 0) { + throw new IllegalArgumentException("units"); + } + this.resourceId = resourceId; + this.units = units; + } + + public String getResourceId() { + return resourceId; + } + + public float getUnits() { + return units; + } +} diff --git a/CodenameOne/src/com/codename1/scheduling/ResourceLeveler.java b/CodenameOne/src/com/codename1/scheduling/ResourceLeveler.java new file mode 100644 index 00000000000..39a37084efe --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/ResourceLeveler.java @@ -0,0 +1,308 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/// Finds where a project asks more of a resource than there is, and proposes +/// delays that take the excess away. +/// +/// Leveling never shortens or splits a task and never changes a link. It +/// takes the earliest moment a resource is overbooked, keeps the tasks that +/// can least afford to wait and holds one of the others back until a place is +/// free: the one with the most slack first, so the project's finish moves +/// only when no slack is left, then the one with the lowest priority. A task +/// that is scheduled manually, has a "must" constraint or has priority 1000 +/// is never moved. +/// +/// This is a heuristic, as it is in every planning tool: the best order is +/// not something that can be computed for a project of any size. +public final class ResourceLeveler { + private static final float EPSILON = 0.0001f; + + private ResourceLeveler() { + } + + /// Every stretch in which a resource is overbooked, ordered by resource + /// and then by time. + public static List findOverallocations(ProjectModel model) { + List out = new ArrayList(); + List resources = model.getResources(); + List leaves = leaves(model); + for (int r = 0; r < resources.size(); r++) { + scan(resources.get(r), leaves, out, null); + } + return out; + } + + /// How much of a resource is asked for over time: the most units in use + /// at once in each of the `buckets` equal slices of `[from, to)`. Divide + /// by the resource's capacity for a load. + public static float[] loadProfile(ProjectModel model, String resourceId, long from, long to, int buckets) { + float[] out = new float[Math.max(0, buckets)]; + if (buckets <= 0 || to <= from) { + return out; + } + List leaves = leaves(model); + double width = (to - from) / (double) buckets; + for (int i = 0; i < leaves.size(); i++) { + GanttTask t = leaves.get(i); + float units = t.getUnits(resourceId); + if (units <= 0 || t.getEnd() <= from || t.getStart() >= to || t.getEnd() <= t.getStart()) { + continue; + } + // Tasks that overlap in a bucket add up only where they overlap in + // time, so the peak is taken per boundary inside the bucket below. + int first = (int) Math.max(0, Math.floor((t.getStart() - from) / width)); + int last = (int) Math.min(buckets - 1, Math.floor((t.getEnd() - 1 - from) / width)); + for (int b = first; b <= last; b++) { + out[b] = -1f; + } + } + for (int b = 0; b < buckets; b++) { + if (out[b] < 0) { + long bs = from + (long) (b * width); + long be = from + (long) ((b + 1) * width); + out[b] = peak(leaves, resourceId, bs, be); + } + } + return out; + } + + private static float peak(List leaves, String resourceId, long from, long to) { + float best = 0; + // The sum only changes where a task starts, so those are the only + // instants worth measuring, with the start of the range itself. + for (int i = -1; i < leaves.size(); i++) { + long at = from; + if (i >= 0) { + GanttTask t = leaves.get(i); + if (t.getUnits(resourceId) <= 0 || t.getStart() <= from || t.getStart() >= to) { + continue; + } + at = t.getStart(); + } + float sum = 0; + for (int j = 0; j < leaves.size(); j++) { + GanttTask o = leaves.get(j); + if (o.getStart() <= at && o.getEnd() > at) { + sum += o.getUnits(resourceId); + } + } + best = Math.max(best, sum); + } + return best; + } + + /// Proposes leveling delays for the whole project, starting from none. + /// The project is left exactly as it was; see `LevelingResult.apply`. + public static LevelingResult level(ProjectModel model) { + List all = model.getTasks(); + int n = all.size(); + long[] before = new long[n]; + for (int i = 0; i < n; i++) { + before[i] = all.get(i).getLevelingDelay(); + } + ProjectScheduler.schedule(model); + long finishBefore = model.getFinish(); + for (int i = 0; i < n; i++) { + all.get(i).setLevelingDelay(0); + } + ProjectScheduler.schedule(model); + + WorkCalendar cal = model.getCalendar(); + List leaves = leaves(model); + List resources = model.getResources(); + List stuck = new ArrayList(); + List found = new ArrayList(); + List conflict = new ArrayList(); + // Every step moves one task later, so this ends by itself; the limit + // is for a calendar or a set of constraints that makes it crawl. + int limit = 200 + leaves.size() * leaves.size() * 4; + for (int step = 0; step < limit; step++) { + ResourceOverallocation first = null; + for (int r = 0; r < resources.size(); r++) { + found.clear(); + scan(resources.get(r), leaves, found, stuck); + if (!found.isEmpty() && (first == null || found.get(0).getStart() < first.getStart())) { + first = found.get(0); + } + } + if (first == null) { + break; + } + conflict.clear(); + for (int i = 0; i < leaves.size(); i++) { + GanttTask t = leaves.get(i); + if (t.getUnits(first.getResourceId()) > 0 && t.getStart() <= first.getStart() && t.getEnd() > first.getStart()) { + conflict.add(t); + } + } + GanttTask victim = conflict.size() < 2 ? null : pick(conflict, stuck); + if (victim == null) { + addAllOnce(stuck, conflict); + continue; + } + long until = Long.MAX_VALUE; + for (int i = 0; i < conflict.size(); i++) { + GanttTask o = conflict.get(i); + if (o != victim) { //NOPMD CompareObjectsWithEquals + until = Math.min(until, o.getEnd()); + } + } + long add = cal.workingTimeBetween(victim.getStart(), cal.nextWorkingTime(until)); + if (add <= 0) { + stuck.add(victim); + continue; + } + victim.setLevelingDelay(victim.getLevelingDelay() + add); + ProjectScheduler.schedule(model); + } + List unresolved = findOverallocations(model); + LevelingResult result = new LevelingResult(model, all, unresolved, finishBefore, model.getFinish(), before); + for (int i = 0; i < n; i++) { + all.get(i).setLevelingDelay(before[i]); + } + ProjectScheduler.schedule(model); + return result; + } + + // The task to hold back: most slack, then lowest priority, then the one + // that starts last, then the one furthest down the outline. + private static GanttTask pick(List conflict, List stuck) { + GanttTask best = null; + for (int i = 0; i < conflict.size(); i++) { + GanttTask t = conflict.get(i); + if (t.isManuallyScheduled() || t.getPriority() >= 1000 || stuck.contains(t) + || t.getConstraint() == GanttTask.Constraint.MUST_START_ON + || t.getConstraint() == GanttTask.Constraint.MUST_FINISH_ON) { + continue; + } + if (best == null || later(t, best)) { + best = t; + } + } + return best; + } + + private static boolean later(GanttTask a, GanttTask b) { + long sa = Math.max(0, a.getTotalSlack()); + long sb = Math.max(0, b.getTotalSlack()); + if (sa != sb) { + return sa > sb; + } + if (a.getPriority() != b.getPriority()) { + return a.getPriority() < b.getPriority(); + } + if (a.getStart() != b.getStart()) { + return a.getStart() > b.getStart(); + } + return a.index > b.index; + } + + private static void addAllOnce(List into, List from) { + for (int i = 0; i < from.size(); i++) { + if (!into.contains(from.get(i))) { + into.add(from.get(i)); + } + } + } + + private static List leaves(ProjectModel model) { + List all = model.getTasks(); + List out = new ArrayList(); + for (int i = 0; i < all.size(); i++) { + GanttTask t = all.get(i); + if (!t.isSummary() && !t.getAssignments().isEmpty()) { + out.add(t); + } + } + return out; + } + + // Adds the overbooked stretches of one resource to `out`, in time order. + // A stretch made only of tasks in `ignore` is left out. + private static void scan(ScheduleResource res, List leaves, List out, + List ignore) { + String id = res.getId(); + int count = 0; + for (int i = 0; i < leaves.size(); i++) { + GanttTask t = leaves.get(i); + if (t.getUnits(id) > 0 && t.getEnd() > t.getStart()) { + count++; + } + } + if (count == 0) { + return; + } + long[] bounds = new long[count * 2]; + int b = 0; + for (int i = 0; i < leaves.size(); i++) { + GanttTask t = leaves.get(i); + if (t.getUnits(id) > 0 && t.getEnd() > t.getStart()) { + bounds[b++] = t.getStart(); + bounds[b++] = t.getEnd(); + } + } + Arrays.sort(bounds); + long runStart = 0; + long runEnd = 0; + float runPeak = 0; + boolean open = false; + for (int s = 0; s + 1 < bounds.length; s++) { + long from = bounds[s]; + long to = bounds[s + 1]; + if (to == from) { + continue; + } + float sum = 0; + boolean movable = false; + for (int i = 0; i < leaves.size(); i++) { + GanttTask t = leaves.get(i); + float u = t.getUnits(id); + if (u > 0 && t.getStart() <= from && t.getEnd() >= to && t.getEnd() > t.getStart()) { + sum += u; + movable |= ignore == null || !ignore.contains(t); + } + } + boolean over = sum > res.getCapacity() + EPSILON && movable; + if (over && open && from == runEnd) { + runEnd = to; + runPeak = Math.max(runPeak, sum); + } else { + if (open) { + out.add(new ResourceOverallocation(id, runStart, runEnd, runPeak, res.getCapacity())); + } + open = over; + runStart = from; + runEnd = to; + runPeak = sum; + } + } + if (open) { + out.add(new ResourceOverallocation(id, runStart, runEnd, runPeak, res.getCapacity())); + } + } +} diff --git a/CodenameOne/src/com/codename1/scheduling/ResourceOverallocation.java b/CodenameOne/src/com/codename1/scheduling/ResourceOverallocation.java new file mode 100644 index 00000000000..23d103c998c --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/ResourceOverallocation.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +/// A stretch of time in which the tasks assigned to a resource need more of +/// it than there is. +public final class ResourceOverallocation { + private final String resourceId; + private final long start; + private final long end; + private final float peak; + private final float capacity; + + ResourceOverallocation(String resourceId, long start, long end, float peak, float capacity) { + this.resourceId = resourceId; + this.start = start; + this.end = end; + this.peak = peak; + this.capacity = capacity; + } + + public String getResourceId() { + return resourceId; + } + + public long getStart() { + return start; + } + + public long getEnd() { + return end; + } + + /// The most units asked for at once in the stretch. + public float getPeak() { + return peak; + } + + /// The units the resource has. + public float getCapacity() { + return capacity; + } +} diff --git a/CodenameOne/src/com/codename1/scheduling/ScheduleChangeListener.java b/CodenameOne/src/com/codename1/scheduling/ScheduleChangeListener.java new file mode 100644 index 00000000000..072468d043c --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/ScheduleChangeListener.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +/// Told when the items or resources of a `ScheduleModel` change. +public interface ScheduleChangeListener { + /// Something in `model` is different. A view repaints the range it shows. + void scheduleChanged(ScheduleModel model); +} diff --git a/CodenameOne/src/com/codename1/scheduling/ScheduleItem.java b/CodenameOne/src/com/codename1/scheduling/ScheduleItem.java new file mode 100644 index 00000000000..9704a71aa06 --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/ScheduleItem.java @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +/// One thing on a schedule: an appointment, a shift, a block of busy time. +/// +/// `start` and `end` are epoch milliseconds and `end` is exclusive. An all-day +/// item runs from the start of its first day to the start of the day after its +/// last, in the zone of the view that shows it. +public final class ScheduleItem { + /// How an item is drawn, apart from its colour. + public enum Kind { + /// An item of its own: a block with a title that can be selected. + EVENT, + /// A band under the events, such as the hours somebody is available. + /// It cannot be selected or moved. + BACKGROUND, + /// Time that is taken but is nobody's to edit here, such as busy time + /// read from another calendar. Drawn hatched. + BLOCKED + } + + private final String id; + private String title = ""; + private String detail; + private long start; + private long end; + private boolean allDay; + private String resourceId; + private Kind kind = Kind.EVENT; + private String category; + private int color = -1; + private boolean movable = true; + private boolean resizable = true; + private boolean recurring; + private Object userObject; + + public ScheduleItem(String id, String title, long start, long end) { + if (id == null) { + throw new IllegalArgumentException("id"); + } + this.id = id; + setTitle(title); + setTime(start, end); + } + + public String getId() { + return id; + } + + public String getTitle() { + return title; + } + + public ScheduleItem setTitle(String v) { + title = v == null ? "" : v; + return this; + } + + /// A second line: a place, a client's name. + public String getDetail() { + return detail; + } + + public ScheduleItem setDetail(String v) { + detail = v; + return this; + } + + public long getStart() { + return start; + } + + public long getEnd() { + return end; + } + + public ScheduleItem setTime(long start, long end) { + if (end < start) { + throw new IllegalArgumentException("end before start"); + } + this.start = start; + this.end = end; + return this; + } + + public boolean isAllDay() { + return allDay; + } + + public ScheduleItem setAllDay(boolean v) { + allDay = v; + return this; + } + + /// The resource the item belongs to, or null when the schedule has none. + public String getResourceId() { + return resourceId; + } + + public ScheduleItem setResourceId(String v) { + resourceId = v; + return this; + } + + public Kind getKind() { + return kind; + } + + public ScheduleItem setKind(Kind v) { + kind = v == null ? Kind.EVENT : v; + return this; + } + + /// A name a theme can style: an item of category `Done` is drawn with the + /// UIID `SchedulerItemDone` when the theme has one. + public String getCategory() { + return category; + } + + public ScheduleItem setCategory(String v) { + category = v; + return this; + } + + /// An RGB colour, or -1 for the resource's or the theme's. + public int getColor() { + return color; + } + + public ScheduleItem setColor(int v) { + color = v; + return this; + } + + public boolean isMovable() { + return movable && kind == Kind.EVENT; + } + + public ScheduleItem setMovable(boolean v) { + movable = v; + return this; + } + + public boolean isResizable() { + return resizable && kind == Kind.EVENT && !allDay; + } + + public ScheduleItem setResizable(boolean v) { + resizable = v; + return this; + } + + /// Whether the item is one occurrence of a series, which a view marks. + public boolean isRecurring() { + return recurring; + } + + public ScheduleItem setRecurring(boolean v) { + recurring = v; + return this; + } + + public Object getUserObject() { + return userObject; + } + + public ScheduleItem setUserObject(Object v) { + userObject = v; + return this; + } + + /// Whether any of the item is inside `[from, to)`. + public boolean intersects(long from, long to) { + return start < to && (end > from || (end == start && start >= from)); + } + + @Override + public String toString() { + return title; + } +} diff --git a/CodenameOne/src/com/codename1/scheduling/ScheduleModel.java b/CodenameOne/src/com/codename1/scheduling/ScheduleModel.java new file mode 100644 index 00000000000..276417fda74 --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/ScheduleModel.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +import java.util.List; + +/// What a `Scheduler` shows and edits. +/// +/// A view asks for the items of the range on screen every time it paints, so +/// `getItems` has to be cheap: a model that loads from a server answers from +/// what it has and fires a change when more arrives. `setVisibleRange` is how +/// it learns what to load. +/// +/// `DefaultScheduleModel` holds items in memory, and `CalendarScheduleModel` +/// reads a calendar source. Extend `AbstractScheduleModel` for one of your own. +public interface ScheduleModel { + /// The resources, in the order they are shown. Empty for a schedule that + /// is one person's. + List getResources(); + + /// Adds every item with any part inside `[from, to)` to `out`. + void getItems(long from, long to, List out); + + /// Told by a view whenever the range it shows changes, with a margin + /// around it. A model that keeps everything in memory ignores it. + void setVisibleRange(long from, long to); + + /// Moves or resizes an item, and gives it to another resource when + /// `resourceId` differs from the one it has. + /// + /// #### Returns + /// + /// false when the model refuses the change; the view puts the item back + boolean updateItem(ScheduleItem item, long start, long end, String resourceId); + + /// Creates an item for a range the user dragged out on empty space. + /// + /// #### Returns + /// + /// the new item, or null when the model does not create items this way + ScheduleItem createItem(long start, long end, String resourceId); + + /// Removes an item. + /// + /// #### Returns + /// + /// false when the model refuses + boolean removeItem(ScheduleItem item); + + void addChangeListener(ScheduleChangeListener l); + + void removeChangeListener(ScheduleChangeListener l); +} diff --git a/CodenameOne/src/com/codename1/scheduling/ScheduleResource.java b/CodenameOne/src/com/codename1/scheduling/ScheduleResource.java new file mode 100644 index 00000000000..f8ccb131fd9 --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/ScheduleResource.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +/// Something work is booked against: a person, a room, a machine, a calendar. +/// +/// A `Scheduler` draws one row or one column per resource, and a project +/// assigns tasks to them. `capacity` is how much of it there is at once: +/// `1` is one person full time, `3` a pool of three. +public final class ScheduleResource { + private final String id; + private String name; + private String detail; + private String groupId; + private float capacity = 1f; + private WorkCalendar calendar; + private int color = -1; + private Object userObject; + + public ScheduleResource(String id, String name) { + if (id == null) { + throw new IllegalArgumentException("id"); + } + this.id = id; + this.name = name == null ? "" : name; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public ScheduleResource setName(String v) { + name = v == null ? "" : v; + return this; + } + + /// A second line under the name: a role, a room's size. + public String getDetail() { + return detail; + } + + public ScheduleResource setDetail(String v) { + detail = v; + return this; + } + + /// The id of the resource this one is grouped under, or null. + public String getGroupId() { + return groupId; + } + + public ScheduleResource setGroupId(String v) { + groupId = v; + return this; + } + + public float getCapacity() { + return capacity; + } + + public ScheduleResource setCapacity(float v) { + if (v <= 0) { + throw new IllegalArgumentException("capacity"); + } + capacity = v; + return this; + } + + /// The resource's own working hours, or null for the project's. + public WorkCalendar getCalendar() { + return calendar; + } + + public ScheduleResource setCalendar(WorkCalendar v) { + calendar = v; + return this; + } + + /// An RGB colour for the resource's items, or -1 for the theme's. + public int getColor() { + return color; + } + + public ScheduleResource setColor(int v) { + color = v; + return this; + } + + public Object getUserObject() { + return userObject; + } + + public ScheduleResource setUserObject(Object v) { + userObject = v; + return this; + } + + @Override + public String toString() { + return name; + } +} diff --git a/CodenameOne/src/com/codename1/scheduling/TaskDependency.java b/CodenameOne/src/com/codename1/scheduling/TaskDependency.java new file mode 100644 index 00000000000..ab4d4856523 --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/TaskDependency.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +/// A link between two tasks: the successor waits for the predecessor. +/// +/// `lag` is working time added to the wait, and a negative lag is a lead: a +/// finish-to-start link with a lag of minus one day lets the successor start +/// a day before the predecessor finishes. +public final class TaskDependency { + /// Which end of the predecessor holds which end of the successor. + public enum Type { + /// The successor starts after the predecessor finishes. The usual one. + FINISH_TO_START, + /// The successor starts after the predecessor starts. + START_TO_START, + /// The successor finishes after the predecessor finishes. + FINISH_TO_FINISH, + /// The successor finishes after the predecessor starts. + START_TO_FINISH + } + + private final GanttTask predecessor; + private final GanttTask successor; + private Type type; + private long lag; + + TaskDependency(GanttTask predecessor, GanttTask successor, Type type, long lag) { + this.predecessor = predecessor; + this.successor = successor; + this.type = type == null ? Type.FINISH_TO_START : type; + this.lag = lag; + } + + public GanttTask getPredecessor() { + return predecessor; + } + + public GanttTask getSuccessor() { + return successor; + } + + public Type getType() { + return type; + } + + public TaskDependency setType(Type v) { + type = v == null ? Type.FINISH_TO_START : v; + return this; + } + + /// Working time in milliseconds; negative for a lead. + public long getLag() { + return lag; + } + + public TaskDependency setLag(long millis) { + lag = millis; + return this; + } + + /// Whether the link holds the successor's start (and not its finish). + public boolean constrainsStart() { + return type == Type.FINISH_TO_START || type == Type.START_TO_START; + } + + /// Whether the link is measured from the predecessor's finish. + public boolean fromFinish() { + return type == Type.FINISH_TO_START || type == Type.FINISH_TO_FINISH; + } +} diff --git a/CodenameOne/src/com/codename1/scheduling/TimeMath.java b/CodenameOne/src/com/codename1/scheduling/TimeMath.java new file mode 100644 index 00000000000..e836c03a686 --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/TimeMath.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +/// Calendar arithmetic on epoch milliseconds in a time zone. +/// +/// The scheduling model keeps every instant as epoch milliseconds, because a +/// view compares and subtracts them for every item it paints. These are the +/// few places where an instant has to become a calendar day and back, and they +/// go through `java.time` so a day that is 23 or 25 hours long is handled by +/// the zone rules and not by adding `24 * 60 * 60 * 1000`. +public final class TimeMath { + /// Milliseconds in a minute. + public static final long MINUTE = 60000L; + /// Milliseconds in an hour. + public static final long HOUR = 3600000L; + /// Milliseconds in a day that has no zone transition in it. + public static final long DAY = 86400000L; + /// Minutes in a day. + public static final int MINUTES_PER_DAY = 1440; + + private TimeMath() { + } + + /// The day `millis` falls on in `zone`, as a count of days since + /// 1970-01-01. + public static long epochDay(long millis, ZoneId zone) { + return ZonedDateTime.ofInstant(Instant.ofEpochMilli(millis), zone).toLocalDate().toEpochDay(); + } + + /// The instant the day `epochDay` starts at in `zone`. + public static long startOfEpochDay(long epochDay, ZoneId zone) { + return LocalDate.ofEpochDay(epochDay).atStartOfDay(zone).toInstant().toEpochMilli(); + } + + /// The start of the day `millis` falls on. + public static long startOfDay(long millis, ZoneId zone) { + return startOfEpochDay(epochDay(millis, zone), zone); + } + + /// The instant of a wall-clock minute on a day. `minuteOfDay` may be + /// `1440`, which is the start of the following day. + public static long at(long epochDay, int minuteOfDay, ZoneId zone) { + if (minuteOfDay >= MINUTES_PER_DAY) { + return startOfEpochDay(epochDay + 1, zone); + } + if (minuteOfDay <= 0) { + return startOfEpochDay(epochDay, zone); + } + return ZonedDateTime.of(LocalDate.ofEpochDay(epochDay), + LocalTime.of(minuteOfDay / 60, minuteOfDay % 60), zone).toInstant().toEpochMilli(); + } + + /// The wall-clock minute of the day at `millis`. + public static int minuteOfDay(long millis, ZoneId zone) { + return ZonedDateTime.ofInstant(Instant.ofEpochMilli(millis), zone).toLocalTime().toSecondOfDay() / 60; + } + + /// The ISO day of the week of a day: 1 is Monday, 7 is Sunday. + public static int dayOfWeek(long epochDay) { + // 1970-01-01 was a Thursday. + long m = (epochDay + 3) % 7; + if (m < 0) { + m += 7; + } + return (int) m + 1; + } + + /// The first day of the week `epochDay` is in, when weeks start on + /// `firstDayOfWeek` (ISO, 1 is Monday). + public static long startOfWeek(long epochDay, int firstDayOfWeek) { + int back = dayOfWeek(epochDay) - firstDayOfWeek; + if (back < 0) { + back += 7; + } + return epochDay - back; + } + + /// The first day of the month `epochDay` is in. + public static long startOfMonth(long epochDay) { + return LocalDate.ofEpochDay(epochDay).withDayOfMonth(1).toEpochDay(); + } + + /// The first day of the month `months` after the one `epochDay` is in. + public static long plusMonths(long epochDay, int months) { + return LocalDate.ofEpochDay(epochDay).withDayOfMonth(1).plusMonths(months).toEpochDay(); + } +} diff --git a/CodenameOne/src/com/codename1/scheduling/WorkCalendar.java b/CodenameOne/src/com/codename1/scheduling/WorkCalendar.java new file mode 100644 index 00000000000..9ade91ef089 --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/WorkCalendar.java @@ -0,0 +1,290 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +import java.time.ZoneId; +import java.util.HashMap; +import java.util.Map; + +/// The hours work happens in: a weekly pattern, and the days that differ from +/// it. +/// +/// A duration in a project is working time. Two days of work that start on a +/// Friday afternoon end on Tuesday, and this is the class that says so. A +/// `Scheduler` reads the same object to shade the hours nobody works. +/// +/// The default is Monday to Friday, 09:00 to 17:00, in the device's zone. +public final class WorkCalendar { + private static final int[] NONE = new int[0]; + // A stretch of days with no working time longer than this is a calendar + // that never works again, not a holiday. + private static final int MAX_IDLE_DAYS = 3700; + + private final ZoneId zone; + private final boolean[] workingDays = new boolean[8]; + private int[] hours = new int[]{9 * 60, 17 * 60}; + private final int[][] dayHours = new int[8][]; + private final Map exceptions = new HashMap(); + + /// A Monday to Friday, 09:00 to 17:00 calendar in the device's zone. + public WorkCalendar() { + this(ZoneId.systemDefault()); + } + + /// A Monday to Friday, 09:00 to 17:00 calendar in `zone`. + public WorkCalendar(ZoneId zone) { + if (zone == null) { + throw new IllegalArgumentException("zone"); + } + this.zone = zone; + for (int d = 1; d <= 5; d++) { + workingDays[d] = true; + } + } + + /// A calendar in which every minute of every day is working time, for + /// durations that are elapsed time. + public static WorkCalendar continuous(ZoneId zone) { + WorkCalendar c = new WorkCalendar(zone); + for (int d = 1; d <= 7; d++) { + c.workingDays[d] = true; + } + c.hours = new int[]{0, TimeMath.MINUTES_PER_DAY}; + return c; + } + + public ZoneId getZone() { + return zone; + } + + /// Whether a day of the week is worked. `isoDay` is 1 for Monday to 7 for + /// Sunday. + public WorkCalendar setWorkingDay(int isoDay, boolean working) { + checkDay(isoDay); + workingDays[isoDay] = working; + return this; + } + + public boolean isWorkingWeekday(int isoDay) { + checkDay(isoDay); + return workingDays[isoDay]; + } + + /// The hours of every working day that has none of its own, as pairs of + /// minutes since midnight: `setWorkingHours(8 * 60, 12 * 60, 13 * 60, 17 * 60)` + /// is a day with a lunch break. An end of `1440` is midnight. + public WorkCalendar setWorkingHours(int... startEndMinutes) { + hours = checked(startEndMinutes); + return this; + } + + /// The hours of one day of the week, which also makes it a working day. + public WorkCalendar setWorkingHours(int isoDay, int[] startEndMinutes) { + checkDay(isoDay); + dayHours[isoDay] = checked(startEndMinutes); + workingDays[isoDay] = dayHours[isoDay].length > 0; + return this; + } + + /// Makes one date a day off, whatever the week says. + public WorkCalendar addHoliday(long epochDay) { + exceptions.put(Long.valueOf(epochDay), NONE); + return this; + } + + /// Gives one date hours of its own, whatever the week says. + public WorkCalendar setException(long epochDay, int... startEndMinutes) { + exceptions.put(Long.valueOf(epochDay), checked(startEndMinutes)); + return this; + } + + /// Returns a date to the weekly pattern. + public WorkCalendar removeException(long epochDay) { + exceptions.remove(Long.valueOf(epochDay)); + return this; + } + + /// The working hours of a date, as pairs of minutes since midnight. Empty + /// for a day off. The array is the calendar's own: read it, do not change it. + public int[] getWorkingHours(long epochDay) { + if (!exceptions.isEmpty()) { + int[] ex = exceptions.get(Long.valueOf(epochDay)); + if (ex != null) { + return ex; + } + } + int dow = TimeMath.dayOfWeek(epochDay); + if (!workingDays[dow]) { + return NONE; + } + return dayHours[dow] != null ? dayHours[dow] : hours; + } + + public boolean isWorkingDay(long epochDay) { + return getWorkingHours(epochDay).length > 0; + } + + /// Whether `millis` is inside working hours. + public boolean isWorkingTime(long millis) { + long day = TimeMath.epochDay(millis, zone); + int[] iv = getWorkingHours(day); + for (int i = 0; i < iv.length; i += 2) { + if (millis >= TimeMath.at(day, iv[i], zone) && millis < TimeMath.at(day, iv[i + 1], zone)) { + return true; + } + } + return false; + } + + /// The first working instant at or after `millis`. + public long nextWorkingTime(long millis) { + return addWorkingTime(millis, 0); + } + + /// The last instant at or before `millis` that work can end at: `millis` + /// itself when it is inside working hours or closes them. + public long previousWorkingTime(long millis) { + return subtractWorkingTime(millis, 0); + } + + /// The instant `duration` milliseconds of working time after `from`. A + /// duration that ends exactly as a working period closes ends there, not + /// at the opening of the next one. A negative duration goes backwards. + public long addWorkingTime(long from, long duration) { + if (duration < 0) { + return subtractWorkingTime(from, -duration); + } + long day = TimeMath.epochDay(from, zone); + long remaining = duration; + long cursor = from; + int idle = 0; + while (true) { + int[] iv = getWorkingHours(day); + idle = iv.length == 0 ? idle + 1 : 0; + if (idle > MAX_IDLE_DAYS) { + throw new IllegalStateException("The calendar has no working time"); + } + for (int i = 0; i < iv.length; i += 2) { + long e = TimeMath.at(day, iv[i + 1], zone); + if (e <= cursor) { + continue; + } + long begin = Math.max(TimeMath.at(day, iv[i], zone), cursor); + long avail = e - begin; + if (remaining <= avail) { + return begin + remaining; + } + remaining -= avail; + cursor = e; + } + day++; + } + } + + /// The instant `duration` milliseconds of working time before `from`. + public long subtractWorkingTime(long from, long duration) { + if (duration < 0) { + return addWorkingTime(from, -duration); + } + long day = TimeMath.epochDay(from, zone); + long remaining = duration; + long cursor = from; + int idle = 0; + while (true) { + int[] iv = getWorkingHours(day); + idle = iv.length == 0 ? idle + 1 : 0; + if (idle > MAX_IDLE_DAYS) { + throw new IllegalStateException("The calendar has no working time"); + } + for (int i = iv.length - 2; i >= 0; i -= 2) { + long s = TimeMath.at(day, iv[i], zone); + if (s >= cursor) { + continue; + } + long end = Math.min(TimeMath.at(day, iv[i + 1], zone), cursor); + long avail = end - s; + if (remaining <= avail) { + return end - remaining; + } + remaining -= avail; + cursor = s; + } + day--; + } + } + + /// The working time between two instants, negative when `to` is before + /// `from`. + public long workingTimeBetween(long from, long to) { + if (to < from) { + return -workingTimeBetween(to, from); + } + long first = TimeMath.epochDay(from, zone); + long last = TimeMath.epochDay(to, zone); + long total = 0; + for (long day = first; day <= last; day++) { + int[] iv = getWorkingHours(day); + for (int i = 0; i < iv.length; i += 2) { + long s = Math.max(TimeMath.at(day, iv[i], zone), from); + long e = Math.min(TimeMath.at(day, iv[i + 1], zone), to); + if (e > s) { + total += e - s; + } + } + } + return total; + } + + /// The working time in a full day of the weekly pattern, which is what "a + /// day" of duration means in this calendar. + public long getMillisPerDay() { + long total = 0; + for (int i = 0; i < hours.length; i += 2) { + total += (hours[i + 1] - hours[i]) * TimeMath.MINUTE; + } + return total; + } + + private static void checkDay(int isoDay) { + if (isoDay < 1 || isoDay > 7) { + throw new IllegalArgumentException("isoDay " + isoDay); + } + } + + private static int[] checked(int[] v) { + if (v == null || v.length % 2 != 0) { + throw new IllegalArgumentException("Working hours are pairs of start and end minutes"); + } + int[] out = new int[v.length]; + int last = 0; + for (int i = 0; i < v.length; i += 2) { + if (v[i] < last || v[i + 1] <= v[i] || v[i + 1] > TimeMath.MINUTES_PER_DAY) { + throw new IllegalArgumentException("Working hours must be ascending and inside one day"); + } + out[i] = v[i]; + out[i + 1] = v[i + 1]; + last = v[i + 1]; + } + return out; + } +} diff --git a/CodenameOne/src/com/codename1/scheduling/package-info.java b/CodenameOne/src/com/codename1/scheduling/package-info.java new file mode 100644 index 00000000000..f81e217dbbc --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/package-info.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// Schedules and projects as data: what a `Scheduler` or a `Gantt` shows, +/// and the engine that works out a project's dates. +/// +/// Nothing here draws. The components are in `com.codename1.ui.scheduler`, +/// and everything in this package runs without one, on a server too. +/// +/// - `ScheduleModel` is items in time, optionally per `ScheduleResource`. +/// `DefaultScheduleModel` keeps them in memory, and `CalendarScheduleModel` +/// reads and writes a `com.codename1.calendar.CalendarSource`. +/// - `ProjectModel` is a tree of `GanttTask` with `TaskDependency` links. +/// `ProjectScheduler` gives every task its dates and its slack, in the +/// working time a `WorkCalendar` describes, and marks the critical path. +/// - `ResourceLeveler` finds resources that are overbooked and proposes +/// delays that resolve it. +/// +/// Every instant is epoch milliseconds, and every duration is milliseconds of +/// working time. The classes are not thread safe: use a model from one +/// thread, which for a model shown by a component is the EDT. +package com.codename1.scheduling; diff --git a/maven/core-unittests/src/test/java/com/codename1/scheduling/ProjectSchedulerTest.java b/maven/core-unittests/src/test/java/com/codename1/scheduling/ProjectSchedulerTest.java new file mode 100644 index 00000000000..bce22c718a2 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/scheduling/ProjectSchedulerTest.java @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +import org.junit.jupiter.api.Test; + +import static com.codename1.scheduling.WorkCalendarTest.UTC; +import static com.codename1.scheduling.WorkCalendarTest.at; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// A project that starts on Monday 2026-01-05, 09:00 to 17:00, UTC. +class ProjectSchedulerTest { + static final long D = 8 * TimeMath.HOUR; + + static ProjectModel project() { + return new ProjectModel(new WorkCalendar(UTC), at(5, 0)); + } + + static GanttTask task(ProjectModel p, String id, int days) { + GanttTask t = new GanttTask(id, id, days * D); + p.addTask(t); + return t; + } + + static void assertDates(GanttTask t, long start, long end) { + assertEquals(start, t.getStart(), t.getId() + " start"); + assertEquals(end, t.getEnd(), t.getId() + " end"); + } + + @Test + void aChainRunsEndToEndAndIsCritical() { + ProjectModel p = project(); + GanttTask a = task(p, "a", 2); + GanttTask b = task(p, "b", 3); + GanttTask c = task(p, "c", 1); + p.link(a, b); + p.update(); + assertDates(a, at(5, 9), at(6, 17)); + assertDates(b, at(7, 9), at(9, 17)); + assertDates(c, at(5, 9), at(5, 17)); + assertEquals(at(9, 17), p.getFinish()); + assertTrue(a.isCritical()); + assertTrue(b.isCritical()); + assertFalse(c.isCritical()); + assertEquals(0, a.getTotalSlack()); + assertEquals(4 * D, c.getTotalSlack()); + assertEquals(4 * D, c.getFreeSlack()); + assertEquals(at(9, 9), c.getLateStart()); + assertEquals(at(9, 17), c.getLateFinish()); + assertEquals(at(6, 17), a.getLateFinish()); + } + + @Test + void aSuccessorStartsAfterTheWeekend() { + ProjectModel p = project(); + GanttTask a = task(p, "a", 5); + GanttTask b = task(p, "b", 1); + p.link(a, b); + p.update(); + assertDates(a, at(5, 9), at(9, 17)); + assertDates(b, at(12, 9), at(12, 17)); + } + + @Test + void lagAndLeadAreWorkingTime() { + ProjectModel p = project(); + GanttTask a = task(p, "a", 2); + GanttTask b = task(p, "b", 1); + TaskDependency d = p.link(a, b, TaskDependency.Type.FINISH_TO_START, D); + p.update(); + assertDates(b, at(8, 9), at(8, 17)); + d.setLag(-D); + p.update(); + assertDates(b, at(6, 9), at(6, 17)); + // Slack is what is left before the project's end, which a still sets. + assertEquals(0, b.getTotalSlack()); + } + + @Test + void everyLinkTypeHoldsTheRightEnd() { + ProjectModel p = project(); + GanttTask a = task(p, "a", 3); + GanttTask ss = task(p, "ss", 1); + GanttTask ff = task(p, "ff", 1); + GanttTask sf = task(p, "sf", 1); + p.link(a, ss, TaskDependency.Type.START_TO_START, D); + p.link(a, ff, TaskDependency.Type.FINISH_TO_FINISH, 0); + p.link(a, sf, TaskDependency.Type.START_TO_FINISH, 2 * D); + p.update(); + assertDates(a, at(5, 9), at(7, 17)); + assertDates(ss, at(6, 9), at(6, 17)); + assertDates(ff, at(7, 9), at(7, 17)); + // Finishes two days after a starts: the end of Tuesday. + assertDates(sf, at(6, 9), at(6, 17)); + assertTrue(ff.isCritical()); + assertEquals(D, ss.getTotalSlack()); + } + + @Test + void constraintsMoveATaskOrAreReportedBroken() { + ProjectModel p = project(); + GanttTask a = task(p, "a", 2); + GanttTask b = task(p, "b", 1); + p.link(a, b); + b.setConstraint(GanttTask.Constraint.START_NO_EARLIER_THAN, at(8, 9)); + p.update(); + assertDates(b, at(8, 9), at(8, 17)); + assertFalse(b.isConstraintViolated()); + assertEquals(D, a.getTotalSlack()); + + b.setConstraint(GanttTask.Constraint.FINISH_NO_LATER_THAN, at(6, 17)); + p.update(); + assertDates(b, at(7, 9), at(7, 17)); + assertTrue(b.isConstraintViolated()); + assertTrue(a.getTotalSlack() < 0); + assertTrue(a.isCritical()); + + b.setConstraint(GanttTask.Constraint.MUST_START_ON, at(6, 9)); + p.update(); + assertDates(b, at(6, 9), at(6, 17)); + assertTrue(b.isConstraintViolated()); + + b.setConstraint(GanttTask.Constraint.MUST_FINISH_ON, at(13, 17)); + p.update(); + assertDates(b, at(13, 9), at(13, 17)); + assertFalse(b.isConstraintViolated()); + + b.setConstraint(GanttTask.Constraint.FINISH_NO_EARLIER_THAN, at(9, 17)); + p.update(); + assertDates(b, at(9, 9), at(9, 17)); + } + + @Test + void aSummarySpansItsChildrenAndPassesItsLinksDown() { + ProjectModel p = project(); + GanttTask prep = task(p, "prep", 1); + GanttTask phase = new GanttTask("phase", "Phase"); + p.addTask(phase); + GanttTask x = new GanttTask("x", "x", 2 * D).setPercentComplete(100); + GanttTask y = new GanttTask("y", "y", 2 * D); + p.addTask(phase, x).addTask(phase, y); + p.link(x, y); + p.link(prep, phase); + GanttTask after = task(p, "after", 1); + p.link(phase, after); + p.update(); + assertTrue(phase.isSummary()); + assertEquals(1, x.getLevel()); + assertDates(x, at(6, 9), at(7, 17)); + assertDates(y, at(8, 9), at(9, 17)); + assertDates(phase, at(6, 9), at(9, 17)); + assertEquals(4 * D, phase.getDuration()); + assertEquals(50, phase.getPercentComplete()); + assertDates(after, at(12, 9), at(12, 17)); + assertTrue(phase.isCritical()); + assertTrue(prep.isCritical()); + assertEquals(at(9, 17), y.getLateFinish()); + } + + @Test + void aLoopIsRefused() { + ProjectModel p = project(); + GanttTask a = task(p, "a", 1); + GanttTask b = task(p, "b", 1); + GanttTask c = task(p, "c", 1); + p.link(a, b); + p.link(b, c); + assertFalse(p.canLink(c, a)); + assertFalse(p.canLink(a, a)); + assertFalse(p.canLink(a, b)); + assertThrows(IllegalArgumentException.class, () -> p.link(c, a)); + assertEquals(2, p.getDependencies().size()); + + GanttTask group = new GanttTask("group", "group"); + p.addTask(group); + p.addTask(group, new GanttTask("inner", "inner", D)); + assertFalse(p.canLink(group, p.getTask("inner"))); + // c waits for b; putting b under a summary that waits for c is a loop. + p.link(c, group); + assertFalse(p.moveTask(b, group, -1)); + assertNull(b.getParent()); + // And so is a, which b waits for. + assertFalse(p.moveTask(a, group, 0)); + GanttTask free = task(p, "free", 1); + assertTrue(p.moveTask(free, group, 0)); + assertEquals(group, free.getParent()); + assertEquals(free, group.getChildren().get(0)); + assertFalse(p.moveTask(group, free, -1)); + p.update(); + assertNotNull(p.getTask("inner")); + } + + @Test + void aMilestoneSitsWhereThePreviousTaskEnds() { + ProjectModel p = project(); + GanttTask a = task(p, "a", 2); + GanttTask done = task(p, "done", 0); + GanttTask next = task(p, "next", 1); + p.link(a, done); + p.link(done, next); + p.update(); + assertTrue(done.isMilestone()); + assertDates(done, at(6, 17), at(6, 17)); + assertDates(next, at(7, 9), at(7, 17)); + assertTrue(done.isCritical()); + } + + @Test + void aManualTaskKeepsItsDatesAndItsSuccessorsFollow() { + ProjectModel p = project(); + GanttTask a = task(p, "a", 1); + GanttTask fixed = task(p, "fixed", 1).setManuallyScheduled(true).setDates(at(14, 9), at(15, 17)); + GanttTask b = task(p, "b", 1); + p.link(a, fixed); + p.link(fixed, b); + p.update(); + assertDates(fixed, at(14, 9), at(15, 17)); + assertDates(b, at(16, 9), at(16, 17)); + assertFalse(a.isCritical()); + } + + @Test + void removingATaskRemovesItsLinksAndTheBaselineRemembers() { + ProjectModel p = project(); + GanttTask a = task(p, "a", 1); + GanttTask b = task(p, "b", 1); + GanttTask c = task(p, "c", 1); + p.link(a, b); + p.link(b, c); + p.update(); + p.captureBaseline(); + assertEquals(1, p.getPredecessors(b).size()); + assertEquals(1, p.getSuccessors(b).size()); + p.removeTask(b); + p.setCriticalSlack(D); + p.update(); + assertEquals(0, p.getDependencies().size()); + assertDates(c, at(5, 9), at(5, 17)); + assertTrue(c.hasBaseline()); + assertEquals(at(7, 9), c.getBaselineStart()); + assertEquals(2, p.getTasks().size()); + assertTrue(c.isCritical()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/scheduling/ResourceLevelerTest.java b/maven/core-unittests/src/test/java/com/codename1/scheduling/ResourceLevelerTest.java new file mode 100644 index 00000000000..5f4ca424594 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/scheduling/ResourceLevelerTest.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +import java.util.List; +import org.junit.jupiter.api.Test; + +import static com.codename1.scheduling.ProjectSchedulerTest.D; +import static com.codename1.scheduling.ProjectSchedulerTest.project; +import static com.codename1.scheduling.ProjectSchedulerTest.task; +import static com.codename1.scheduling.WorkCalendarTest.at; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ResourceLevelerTest { + @Test + void twoTasksForOnePersonAreDoneOneAfterTheOther() { + ProjectModel p = project(); + p.addResource(new ScheduleResource("ann", "Ann")); + GanttTask a = task(p, "a", 2).assign("ann"); + GanttTask b = task(p, "b", 2).assign("ann"); + p.update(); + List over = ResourceLeveler.findOverallocations(p); + assertEquals(1, over.size()); + assertEquals("ann", over.get(0).getResourceId()); + assertEquals(at(5, 9), over.get(0).getStart()); + assertEquals(at(6, 17), over.get(0).getEnd()); + assertEquals(2f, over.get(0).getPeak(), 0.001f); + + LevelingResult r = ResourceLeveler.level(p); + // A proposal: nothing has moved yet. + assertEquals(at(5, 9), b.getStart()); + assertEquals(0, b.getLevelingDelay()); + assertTrue(r.isChanged()); + assertEquals(1, r.getChangedTasks().size()); + assertEquals(b, r.getChangedTasks().get(0)); + assertEquals(2 * D, r.getDelay(b)); + assertEquals(at(6, 17), r.getFinishBefore()); + assertEquals(at(8, 17), r.getFinishAfter()); + assertTrue(r.getUnresolved().isEmpty()); + + r.apply(); + assertEquals(at(5, 9), a.getStart()); + assertEquals(at(7, 9), b.getStart()); + assertTrue(ResourceLeveler.findOverallocations(p).isEmpty()); + + p.clearLeveling().update(); + assertEquals(at(5, 9), b.getStart()); + } + + @Test + void theTaskWithSlackWaitsAndTheFinishDoesNotMove() { + ProjectModel p = project(); + p.addResource(new ScheduleResource("ann", "Ann")); + GanttTask longOne = task(p, "long", 5); + GanttTask spare = task(p, "spare", 1).assign("ann"); + GanttTask onPath = task(p, "onPath", 1).assign("ann"); + GanttTask tail = task(p, "tail", 4); + p.link(onPath, tail); + p.update(); + assertTrue(onPath.isCritical()); + assertFalse(spare.isCritical()); + LevelingResult r = ResourceLeveler.level(p); + assertEquals(0, r.getDelay(onPath)); + assertEquals(D, r.getDelay(spare)); + assertEquals(r.getFinishBefore(), r.getFinishAfter()); + assertEquals(at(9, 17), longOne.getEnd()); + } + + @Test + void priorityDecidesBetweenEquals() { + ProjectModel p = project(); + p.addResource(new ScheduleResource("ann", "Ann")); + GanttTask first = task(p, "first", 1).assign("ann").setPriority(100); + GanttTask second = task(p, "second", 1).assign("ann").setPriority(900); + p.update(); + LevelingResult r = ResourceLeveler.level(p); + assertEquals(D, r.getDelay(first)); + assertEquals(0, r.getDelay(second)); + } + + @Test + void capacityAndUnitsAreCounted() { + ProjectModel p = project(); + p.addResource(new ScheduleResource("team", "Team").setCapacity(2)); + task(p, "a", 2).assign("team"); + task(p, "b", 2).assign("team", 0.5f); + GanttTask c = task(p, "c", 2).assign("team", 0.5f); + p.update(); + assertTrue(ResourceLeveler.findOverallocations(p).isEmpty()); + c.assign("team", 1f); + assertEquals(1f, c.getUnits("team"), 0.001f); + assertEquals(1, c.getAssignments().size()); + p.update(); + assertEquals(1, ResourceLeveler.findOverallocations(p).size()); + + float[] load = ResourceLeveler.loadProfile(p, "team", at(5, 0), at(9, 0), 4); + assertEquals(2.5f, load[0], 0.001f); + assertEquals(2.5f, load[1], 0.001f); + assertEquals(0f, load[2], 0.001f); + assertEquals(0, ResourceLeveler.loadProfile(p, "team", at(5, 0), at(5, 0), 4)[0], 0.001f); + } + + @Test + void whatCannotBeMovedIsReportedAndLevelingStillEnds() { + ProjectModel p = project(); + p.addResource(new ScheduleResource("ann", "Ann")); + // Needs two of one person by itself. + task(p, "double", 1).assign("ann", 2f); + // Two that may not be moved. + task(p, "m1", 1).assign("ann").setConstraint(GanttTask.Constraint.MUST_START_ON, at(12, 9)); + task(p, "m2", 1).assign("ann").setPriority(1000).setConstraint(GanttTask.Constraint.START_NO_EARLIER_THAN, at(12, 9)); + // And one that can be, after them. + GanttTask free = task(p, "free", 1).assign("ann").setConstraint(GanttTask.Constraint.START_NO_EARLIER_THAN, at(12, 9)); + p.update(); + LevelingResult r = ResourceLeveler.level(p); + assertEquals(D, r.getDelay(free)); + assertEquals(2, r.getUnresolved().size()); + r.apply(); + assertEquals(at(13, 9), free.getStart()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/scheduling/WorkCalendarTest.java b/maven/core-unittests/src/test/java/com/codename1/scheduling/WorkCalendarTest.java new file mode 100644 index 00000000000..453adc7684b --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/scheduling/WorkCalendarTest.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// January 2026 in UTC: the 5th is a Monday, the 9th a Friday. +class WorkCalendarTest { + static final ZoneId UTC = ZoneId.of("UTC"); + static final long H = TimeMath.HOUR; + + static long at(int day, int hour) { + return at(UTC, 2026, 1, day, hour, 0); + } + + static long at(ZoneId zone, int year, int month, int day, int hour, int minute) { + return ZonedDateTime.of(year, month, day, hour, minute, 0, 0, zone).toInstant().toEpochMilli(); + } + + @Test + void dayOfWeekIsIso() { + assertEquals(1, TimeMath.dayOfWeek(TimeMath.epochDay(at(5, 12), UTC))); + assertEquals(7, TimeMath.dayOfWeek(TimeMath.epochDay(at(4, 12), UTC))); + assertEquals(4, TimeMath.dayOfWeek(0)); + assertEquals(3, TimeMath.dayOfWeek(-1)); + long sunday = TimeMath.epochDay(at(4, 0), UTC); + assertEquals(sunday, TimeMath.startOfWeek(TimeMath.epochDay(at(7, 0), UTC), 7)); + assertEquals(sunday + 1, TimeMath.startOfWeek(TimeMath.epochDay(at(7, 0), UTC), 1)); + } + + @Test + void workSkipsTheNightAndTheWeekend() { + WorkCalendar cal = new WorkCalendar(UTC); + assertEquals(at(12, 11), cal.addWorkingTime(at(9, 15), 4 * H)); + assertEquals(at(9, 15), cal.subtractWorkingTime(at(12, 11), 4 * H)); + assertEquals(4 * H, cal.workingTimeBetween(at(9, 15), at(12, 11))); + assertEquals(-4 * H, cal.workingTimeBetween(at(12, 11), at(9, 15))); + assertEquals(8 * H, cal.getMillisPerDay()); + } + + @Test + void workThatFillsADayEndsAtItsClose() { + WorkCalendar cal = new WorkCalendar(UTC); + assertEquals(at(5, 17), cal.addWorkingTime(at(5, 9), 8 * H)); + // The next thing starts the morning after, and a finish found from + // that morning is the evening before. + assertEquals(at(6, 9), cal.nextWorkingTime(at(5, 17))); + assertEquals(at(5, 17), cal.previousWorkingTime(at(6, 9))); + assertEquals(at(5, 17), cal.previousWorkingTime(at(5, 17))); + assertEquals(at(5, 11), cal.nextWorkingTime(at(5, 11))); + assertEquals(at(5, 9), cal.nextWorkingTime(at(3, 13))); + assertEquals(at(9, 17), cal.previousWorkingTime(at(11, 13))); + } + + @Test + void holidaysAndExceptionsOverrideTheWeek() { + WorkCalendar cal = new WorkCalendar(UTC); + cal.addHoliday(TimeMath.epochDay(at(6, 0), UTC)); + assertEquals(at(7, 17), cal.addWorkingTime(at(5, 9), 16 * H)); + assertFalse(cal.isWorkingDay(TimeMath.epochDay(at(6, 0), UTC))); + // A Saturday that is worked in the morning. + cal.setException(TimeMath.epochDay(at(10, 0), UTC), 9 * 60, 13 * 60); + assertEquals(at(10, 11), cal.addWorkingTime(at(9, 17), 2 * H)); + assertTrue(cal.isWorkingTime(at(10, 10))); + assertFalse(cal.isWorkingTime(at(10, 13))); + cal.removeException(TimeMath.epochDay(at(10, 0), UTC)); + assertEquals(at(12, 11), cal.addWorkingTime(at(9, 17), 2 * H)); + } + + @Test + void aLunchBreakIsNotWorked() { + WorkCalendar cal = new WorkCalendar(UTC).setWorkingHours(8 * 60, 12 * 60, 13 * 60, 17 * 60); + assertEquals(at(5, 14), cal.addWorkingTime(at(5, 11), 2 * H)); + assertEquals(at(5, 11), cal.subtractWorkingTime(at(5, 14), 2 * H)); + assertEquals(at(5, 13), cal.nextWorkingTime(at(5, 12))); + // Friday is a half day. + cal.setWorkingHours(5, new int[]{8 * 60, 12 * 60}); + assertEquals(at(12, 9), cal.addWorkingTime(at(9, 11), 2 * H)); + } + + @Test + void aDayThatLosesAnHourIsStillADayOfWork() { + ZoneId ny = ZoneId.of("America/New_York"); + // 2026-03-08 is 23 hours long in New York. + WorkCalendar always = WorkCalendar.continuous(ny); + long satNoon = at(ny, 2026, 3, 7, 12, 0); + assertEquals(at(ny, 2026, 3, 8, 13, 0), always.addWorkingTime(satNoon, 24 * H)); + assertEquals(23 * H, always.workingTimeBetween(at(ny, 2026, 3, 8, 0, 0), at(ny, 2026, 3, 9, 0, 0))); + + WorkCalendar weekend = new WorkCalendar(ny).setWorkingDay(7, true); + assertEquals(at(ny, 2026, 3, 8, 17, 0), weekend.addWorkingTime(at(ny, 2026, 3, 6, 17, 0), 8 * H)); + assertEquals(at(ny, 2026, 3, 9, 9, 0), weekend.nextWorkingTime(at(ny, 2026, 3, 8, 17, 0))); + } + + @Test + void aCalendarThatNeverWorksSaysSo() { + final WorkCalendar cal = new WorkCalendar(UTC); + for (int d = 1; d <= 7; d++) { + cal.setWorkingDay(d, false); + } + assertThrows(IllegalStateException.class, () -> cal.addWorkingTime(at(5, 9), H)); + assertThrows(IllegalStateException.class, () -> cal.subtractWorkingTime(at(5, 9), H)); + assertThrows(IllegalArgumentException.class, () -> new WorkCalendar(UTC).setWorkingHours(600, 500)); + assertThrows(IllegalArgumentException.class, () -> new WorkCalendar(UTC).setWorkingHours(600)); + } +} From 4c87293a76d0593bc43b2ae88a4740e122ce0cba Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:46:02 +0200 Subject: [PATCH 02/19] Scheduler: day, week, month, agenda and resource views over a schedule model The views paint their items and keep no component per item. A shared surface does the two-axis scrolling under pinned headers, the fling, the wheel, pinch zoom and the tap/drag/long-press split; items are moved, resized and created by dragging, each change offered to the listeners and then to the model. Co-Authored-By: Claude Fable 5.2 --- .../com/codename1/scheduling/GanttTask.java | 12 +- .../codename1/scheduling/ScheduleItem.java | 15 +- .../codename1/ui/scheduler/AgendaList.java | 283 +++++ .../src/com/codename1/ui/scheduler/Dates.java | 147 +++ .../com/codename1/ui/scheduler/DayGrid.java | 992 ++++++++++++++++++ .../DefaultScheduleItemRenderer.java | 150 +++ .../src/com/codename1/ui/scheduler/Look.java | 136 +++ .../com/codename1/ui/scheduler/MonthGrid.java | 375 +++++++ .../ui/scheduler/ResourceTimeline.java | 605 +++++++++++ .../ui/scheduler/ScheduleItemRenderer.java | 54 + .../com/codename1/ui/scheduler/Scheduler.java | 859 +++++++++++++++ .../ui/scheduler/SchedulerAdapter.java | 49 + .../ui/scheduler/SchedulerListener.java | 53 + .../codename1/ui/scheduler/ScrollSurface.java | 548 ++++++++++ .../com/codename1/ui/scheduler/TimeAxis.java | 368 +++++++ .../codename1/ui/scheduler/package-info.java | 30 + .../codename1/ui/scheduler/SchedulerTest.java | 461 ++++++++ 17 files changed, 5124 insertions(+), 13 deletions(-) create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/AgendaList.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/Dates.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/DayGrid.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/DefaultScheduleItemRenderer.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/Look.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/MonthGrid.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/ResourceTimeline.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/ScheduleItemRenderer.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/Scheduler.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/SchedulerAdapter.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/SchedulerListener.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/ScrollSurface.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/TimeAxis.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/package-info.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/ui/scheduler/SchedulerTest.java diff --git a/CodenameOne/src/com/codename1/scheduling/GanttTask.java b/CodenameOne/src/com/codename1/scheduling/GanttTask.java index 5cf0b98e6c3..24119f28209 100644 --- a/CodenameOne/src/com/codename1/scheduling/GanttTask.java +++ b/CodenameOne/src/com/codename1/scheduling/GanttTask.java @@ -70,7 +70,7 @@ public enum Constraint { private long baselineStart; private long baselineEnd; private int color = -1; - private String category; + private String uiid; private Object userObject; // What the scheduler worked out. @@ -306,13 +306,13 @@ public GanttTask setColor(int v) { return this; } - /// A name a theme can style: `GanttBar` plus the category. - public String getCategory() { - return category; + /// The UIID the bar is drawn with in place of `GanttBar`, or null. + public String getUIID() { + return uiid; } - public GanttTask setCategory(String v) { - category = v; + public GanttTask setUIID(String v) { + uiid = v; return this; } diff --git a/CodenameOne/src/com/codename1/scheduling/ScheduleItem.java b/CodenameOne/src/com/codename1/scheduling/ScheduleItem.java index 9704a71aa06..c6fcca70bb7 100644 --- a/CodenameOne/src/com/codename1/scheduling/ScheduleItem.java +++ b/CodenameOne/src/com/codename1/scheduling/ScheduleItem.java @@ -48,7 +48,7 @@ public enum Kind { private boolean allDay; private String resourceId; private Kind kind = Kind.EVENT; - private String category; + private String uiid; private int color = -1; private boolean movable = true; private boolean resizable = true; @@ -132,14 +132,15 @@ public ScheduleItem setKind(Kind v) { return this; } - /// A name a theme can style: an item of category `Done` is drawn with the - /// UIID `SchedulerItemDone` when the theme has one. - public String getCategory() { - return category; + /// The UIID the item is drawn with in place of `SchedulerItem`, or null. + /// Its background, foreground and font are used, and its selected style + /// while the item is selected. + public String getUIID() { + return uiid; } - public ScheduleItem setCategory(String v) { - category = v; + public ScheduleItem setUIID(String v) { + uiid = v; return this; } diff --git a/CodenameOne/src/com/codename1/ui/scheduler/AgendaList.java b/CodenameOne/src/com/codename1/ui/scheduler/AgendaList.java new file mode 100644 index 00000000000..fcdd02129b9 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/AgendaList.java @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.scheduling.ScheduleItem; +import com.codename1.scheduling.ScheduleResource; +import com.codename1.scheduling.TimeMath; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; + +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/// What is scheduled as a list, day by day, with the days that have nothing +/// left out. +final class AgendaList extends ScrollSurface { + /// A line of the list: a day's heading when `item` is null. + static final class Row { + long day; + ScheduleItem item; + int top; + int height; + } + + private static final Comparator ORDER = new Comparator() { + @Override + public int compare(ScheduleItem a, ScheduleItem b) { + if (a.isAllDay() != b.isAllDay()) { + return a.isAllDay() ? -1 : 1; + } + if (a.getStart() != b.getStart()) { + return a.getStart() < b.getStart() ? -1 : 1; + } + return a.getId().compareTo(b.getId()); + } + }; + + private final Scheduler owner; + private long firstDay; + private int count = 30; + private boolean dirty = true; + private int laidW = -1; + private int laidGen = -1; + private int total; + private int timeW; + private final ArrayList rows = new ArrayList(); + private final ArrayList loaded = new ArrayList(); + + AgendaList(Scheduler owner) { + this.owner = owner; + setUIID("Scheduler"); + } + + void show(long day, int days) { + firstDay = day; + count = days; + dirty = true; + setOffsets(0, 0); + repaint(); + } + + void modelChanged() { + dirty = true; + repaint(); + } + + List rows() { + return rows; + } + + @Override + void prepare(int width, int height) { + int gen = UIManager.getThemeGeneration(); + if (!dirty && width == laidW && gen == laidGen) { + return; + } + dirty = false; + laidW = width; + laidGen = gen; + headerW = 0; + headerH = 0; + Font hf = Look.style("SchedulerHeader").getFont(); + Font f = Look.style("SchedulerAgendaItem").getFont(); + int headH = hf.getHeight() + Look.mm(3); + int itemH = f.getHeight() * 2 + Look.mm(3); + boolean h24 = owner.is24Hours(); + timeW = Math.max(f.stringWidth(Dates.time(22 * 60 + 30, h24, false)), + f.stringWidth(owner.localize("scheduler.allDay", "All day"))) + Look.mm(3); + rows.clear(); + loaded.clear(); + total = 0; + if (owner.getModel() == null) { + return; + } + ZoneId zone = owner.getZone(); + owner.getModel().getItems(TimeMath.startOfEpochDay(firstDay, zone), + TimeMath.startOfEpochDay(firstDay + count, zone), loaded); + Collections.sort(loaded, ORDER); + for (int d = 0; d < count; d++) { + long day = firstDay + d; + long ds = TimeMath.startOfEpochDay(day, zone); + long de = TimeMath.startOfEpochDay(day + 1, zone); + boolean headed = false; + for (ScheduleItem item : loaded) { + if (item.getKind() != ScheduleItem.Kind.EVENT) { + continue; + } + boolean instant = item.getStart() == item.getEnd() && item.getStart() >= ds && item.getStart() < de; + if (!instant && !item.intersects(ds, de)) { + continue; + } + if (!headed) { + headed = true; + add(day, null, headH); + } + add(day, item, itemH); + } + } + } + + private void add(long day, ScheduleItem item, int height) { + Row r = new Row(); + r.day = day; + r.item = item; + r.top = total; + r.height = height; + total += height; + rows.add(r); + } + + @Override + int contentWidth() { + return bodyWidth(); + } + + @Override + int contentHeight() { + return total; + } + + @Override + void paintBody(Graphics g, int x, int y) { + Style grid = Look.style("SchedulerGrid"); + Style header = Look.style("SchedulerHeader"); + Style st = Look.style("SchedulerAgendaItem"); + Style sel = Look.style("SchedulerSelection"); + Font hf = header.getFont(); + Font f = st.getFont(); + int fh = f.getHeight(); + int cy = g.getClipY(); + int ch = g.getClipHeight(); + int w = bodyWidth(); + Look.fill(g, grid, g.getClipX(), cy, g.getClipWidth(), ch); + ZoneId zone = owner.getZone(); + if (rows.isEmpty()) { + String none = owner.localize("scheduler.empty", "Nothing scheduled"); + g.setFont(f); + g.setColor(st.getFgColor()); + g.drawString(none, x + (w - f.stringWidth(none)) / 2, y + Look.mm(8)); + return; + } + long today = TimeMath.epochDay(owner.now(), zone); + boolean h24 = owner.is24Hours(); + ScheduleItem selected = owner.getSelectedItem(); + int line = Look.mix(grid.getBgColor(), grid.getFgColor(), 110); + int pad = Look.mm(1.5f); + int alpha = g.getAlpha(); + for (Row r : rows) { + int top = y + r.top; + if (top > cy + ch || top + r.height < cy) { + continue; + } + if (r.item == null) { + Look.fill(g, header, x, top, w, r.height); + g.setFont(hf); + g.setColor(r.day == today ? Look.style("SchedulerNow").getFgColor() : header.getFgColor()); + String title = Dates.dayTitle(r.day, TimeMath.dayOfWeek(r.day)); + if (r.day == today) { + title = owner.localize("scheduler.today", "Today") + " - " + title; + } + g.drawString(Look.fit(title, hf, w - 2 * pad), x + pad, top + (r.height - hf.getHeight()) / 2); + continue; + } + ScheduleItem item = r.item; + if (item == selected) { //NOPMD CompareObjectsWithEquals - the selection is this very object + Look.fill(g, sel, x, top, w, r.height); + } + long ds = TimeMath.startOfEpochDay(r.day, zone); + long de = TimeMath.startOfEpochDay(r.day + 1, zone); + int ty = top + (r.height - 2 * fh) / 2; + g.setFont(f); + g.setColor(st.getFgColor()); + if (item.isAllDay() || (item.getStart() <= ds && item.getEnd() >= de)) { + g.drawString(owner.localize("scheduler.allDay", "All day"), x + pad, ty + fh / 2); + } else { + g.drawString(Dates.time(item.getStart() < ds ? 0 : TimeMath.minuteOfDay(item.getStart(), zone), h24, + false), x + pad, ty); + g.setAlpha(alpha * 160 / 255); + g.drawString(Dates.time(item.getEnd() > de ? 0 : TimeMath.minuteOfDay(item.getEnd(), zone), h24, + false), x + pad, ty + fh); + g.setAlpha(alpha); + } + int own = item.getColor() >= 0 ? item.getColor() : owner.resourceColor(item.getResourceId()); + int bx = x + pad + timeW; + int bar = Look.mm(1); + boolean aa = g.isAntiAliased(); + g.setAntiAliased(true); + g.setColor(own >= 0 ? own : Look.style(item.getUIID() != null ? item.getUIID() : "SchedulerItem") + .getBgColor()); + g.fillRoundRect(bx, top + pad, bar, r.height - 2 * pad, bar, bar); + g.setAntiAliased(aa); + int tx = bx + bar + pad; + int tw = x + w - pad - tx; + g.setColor(st.getFgColor()); + String second = item.getDetail(); + if (second == null || second.length() == 0) { + ScheduleResource res = owner.getResource(item.getResourceId()); + second = res == null ? null : res.getName(); + } + if (second == null || second.length() == 0) { + g.drawString(Look.fit(item.getTitle(), f, tw), tx, ty + fh / 2); + } else { + g.drawString(Look.fit(item.getTitle(), f, tw), tx, ty); + g.setAlpha(alpha * 160 / 255); + g.drawString(Look.fit(second, f, tw), tx, ty + fh); + g.setAlpha(alpha); + } + g.setColor(line); + g.drawLine(tx, top + r.height - 1, x + w, top + r.height - 1); + } + } + + Row rowAt(int py) { + int cy = py + getOffY(); + for (Row r : rows) { + if (cy >= r.top && cy < r.top + r.height) { + return r; + } + } + return null; + } + + @Override + void tapped(int x, int y, boolean doubleTap) { + Row r = rowAt(y); + if (r == null) { + return; + } + if (r.item == null) { + owner.viewDrill(r.day); + return; + } + owner.viewSelected(r.item); + if (doubleTap || owner.isOpenOnTap()) { + owner.viewOpened(r.item); + } + } +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/Dates.java b/CodenameOne/src/com/codename1/ui/scheduler/Dates.java new file mode 100644 index 00000000000..eaa86e06309 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/Dates.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.l10n.DateFormatSymbols; +import com.codename1.l10n.L10NManager; +import com.codename1.ui.plaf.UIManager; + +import java.time.LocalDate; +import java.util.Calendar; +import java.util.Hashtable; +import java.util.Map; + +/// Names and labels for dates that are held as epoch days. +/// +/// The views work in a zone of their own choosing, which need not be the +/// device's, so nothing here formats a `java.util.Date`: the day, month and +/// minute are worked out first and only named here. +final class Dates { + private static DateFormatSymbols symbols; + private static Map symbolsBundle; + + private Dates() { + } + + private static DateFormatSymbols symbols() { + Map bundle = UIManager.getInstance().getBundle(); + if (symbols == null || bundle != symbolsBundle) { //NOPMD CompareObjectsWithEquals - the bundle's identity is the point + DateFormatSymbols s = new DateFormatSymbols(); + if (bundle instanceof Hashtable) { + s.setResourceBundle((Hashtable) bundle); + } + symbols = s; + symbolsBundle = bundle; + } + return symbols; + } + + /// `isoDay` is 1 for Monday to 7 for Sunday. + static String weekdayShort(int isoDay) { + return symbols().getShortWeekdays()[isoDay % 7]; + } + + static String weekdayLong(int isoDay) { + return symbols().getWeekdays()[isoDay % 7]; + } + + /// `month` is 1 for January. + static String monthShort(int month) { + return symbols().getShortMonths()[month - 1]; + } + + static String monthLong(int month) { + return symbols().getMonths()[month - 1]; + } + + static int year(long epochDay) { + return LocalDate.ofEpochDay(epochDay).getYear(); + } + + static int month(long epochDay) { + return LocalDate.ofEpochDay(epochDay).getMonthValue(); + } + + static int dayOfMonth(long epochDay) { + return LocalDate.ofEpochDay(epochDay).getDayOfMonth(); + } + + /// "Mon, Jan 5". + static String dayTitle(long epochDay, int isoDay) { + LocalDate d = LocalDate.ofEpochDay(epochDay); + return weekdayShort(isoDay) + ", " + monthShort(d.getMonthValue()) + " " + d.getDayOfMonth(); + } + + /// "Jan 5". + static String monthDay(long epochDay) { + LocalDate d = LocalDate.ofEpochDay(epochDay); + return monthShort(d.getMonthValue()) + " " + d.getDayOfMonth(); + } + + /// "January 2026". + static String monthTitle(long epochDay) { + LocalDate d = LocalDate.ofEpochDay(epochDay); + return monthLong(d.getMonthValue()) + " " + d.getYear(); + } + + /// A time of day: "09:30" on a 24 hour clock, otherwise "9:30 AM", and + /// "9 AM" on the hour when `brief`. + static String time(int minuteOfDay, boolean h24, boolean brief) { + int m = minuteOfDay % 1440; + int h = m / 60; + int mm = m % 60; + StringBuilder sb = new StringBuilder(); + if (h24) { + if (h < 10) { + sb.append('0'); + } + sb.append(h).append(':'); + if (mm < 10) { + sb.append('0'); + } + sb.append(mm); + return sb.toString(); + } + int h12 = h % 12 == 0 ? 12 : h % 12; + sb.append(h12); + if (mm != 0 || !brief) { + sb.append(':'); + if (mm < 10) { + sb.append('0'); + } + sb.append(mm); + } + String[] ampm = symbols().getAmPmStrings(); + sb.append(' ').append(ampm[h < 12 ? 0 : 1]); + return sb.toString(); + } + + /// Whether the device's locale writes one in the afternoon as 13. + static boolean deviceUses24Hours() { + Calendar c = Calendar.getInstance(); + c.set(Calendar.HOUR_OF_DAY, 13); + c.set(Calendar.MINUTE, 5); + String s = L10NManager.getInstance().formatTime(c.getTime()); + return s != null && s.indexOf("13") >= 0; + } +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/DayGrid.java b/CodenameOne/src/com/codename1/ui/scheduler/DayGrid.java new file mode 100644 index 00000000000..92f6d347b1f --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/DayGrid.java @@ -0,0 +1,992 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.scheduling.ScheduleItem; +import com.codename1.scheduling.ScheduleResource; +import com.codename1.scheduling.TimeMath; +import com.codename1.scheduling.WorkCalendar; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; + +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/// Time running down the screen under a row of columns: the day, work week +/// and week views, where a column is a day, and the resource day view, where +/// a column is a resource and the day is the same in all of them. +final class DayGrid extends ScrollSurface { + private static final int MOVE = 1; + private static final int RESIZE_START = 2; + private static final int RESIZE_END = 3; + private static final int RANGE = 4; + private static final int MAX_ALL_DAY_ROWS = 3; + // An item shorter than this is laid out as if it lasted this long, so + // that it has room for a line of text and its neighbours stay off it. + private static final int MIN_LAYOUT_MINUTES = 25; + + /// An item, or the part of one that falls in a column. + static final class Box { + ScheduleItem item; + int col; + int startMin; + int endMin; + int lane; + int lanes = 1; + boolean startsBefore; + boolean endsAfter; + } + + /// An item in the lane above the grid: all day, or a day long or more. + static final class Chip { + ScheduleItem item; + int fromCol; + int toCol; + int row; + boolean startsBefore; + boolean endsAfter; + } + + private static final Comparator BY_START = new Comparator() { + @Override + public int compare(Box a, Box b) { + if (a.startMin != b.startMin) { + return a.startMin < b.startMin ? -1 : 1; + } + if (a.endMin != b.endMin) { + return a.endMin > b.endMin ? -1 : 1; + } + return a.item.getId().compareTo(b.item.getId()); + } + }; + + private static final Comparator CHIP_ORDER = new Comparator() { + @Override + public int compare(Chip a, Chip b) { + if (a.fromCol != b.fromCol) { + return a.fromCol < b.fromCol ? -1 : 1; + } + if (a.toCol != b.toCol) { + return a.toCol > b.toCol ? -1 : 1; + } + return a.item.getId().compareTo(b.item.getId()); + } + }; + + private final Scheduler owner; + private long[] days = new long[0]; + private ScheduleResource[] resources; + private boolean dirty = true; + private int laidW = -1; + private int laidH = -1; + private int laidGen = -1; + private float hourH; + private int colHeaderH; + private int allDayH; + private int chipH; + private int allDayRows; + private int contentW; + private int pendingScrollMinute = -1; + private final ArrayList boxes = new ArrayList(); + private final ArrayList backgrounds = new ArrayList(); + private final ArrayList chips = new ArrayList(); + private final ArrayList loaded = new ArrayList(); + private final int[] rect = new int[4]; + + private int mode; + private ScheduleItem editItem; + private long editStart; + private long editEnd; + private String editResource; + private long grabTime; + private int anchorCol; + private int anchorMin; + + DayGrid(Scheduler owner) { + this.owner = owner; + setUIID("Scheduler"); + } + + /// Shows `count` days from `firstDay`, or one day across `columns` when + /// that is not null. + void show(long firstDay, int count, List columns) { + boolean first = days.length == 0; + if (columns != null) { + resources = new ScheduleResource[columns.size()]; + columns.toArray(resources); + days = new long[Math.max(1, resources.length)]; + for (int i = 0; i < days.length; i++) { + days[i] = firstDay; + } + } else { + resources = null; + days = new long[count]; + for (int i = 0; i < count; i++) { + days[i] = firstDay + i; + } + } + dirty = true; + if (first) { + pendingScrollMinute = owner.getInitialMinute(); + } + repaint(); + } + + /// Lists the days to show one by one, for a work week that skips some. + void show(long[] list) { + boolean first = days.length == 0; + resources = null; + days = list; + dirty = true; + if (first) { + pendingScrollMinute = owner.getInitialMinute(); + } + repaint(); + } + + void modelChanged() { + dirty = true; + repaint(); + } + + void scrollToMinute(int minute) { + pendingScrollMinute = minute; + dirty = true; + repaint(); + } + + List boxes() { + return boxes; + } + + List chips() { + return chips; + } + + // ------------------------------------------------------------- layout + + @Override + void prepare(int width, int height) { + int gen = UIManager.getThemeGeneration(); + if (!dirty && width == laidW && height == laidH && gen == laidGen) { + return; + } + laidW = width; + laidH = height; + laidGen = gen; + if (dirty) { + dirty = false; + load(); + } + Style time = Look.style("SchedulerTimeLabel"); + Style header = Look.style("SchedulerHeader"); + Font hf = header.getFont(); + headerW = time.getFont().stringWidth(Dates.time(22 * 60, owner.is24Hours(), true)) + Look.mm(2.5f); + if (resources != null) { + boolean detail = false; + for (ScheduleResource r : resources) { + if (r.getDetail() != null && r.getDetail().length() > 0) { + detail = true; + } + } + colHeaderH = hf.getHeight() * (detail ? 2 : 1) + Look.mm(2.5f); + } else { + colHeaderH = hf.getHeight() * 2 + Look.mm(3.5f); + } + chipH = Look.style("SchedulerItem").getFont().getHeight() + Look.mm(1.2f); + allDayH = allDayRows == 0 ? 0 : Math.min(allDayRows, MAX_ALL_DAY_ROWS) * chipH + Look.mm(1); + headerH = colHeaderH + allDayH; + int bodyW = Math.max(0, width - headerW); + int bodyH = Math.max(0, height - headerH); + int minCol = Look.mm(resources != null ? 24 : 7); + contentW = Math.max(bodyW, days.length * minCol); + float least = Math.max(Look.mm(4), bodyH / 24f); + if (hourH <= 0) { + hourH = Look.mm(9); + } + hourH = Math.max(least, Math.min(Look.mm(40), hourH)); + if (pendingScrollMinute >= 0 && bodyH > 0) { + setOffsets(getOffX(), Math.round(pendingScrollMinute * hourH / 60f)); + pendingScrollMinute = -1; + } + } + + @Override + int contentWidth() { + return contentW; + } + + @Override + int contentHeight() { + return Math.round(24 * hourH); + } + + private int colX(int col) { + return (int) ((long) col * contentW / days.length); + } + + private int colAt(int cx) { + int c = (int) ((long) cx * days.length / Math.max(1, contentW)); + return Math.max(0, Math.min(days.length - 1, c)); + } + + private int minuteY(int minute) { + return Math.round(minute * hourH / 60f); + } + + private int minuteAt(int cy) { + return Math.max(0, Math.min(TimeMath.MINUTES_PER_DAY, (int) (cy * 60f / hourH))); + } + + private int resourceColumn(String id) { + if (id == null) { + return -1; + } + for (int i = 0; i < resources.length; i++) { + if (resources[i].getId().equals(id)) { + return i; + } + } + return -1; + } + + private void load() { + boxes.clear(); + backgrounds.clear(); + chips.clear(); + loaded.clear(); + allDayRows = 0; + if (days.length == 0 || owner.getModel() == null) { + return; + } + ZoneId zone = owner.getZone(); + long firstDay = days[0]; + long lastDay = days[days.length - 1]; + long from = TimeMath.startOfEpochDay(firstDay, zone); + long to = TimeMath.startOfEpochDay(lastDay + 1, zone); + owner.getModel().getItems(from, to, loaded); + for (ScheduleItem item : loaded) { + boolean event = item.getKind() == ScheduleItem.Kind.EVENT; + int only = -1; + if (resources != null) { + only = resourceColumn(item.getResourceId()); + if (only < 0 && (event || item.getResourceId() != null)) { + continue; + } + } + if (event && (item.isAllDay() || item.getEnd() - item.getStart() >= TimeMath.DAY)) { + addChip(item, only, zone); + continue; + } + for (int c = 0; c < days.length; c++) { + if (resources != null && only >= 0 && c != only) { + continue; + } + long ds = TimeMath.startOfEpochDay(days[c], zone); + long de = TimeMath.startOfEpochDay(days[c] + 1, zone); + boolean instant = item.getEnd() == item.getStart() && item.getStart() >= ds && item.getStart() < de; + if (!instant && !item.intersects(ds, de)) { + continue; + } + Box b = new Box(); + b.item = item; + b.col = c; + b.startsBefore = item.getStart() < ds; + b.endsAfter = item.getEnd() > de; + b.startMin = b.startsBefore ? 0 : TimeMath.minuteOfDay(item.getStart(), zone); + b.endMin = item.getEnd() >= de ? TimeMath.MINUTES_PER_DAY : TimeMath.minuteOfDay(item.getEnd(), zone); + if (b.endMin < b.startMin) { + b.endMin = b.startMin; + } + if (event) { + boxes.add(b); + } else { + backgrounds.add(b); + } + } + } + Collections.sort(boxes, BY_START); + for (int c = 0; c < days.length; c++) { + layoutColumn(c); + } + layoutChips(); + } + + private void addChip(ScheduleItem item, int only, ZoneId zone) { + Chip chip = new Chip(); + chip.item = item; + if (resources != null) { + chip.fromCol = only; + chip.toCol = only; + } else { + long s = TimeMath.epochDay(item.getStart(), zone); + // The end is exclusive: an item that ends at midnight is not on + // the day that starts then. + long e = TimeMath.epochDay(Math.max(item.getStart(), item.getEnd() - 1), zone); + chip.fromCol = -1; + chip.toCol = -1; + for (int c = 0; c < days.length; c++) { + if (days[c] >= s && days[c] <= e) { + if (chip.fromCol < 0) { + chip.fromCol = c; + } + chip.toCol = c; + } + } + if (chip.fromCol < 0) { + return; + } + chip.startsBefore = s < days[chip.fromCol]; + chip.endsAfter = e > days[chip.toCol]; + } + chips.add(chip); + } + + // The boxes of a column that overlap, directly or through one another, + // share its width in equal lanes. + private void layoutColumn(int col) { + ArrayList group = new ArrayList(); + ArrayList laneEnd = new ArrayList(); + int groupEnd = -1; + for (Box b : boxes) { + if (b.col != col) { + continue; + } + int end = Math.max(b.endMin, b.startMin + MIN_LAYOUT_MINUTES); + if (!group.isEmpty() && b.startMin >= groupEnd) { + close(group, laneEnd.size()); + group.clear(); + laneEnd.clear(); + } + int lane = -1; + for (int i = 0; i < laneEnd.size(); i++) { + if (laneEnd.get(i).intValue() <= b.startMin) { + lane = i; + break; + } + } + if (lane < 0) { + lane = laneEnd.size(); + laneEnd.add(Integer.valueOf(end)); + } else { + laneEnd.set(lane, Integer.valueOf(end)); + } + b.lane = lane; + group.add(b); + groupEnd = Math.max(groupEnd, end); + } + close(group, laneEnd.size()); + } + + private static void close(List group, int lanes) { + for (Box b : group) { + b.lanes = Math.max(1, lanes); + } + } + + private void layoutChips() { + Collections.sort(chips, CHIP_ORDER); + ArrayList rowEnd = new ArrayList(); + for (Chip c : chips) { + int row = -1; + for (int i = 0; i < rowEnd.size(); i++) { + if (rowEnd.get(i).intValue() < c.fromCol) { + row = i; + break; + } + } + if (row < 0) { + row = rowEnd.size(); + rowEnd.add(Integer.valueOf(c.toCol)); + } else { + rowEnd.set(row, Integer.valueOf(c.toCol)); + } + c.row = row; + } + allDayRows = rowEnd.size(); + } + + /// The rectangle of a box in the content's coordinates. + void boxRect(Box b, int[] out) { + int x0 = colX(b.col); + int cw = colX(b.col + 1) - x0; + // A strip is left clear at the edge of a column that has room, so + // there is always empty space to press on. + int usable = cw - (cw > Look.mm(14) ? Look.mm(1.6f) : 1); + int lx = x0 + (int) ((long) b.lane * usable / b.lanes); + int rx = x0 + (int) ((long) (b.lane + 1) * usable / b.lanes); + int top = minuteY(b.startMin); + int bottom = minuteY(b.endMin); + out[0] = lx + 1; + out[1] = top + 1; + out[2] = Math.max(2, rx - lx - 1); + out[3] = Math.max(Look.mm(2.6f), bottom - top - 1); + } + + private boolean chipHidden(Chip c) { + return allDayRows > MAX_ALL_DAY_ROWS && c.row >= MAX_ALL_DAY_ROWS - 1; + } + + private void chipRect(Chip c, int[] out) { + int x0 = colX(c.fromCol); + int x1 = colX(c.toCol + 1); + out[0] = x0 + 1; + out[1] = colHeaderH + c.row * chipH; + out[2] = x1 - x0 - 2; + out[3] = chipH - 1; + } + + private int hiddenChips(int col) { + int n = 0; + for (Chip c : chips) { + if (chipHidden(c) && c.fromCol <= col && c.toCol >= col) { + n++; + } + } + return n; + } + + // -------------------------------------------------------------- paint + + @Override + void paintBody(Graphics g, int x, int y) { + Style grid = Look.style("SchedulerGrid"); + Style off = Look.style("SchedulerOffHours"); + int cx = g.getClipX(); + int cy = g.getClipY(); + int cw = g.getClipWidth(); + int ch = g.getClipHeight(); + Look.fill(g, grid, cx, cy, cw, ch); + int height = contentHeight(); + ZoneId zone = owner.getZone(); + + for (int c = 0; c < days.length; c++) { + int x0 = x + colX(c); + int x1 = x + colX(c + 1); + if (x1 < cx || x0 > cx + cw) { + continue; + } + WorkCalendar cal = resources != null && resources.length > 0 && resources[c].getCalendar() != null + ? resources[c].getCalendar() : owner.getWorkCalendar(); + if (cal != null) { + int[] iv = cal.getWorkingHours(days[c]); + int cursor = 0; + for (int i = 0; i < iv.length; i += 2) { + if (iv[i] > cursor) { + Look.fill(g, off, x0, y + minuteY(cursor), x1 - x0, minuteY(iv[i]) - minuteY(cursor)); + } + cursor = iv[i + 1]; + } + if (cursor < TimeMath.MINUTES_PER_DAY) { + Look.fill(g, off, x0, y + minuteY(cursor), x1 - x0, height - minuteY(cursor)); + } + } + } + + int line = Look.mix(grid.getBgColor(), grid.getFgColor(), 150); + int faint = Look.mix(grid.getBgColor(), grid.getFgColor(), 60); + boolean halves = hourH >= Look.mm(11); + for (int h = 0; h <= 24; h++) { + int yy = y + minuteY(h * 60); + if (yy >= cy - 1 && yy <= cy + ch) { + g.setColor(line); + g.drawLine(cx, yy, cx + cw, yy); + } + if (halves && h < 24) { + int hy = y + minuteY(h * 60 + 30); + if (hy >= cy && hy <= cy + ch) { + g.setColor(faint); + g.drawLine(cx, hy, cx + cw, hy); + } + } + } + g.setColor(line); + for (int c = 1; c < days.length; c++) { + int lx = x + colX(c); + g.drawLine(lx, cy, lx, cy + ch); + } + + ScheduleItemRenderer renderer = owner.getItemRenderer(); + for (Box b : backgrounds) { + int x0 = x + colX(b.col); + int x1 = x + colX(b.col + 1); + int top = y + minuteY(b.startMin); + renderer.paintItem(g, owner, b.item, x0 + 1, top, x1 - x0 - 1, y + minuteY(b.endMin) - top, 0); + } + ScheduleItem selected = owner.getSelectedItem(); + Box selectedBox = null; + for (Box b : boxes) { + boxRect(b, rect); + if (y + rect[1] > cy + ch || y + rect[1] + rect[3] < cy) { + continue; + } + int state = 0; + if (b.item == selected) { //NOPMD CompareObjectsWithEquals - the selection is this very object + state |= ScheduleItemRenderer.SELECTED; + selectedBox = b; + } + if (b.startsBefore) { + state |= ScheduleItemRenderer.STARTS_BEFORE; + } + if (b.endsAfter) { + state |= ScheduleItemRenderer.ENDS_AFTER; + } + renderer.paintItem(g, owner, b.item, x + rect[0], y + rect[1], rect[2], rect[3], state); + } + if (selectedBox != null && owner.isEditable() && selectedBox.item.isResizable() && !isDragEdits() + && mode == 0) { + boxRect(selectedBox, rect); + paintHandle(g, grid, x + rect[0] + rect[2] / 2, y + rect[1]); + paintHandle(g, grid, x + rect[0] + rect[2] / 2, y + rect[1] + rect[3]); + } + if (mode != 0) { + paintEdit(g, x, y, zone); + } + paintNow(g, x, y, zone); + } + + private static void paintHandle(Graphics g, Style grid, int cx, int cy) { + int r = Look.mm(1.1f); + boolean aa = g.isAntiAliased(); + g.setAntiAliased(true); + g.setColor(grid.getBgColor()); + g.fillArc(cx - r, cy - r, 2 * r, 2 * r, 0, 360); + g.setColor(Look.style("SchedulerNow").getFgColor()); + g.drawArc(cx - r, cy - r, 2 * r, 2 * r, 0, 360); + g.setAntiAliased(aa); + } + + // What is being dragged out: a new range, or an item at the time it + // would move to. + private void paintEdit(Graphics g, int x, int y, ZoneId zone) { + for (int c = 0; c < days.length; c++) { + if (resources != null) { + int target = mode == RANGE ? anchorCol : resourceColumn(editResource); + if (c != target) { + continue; + } + } + long ds = TimeMath.startOfEpochDay(days[c], zone); + long de = TimeMath.startOfEpochDay(days[c] + 1, zone); + if (editEnd <= ds || editStart >= de) { + continue; + } + int top = editStart <= ds ? 0 : minuteY(TimeMath.minuteOfDay(editStart, zone)); + int bottom = editEnd >= de ? contentHeight() : minuteY(TimeMath.minuteOfDay(editEnd, zone)); + int x0 = x + colX(c) + 1; + int w = colX(c + 1) - colX(c) - 2; + int h = Math.max(Look.mm(2.6f), bottom - top); + if (mode == RANGE) { + Style sel = Look.style("SchedulerSelection"); + Look.fill(g, sel, x0, y + top, w, h); + g.setColor(sel.getFgColor()); + g.drawRect(x0, y + top, w - 1, h - 1); + Font f = sel.getFont(); + if (h >= f.getHeight()) { + g.setFont(f); + g.drawString(Look.fit(owner.formatRange(editStart, editEnd), f, w - Look.mm(2)), x0 + Look.mm(1), + y + top + 1); + } + } else { + owner.getItemRenderer().paintItem(g, owner, editItem, x0, y + top, w, h, + ScheduleItemRenderer.DRAGGED | ScheduleItemRenderer.SELECTED); + Style sel = Look.style("SchedulerSelection"); + Font f = sel.getFont(); + g.setFont(f); + g.setColor(Look.style("SchedulerGrid").getFgColor()); + String s = owner.formatRange(editStart, editEnd); + int ty = y + top - f.getHeight(); + if (ty > y + getOffY()) { + g.drawString(Look.fit(s, f, w), x0, ty); + } + } + } + } + + private void paintNow(Graphics g, int x, int y, ZoneId zone) { + long now = owner.now(); + long today = TimeMath.epochDay(now, zone); + int first = -1; + int last = -1; + for (int c = 0; c < days.length; c++) { + if (days[c] == today) { + if (first < 0) { + first = c; + } + last = c; + } + } + if (first < 0) { + return; + } + Style st = Look.style("SchedulerNow"); + int yy = y + minuteY(TimeMath.minuteOfDay(now, zone)); + int x0 = x + colX(first); + int x1 = x + colX(last + 1); + int r = Look.mm(0.9f); + boolean aa = g.isAntiAliased(); + g.setAntiAliased(true); + g.setColor(st.getFgColor()); + g.fillRect(x0, yy - 1, x1 - x0, 2); + g.fillArc(x0 - r, yy - r, 2 * r, 2 * r, 0, 360); + g.setAntiAliased(aa); + } + + @Override + void paintTopHeader(Graphics g, int x, int y) { + Style header = Look.style("SchedulerHeader"); + Style todaySt = Look.style("SchedulerHeaderToday"); + Font f = header.getFont(); + int fh = f.getHeight(); + int cx = g.getClipX(); + int cw = g.getClipWidth(); + Look.fill(g, header, cx, y, cw, headerH); + long today = TimeMath.epochDay(owner.now(), owner.getZone()); + g.setFont(f); + int alpha = g.getAlpha(); + for (int c = 0; c < days.length; c++) { + int x0 = x + colX(c); + int w = colX(c + 1) - colX(c); + if (x0 + w < cx || x0 > cx + cw) { + continue; + } + if (resources != null) { + if (resources.length == 0) { + continue; + } + ScheduleResource r = resources[c]; + String name = Look.fit(r.getName(), f, w - Look.mm(2)); + boolean two = colHeaderH > fh * 2; + int ty = y + (colHeaderH - (two ? 2 * fh : fh)) / 2; + g.setColor(header.getFgColor()); + g.drawString(name, x0 + (w - f.stringWidth(name)) / 2, ty); + if (two && r.getDetail() != null) { + String d = Look.fit(r.getDetail(), f, w - Look.mm(2)); + g.setAlpha(alpha * 160 / 255); + g.drawString(d, x0 + (w - f.stringWidth(d)) / 2, ty + fh); + g.setAlpha(alpha); + } + continue; + } + String wd = Look.fit(Dates.weekdayShort(TimeMath.dayOfWeek(days[c])), f, w); + String num = String.valueOf(Dates.dayOfMonth(days[c])); + int top = y + Look.mm(0.8f); + g.setColor(header.getFgColor()); + g.setAlpha(alpha * 170 / 255); + g.drawString(wd, x0 + (w - f.stringWidth(wd)) / 2, top); + g.setAlpha(alpha); + int nw = f.stringWidth(num); + int ny = top + fh + Look.mm(0.6f); + if (days[c] == today) { + int d = Math.min(w - 2, fh + Look.mm(1.4f)); + boolean aa = g.isAntiAliased(); + g.setAntiAliased(true); + g.setColor(todaySt.getBgColor()); + g.fillArc(x0 + (w - d) / 2, ny + (fh - d) / 2, d, d, 0, 360); + g.setAntiAliased(aa); + g.setColor(todaySt.getFgColor()); + } + g.drawString(num, x0 + (w - nw) / 2, ny); + } + if (allDayH > 0) { + ScheduleItemRenderer renderer = owner.getItemRenderer(); + ScheduleItem selected = owner.getSelectedItem(); + for (Chip c : chips) { + if (chipHidden(c)) { + continue; + } + chipRect(c, rect); + int state = ScheduleItemRenderer.COMPACT; + if (c.item == selected) { //NOPMD CompareObjectsWithEquals - the selection is this very object + state |= ScheduleItemRenderer.SELECTED; + } + if (c.startsBefore) { + state |= ScheduleItemRenderer.STARTS_BEFORE; + } + if (c.endsAfter) { + state |= ScheduleItemRenderer.ENDS_AFTER; + } + renderer.paintItem(g, owner, c.item, x + rect[0], y + rect[1], rect[2], rect[3], state); + } + if (allDayRows > MAX_ALL_DAY_ROWS) { + g.setFont(f); + g.setColor(header.getFgColor()); + for (int c = 0; c < days.length; c++) { + int n = hiddenChips(c); + if (n > 0) { + g.drawString("+" + n, x + colX(c) + Look.mm(1), + y + colHeaderH + (MAX_ALL_DAY_ROWS - 1) * chipH + (chipH - fh) / 2); + } + } + } + } + g.setColor(Look.mix(header.getBgColor(), header.getFgColor(), 50)); + g.drawLine(cx, y + headerH - 1, cx + cw, y + headerH - 1); + } + + @Override + void paintLeftHeader(Graphics g, int x, int y) { + Style st = Look.style("SchedulerTimeLabel"); + Font f = st.getFont(); + int fh = f.getHeight(); + Look.fill(g, st, x, g.getClipY(), headerW, g.getClipHeight()); + g.setFont(f); + g.setColor(st.getFgColor()); + int every = hourH < fh * 1.3f ? 2 : 1; + boolean h24 = owner.is24Hours(); + for (int h = every; h < 24; h += every) { + String s = Dates.time(h * 60, h24, true); + g.drawString(s, x + headerW - f.stringWidth(s) - Look.mm(1), y + minuteY(h * 60) - fh / 2); + } + } + + @Override + void paintCorner(Graphics g, int x, int y) { + Style header = Look.style("SchedulerHeader"); + Look.fill(g, header, x, y, headerW, headerH); + g.setColor(Look.mix(header.getBgColor(), header.getFgColor(), 50)); + g.drawLine(x, y + headerH - 1, x + headerW, y + headerH - 1); + } + + // ------------------------------------------------------------ pointer + + /// The box at a point of the component, or null. + Box boxAt(int px, int py) { + if (px < headerW || py < headerH) { + return null; + } + int cx = px - headerW + getOffX(); + int cy = py - headerH + getOffY(); + for (int i = boxes.size() - 1; i >= 0; i--) { + Box b = boxes.get(i); + boxRect(b, rect); + if (cx >= rect[0] && cx < rect[0] + rect[2] && cy >= rect[1] && cy < rect[1] + rect[3]) { + return b; + } + } + return null; + } + + private Chip chipAt(int px, int py) { + int cx = px - headerW + getOffX(); + for (Chip c : chips) { + if (chipHidden(c)) { + continue; + } + chipRect(c, rect); + if (cx >= rect[0] && cx < rect[0] + rect[2] && py >= rect[1] && py < rect[1] + rect[3]) { + return c; + } + } + return null; + } + + private long snap(long time) { + ZoneId zone = owner.getZone(); + int step = Math.max(1, owner.getSnapMinutes()); + long day = TimeMath.epochDay(time, zone); + int m = TimeMath.minuteOfDay(time, zone); + int snapped = (m + step / 2) / step * step; + return TimeMath.at(day, Math.min(TimeMath.MINUTES_PER_DAY, snapped), zone); + } + + private int snapMinute(int minute, boolean down) { + int step = Math.max(1, owner.getSnapMinutes()); + return (down ? minute : minute + step / 2) / step * step; + } + + @Override + boolean beginEdit(int x, int y, boolean handlesOnly) { + if (!owner.isEditable() || x < headerW || y < headerH || days.length == 0) { + return false; + } + int cx = x - headerW + getOffX(); + int cy = y - headerH + getOffY(); + ZoneId zone = owner.getZone(); + Box b = boxAt(x, y); + if (b == null && handlesOnly) { + // The handles reach a little outside the item they belong to. + int reach = Look.mm(2.5f); + b = boxAt(x, y - reach); + if (b == null) { + b = boxAt(x, y + reach); + } + } + if (b != null) { + boxRect(b, rect); + int edge = Math.min(rect[3] / 3, Look.mm(handlesOnly ? 3f : 1.6f)); + boolean bottom = !b.endsAfter && rect[1] + rect[3] - cy <= edge; + boolean top = !bottom && !b.startsBefore && cy - rect[1] <= edge; + if (handlesOnly && (b.item != owner.getSelectedItem() || !(top || bottom))) { //NOPMD CompareObjectsWithEquals + return false; + } + if ((top || bottom) && b.item.isResizable()) { + mode = bottom ? RESIZE_END : RESIZE_START; + } else if (!handlesOnly && b.item.isMovable()) { + mode = MOVE; + } else { + return false; + } + editItem = b.item; + editStart = b.item.getStart(); + editEnd = b.item.getEnd(); + editResource = b.item.getResourceId(); + grabTime = TimeMath.at(days[b.col], minuteAt(cy), zone); + owner.viewSelected(b.item); + return true; + } + if (handlesOnly) { + return false; + } + mode = RANGE; + editItem = null; + anchorCol = colAt(cx); + anchorMin = snapMinute(minuteAt(cy), true); + editStart = TimeMath.at(days[anchorCol], anchorMin, zone); + editEnd = editStart + owner.getDefaultMinutes() * TimeMath.MINUTE; + editResource = resources != null && resources.length > 0 ? resources[anchorCol].getId() : null; + return true; + } + + @Override + void editDragged(int x, int y) { + if (mode == 0) { + return; + } + ZoneId zone = owner.getZone(); + int cx = Math.max(0, Math.min(contentW - 1, x - headerW + getOffX())); + int cy = Math.max(0, Math.min(contentHeight(), y - headerH + getOffY())); + int col = colAt(cx); + int minute = minuteAt(cy); + long step = Math.max(1, owner.getSnapMinutes()) * TimeMath.MINUTE; + long pointer = TimeMath.at(days[col], minute, zone); + if (mode == MOVE) { + long length = editItem.getEnd() - editItem.getStart(); + editStart = snap(editItem.getStart() + pointer - grabTime); + editEnd = editStart + length; + if (resources != null && resources.length > 0) { + editResource = resources[col].getId(); + } + } else if (mode == RESIZE_END) { + editEnd = Math.max(editStart + step, snap(pointer)); + } else if (mode == RESIZE_START) { + editStart = Math.min(editEnd - step, snap(pointer)); + } else { + int m = snapMinute(minute, false); + if (m > anchorMin) { + editStart = TimeMath.at(days[anchorCol], anchorMin, zone); + editEnd = TimeMath.at(days[anchorCol], m, zone); + } else if (m < anchorMin) { + editStart = TimeMath.at(days[anchorCol], m, zone); + editEnd = TimeMath.at(days[anchorCol], anchorMin, zone) + step; + } else { + editStart = TimeMath.at(days[anchorCol], anchorMin, zone); + editEnd = editStart + owner.getDefaultMinutes() * TimeMath.MINUTE; + } + } + } + + @Override + void endEdit(boolean commit) { + int m = mode; + ScheduleItem item = editItem; + mode = 0; + editItem = null; + if (!commit) { + return; + } + if (m == RANGE) { + owner.viewRange(editStart, editEnd, editResource); + } else if (item != null && (editStart != item.getStart() || editEnd != item.getEnd() + || !same(editResource, item.getResourceId()))) { + owner.viewChanged(item, editStart, editEnd, editResource); + } + } + + private static boolean same(String a, String b) { + return a == null ? b == null : a.equals(b); + } + + @Override + void tapped(int x, int y, boolean doubleTap) { + if (days.length == 0 || x < headerW) { + return; + } + int cx = x - headerW + getOffX(); + if (y < headerH) { + Chip chip = chipAt(x, y); + if (chip != null) { + pick(chip.item, doubleTap); + } else if (resources == null && (y < colHeaderH || hiddenChips(colAt(cx)) > 0)) { + owner.viewDrill(days[colAt(cx)]); + } + return; + } + Box b = boxAt(x, y); + if (b != null) { + pick(b.item, doubleTap); + return; + } + if (owner.getSelectedItem() != null) { + owner.viewSelected(null); + } else if (doubleTap && owner.isEditable()) { + int col = colAt(cx); + int minute = snapMinute(minuteAt(y - headerH + getOffY()), true); + long start = TimeMath.at(days[col], minute, owner.getZone()); + owner.viewRange(start, start + owner.getDefaultMinutes() * TimeMath.MINUTE, + resources != null && resources.length > 0 ? resources[col].getId() : null); + } + } + + private void pick(ScheduleItem item, boolean doubleTap) { + owner.viewSelected(item); + if (doubleTap || owner.isOpenOnTap()) { + owner.viewOpened(item); + } + } + + @Override + boolean zoomBy(float factor, int x, int y) { + float before = hourH; + float least = Math.max(Look.mm(4), bodyHeight() / 24f); + float next = Math.max(least, Math.min(Look.mm(40), hourH * factor)); + if (Math.abs(next - before) < 0.01f) { + return false; + } + // The minute under the pointer stays under it. + float minute = (y - headerH + getOffY()) * 60f / before; + hourH = next; + setOffsets(getOffX(), Math.round(minute * next / 60f) - (y - headerH)); + repaint(); + return true; + } + + float getHourHeight() { + return hourH; + } +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/DefaultScheduleItemRenderer.java b/CodenameOne/src/com/codename1/ui/scheduler/DefaultScheduleItemRenderer.java new file mode 100644 index 00000000000..346e725c968 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/DefaultScheduleItemRenderer.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.scheduling.ScheduleItem; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.plaf.Style; + +/// The renderer a `Scheduler` starts with: a rounded block in the item's +/// colour with its title, and its time and detail where they fit. +/// +/// The colours and the font come from a UIID: the item's own when it has +/// one, otherwise `SchedulerItem`, `SchedulerBackgroundItem` or +/// `SchedulerBlocked` by its kind. An item or a resource with a colour of +/// its own overrides the background, and the text turns black or white to +/// stay readable on it. +public class DefaultScheduleItemRenderer implements ScheduleItemRenderer { + @Override + public void paintItem(Graphics g, Scheduler scheduler, ScheduleItem item, int x, int y, int width, int height, + int state) { + if (width <= 0 || height <= 0) { + return; + } + String uiid = uiidOf(item); + Style st = (state & SELECTED) != 0 ? Look.selected(uiid) : Look.style(uiid); + int own = item.getColor() >= 0 ? item.getColor() : scheduler.resourceColor(item.getResourceId()); + int bg = own >= 0 ? own : st.getBgColor(); + int fg = own >= 0 ? Look.textOn(bg) : st.getFgColor(); + int opacity = st.getBgTransparency() & 0xff; + if (own >= 0 && opacity == 0) { + opacity = 255; + } + if ((state & SELECTED) != 0 && own >= 0) { + bg = Look.mix(bg, 0, 50); + } + int alpha = g.getAlpha(); + boolean aa = g.isAntiAliased(); + if ((state & DRAGGED) != 0) { + opacity = opacity * 190 / 255; + } + boolean compact = (state & COMPACT) != 0; + int arc = Math.min(Math.min(width, height), Look.mm(compact ? 1f : 1.6f)); + g.setAntiAliased(true); + g.setColor(bg); + g.setAlpha(alpha * opacity / 255); + g.fillRoundRect(x, y, width, height, arc, arc); + if (item.getKind() == ScheduleItem.Kind.BLOCKED) { + hatch(g, fg, alpha, x, y, width, height); + } + g.setAlpha(alpha); + if ((state & SELECTED) != 0) { + g.setColor(fg); + g.drawRoundRect(x, y, width - 1, height - 1, arc, arc); + } + g.setAntiAliased(aa); + paintText(g, scheduler, item, st.getFont(), fg, x, y, width, height, state); + } + + /// The UIID an item is painted with. + protected String uiidOf(ScheduleItem item) { + if (item.getUIID() != null) { + return item.getUIID(); + } + if (item.getKind() == ScheduleItem.Kind.BACKGROUND) { + return "SchedulerBackgroundItem"; + } + if (item.getKind() == ScheduleItem.Kind.BLOCKED) { + return "SchedulerBlocked"; + } + return "SchedulerItem"; + } + + /// Draws the title, and under it the time and the detail when the + /// rectangle is tall enough. + protected void paintText(Graphics g, Scheduler scheduler, ScheduleItem item, Font font, int color, int x, int y, + int width, int height, int state) { + boolean compact = (state & COMPACT) != 0; + int pad = Look.mm(compact ? 0.7f : 1f); + int fh = font.getHeight(); + int tw = width - 2 * pad; + if (tw <= 0 || height < fh * 2 / 3) { + return; + } + int cx = g.getClipX(); + int cy = g.getClipY(); + int cw = g.getClipWidth(); + int ch = g.getClipHeight(); + g.clipRect(x, y, width, height); + g.setFont(font); + g.setColor(color); + int ty = height < fh + 2 * pad ? y + (height - fh) / 2 : y + pad; + String title = item.getTitle(); + if (compact && !item.isAllDay() && item.getKind() == ScheduleItem.Kind.EVENT + && (state & HORIZONTAL) == 0) { + title = scheduler.formatTime(item.getStart()) + " " + title; + } + g.drawString(Look.fit(title, font, tw), x + pad, ty); + if (!compact && item.getKind() == ScheduleItem.Kind.EVENT) { + int ly = ty + fh; + int alpha = g.getAlpha(); + g.setAlpha(alpha * 200 / 255); + if (ly + fh <= y + height - pad / 2 && !item.isAllDay()) { + g.drawString(Look.fit(scheduler.formatRange(item.getStart(), item.getEnd()), font, tw), x + pad, ly); + ly += fh; + } + String detail = item.getDetail(); + if (detail != null && detail.length() > 0 && ly + fh <= y + height - pad / 2) { + g.drawString(Look.fit(detail, font, tw), x + pad, ly); + } + g.setAlpha(alpha); + } + g.setClip(cx, cy, cw, ch); + } + + private static void hatch(Graphics g, int color, int alpha, int x, int y, int w, int h) { + int cx = g.getClipX(); + int cy = g.getClipY(); + int cw = g.getClipWidth(); + int ch = g.getClipHeight(); + g.clipRect(x, y, w, h); + g.setColor(color); + g.setAlpha(alpha * 40 / 255); + int step = Look.mm(1.6f); + for (int i = -h; i < w; i += step) { + g.drawLine(x + i, y + h, x + i + h, y); + } + g.setClip(cx, cy, cw, ch); + } +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/Look.java b/CodenameOne/src/com/codename1/ui/scheduler/Look.java new file mode 100644 index 00000000000..7a1a8b22e2b --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/Look.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.ui.Display; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; + +import java.util.HashMap; + +/// The styles the scheduling components paint with, looked up by UIID once +/// per theme and not once per item. +/// +/// The surfaces draw thousands of rectangles with no component behind them, +/// so a UIID here is a source of colours and a font, not of a border or a +/// padding. +final class Look { + private static final HashMap STYLES = new HashMap(); + private static int generation = -1; + + private Look() { + } + + private static void check() { + int now = UIManager.getThemeGeneration(); + if (now != generation) { + generation = now; + STYLES.clear(); + } + } + + static Style style(String uiid) { + check(); + Style s = STYLES.get(uiid); + if (s == null) { + s = UIManager.getInstance().getComponentStyle(uiid); + STYLES.put(uiid, s); + } + return s; + } + + static Style selected(String uiid) { + check(); + String key = uiid + "#sel"; + Style s = STYLES.get(key); + if (s == null) { + s = UIManager.getInstance().getComponentSelectedStyle(uiid); + STYLES.put(key, s); + } + return s; + } + + static int mm(float mm) { + return Math.max(1, Display.getInstance().convertToPixels(mm)); + } + + /// Fills with a style's background colour and opacity. + static void fill(Graphics g, Style s, int x, int y, int w, int h) { + int op = s.getBgTransparency() & 0xff; + if (op == 0 || w <= 0 || h <= 0) { + return; + } + g.setColor(s.getBgColor()); + if (op == 255) { + g.fillRect(x, y, w, h); + } else { + int a = g.getAlpha(); + g.setAlpha(a * op / 255); + g.fillRect(x, y, w, h); + g.setAlpha(a); + } + } + + /// The longest start of `text` that fits `width`, ending in an ellipsis + /// when anything was cut. Empty when not even that fits. + static String fit(String text, Font f, int width) { + if (text == null || text.length() == 0 || width <= 0) { + return ""; + } + if (f.stringWidth(text) <= width) { + return text; + } + int dots = f.stringWidth("..."); + if (dots > width) { + return ""; + } + int lo = 0; + int hi = text.length(); + while (lo < hi) { + int mid = (lo + hi + 1) / 2; + if (f.substringWidth(text, 0, mid) + dots <= width) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo == 0 ? "" : text.substring(0, lo) + "..."; + } + + /// Mixes two RGB colours: 0 is all `a`, 255 all `b`. + static int mix(int a, int b, int amount) { + int inv = 255 - amount; + int r = (((a >> 16) & 0xff) * inv + ((b >> 16) & 0xff) * amount) / 255; + int gr = (((a >> 8) & 0xff) * inv + ((b >> 8) & 0xff) * amount) / 255; + int bl = ((a & 0xff) * inv + (b & 0xff) * amount) / 255; + return (r << 16) | (gr << 8) | bl; + } + + /// Black or white, whichever reads on `rgb`. + static int textOn(int rgb) { + int lum = (((rgb >> 16) & 0xff) * 299 + ((rgb >> 8) & 0xff) * 587 + (rgb & 0xff) * 114) / 1000; + return lum > 150 ? 0x1c1b1f : 0xffffff; + } +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/MonthGrid.java b/CodenameOne/src/com/codename1/ui/scheduler/MonthGrid.java new file mode 100644 index 00000000000..72da5c71c14 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/MonthGrid.java @@ -0,0 +1,375 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.scheduling.ScheduleItem; +import com.codename1.scheduling.TimeMath; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; + +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/// A month as weeks of day cells, each listing what is on that day and +/// counting what does not fit. +final class MonthGrid extends ScrollSurface { + private static final int MOVE = 1; + private static final int RANGE = 4; + + private static final Comparator ORDER = new Comparator() { + @Override + public int compare(ScheduleItem a, ScheduleItem b) { + boolean la = a.isAllDay() || a.getEnd() - a.getStart() >= TimeMath.DAY; + boolean lb = b.isAllDay() || b.getEnd() - b.getStart() >= TimeMath.DAY; + if (la != lb) { + return la ? -1 : 1; + } + if (a.getStart() != b.getStart()) { + return a.getStart() < b.getStart() ? -1 : 1; + } + return a.getId().compareTo(b.getId()); + } + }; + + private final Scheduler owner; + private long firstDay; + private long monthStart; + private long monthEnd; + private int weeks = 5; + private boolean dirty = true; + private int laidW = -1; + private int laidH = -1; + private int laidGen = -1; + private int numberH; + private int chipH; + private int capacity; + private ArrayList[] cells; + private final ArrayList loaded = new ArrayList(); + + private int mode; + private ScheduleItem editItem; + private int grabCell; + private int anchorCell; + private int editCell; + + MonthGrid(Scheduler owner) { + this.owner = owner; + setUIID("Scheduler"); + } + + /// Shows the month `day` is in. + void show(long day) { + monthStart = TimeMath.startOfMonth(day); + monthEnd = TimeMath.plusMonths(monthStart, 1); + firstDay = TimeMath.startOfWeek(monthStart, owner.getFirstDayOfWeek()); + weeks = (int) ((monthEnd - firstDay + 6) / 7); + dirty = true; + repaint(); + } + + long firstDay() { + return firstDay; + } + + int dayCount() { + return weeks * 7; + } + + void modelChanged() { + dirty = true; + repaint(); + } + + List itemsOn(long day) { + int i = (int) (day - firstDay); + return cells == null || i < 0 || i >= cells.length ? null : cells[i]; + } + + @Override + @SuppressWarnings("unchecked") + void prepare(int width, int height) { + int gen = UIManager.getThemeGeneration(); + if (!dirty && width == laidW && height == laidH && gen == laidGen) { + return; + } + laidW = width; + laidH = height; + laidGen = gen; + Style header = Look.style("SchedulerHeader"); + Font nf = Look.style("SchedulerMonthDay").getFont(); + headerW = 0; + headerH = header.getFont().getHeight() + Look.mm(2); + numberH = nf.getHeight() + Look.mm(1.6f); + chipH = Look.style("SchedulerItem").getFont().getHeight() + Look.mm(0.8f); + int cellH = Math.max(1, (height - headerH) / weeks); + capacity = Math.max(0, (cellH - numberH) / chipH); + if (dirty) { + dirty = false; + int n = weeks * 7; + cells = new ArrayList[n]; + for (int i = 0; i < n; i++) { + cells[i] = new ArrayList(); + } + loaded.clear(); + if (owner.getModel() != null) { + ZoneId zone = owner.getZone(); + owner.getModel().getItems(TimeMath.startOfEpochDay(firstDay, zone), + TimeMath.startOfEpochDay(firstDay + n, zone), loaded); + for (ScheduleItem item : loaded) { + if (item.getKind() != ScheduleItem.Kind.EVENT) { + continue; + } + long s = TimeMath.epochDay(item.getStart(), zone); + long e = TimeMath.epochDay(Math.max(item.getStart(), item.getEnd() - 1), zone); + for (long d = Math.max(s, firstDay); d <= e && d < firstDay + n; d++) { + cells[(int) (d - firstDay)].add(item); + } + } + for (int i = 0; i < n; i++) { + Collections.sort(cells[i], ORDER); + } + } + } + } + + @Override + int contentWidth() { + return bodyWidth(); + } + + @Override + int contentHeight() { + return bodyHeight(); + } + + private int colX(int col) { + return col * bodyWidth() / 7; + } + + private int rowY(int row) { + return row * bodyHeight() / weeks; + } + + private int cellAt(int px, int py) { + int col = Math.max(0, Math.min(6, px * 7 / Math.max(1, bodyWidth()))); + int row = Math.max(0, Math.min(weeks - 1, (py - headerH) * weeks / Math.max(1, bodyHeight()))); + return row * 7 + col; + } + + // How many items of a cell are drawn as chips; the rest are counted. + private int shown(int cell) { + int n = cells[cell].size(); + return n <= capacity ? n : Math.max(0, capacity - 1); + } + + private ScheduleItem itemAt(int px, int py) { + if (py < headerH || cells == null) { + return null; + } + int cell = cellAt(px, py); + int top = headerH + rowY(cell / 7) + numberH; + if (py < top) { + return null; + } + int i = (py - top) / chipH; + return i < shown(cell) ? cells[cell].get(i) : null; + } + + @Override + void paintBody(Graphics g, int x, int y) { + Style grid = Look.style("SchedulerGrid"); + Style dayStyle = Look.style("SchedulerMonthDay"); + Style other = Look.style("SchedulerMonthOther"); + Style todaySt = Look.style("SchedulerHeaderToday"); + Style sel = Look.style("SchedulerSelection"); + Font nf = dayStyle.getFont(); + int bw = bodyWidth(); + int bh = bodyHeight(); + Look.fill(g, grid, x, y, bw, bh); + ZoneId zone = owner.getZone(); + long today = TimeMath.epochDay(owner.now(), zone); + ScheduleItemRenderer renderer = owner.getItemRenderer(); + ScheduleItem selected = owner.getSelectedItem(); + int line = Look.mix(grid.getBgColor(), grid.getFgColor(), 150); + int lo = Math.min(anchorCell, editCell); + int hi = Math.max(anchorCell, editCell); + for (int cell = 0; cell < weeks * 7; cell++) { + long day = firstDay + cell; + int x0 = x + colX(cell % 7); + int x1 = x + colX(cell % 7 + 1); + int y0 = y + rowY(cell / 7); + int y1 = y + rowY(cell / 7 + 1); + boolean inMonth = day >= monthStart && day < monthEnd; + if (!inMonth) { + Look.fill(g, other, x0, y0, x1 - x0, y1 - y0); + } + if ((mode == MOVE && cell == editCell) || (mode == RANGE && cell >= lo && cell <= hi)) { + Look.fill(g, sel, x0, y0, x1 - x0, y1 - y0); + } + String num = String.valueOf(Dates.dayOfMonth(day)); + int nw = nf.stringWidth(num); + int nx = x0 + Look.mm(1.2f); + int ny = y0 + Look.mm(0.8f); + g.setFont(nf); + if (day == today) { + int d = nf.getHeight() + Look.mm(1); + boolean aa = g.isAntiAliased(); + g.setAntiAliased(true); + g.setColor(todaySt.getBgColor()); + g.fillArc(nx + nw / 2 - d / 2, ny + nf.getHeight() / 2 - d / 2, d, d, 0, 360); + g.setAntiAliased(aa); + g.setColor(todaySt.getFgColor()); + } else { + g.setColor(inMonth ? dayStyle.getFgColor() : other.getFgColor()); + } + g.drawString(num, nx, ny); + List list = cells[cell]; + int n = shown(cell); + int cy = y0 + numberH; + for (int i = 0; i < n; i++) { + ScheduleItem item = list.get(i); + int state = ScheduleItemRenderer.COMPACT; + if (item == selected) { //NOPMD CompareObjectsWithEquals - the selection is this very object + state |= ScheduleItemRenderer.SELECTED; + } + if (TimeMath.epochDay(item.getStart(), zone) < day) { + state |= ScheduleItemRenderer.STARTS_BEFORE; + } + if (TimeMath.epochDay(Math.max(item.getStart(), item.getEnd() - 1), zone) > day) { + state |= ScheduleItemRenderer.ENDS_AFTER; + } + renderer.paintItem(g, owner, item, x0 + 1, cy, x1 - x0 - 2, chipH - 1, state); + cy += chipH; + } + if (list.size() > n) { + g.setFont(nf); + g.setColor(dayStyle.getFgColor()); + String more = "+" + (list.size() - n); + if (capacity == 0) { + g.drawString(more, x1 - nf.stringWidth(more) - Look.mm(1), ny); + } else { + g.drawString(more, x0 + Look.mm(1.2f), cy + (chipH - nf.getHeight()) / 2); + } + } + } + g.setColor(line); + for (int c = 1; c < 7; c++) { + g.drawLine(x + colX(c), y, x + colX(c), y + bh); + } + for (int r = 1; r < weeks; r++) { + g.drawLine(x, y + rowY(r), x + bw, y + rowY(r)); + } + } + + @Override + void paintTopHeader(Graphics g, int x, int y) { + Style header = Look.style("SchedulerHeader"); + Font f = header.getFont(); + Look.fill(g, header, x, y, bodyWidth(), headerH); + g.setFont(f); + g.setColor(header.getFgColor()); + for (int c = 0; c < 7; c++) { + int w = colX(c + 1) - colX(c); + String s = Look.fit(Dates.weekdayShort(TimeMath.dayOfWeek(firstDay + c)), f, w); + g.drawString(s, x + colX(c) + (w - f.stringWidth(s)) / 2, y + (headerH - f.getHeight()) / 2); + } + g.setColor(Look.mix(header.getBgColor(), header.getFgColor(), 50)); + g.drawLine(x, y + headerH - 1, x + bodyWidth(), y + headerH - 1); + } + + @Override + boolean beginEdit(int x, int y, boolean handlesOnly) { + if (handlesOnly || !owner.isEditable() || y < headerH || cells == null) { + return false; + } + ScheduleItem item = itemAt(x, y); + int cell = cellAt(x, y); + if (item != null) { + if (!item.isMovable()) { + return false; + } + mode = MOVE; + editItem = item; + grabCell = cell; + editCell = cell; + owner.viewSelected(item); + return true; + } + mode = RANGE; + anchorCell = cell; + editCell = cell; + return true; + } + + @Override + void editDragged(int x, int y) { + if (mode != 0) { + editCell = cellAt(Math.max(0, Math.min(getWidth() - 1, x)), Math.max(headerH, Math.min(getHeight() - 1, y))); + } + } + + @Override + void endEdit(boolean commit) { + int m = mode; + ScheduleItem item = editItem; + mode = 0; + editItem = null; + if (!commit) { + return; + } + ZoneId zone = owner.getZone(); + if (m == RANGE) { + long a = firstDay + Math.min(anchorCell, editCell); + long b = firstDay + Math.max(anchorCell, editCell) + 1; + owner.viewRange(TimeMath.startOfEpochDay(a, zone), TimeMath.startOfEpochDay(b, zone), null); + } else if (item != null && editCell != grabCell) { + // The item keeps its time of day and its length on the new date. + long day = TimeMath.epochDay(item.getStart(), zone) + editCell - grabCell; + long start = TimeMath.at(day, TimeMath.minuteOfDay(item.getStart(), zone), zone); + owner.viewChanged(item, start, start + item.getEnd() - item.getStart(), item.getResourceId()); + } + } + + @Override + void tapped(int x, int y, boolean doubleTap) { + if (y < headerH || cells == null) { + return; + } + ScheduleItem item = itemAt(x, y); + if (item != null) { + owner.viewSelected(item); + if (doubleTap || owner.isOpenOnTap()) { + owner.viewOpened(item); + } + } else if (owner.getSelectedItem() != null) { + owner.viewSelected(null); + } else { + owner.viewDrill(firstDay + cellAt(x, y)); + } + } +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/ResourceTimeline.java b/CodenameOne/src/com/codename1/ui/scheduler/ResourceTimeline.java new file mode 100644 index 00000000000..520484aa037 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/ResourceTimeline.java @@ -0,0 +1,605 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.scheduling.ScheduleItem; +import com.codename1.scheduling.ScheduleResource; +import com.codename1.scheduling.TimeMath; +import com.codename1.scheduling.WorkCalendar; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; + +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/// Time running across the screen under a row per resource: who or what is +/// booked when, over days or weeks. Items of a row that overlap are stacked, +/// and the row grows to hold them. +final class ResourceTimeline extends ScrollSurface { + private static final int MOVE = 1; + private static final int RESIZE_START = 2; + private static final int RESIZE_END = 3; + private static final int RANGE = 4; + + /// An item in a row. + static final class Bar { + ScheduleItem item; + int row; + int lane; + } + + private static final Comparator BY_START = new Comparator() { + @Override + public int compare(Bar a, Bar b) { + if (a.row != b.row) { + return a.row < b.row ? -1 : 1; + } + if (a.item.getStart() != b.item.getStart()) { + return a.item.getStart() < b.item.getStart() ? -1 : 1; + } + return a.item.getId().compareTo(b.item.getId()); + } + }; + + private final Scheduler owner; + private final TimeAxis axis; + private ScheduleResource[] resources = new ScheduleResource[0]; + private int rowCount = 1; + private int[] rowTop = new int[2]; + private int[] rowLanes = new int[1]; + private boolean dirty = true; + private boolean scaled; + private boolean pendingScroll; + private int laidW = -1; + private int laidH = -1; + private int laidGen = -1; + private int laneH; + private int rowPad; + private int days = 1; + private final ArrayList bars = new ArrayList(); + private final ArrayList backgrounds = new ArrayList(); + private final ArrayList loaded = new ArrayList(); + private final int[] rect = new int[4]; + + private int mode; + private ScheduleItem editItem; + private long editStart; + private long editEnd; + private int editRow; + private long grabTime; + private long anchorTime; + + ResourceTimeline(Scheduler owner) { + this.owner = owner; + axis = new TimeAxis(owner.getZone()); + setUIID("Scheduler"); + } + + void show(long firstDay, int count) { + ZoneId zone = owner.getZone(); + axis.setZone(zone); + axis.setFirstDayOfWeek(owner.getFirstDayOfWeek()); + axis.set24Hours(owner.is24Hours()); + axis.setRange(TimeMath.startOfEpochDay(firstDay, zone), TimeMath.startOfEpochDay(firstDay + count, zone)); + if (count != days) { + scaled = false; + } + days = count; + dirty = true; + pendingScroll = true; + repaint(); + } + + void modelChanged() { + dirty = true; + repaint(); + } + + TimeAxis axis() { + return axis; + } + + List bars() { + return bars; + } + + // ------------------------------------------------------------- layout + + @Override + void prepare(int width, int height) { + int gen = UIManager.getThemeGeneration(); + if (!dirty && width == laidW && height == laidH && gen == laidGen) { + return; + } + boolean reload = dirty; + laidW = width; + laidH = height; + laidGen = gen; + dirty = false; + Style header = Look.style("SchedulerHeader"); + Font hf = header.getFont(); + Font itemFont = Look.style("SchedulerItem").getFont(); + List list = owner.getModel() == null ? null : owner.getModel().getResources(); + resources = new ScheduleResource[list == null ? 0 : list.size()]; + if (list != null) { + list.toArray(resources); + } + rowCount = Math.max(1, resources.length); + int widest = 0; + boolean detail = false; + for (ScheduleResource r : resources) { + widest = Math.max(widest, hf.stringWidth(r.getName())); + if (r.getDetail() != null && r.getDetail().length() > 0) { + detail = true; + widest = Math.max(widest, hf.stringWidth(r.getDetail())); + } + } + headerW = resources.length == 0 ? 0 : Math.min(width * 2 / 5, Math.min(Look.mm(38), widest + Look.mm(4))); + headerH = axis.headerHeight(header); + laneH = itemFont.getHeight() + Look.mm(2.2f); + rowPad = Look.mm(1); + int minRow = hf.getHeight() * (detail ? 2 : 1) + Look.mm(3); + + int bodyW = Math.max(1, width - headerW); + long span = axis.getEnd() - axis.getStart(); + axis.setScaleLimits(15.0 * TimeMath.MINUTE / Look.mm(9), Math.max(1.0, (double) span / bodyW)); + if (!scaled && width > 0) { + scaled = true; + axis.setMillisPerPixel(days <= 7 ? (double) TimeMath.DAY / Math.max(bodyW, Look.mm(110)) + : (double) TimeMath.DAY / Look.mm(14)); + } + axis.pickUnits(hf.stringWidth(Dates.time(22 * 60, owner.is24Hours(), true)) + Look.mm(3)); + + if (reload) { + load(); + } + rowTop = new int[rowCount + 1]; + for (int r = 0; r < rowCount; r++) { + rowTop[r + 1] = rowTop[r] + Math.max(minRow, Math.max(1, rowLanes[r]) * laneH + 2 * rowPad); + } + if (pendingScroll && width > 0) { + pendingScroll = false; + long now = owner.now(); + long target = now >= axis.getStart() && now < axis.getEnd() ? now - 2 * TimeMath.HOUR + : axis.getStart() + owner.getInitialMinute() * TimeMath.MINUTE; + setOffsets(Math.max(0, axis.x(target)), getOffY()); + } + } + + private int rowOf(String resourceId) { + if (resources.length == 0) { + return 0; + } + if (resourceId == null) { + return -1; + } + for (int i = 0; i < resources.length; i++) { + if (resources[i].getId().equals(resourceId)) { + return i; + } + } + return -1; + } + + private void load() { + bars.clear(); + backgrounds.clear(); + loaded.clear(); + rowLanes = new int[rowCount]; + if (owner.getModel() == null) { + return; + } + owner.getModel().getItems(axis.getStart(), axis.getEnd(), loaded); + for (ScheduleItem item : loaded) { + int row = rowOf(item.getResourceId()); + boolean event = item.getKind() == ScheduleItem.Kind.EVENT; + if (row < 0) { + if (event || item.getResourceId() != null) { + continue; + } + // Background with no resource is everybody's. + for (int r = 0; r < rowCount; r++) { + Bar b = new Bar(); + b.item = item; + b.row = r; + backgrounds.add(b); + } + continue; + } + Bar b = new Bar(); + b.item = item; + b.row = row; + if (event) { + bars.add(b); + } else { + backgrounds.add(b); + } + } + Collections.sort(bars, BY_START); + ArrayList laneEnd = new ArrayList(); + int row = -1; + for (Bar b : bars) { + if (b.row != row) { + row = b.row; + laneEnd.clear(); + } + int lane = -1; + for (int i = 0; i < laneEnd.size(); i++) { + if (laneEnd.get(i).longValue() <= b.item.getStart()) { + lane = i; + break; + } + } + long end = Math.max(b.item.getEnd(), b.item.getStart() + 1); + if (lane < 0) { + lane = laneEnd.size(); + laneEnd.add(Long.valueOf(end)); + } else { + laneEnd.set(lane, Long.valueOf(end)); + } + b.lane = lane; + rowLanes[row] = Math.max(rowLanes[row], laneEnd.size()); + } + } + + @Override + int contentWidth() { + return axis.width(); + } + + @Override + int contentHeight() { + return rowTop[rowCount]; + } + + private int rowAt(int cy) { + for (int r = 0; r < rowCount; r++) { + if (cy < rowTop[r + 1]) { + return r; + } + } + return rowCount - 1; + } + + /// The rectangle of a bar in the content's coordinates. + void barRect(Bar b, int[] out) { + int x0 = axis.x(b.item.getStart()); + int x1 = axis.x(b.item.getEnd()); + out[0] = x0; + out[1] = rowTop[b.row] + rowPad + b.lane * laneH; + out[2] = Math.max(Look.mm(1.5f), x1 - x0 - 1); + out[3] = laneH - 1; + } + + // -------------------------------------------------------------- paint + + @Override + void paintBody(Graphics g, int x, int y) { + Style grid = Look.style("SchedulerGrid"); + Style off = Look.style("SchedulerOffHours"); + int cx = g.getClipX(); + int cy = g.getClipY(); + int cw = g.getClipWidth(); + int ch = g.getClipHeight(); + Look.fill(g, grid, cx, cy, cw, ch); + for (int r = 0; r < rowCount; r++) { + int top = y + rowTop[r]; + int h = rowTop[r + 1] - rowTop[r]; + if (top > cy + ch || top + h < cy) { + continue; + } + WorkCalendar cal = resources.length > 0 && resources[r].getCalendar() != null + ? resources[r].getCalendar() : owner.getWorkCalendar(); + axis.paintOffHours(g, cal, off, x, top, h, cx, cx + cw); + } + axis.paintGrid(g, Look.mix(grid.getBgColor(), grid.getFgColor(), 150), grid.getBgColor(), x, cy, ch, cx, + cx + cw); + g.setColor(Look.mix(grid.getBgColor(), grid.getFgColor(), 150)); + for (int r = 1; r <= rowCount; r++) { + int ly = y + rowTop[r]; + g.drawLine(cx, ly, cx + cw, ly); + } + ScheduleItemRenderer renderer = owner.getItemRenderer(); + for (Bar b : backgrounds) { + int x0 = x + axis.x(b.item.getStart()); + int x1 = x + axis.x(b.item.getEnd()); + if (x1 < cx || x0 > cx + cw) { + continue; + } + renderer.paintItem(g, owner, b.item, x0, y + rowTop[b.row] + 1, x1 - x0, rowTop[b.row + 1] - rowTop[b.row] + - 1, ScheduleItemRenderer.HORIZONTAL | ScheduleItemRenderer.COMPACT); + } + ScheduleItem selected = owner.getSelectedItem(); + for (Bar b : bars) { + barRect(b, rect); + if (x + rect[0] > cx + cw || x + rect[0] + rect[2] < cx) { + continue; + } + int state = ScheduleItemRenderer.HORIZONTAL | ScheduleItemRenderer.COMPACT; + if (b.item == selected) { //NOPMD CompareObjectsWithEquals - the selection is this very object + state |= ScheduleItemRenderer.SELECTED; + } + if (b.item.getStart() < axis.getStart()) { + state |= ScheduleItemRenderer.STARTS_BEFORE; + } + if (b.item.getEnd() > axis.getEnd()) { + state |= ScheduleItemRenderer.ENDS_AFTER; + } + // The title stays in view while its item does. + int left = Math.max(x + rect[0], cx); + int right = x + rect[0] + rect[2]; + if (left > x + rect[0] && right - left > Look.mm(6)) { + renderer.paintItem(g, owner, b.item, left - Look.mm(2), y + rect[1], right - left + Look.mm(2), rect[3], + state | ScheduleItemRenderer.STARTS_BEFORE); + } else { + renderer.paintItem(g, owner, b.item, x + rect[0], y + rect[1], rect[2], rect[3], state); + } + } + if (mode != 0) { + int x0 = x + axis.x(editStart); + int x1 = x + axis.x(editEnd); + int w = Math.max(Look.mm(1.5f), x1 - x0); + if (mode == RANGE) { + Style sel = Look.style("SchedulerSelection"); + int top = y + rowTop[editRow] + 1; + int h = rowTop[editRow + 1] - rowTop[editRow] - 1; + Look.fill(g, sel, x0, top, w, h); + g.setColor(sel.getFgColor()); + g.drawRect(x0, top, w - 1, h - 1); + } else { + renderer.paintItem(g, owner, editItem, x0, y + rowTop[editRow] + rowPad, w, laneH - 1, + ScheduleItemRenderer.DRAGGED | ScheduleItemRenderer.SELECTED | ScheduleItemRenderer.HORIZONTAL + | ScheduleItemRenderer.COMPACT); + } + Style sel = Look.style("SchedulerSelection"); + Font f = sel.getFont(); + g.setFont(f); + g.setColor(grid.getFgColor()); + g.drawString(owner.formatRange(editStart, editEnd), Math.max(cx + 2, x0), + Math.max(cy, y + rowTop[editRow] - f.getHeight())); + } + long now = owner.now(); + if (now >= axis.getStart() && now < axis.getEnd()) { + g.setColor(Look.style("SchedulerNow").getFgColor()); + g.fillRect(x + axis.x(now) - 1, cy, 2, ch); + } + } + + @Override + void paintTopHeader(Graphics g, int x, int y) { + axis.paintHeader(g, Look.style("SchedulerHeader"), x, y, g.getClipX(), g.getClipX() + g.getClipWidth()); + } + + @Override + void paintLeftHeader(Graphics g, int x, int y) { + Style header = Look.style("SchedulerHeader"); + Font f = header.getFont(); + int fh = f.getHeight(); + Look.fill(g, header, x, g.getClipY(), headerW, g.getClipHeight()); + g.setFont(f); + int line = Look.mix(header.getBgColor(), header.getFgColor(), 50); + int pad = Look.mm(1.5f); + int alpha = g.getAlpha(); + for (int r = 0; r < resources.length; r++) { + int top = y + rowTop[r]; + int h = rowTop[r + 1] - rowTop[r]; + ScheduleResource res = resources[r]; + boolean two = res.getDetail() != null && res.getDetail().length() > 0; + int ty = top + (h - (two ? 2 * fh : fh)) / 2; + g.setColor(header.getFgColor()); + g.drawString(Look.fit(res.getName(), f, headerW - 2 * pad), x + pad, ty); + if (two) { + g.setAlpha(alpha * 160 / 255); + g.drawString(Look.fit(res.getDetail(), f, headerW - 2 * pad), x + pad, ty + fh); + g.setAlpha(alpha); + } + g.setColor(line); + g.drawLine(x, top + h, x + headerW, top + h); + } + g.setColor(line); + g.drawLine(x + headerW - 1, g.getClipY(), x + headerW - 1, g.getClipY() + g.getClipHeight()); + } + + @Override + void paintCorner(Graphics g, int x, int y) { + Style header = Look.style("SchedulerHeader"); + Look.fill(g, header, x, y, headerW, headerH); + g.setColor(Look.mix(header.getBgColor(), header.getFgColor(), 50)); + g.drawLine(x, y + headerH - 1, x + headerW, y + headerH - 1); + g.drawLine(x + headerW - 1, y, x + headerW - 1, y + headerH); + } + + // ------------------------------------------------------------ pointer + + Bar barAt(int px, int py) { + if (px < headerW || py < headerH) { + return null; + } + int cx = px - headerW + getOffX(); + int cy = py - headerH + getOffY(); + for (int i = bars.size() - 1; i >= 0; i--) { + Bar b = bars.get(i); + barRect(b, rect); + if (cx >= rect[0] && cx < rect[0] + rect[2] && cy >= rect[1] && cy < rect[1] + rect[3]) { + return b; + } + } + return null; + } + + private long snap(long time) { + ZoneId zone = owner.getZone(); + if (axis.snapsToDays()) { + return TimeMath.startOfDay(time + TimeMath.DAY / 2, zone); + } + int step = Math.max(1, owner.getSnapMinutes()); + long day = TimeMath.epochDay(time, zone); + int m = TimeMath.minuteOfDay(time, zone); + return TimeMath.at(day, Math.min(TimeMath.MINUTES_PER_DAY, (m + step / 2) / step * step), zone); + } + + private long step() { + return axis.snapsToDays() ? TimeMath.DAY : Math.max(1, owner.getSnapMinutes()) * TimeMath.MINUTE; + } + + @Override + boolean beginEdit(int x, int y, boolean handlesOnly) { + if (!owner.isEditable() || x < headerW || y < headerH) { + return false; + } + int cx = x - headerW + getOffX(); + int cy = y - headerH + getOffY(); + Bar b = barAt(x, y); + if (b != null) { + barRect(b, rect); + int edge = Math.min(rect[2] / 3, Look.mm(handlesOnly ? 3f : 1.6f)); + boolean right = rect[0] + rect[2] - cx <= edge; + boolean left = !right && cx - rect[0] <= edge; + if (handlesOnly && (b.item != owner.getSelectedItem() || !(left || right))) { //NOPMD CompareObjectsWithEquals + return false; + } + if ((left || right) && b.item.isResizable()) { + mode = right ? RESIZE_END : RESIZE_START; + } else if (!handlesOnly && b.item.isMovable()) { + mode = MOVE; + } else { + return false; + } + editItem = b.item; + editStart = b.item.getStart(); + editEnd = b.item.getEnd(); + editRow = b.row; + grabTime = axis.time(cx); + owner.viewSelected(b.item); + return true; + } + if (handlesOnly) { + return false; + } + mode = RANGE; + editItem = null; + editRow = rowAt(cy); + anchorTime = snap(axis.time(cx) - step() / 2); + editStart = anchorTime; + editEnd = anchorTime + defaultLength(); + return true; + } + + private long defaultLength() { + return axis.snapsToDays() ? TimeMath.DAY : owner.getDefaultMinutes() * TimeMath.MINUTE; + } + + @Override + void editDragged(int x, int y) { + if (mode == 0) { + return; + } + int cx = Math.max(0, Math.min(contentWidth(), x - headerW + getOffX())); + int cy = Math.max(0, y - headerH + getOffY()); + long pointer = axis.time(cx); + long step = step(); + if (mode == MOVE) { + long length = editItem.getEnd() - editItem.getStart(); + editStart = snap(editItem.getStart() + pointer - grabTime); + editEnd = editStart + length; + if (resources.length > 0) { + editRow = rowAt(cy); + } + } else if (mode == RESIZE_END) { + editEnd = Math.max(editStart + step, snap(pointer)); + } else if (mode == RESIZE_START) { + editStart = Math.min(editEnd - step, snap(pointer)); + } else { + long p = snap(pointer); + if (p > anchorTime) { + editStart = anchorTime; + editEnd = p; + } else if (p < anchorTime) { + editStart = p; + editEnd = anchorTime + step; + } else { + editStart = anchorTime; + editEnd = anchorTime + defaultLength(); + } + } + } + + @Override + void endEdit(boolean commit) { + int m = mode; + ScheduleItem item = editItem; + mode = 0; + editItem = null; + if (!commit) { + return; + } + String res = resources.length > 0 ? resources[editRow].getId() : item == null ? null : item.getResourceId(); + if (m == RANGE) { + owner.viewRange(editStart, editEnd, res); + } else if (item != null && (editStart != item.getStart() || editEnd != item.getEnd() + || !(res == null ? item.getResourceId() == null : res.equals(item.getResourceId())))) { + owner.viewChanged(item, editStart, editEnd, res); + } + } + + @Override + void tapped(int x, int y, boolean doubleTap) { + if (x < headerW || y < headerH) { + return; + } + Bar b = barAt(x, y); + if (b != null) { + owner.viewSelected(b.item); + if (doubleTap || owner.isOpenOnTap()) { + owner.viewOpened(b.item); + } + return; + } + if (owner.getSelectedItem() != null) { + owner.viewSelected(null); + } else if (doubleTap && owner.isEditable()) { + long start = snap(axis.time(x - headerW + getOffX()) - step() / 2); + int row = rowAt(y - headerH + getOffY()); + owner.viewRange(start, start + defaultLength(), resources.length > 0 ? resources[row].getId() : null); + } + } + + @Override + boolean zoomBy(float factor, int x, int y) { + double before = axis.getMillisPerPixel(); + long t = axis.time(x - headerW + getOffX()); + axis.setMillisPerPixel(before / factor); + if (Math.abs(axis.getMillisPerPixel() - before) < before * 0.0001) { + return false; + } + laidW = -1; + setOffsets(axis.x(t) - (x - headerW), getOffY()); + repaint(); + return true; + } +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/ScheduleItemRenderer.java b/CodenameOne/src/com/codename1/ui/scheduler/ScheduleItemRenderer.java new file mode 100644 index 00000000000..8ffdcf12945 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/ScheduleItemRenderer.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.scheduling.ScheduleItem; +import com.codename1.ui.Graphics; + +/// Paints one item of a `Scheduler`. +/// +/// The views draw items themselves and keep no component per item, so this +/// is the place to change how an item looks. `DefaultScheduleItemRenderer` +/// is the one a scheduler starts with, and is made to be extended. +public interface ScheduleItemRenderer { + /// The item is the selected one. + int SELECTED = 1; + /// The item is being dragged, and this is the copy under the pointer. + int DRAGGED = 2; + /// The item began before the part of it that is painted here. + int STARTS_BEFORE = 4; + /// The item goes on after the part of it that is painted here. + int ENDS_AFTER = 8; + /// The rectangle is a single line: a month cell or the all day lane. + int COMPACT = 16; + /// Time runs across, as in the resource timeline, not down. + int HORIZONTAL = 32; + + /// Paints `item` into a rectangle. The clip is already inside the view; + /// an implementation that sets one of its own restores it. + /// + /// #### Parameters + /// + /// - `state`: a combination of the constants of this interface + void paintItem(Graphics g, Scheduler scheduler, ScheduleItem item, int x, int y, int width, int height, int state); +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/Scheduler.java b/CodenameOne/src/com/codename1/ui/scheduler/Scheduler.java new file mode 100644 index 00000000000..6bd95728bf5 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/Scheduler.java @@ -0,0 +1,859 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.scheduling.ScheduleChangeListener; +import com.codename1.scheduling.ScheduleItem; +import com.codename1.scheduling.ScheduleModel; +import com.codename1.scheduling.ScheduleResource; +import com.codename1.scheduling.TimeMath; +import com.codename1.scheduling.WorkCalendar; +import com.codename1.ui.Button; +import com.codename1.ui.ButtonGroup; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.RadioButton; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.UITimer; + +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; + +/// A schedule on screen: the items of a `ScheduleModel` by day, week, month, +/// as an agenda, or by resource. +/// +/// ```java +/// DefaultScheduleModel model = new DefaultScheduleModel(); +/// model.addItem(new ScheduleItem("1", "Design review", start, end)); +/// Scheduler scheduler = new Scheduler(model); +/// scheduler.setView(Scheduler.View.WEEK); +/// form.add(BorderLayout.CENTER, scheduler); +/// ``` +/// +/// The views draw their items and keep no component for each, so a schedule +/// of thousands of items scrolls as one of ten does. Time zooms with a pinch +/// or with the wheel and the control key held. +/// +/// #### Editing +/// +/// With a mouse an item is dragged to move it and by its edge to resize it, +/// and a drag across empty time selects a range. On a touch screen a drag +/// scrolls, so a long press picks an item up or starts a range, and the +/// selected item has handles to resize it by. Every change is offered to the +/// listeners first, then made through `ScheduleModel#updateItem`, and either +/// may refuse. `setEditable(false)` makes the scheduler read only. +/// +/// #### Styling +/// +/// `SchedulerGrid` is the background, with its foreground the colour of the +/// lines; `SchedulerOffHours` shades the time outside a `WorkCalendar`; +/// `SchedulerHeader`, `SchedulerHeaderToday` and `SchedulerTimeLabel` are the +/// headings; `SchedulerItem`, `SchedulerBackgroundItem` and +/// `SchedulerBlocked` the items by kind; `SchedulerNow` the current time and +/// `SchedulerSelection` a range being dragged out. The bar above the views +/// is `SchedulerToolbar`, `SchedulerTitle` and `SchedulerViewButton`. An item +/// that names a UIID of its own is painted with that one. +public class Scheduler extends Container { + /// The ways a schedule is shown. + public enum View { + /// One day, time running down. + DAY, + /// The working days of a week side by side. + WORK_WEEK, + /// A week side by side. + WEEK, + /// A month of day cells. + MONTH, + /// A list, day by day. + AGENDA, + /// A row per resource, time running across. + RESOURCE_TIMELINE, + /// One day with a column per resource. + RESOURCE_DAY + } + + private ScheduleModel model; + private View view = View.WEEK; + private View[] views = {View.DAY, View.WEEK, View.MONTH, View.AGENDA}; + private ZoneId zone = ZoneId.systemDefault(); + private int firstDayOfWeek = 1; + private WorkCalendar workCalendar; + private Boolean hours24; + private int snapMinutes = 15; + private int defaultMinutes = 60; + private int initialMinute = 7 * 60; + private int timelineDays = 7; + private int agendaDays = 30; + private boolean editable = true; + private boolean openOnTap = !Display.getInstance().isDesktop(); + private Boolean dragEdits; + private boolean compactToolbar = !(Display.getInstance().isDesktop() || Display.getInstance().isTablet()); + private long fixedNow = -1; + private long anchorDay; + private long visibleStart; + private long visibleEnd; + private ScheduleItem selected; + private ScheduleItemRenderer renderer = new DefaultScheduleItemRenderer(); + private final ArrayList listeners = new ArrayList(); + + private DayGrid dayGrid; + private MonthGrid monthGrid; + private AgendaList agenda; + private ResourceTimeline timeline; + private Component current; + private final Container toolbar = new Container(new BorderLayout()); + private final Label title = new Label("", "SchedulerTitle"); + private final Container viewButtons = new Container(BoxLayout.x()); + private UITimer clock; + + private final ScheduleChangeListener modelListener = new ScheduleChangeListener() { + @Override + public void scheduleChanged(ScheduleModel m) { + refresh(); + } + }; + + /// A scheduler with nothing in it yet. + public Scheduler() { + this(null); + } + + public Scheduler(ScheduleModel model) { + super(new BorderLayout()); + setUIID("Scheduler"); + toolbar.setUIID("SchedulerToolbar"); + anchorDay = TimeMath.epochDay(System.currentTimeMillis(), zone); + buildToolbar(); + add(BorderLayout.NORTH, toolbar); + setModel(model); + } + + // ------------------------------------------------------------ toolbar + + private Button toolButton(String text, char icon, ActionListener action) { + Button b = new Button(text == null ? "" : text, "SchedulerViewButton"); + if (icon != 0) { + FontImage.setMaterialIcon(b, icon); + } + b.addActionListener(action); + return b; + } + + private void buildToolbar() { + Container nav = new Container(BoxLayout.x()); + nav.add(toolButton(null, FontImage.MATERIAL_CHEVRON_LEFT, new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + previous(); + } + })); + nav.add(toolButton(localize("scheduler.today", "Today"), (char) 0, new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + today(); + } + })); + nav.add(toolButton(null, FontImage.MATERIAL_CHEVRON_RIGHT, new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + next(); + } + })); + toolbar.add(BorderLayout.WEST, nav); + toolbar.add(BorderLayout.CENTER, title); + toolbar.add(BorderLayout.EAST, viewButtons); + buildViewButtons(); + } + + private String viewName(View v) { + switch (v) { + case DAY: + return localize("scheduler.view.day", "Day"); + case WORK_WEEK: + return localize("scheduler.view.workWeek", "Work week"); + case WEEK: + return localize("scheduler.view.week", "Week"); + case MONTH: + return localize("scheduler.view.month", "Month"); + case AGENDA: + return localize("scheduler.view.agenda", "Agenda"); + case RESOURCE_TIMELINE: + return localize("scheduler.view.timeline", "Timeline"); + default: + return localize("scheduler.view.resources", "Resources"); + } + } + + private void buildViewButtons() { + viewButtons.removeAll(); + if (views.length < 2) { + return; + } + if (compactToolbar) { + // One button that names the view and opens the list of them. + final Button pick = new Button(viewName(view), "SchedulerViewButton"); + FontImage.setMaterialIcon(pick, FontImage.MATERIAL_ARROW_DROP_DOWN); + pick.setTextPosition(Component.LEFT); + pick.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + showViewMenu(pick); + } + }); + viewButtons.add(pick); + return; + } + ButtonGroup group = new ButtonGroup(); + for (final View v : views) { + RadioButton b = new RadioButton(viewName(v)); + b.setToggle(true); + b.setUIID("SchedulerViewButton"); + group.add(b); + b.setSelected(v == view); + b.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + setView(v); + } + }); + viewButtons.add(b); + } + } + + private void showViewMenu(Button anchor) { + final Dialog menu = new Dialog(); + menu.setLayout(BoxLayout.y()); + menu.setDisposeWhenPointerOutOfBounds(true); + for (final View v : views) { + Button b = new Button(viewName(v), "SchedulerViewButton"); + b.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + menu.dispose(); + setView(v); + } + }); + menu.add(b); + } + menu.showPopupDialog(anchor); + } + + /// Whether the bar with the title and the buttons is shown. An + /// application with navigation of its own hides it and calls `next`, + /// `previous`, `today` and `setView` itself. + public void setToolbarVisible(boolean v) { + toolbar.setVisible(v); + toolbar.setHidden(!v); + revalidateLater(); + } + + public boolean isToolbarVisible() { + return toolbar.isVisible(); + } + + /// Whether the views are picked from one button with a menu, as on a + /// phone, or each has a button of its own. + public void setCompactToolbar(boolean v) { + compactToolbar = v; + buildViewButtons(); + revalidateLater(); + } + + public boolean isCompactToolbar() { + return compactToolbar; + } + + // -------------------------------------------------------------- model + + public ScheduleModel getModel() { + return model; + } + + public void setModel(ScheduleModel m) { + if (model != null) { + model.removeChangeListener(modelListener); + } + model = m; + selected = null; + if (model != null) { + model.addChangeListener(modelListener); + } + apply(); + } + + /// Reads the model again. A model that fires its change listeners needs + /// no call to this. + public void refresh() { + if (current == dayGrid && dayGrid != null) { + if (view == View.RESOURCE_DAY) { + // The resources themselves may have changed. + apply(); + } else { + dayGrid.modelChanged(); + } + } else if (current == monthGrid && monthGrid != null) { + monthGrid.modelChanged(); + } else if (current == agenda && agenda != null) { + agenda.modelChanged(); + } else if (current == timeline && timeline != null) { + timeline.modelChanged(); + } + } + + /// The resource with an id, or null. + public ScheduleResource getResource(String id) { + if (id == null || model == null) { + return null; + } + List all = model.getResources(); + for (int i = 0; i < all.size(); i++) { + if (id.equals(all.get(i).getId())) { + return all.get(i); + } + } + return null; + } + + int resourceColor(String id) { + ScheduleResource r = getResource(id); + return r == null ? -1 : r.getColor(); + } + + // --------------------------------------------------------------- view + + public View getView() { + return view; + } + + public void setView(View v) { + if (v == null) { + throw new IllegalArgumentException("view"); + } + view = v; + buildViewButtons(); + apply(); + } + + /// The views the toolbar offers, in order. The default is day, week, + /// month and agenda; a schedule with resources adds the two resource + /// views. + public void setViews(View... offered) { + views = new View[offered.length]; + System.arraycopy(offered, 0, views, 0, offered.length); + buildViewButtons(); + revalidateLater(); + } + + /// Shows the day, week or month that an instant is in. + public void setDate(long millis) { + anchorDay = TimeMath.epochDay(millis, zone); + apply(); + } + + /// The start of the day the view is anchored on. + public long getDate() { + return TimeMath.startOfEpochDay(anchorDay, zone); + } + + /// Moves forward by what the view shows: a day, a week, a month. + public void next() { + step(1); + } + + public void previous() { + step(-1); + } + + /// Shows the current day, week or month. + public void today() { + anchorDay = TimeMath.epochDay(now(), zone); + apply(); + } + + private void step(int direction) { + switch (view) { + case DAY: + case RESOURCE_DAY: + anchorDay += direction; + break; + case WEEK: + case WORK_WEEK: + anchorDay += 7 * direction; + break; + case MONTH: + anchorDay = TimeMath.plusMonths(anchorDay, direction); + break; + case AGENDA: + anchorDay += agendaDays * direction; + break; + default: + anchorDay += timelineDays * direction; + break; + } + apply(); + } + + /// The start of what the view shows, inclusive. + public long getVisibleStart() { + return visibleStart; + } + + /// The end of what the view shows, exclusive. + public long getVisibleEnd() { + return visibleEnd; + } + + private void show(Component c) { + if (current == c) { + return; + } + if (current != null) { + removeComponent(current); + } + current = c; + add(BorderLayout.CENTER, c); + revalidateLater(); + } + + private void applyDragEdits(ScrollSurface s) { + if (dragEdits != null) { + s.setDragEdits(dragEdits.booleanValue()); + } + } + + // Puts the view, the date and the model on screen. + private void apply() { + long first; + int count; + String text; + switch (view) { + case MONTH: { + if (monthGrid == null) { + monthGrid = new MonthGrid(this); + } + applyDragEdits(monthGrid); + monthGrid.show(anchorDay); + show(monthGrid); + first = monthGrid.firstDay(); + count = monthGrid.dayCount(); + text = Dates.monthTitle(anchorDay); + break; + } + case AGENDA: { + if (agenda == null) { + agenda = new AgendaList(this); + } + first = anchorDay; + count = agendaDays; + agenda.show(first, count); + show(agenda); + text = rangeTitle(first, count); + break; + } + case RESOURCE_TIMELINE: { + if (timeline == null) { + timeline = new ResourceTimeline(this); + } + applyDragEdits(timeline); + first = timelineDays >= 7 ? TimeMath.startOfWeek(anchorDay, firstDayOfWeek) : anchorDay; + count = timelineDays; + timeline.show(first, count); + show(timeline); + text = rangeTitle(first, count); + break; + } + default: { + if (dayGrid == null) { + dayGrid = new DayGrid(this); + } + applyDragEdits(dayGrid); + if (view == View.DAY || view == View.RESOURCE_DAY) { + first = anchorDay; + count = 1; + dayGrid.show(first, 1, view == View.RESOURCE_DAY && model != null ? model.getResources() : null); + } else { + first = TimeMath.startOfWeek(anchorDay, firstDayOfWeek); + count = 7; + if (view == View.WORK_WEEK) { + dayGrid.show(workDays(first)); + } else { + dayGrid.show(first, 7, null); + } + } + show(dayGrid); + text = rangeTitle(first, count); + break; + } + } + title.setText(text); + long from = TimeMath.startOfEpochDay(first, zone); + long to = TimeMath.startOfEpochDay(first + count, zone); + boolean moved = from != visibleStart || to != visibleEnd; + visibleStart = from; + visibleEnd = to; + if (moved) { + if (model != null) { + // A model that loads from somewhere is told a wider range, so + // the next step is there before it is asked for. + model.setVisibleRange(from - (to - from), to + (to - from)); + } + for (int i = 0; i < listeners.size(); i++) { + listeners.get(i).visibleRangeChanged(this, from, to); + } + } + repaint(); + } + + private long[] workDays(long weekStart) { + long[] tmp = new long[7]; + int n = 0; + for (int i = 0; i < 7; i++) { + int dow = TimeMath.dayOfWeek(weekStart + i); + boolean working = workCalendar == null ? dow <= 5 : workCalendar.isWorkingWeekday(dow); + if (working) { + tmp[n++] = weekStart + i; + } + } + if (n == 0) { + return new long[]{weekStart}; + } + long[] out = new long[n]; + System.arraycopy(tmp, 0, out, 0, n); + return out; + } + + private String rangeTitle(long first, int count) { + if (count <= 1) { + return Dates.dayTitle(first, TimeMath.dayOfWeek(first)) + ", " + Dates.year(first); + } + long last = first + count - 1; + if (Dates.year(first) != Dates.year(last)) { + return Dates.monthDay(first) + ", " + Dates.year(first) + " - " + Dates.monthDay(last) + ", " + + Dates.year(last); + } + if (Dates.month(first) != Dates.month(last)) { + return Dates.monthDay(first) + " - " + Dates.monthDay(last) + ", " + Dates.year(last); + } + return Dates.monthDay(first) + " - " + Dates.dayOfMonth(last) + ", " + Dates.year(last); + } + + // ----------------------------------------------------------- settings + + public ZoneId getZone() { + return zone; + } + + /// The zone the days and hours are those of. The default is the + /// device's. + public void setZone(ZoneId z) { + if (z == null) { + throw new IllegalArgumentException("zone"); + } + long at = getDate(); + zone = z; + anchorDay = TimeMath.epochDay(at, zone); + apply(); + } + + public int getFirstDayOfWeek() { + return firstDayOfWeek; + } + + /// The day a week starts on: 1 for Monday, which is the default, to 7 + /// for Sunday. + public void setFirstDayOfWeek(int isoDay) { + if (isoDay < 1 || isoDay > 7) { + throw new IllegalArgumentException("isoDay " + isoDay); + } + firstDayOfWeek = isoDay; + apply(); + } + + public WorkCalendar getWorkCalendar() { + return workCalendar; + } + + /// The working hours: time outside them is shaded, and the work week + /// shows the days this calendar works. A resource with a calendar of its + /// own is shaded by that one. Null, the default, shades nothing. + public void setWorkCalendar(WorkCalendar c) { + workCalendar = c; + apply(); + } + + /// Whether times are written on a 24 hour clock. The default follows + /// the device's locale. + public boolean is24Hours() { + if (hours24 == null) { + hours24 = Boolean.valueOf(Dates.deviceUses24Hours()); + } + return hours24.booleanValue(); + } + + public void set24Hours(boolean v) { + hours24 = Boolean.valueOf(v); + apply(); + } + + public int getSnapMinutes() { + return snapMinutes; + } + + /// What a drag snaps to, in minutes. The default is 15. + public void setSnapMinutes(int minutes) { + snapMinutes = Math.max(1, minutes); + } + + public int getDefaultMinutes() { + return defaultMinutes; + } + + /// The length of a range chosen without dragging, by a long press or a + /// double click. The default is 60. + public void setDefaultMinutes(int minutes) { + defaultMinutes = Math.max(1, minutes); + } + + public int getInitialMinute() { + return initialMinute; + } + + /// The time of day the day and week views open scrolled to, in minutes + /// since midnight. The default is 07:00. + public void setInitialMinute(int minute) { + initialMinute = Math.max(0, Math.min(TimeMath.MINUTES_PER_DAY, minute)); + if (dayGrid != null) { + dayGrid.scrollToMinute(initialMinute); + } + } + + public int getTimelineDays() { + return timelineDays; + } + + /// How many days the resource timeline covers. The default is 7. + public void setTimelineDays(int days) { + timelineDays = Math.max(1, days); + apply(); + } + + public int getAgendaDays() { + return agendaDays; + } + + /// How many days the agenda lists. The default is 30. + public void setAgendaDays(int days) { + agendaDays = Math.max(1, days); + apply(); + } + + public boolean isEditable() { + return editable; + } + + /// Whether items can be dragged and ranges selected. Selecting and + /// opening an item work either way. + public void setEditable(boolean v) { + editable = v; + } + + public boolean isOpenOnTap() { + return openOnTap; + } + + /// Whether a single tap opens an item. The default is true on a touch + /// screen and false where there is a mouse, which double clicks to open. + public void setOpenOnTap(boolean v) { + openOnTap = v; + } + + /// Whether a plain drag edits: moves the item under it, or selects a + /// range of empty time. The default is true where there is a mouse and + /// false on a touch screen, where a drag scrolls and a long press edits. + public void setDragEdits(boolean v) { + dragEdits = Boolean.valueOf(v); + apply(); + } + + public ScheduleItemRenderer getItemRenderer() { + return renderer; + } + + public void setItemRenderer(ScheduleItemRenderer r) { + if (r == null) { + throw new IllegalArgumentException("renderer"); + } + renderer = r; + repaint(); + } + + /// Fixes what the scheduler takes the current time to be, for a + /// screenshot or a test. A negative value returns to the clock. + public void setCurrentTime(long millis) { + fixedNow = millis; + repaint(); + } + + long now() { + return fixedNow >= 0 ? fixedNow : System.currentTimeMillis(); + } + + // ---------------------------------------------------------- selection + + public ScheduleItem getSelectedItem() { + return selected; + } + + /// Selects an item, or nothing when `item` is null. The listeners are + /// not told: this is the application's own doing. + public void setSelectedItem(ScheduleItem item) { + selected = item; + repaint(); + } + + public void addSchedulerListener(SchedulerListener l) { + if (l != null && !listeners.contains(l)) { + listeners.add(l); + } + } + + public void removeSchedulerListener(SchedulerListener l) { + listeners.remove(l); + } + + void viewSelected(ScheduleItem item) { + if (selected == item) { //NOPMD CompareObjectsWithEquals - the selection is this very object + return; + } + selected = item; + for (int i = 0; i < listeners.size(); i++) { + listeners.get(i).itemSelected(this, item); + } + repaint(); + } + + void viewOpened(ScheduleItem item) { + for (int i = 0; i < listeners.size(); i++) { + listeners.get(i).itemOpened(this, item); + } + } + + void viewRange(long start, long end, String resourceId) { + for (int i = 0; i < listeners.size(); i++) { + listeners.get(i).rangeSelected(this, start, end, resourceId); + } + if (model != null) { + ScheduleItem made = model.createItem(start, end, resourceId); + if (made != null) { + viewSelected(made); + } + } + repaint(); + } + + boolean viewChanged(ScheduleItem item, long start, long end, String resourceId) { + for (int i = 0; i < listeners.size(); i++) { + if (!listeners.get(i).itemChanging(this, item, start, end, resourceId)) { + repaint(); + return false; + } + } + boolean done = model != null && model.updateItem(item, start, end, resourceId); + refresh(); + return done; + } + + // A day was tapped in a view that shows many: go to it. + void viewDrill(long day) { + for (View v : views) { + if (v == View.DAY) { + anchorDay = day; + setView(View.DAY); + return; + } + } + } + + // ---------------------------------------------------------------- text + + /// The time of day of an instant, as the views write it. + public String formatTime(long millis) { + return Dates.time(TimeMath.minuteOfDay(millis, zone), is24Hours(), false); + } + + /// A start and an end as the views write them: "9:00 AM - 10:30 AM". + public String formatRange(long start, long end) { + if (end - start >= TimeMath.DAY) { + long a = TimeMath.epochDay(start, zone); + long b = TimeMath.epochDay(Math.max(start, end - 1), zone); + return a == b ? Dates.monthDay(a) : Dates.monthDay(a) + " - " + Dates.monthDay(b); + } + return formatTime(start) + " - " + formatTime(end); + } + + String localize(String key, String value) { + return UIManager.getInstance().localize(key, value); + } + + // ----------------------------------------------------------- lifecycle + + @Override + protected void initComponent() { + super.initComponent(); + Form f = getComponentForm(); + if (f != null && clock == null) { + // The line of the current time moves, and midnight changes which + // day is today. + clock = UITimer.timer(60000, true, f, new Runnable() { + @Override + public void run() { + repaint(); + } + }); + } + } + + @Override + protected void deinitialize() { + if (clock != null) { + clock.cancel(); + clock = null; + } + super.deinitialize(); + } + + /// The surface of the current view, for the tests of this package. + Component surface() { + return current; + } +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/SchedulerAdapter.java b/CodenameOne/src/com/codename1/ui/scheduler/SchedulerAdapter.java new file mode 100644 index 00000000000..ff36bc544a0 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/SchedulerAdapter.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.scheduling.ScheduleItem; + +/// A `SchedulerListener` that does nothing and refuses nothing. +public class SchedulerAdapter implements SchedulerListener { + @Override + public void itemSelected(Scheduler scheduler, ScheduleItem item) { + } + + @Override + public void itemOpened(Scheduler scheduler, ScheduleItem item) { + } + + @Override + public void rangeSelected(Scheduler scheduler, long start, long end, String resourceId) { + } + + @Override + public boolean itemChanging(Scheduler scheduler, ScheduleItem item, long start, long end, String resourceId) { + return true; + } + + @Override + public void visibleRangeChanged(Scheduler scheduler, long start, long end) { + } +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/SchedulerListener.java b/CodenameOne/src/com/codename1/ui/scheduler/SchedulerListener.java new file mode 100644 index 00000000000..2f9547e0229 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/SchedulerListener.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.scheduling.ScheduleItem; + +/// What a `Scheduler` tells an application about. Extend `SchedulerAdapter` +/// to implement only what is needed. +public interface SchedulerListener { + /// The selection changed. `item` is null when nothing is selected now. + void itemSelected(Scheduler scheduler, ScheduleItem item); + + /// An item was opened: tapped on a touch screen, double clicked with a + /// mouse. This is where an application shows the item's details. + void itemOpened(Scheduler scheduler, ScheduleItem item); + + /// A stretch of empty time was chosen, by a long press, a drag or a double + /// click. `resourceId` is null in a view with no resources. The model is + /// asked to create an item after this returns, so an application that + /// shows its own editor leaves creation off in the model. + void rangeSelected(Scheduler scheduler, long start, long end, String resourceId); + + /// An item was dragged or resized and is about to change. + /// + /// #### Returns + /// + /// false to refuse, which leaves the item where it was + boolean itemChanging(Scheduler scheduler, ScheduleItem item, long start, long end, String resourceId); + + /// The view moved to another stretch of time, `start` inclusive to `end` + /// exclusive. + void visibleRangeChanged(Scheduler scheduler, long start, long end); +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/ScrollSurface.java b/CodenameOne/src/com/codename1/ui/scheduler/ScrollSurface.java new file mode 100644 index 00000000000..0c68117889e --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/ScrollSurface.java @@ -0,0 +1,548 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.ui.Component; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.animations.Motion; +import com.codename1.ui.events.WheelEvent; +import com.codename1.ui.geom.Dimension; + +/// A painted surface that scrolls on two axes under a pinned header row and a +/// pinned header column. +/// +/// The scheduling views draw every item themselves, so a schedule of ten +/// thousand items costs what is on screen and nothing more; a component per +/// item would not scroll. That leaves this class to do what a scrollable +/// `Container` does for components: panning, the fling after it, the wheel, +/// pinch, and telling a tap from a drag from a long press. +/// +/// A subclass says how big its content is, paints the four regions, and +/// answers three questions about a pointer: is an edit starting here, where +/// has it been dragged to, and has it ended. +abstract class ScrollSurface extends Component { + private static final int NONE = 0; + private static final int PAN = 1; + private static final int EDIT = 2; + private static final int DOUBLE_TAP_MS = 350; + + /// The width of the pinned column and the height of the pinned row. A + /// subclass sets them in `prepare`. + int headerW; + int headerH; + + private int offX; + private int offY; + private Motion flingX; + private Motion flingY; + private boolean registered; + private int gesture; + private boolean pressed; + private int pressX; + private int pressY; + private int lastX; + private int lastY; + private long lastTapTime; + private int lastTapX; + private int lastTapY; + private int autoX; + private int autoY; + private boolean pinching; + private boolean dragEdits = Display.getInstance().isDesktop(); + + ScrollSurface() { + setFocusable(true); + setGrabsPointerEvents(true); + } + + // ------------------------------------------------------------ content + + /// Lays the surface out for a size. Called before every paint and every + /// pointer event, so it has to return at once when nothing changed. + abstract void prepare(int width, int height); + + /// The size of what scrolls, in pixels, without the headers. + abstract int contentWidth(); + + abstract int contentHeight(); + + /// Paints what scrolls. `x` and `y` are where the content's own origin is + /// on screen, which is off screen once it has scrolled. + abstract void paintBody(Graphics g, int x, int y); + + /// Paints the pinned row. `x` is where the content's origin is; `y` is + /// the top of the row. + void paintTopHeader(Graphics g, int x, int y) { + } + + /// Paints the pinned column. `x` is its left; `y` is where the content's + /// origin is. + void paintLeftHeader(Graphics g, int x, int y) { + } + + void paintCorner(Graphics g, int x, int y) { + } + + /// Painted last, over everything, in the component's own bounds. + void paintOverlay(Graphics g, int x, int y) { + } + + /// Asked when a drag or a long press begins at a point, in the + /// component's coordinates. `handlesOnly` is true for a plain drag on a + /// touch screen, which pans unless it starts on something made to be + /// dragged, such as the resize handle of the selected item. + /// + /// #### Returns + /// + /// true when an edit has begun and the drag belongs to it + boolean beginEdit(int x, int y, boolean handlesOnly) { + return false; + } + + void editDragged(int x, int y) { + } + + /// The edit is over: `commit` is false when it was cancelled. + void endEdit(boolean commit) { + } + + void tapped(int x, int y, boolean doubleTap) { + } + + /// A long press that started no edit. + void longPressed(int x, int y) { + } + + /// The scroll position changed. + void scrolled() { + } + + /// Zooms by `factor` around a point. False when the surface has no zoom. + boolean zoomBy(float factor, int x, int y) { + return false; + } + + /// Whether a plain drag on an item or on empty space edits instead of + /// panning. On by default where there is a mouse, which has a wheel to + /// scroll with. + void setDragEdits(boolean v) { + dragEdits = v; + } + + boolean isDragEdits() { + return dragEdits; + } + + // ------------------------------------------------------------- scroll + + int bodyWidth() { + return Math.max(0, getWidth() - headerW); + } + + int bodyHeight() { + return Math.max(0, getHeight() - headerH); + } + + int maxOffX() { + return Math.max(0, contentWidth() - bodyWidth()); + } + + int maxOffY() { + return Math.max(0, contentHeight() - bodyHeight()); + } + + int getOffX() { + return offX; + } + + int getOffY() { + return offY; + } + + /// Scrolls to a position, held inside the content. + void scrollTo(int x, int y) { + int nx = Math.max(0, Math.min(maxOffX(), x)); + int ny = Math.max(0, Math.min(maxOffY(), y)); + if (nx != offX || ny != offY) { + offX = nx; + offY = ny; + scrolled(); + repaint(); + } + } + + /// Sets the position without holding it inside the content, for a + /// subclass whose content moves under the scroll position. + void setOffsets(int x, int y) { + offX = x; + offY = y; + } + + /// Lays out for the current size and holds the scroll position inside the + /// content. + void layoutNow() { + prepare(getWidth(), getHeight()); + int nx = Math.max(0, Math.min(maxOffX(), offX)); + int ny = Math.max(0, Math.min(maxOffY(), offY)); + if (nx != offX || ny != offY) { + offX = nx; + offY = ny; + scrolled(); + } + } + + void stopFling() { + flingX = null; + flingY = null; + } + + private void animateNow() { + if (!registered) { + Form f = getComponentForm(); + if (f != null) { + f.registerAnimated(this); + registered = true; + } + } + } + + /// Asks for `animate` to be called on the next frames, for a subclass + /// with a motion of its own. + void requestAnimation() { + animateNow(); + } + + /// Whether the subclass still has something moving. Called every frame + /// while the surface animates. + boolean animateSurface() { + return false; + } + + @Override + public boolean animate() { + boolean busy = false; + int nx = offX; + int ny = offY; + if (flingX != null) { + nx = flingX.getValue(); + if (flingX.isFinished()) { + flingX = null; + } + busy = true; + } + if (flingY != null) { + ny = flingY.getValue(); + if (flingY.isFinished()) { + flingY = null; + } + busy = true; + } + if (gesture == EDIT && (autoX != 0 || autoY != 0)) { + nx += autoX; + ny += autoY; + busy = true; + } + if (busy) { + int bx = offX; + int by = offY; + scrollTo(nx, ny); + if (gesture == EDIT && (bx != offX || by != offY)) { + editDragged(lastX, lastY); + } + } + if (animateSurface()) { + busy = true; + } + if (!busy && registered) { + Form f = getComponentForm(); + if (f != null) { + f.deregisterAnimated(this); + } + registered = false; + } + return busy; + } + + @Override + protected void deinitialize() { + stopFling(); + if (registered) { + Form f = getComponentForm(); + if (f != null) { + f.deregisterAnimated(this); + } + registered = false; + } + super.deinitialize(); + } + + // -------------------------------------------------------------- paint + + @Override + public void paint(Graphics g) { + layoutNow(); + int x0 = getX(); + int y0 = getY(); + int cx = g.getClipX(); + int cy = g.getClipY(); + int cw = g.getClipWidth(); + int ch = g.getClipHeight(); + int bx = x0 + headerW; + int by = y0 + headerH; + int bw = bodyWidth(); + int bh = bodyHeight(); + g.clipRect(bx, by, bw, bh); + paintBody(g, bx - offX, by - offY); + g.setClip(cx, cy, cw, ch); + if (headerH > 0) { + g.clipRect(bx, y0, bw, headerH); + paintTopHeader(g, bx - offX, y0); + g.setClip(cx, cy, cw, ch); + } + if (headerW > 0) { + g.clipRect(x0, by, headerW, bh); + paintLeftHeader(g, x0, by - offY); + g.setClip(cx, cy, cw, ch); + } + if (headerW > 0 && headerH > 0) { + g.clipRect(x0, y0, headerW, headerH); + paintCorner(g, x0, y0); + g.setClip(cx, cy, cw, ch); + } + g.clipRect(x0, y0, getWidth(), getHeight()); + paintOverlay(g, x0, y0); + g.setClip(cx, cy, cw, ch); + } + + @Override + protected Dimension calcPreferredSize() { + // A view is given its size by the layout around it; this is what it + // asks for when nothing constrains it. + return new Dimension(Look.mm(60), Look.mm(60)); + } + + // ------------------------------------------------------------ pointer + + @Override + public void pointerPressed(int x, int y) { + super.pointerPressed(x, y); + layoutNow(); + stopFling(); + pressed = true; + gesture = NONE; + autoX = 0; + autoY = 0; + pressX = x - getAbsoluteX(); + pressY = y - getAbsoluteY(); + lastX = pressX; + lastY = pressY; + } + + @Override + public void pointerDragged(int x, int y) { + if (!pressed || pinching) { + return; + } + int lx = x - getAbsoluteX(); + int ly = y - getAbsoluteY(); + if (gesture == NONE) { + int slop = Look.mm(1.5f); + if (Math.abs(lx - pressX) < slop && Math.abs(ly - pressY) < slop) { + return; + } + gesture = beginEdit(pressX, pressY, !dragEdits) ? EDIT : PAN; + } + if (gesture == EDIT) { + editDragged(lx, ly); + autoScroll(lx, ly); + repaint(); + } else { + scrollTo(offX - (lx - lastX), offY - (ly - lastY)); + } + lastX = lx; + lastY = ly; + } + + @Override + public void longPointerPress(int x, int y) { + if (!pressed || gesture != NONE) { + return; + } + int lx = x - getAbsoluteX(); + int ly = y - getAbsoluteY(); + if (beginEdit(lx, ly, false)) { + gesture = EDIT; + lastX = lx; + lastY = ly; + editDragged(lx, ly); + repaint(); + } else { + // A press this long is not a tap, whatever follows. + gesture = PAN; + longPressed(lx, ly); + } + } + + @Override + public void pointerReleased(int x, int y) { + super.pointerReleased(x, y); + if (!pressed) { + return; + } + pressed = false; + pinching = false; + pinchBase = 0; + autoX = 0; + autoY = 0; + int g = gesture; + gesture = NONE; + if (g == EDIT) { + endEdit(true); + repaint(); + } else if (g == PAN) { + fling(); + } else { + int lx = x - getAbsoluteX(); + int ly = y - getAbsoluteY(); + long now = System.currentTimeMillis(); + int near = Look.mm(4); + boolean twice = now - lastTapTime < DOUBLE_TAP_MS && Math.abs(lx - lastTapX) < near + && Math.abs(ly - lastTapY) < near; + lastTapTime = twice ? 0 : now; + lastTapX = lx; + lastTapY = ly; + tapped(lx, ly, twice); + } + } + + /// Cancels an edit in progress, as Escape does. + void cancelEdit() { + if (gesture == EDIT) { + gesture = PAN; + autoX = 0; + autoY = 0; + endEdit(false); + repaint(); + } + } + + boolean isDragEditing() { + return gesture == EDIT; + } + + private void fling() { + float vx = Display.getInstance().getDragSpeed(false); + float vy = Display.getInstance().getDragSpeed(true); + boolean any = false; + if (Math.abs(vx) > 0.1f && maxOffX() > 0) { + flingX = Motion.createFrictionMotion(offX, vx < 0 ? 0 : maxOffX(), vx, 0.0007f); + flingX.start(); + any = true; + } + if (Math.abs(vy) > 0.1f && maxOffY() > 0) { + flingY = Motion.createFrictionMotion(offY, vy < 0 ? 0 : maxOffY(), vy, 0.0007f); + flingY.start(); + any = true; + } + if (any) { + animateNow(); + } + } + + // While an edit is dragged near an edge the surface scrolls under it, and + // faster the nearer the edge. + private void autoScroll(int lx, int ly) { + int band = Look.mm(6); + int step = Look.mm(2.5f); + autoX = edgeSpeed(lx, headerW, getWidth(), band, step); + autoY = edgeSpeed(ly, headerH, getHeight(), band, step); + if (autoX != 0 || autoY != 0) { + animateNow(); + } + } + + private static int edgeSpeed(int p, int from, int to, int band, int step) { + if (p < from + band) { + return -step * Math.min(band, from + band - p) / band; + } + if (p > to - band) { + return step * Math.min(band, p - (to - band)) / band; + } + return 0; + } + + @Override + protected boolean mouseWheel(WheelEvent ev) { + layoutNow(); + if (ev.isControlDown() || ev.isMetaDown()) { + int d = ev.getDeltaY() != 0 ? ev.getDeltaY() : ev.getDeltaX(); + if (d != 0 && zoomBy(d < 0 ? 1.15f : 1 / 1.15f, ev.getX() - getAbsoluteX(), ev.getY() - getAbsoluteY())) { + return true; + } + } + int dx = ev.getDeltaX(); + int dy = ev.getDeltaY(); + if (ev.isShiftDown() && dx == 0) { + dx = dy; + dy = 0; + } + int bx = offX; + int by = offY; + stopFling(); + scrollTo(offX + dx, offY + dy); + // An event that moved nothing is left for whatever is around this. + return bx != offX || by != offY; + } + + private float pinchBase; + + @Override + protected boolean pinch(float scale) { + // The scale is the fingers' distance over what it was when the pinch + // began, so every call is applied as the change since the last one. + if (pinchBase <= 0) { + pinchBase = 1f; + } + float step = scale / pinchBase; + if (zoomBy(step, lastX, lastY)) { + pinchBase = scale; + pinching = true; + gesture = PAN; + return true; + } + return false; + } + + @Override + protected void pinchReleased(int x, int y) { + pinchBase = 0; + pinching = false; + } + + @Override + protected boolean shouldBlockSideSwipe() { + return true; + } +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/TimeAxis.java b/CodenameOne/src/com/codename1/ui/scheduler/TimeAxis.java new file mode 100644 index 00000000000..eceb9232e0c --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/TimeAxis.java @@ -0,0 +1,368 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.scheduling.TimeMath; +import com.codename1.scheduling.WorkCalendar; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.plaf.Style; + +import java.time.ZoneId; + +/// Time laid out across: which pixel an instant is at, what the ticks are at +/// the current zoom, and the two rows of labels above them. +/// +/// The resource timeline and the Gantt chart share it. It holds a stretch of +/// time and a scale in milliseconds per pixel, and picks the units of its +/// ticks from the scale: hours under days when zoomed in, through days under +/// months, to quarters under years. +final class TimeAxis { + static final int HOUR = 0; + static final int DAY = 1; + static final int WEEK = 2; + static final int MONTH = 3; + static final int QUARTER = 4; + static final int YEAR = 5; + + // The ladder of minor ticks, finest first: a unit and how many of it. + private static final int[] LADDER_UNIT = {HOUR, HOUR, HOUR, HOUR, HOUR, DAY, WEEK, MONTH, QUARTER, YEAR}; + private static final int[] LADDER_COUNT = {1, 2, 3, 6, 12, 1, 1, 1, 1, 1}; + private static final long[] LADDER_MILLIS = {TimeMath.HOUR, 2 * TimeMath.HOUR, 3 * TimeMath.HOUR, + 6 * TimeMath.HOUR, 12 * TimeMath.HOUR, TimeMath.DAY, 7 * TimeMath.DAY, 30 * TimeMath.DAY, + 91 * TimeMath.DAY, 365 * TimeMath.DAY}; + + private ZoneId zone; + private int firstDayOfWeek = 1; + private boolean h24; + private long start; + private long end; + private double millisPerPixel = TimeMath.HOUR / 40.0; + private double minMillisPerPixel = TimeMath.MINUTE / 2.0; + private double maxMillisPerPixel = TimeMath.DAY; + private int minorUnit = HOUR; + private int minorCount = 1; + private int majorUnit = DAY; + private int tickPixels = -1; + + TimeAxis(ZoneId zone) { + this.zone = zone; + } + + void setZone(ZoneId z) { + zone = z; + } + + ZoneId getZone() { + return zone; + } + + void setFirstDayOfWeek(int isoDay) { + firstDayOfWeek = isoDay; + } + + void set24Hours(boolean v) { + h24 = v; + } + + void setRange(long from, long to) { + start = from; + end = Math.max(from + TimeMath.HOUR, to); + } + + long getStart() { + return start; + } + + long getEnd() { + return end; + } + + double getMillisPerPixel() { + return millisPerPixel; + } + + /// Sets the scale, held between the limits. + void setMillisPerPixel(double v) { + millisPerPixel = Math.max(minMillisPerPixel, Math.min(maxMillisPerPixel, v)); + tickPixels = -1; + } + + /// The limits of zooming: the finest and the coarsest scale. + void setScaleLimits(double finest, double coarsest) { + minMillisPerPixel = finest; + maxMillisPerPixel = Math.max(finest, coarsest); + setMillisPerPixel(millisPerPixel); + } + + /// The width of the whole range in pixels. + int width() { + return (int) Math.ceil((end - start) / millisPerPixel); + } + + /// The pixel an instant is at, from the start of the range. + int x(long time) { + return (int) Math.floor((time - start) / millisPerPixel); + } + + long time(int x) { + return start + (long) (x * millisPerPixel); + } + + /// Picks the units of the ticks for the scale. `minTick` is the least + /// width a minor tick needs for its label. + void pickUnits(int minTick) { + if (tickPixels == minTick) { + return; + } + tickPixels = minTick; + int pick = LADDER_UNIT.length - 1; + for (int i = 0; i < LADDER_UNIT.length; i++) { + if (LADDER_MILLIS[i] / millisPerPixel >= minTick) { + pick = i; + break; + } + } + minorUnit = LADDER_UNIT[pick]; + minorCount = LADDER_COUNT[pick]; + if (minorUnit == HOUR) { + majorUnit = DAY; + } else if (minorUnit == DAY || minorUnit == WEEK) { + majorUnit = MONTH; + } else { + majorUnit = YEAR; + } + } + + int getMinorUnit() { + return minorUnit; + } + + /// What a drag snaps to at this scale when the application's own step is + /// finer than a pixel: the start of a day once ticks are days or more. + boolean snapsToDays() { + return minorUnit != HOUR; + } + + /// The start of the tick of a unit that `t` is in. + long floor(long t, int unit, int count) { + long day = TimeMath.epochDay(t, zone); + switch (unit) { + case HOUR: { + int step = count * 60; + int m = TimeMath.minuteOfDay(t, zone) / step * step; + return TimeMath.at(day, m, zone); + } + case DAY: + return TimeMath.startOfEpochDay(day, zone); + case WEEK: + return TimeMath.startOfEpochDay(TimeMath.startOfWeek(day, firstDayOfWeek), zone); + case MONTH: + return TimeMath.startOfEpochDay(TimeMath.startOfMonth(day), zone); + case QUARTER: { + long ms = TimeMath.startOfMonth(day); + int back = (Dates.month(ms) - 1) % 3; + return TimeMath.startOfEpochDay(TimeMath.plusMonths(ms, -back), zone); + } + default: { + long ms = TimeMath.startOfMonth(day); + return TimeMath.startOfEpochDay(TimeMath.plusMonths(ms, 1 - Dates.month(ms)), zone); + } + } + } + + /// The start of the tick after the one that starts at `tick`. + long next(long tick, int unit, int count) { + long day = TimeMath.epochDay(tick, zone); + switch (unit) { + case HOUR: { + int m = TimeMath.minuteOfDay(tick, zone) + count * 60; + long n = m >= TimeMath.MINUTES_PER_DAY ? TimeMath.startOfEpochDay(day + 1, zone) + : TimeMath.at(day, m, zone); + // A day that loses an hour to daylight saving has a minute of + // the day that maps back to where it started. + return n > tick ? n : tick + count * TimeMath.HOUR; + } + case DAY: + return TimeMath.startOfEpochDay(day + 1, zone); + case WEEK: + return TimeMath.startOfEpochDay(day + 7, zone); + case MONTH: + return TimeMath.startOfEpochDay(TimeMath.plusMonths(day, 1), zone); + case QUARTER: + return TimeMath.startOfEpochDay(TimeMath.plusMonths(day, 3), zone); + default: + return TimeMath.startOfEpochDay(TimeMath.plusMonths(day, 12), zone); + } + } + + String label(long tick, int unit, int width, Font f) { + long day = TimeMath.epochDay(tick, zone); + switch (unit) { + case HOUR: + return Dates.time(TimeMath.minuteOfDay(tick, zone), h24, true); + case DAY: { + String wide = Dates.weekdayShort(TimeMath.dayOfWeek(day)) + " " + Dates.dayOfMonth(day); + return f.stringWidth(wide) + Look.mm(1.5f) <= width ? wide : String.valueOf(Dates.dayOfMonth(day)); + } + case WEEK: + return Dates.monthDay(day); + case MONTH: + return Dates.monthShort(Dates.month(day)); + case QUARTER: + return "Q" + ((Dates.month(day) - 1) / 3 + 1); + default: + return String.valueOf(Dates.year(day)); + } + } + + private String majorLabel(long tick) { + long day = TimeMath.epochDay(tick, zone); + if (majorUnit == DAY) { + return Dates.dayTitle(day, TimeMath.dayOfWeek(day)); + } + if (majorUnit == MONTH) { + return Dates.monthTitle(day); + } + return String.valueOf(Dates.year(day)); + } + + /// The height of the two rows of labels. + int headerHeight(Style header) { + return 2 * (header.getFont().getHeight() + Look.mm(1.4f)); + } + + /// Paints the two rows of labels. `x0` is where the start of the range + /// is on screen, and `left` to `right` the part of the screen to paint. + void paintHeader(Graphics g, Style header, int x0, int y, int left, int right) { + Font f = header.getFont(); + int rowH = f.getHeight() + Look.mm(1.4f); + int pad = Look.mm(1); + Look.fill(g, header, left, y, right - left, 2 * rowH); + g.setFont(f); + int line = Look.mix(header.getBgColor(), header.getFgColor(), 50); + long from = time(left - x0); + long to = time(right - x0); + + // The upper row. A label stays in view for as long as its tick does. + long t = floor(from, majorUnit, 1); + while (t < to) { + long n = next(t, majorUnit, 1); + int tx = x0 + x(t); + int nx = x0 + x(n); + String s = majorLabel(t); + int lx = Math.max(tx, left) + pad; + int room = nx - lx - pad; + if (room > 0) { + g.setColor(header.getFgColor()); + g.drawString(Look.fit(s, f, room), lx, y + (rowH - f.getHeight()) / 2); + } + if (tx >= left) { + g.setColor(line); + g.drawLine(tx, y, tx, y + rowH); + } + t = n; + } + + t = floor(from, minorUnit, minorCount); + int alpha = g.getAlpha(); + while (t < to) { + long n = next(t, minorUnit, minorCount); + int tx = x0 + x(t); + int nx = x0 + x(n); + String s = label(t, minorUnit, nx - tx, f); + int sw = f.stringWidth(s); + g.setColor(header.getFgColor()); + g.setAlpha(alpha * 190 / 255); + if (minorUnit == HOUR) { + g.drawString(s, tx + pad, y + rowH + (rowH - f.getHeight()) / 2); + } else if (sw <= nx - tx) { + g.drawString(s, tx + (nx - tx - sw) / 2, y + rowH + (rowH - f.getHeight()) / 2); + } + g.setAlpha(alpha); + g.setColor(line); + g.drawLine(tx, y + rowH, tx, y + 2 * rowH); + t = n; + } + g.setColor(line); + g.drawLine(left, y + 2 * rowH - 1, right, y + 2 * rowH - 1); + } + + /// Paints a line down every tick: fainter for the minor ones. + void paintGrid(Graphics g, int color, int background, int x0, int y, int height, int left, int right) { + long from = time(left - x0); + long to = time(right - x0); + long t = floor(from, minorUnit, minorCount); + int faint = Look.mix(background, color, 110); + long major = floor(from, majorUnit, 1); + while (t < to) { + while (major < t) { + major = next(major, majorUnit, 1); + } + g.setColor(major == t ? color : faint); + int tx = x0 + x(t); + g.drawLine(tx, y, tx, y + height); + t = next(t, minorUnit, minorCount); + } + } + + /// Shades the time a calendar does not work: the hours while ticks are + /// hours, whole days off while a day is still wide enough to see. + void paintOffHours(Graphics g, WorkCalendar cal, Style off, int x0, int y, int height, int left, int right) { + if (cal == null || (off.getBgTransparency() & 0xff) == 0) { + return; + } + if (TimeMath.DAY / millisPerPixel < 3) { + return; + } + ZoneId z = cal.getZone(); + long first = TimeMath.epochDay(time(left - x0), z); + long last = TimeMath.epochDay(time(right - x0), z); + for (long day = first; day <= last; day++) { + int[] iv = cal.getWorkingHours(day); + if (minorUnit != HOUR) { + if (iv.length == 0) { + shade(g, off, x0, y, height, TimeMath.startOfEpochDay(day, z), + TimeMath.startOfEpochDay(day + 1, z)); + } + continue; + } + int cursor = 0; + for (int i = 0; i < iv.length; i += 2) { + if (iv[i] > cursor) { + shade(g, off, x0, y, height, TimeMath.at(day, cursor, z), TimeMath.at(day, iv[i], z)); + } + cursor = iv[i + 1]; + } + if (cursor < TimeMath.MINUTES_PER_DAY) { + shade(g, off, x0, y, height, TimeMath.at(day, cursor, z), TimeMath.startOfEpochDay(day + 1, z)); + } + } + } + + private void shade(Graphics g, Style off, int x0, int y, int height, long from, long to) { + int a = x0 + x(from); + int b = x0 + x(to); + Look.fill(g, off, a, y, b - a, height); + } +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/package-info.java b/CodenameOne/src/com/codename1/ui/scheduler/package-info.java new file mode 100644 index 00000000000..d819e9c23b1 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/package-info.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// Components that show schedules and projects: `Scheduler` for the items of +/// a `com.codename1.scheduling.ScheduleModel` by day, week, month, agenda or +/// resource. +/// +/// The components draw their items and keep no component for each, so the +/// number of items costs nothing that is not on screen. How an item looks is +/// a renderer's business: `ScheduleItemRenderer`. +package com.codename1.ui.scheduler; diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/scheduler/SchedulerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/scheduler/SchedulerTest.java new file mode 100644 index 00000000000..dd389a0b890 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/scheduler/SchedulerTest.java @@ -0,0 +1,461 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.scheduling.DefaultScheduleModel; +import com.codename1.scheduling.ScheduleItem; +import com.codename1.scheduling.ScheduleResource; +import com.codename1.scheduling.TimeMath; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The scheduler through its views: where items are laid out, what a point +/// hits, and what a drag does to the model. +class SchedulerTest extends UITestBase { + private static final ZoneId UTC = ZoneId.of("UTC"); + + /// A day of January 2026. The 5th is a Monday. + private static long day(int dayOfMonth) { + return LocalDate.of(2026, 1, dayOfMonth).toEpochDay(); + } + + private static long at(int dayOfMonth, int hour, int minute) { + return TimeMath.at(day(dayOfMonth), hour * 60 + minute, UTC); + } + + private static ScheduleItem item(String id, long start, long end) { + return new ScheduleItem(id, id, start, end); + } + + private Scheduler show(DefaultScheduleModel model, Scheduler.View view) { + Form form = Display.getInstance().getCurrent(); + form.setLayout(new BorderLayout()); + Scheduler s = new Scheduler(model); + s.setZone(UTC); + s.set24Hours(true); + s.setCurrentTime(at(5, 10, 0)); + s.setDate(at(5, 0, 0)); + s.setView(view); + s.setDragEdits(true); + s.setOpenOnTap(false); + form.add(BorderLayout.CENTER, s); + form.revalidate(); + ScrollSurface surface = (ScrollSurface) s.surface(); + surface.layoutNow(); + return s; + } + + private static DayGrid.Box box(DayGrid grid, String id) { + for (DayGrid.Box b : grid.boxes()) { + if (b.item.getId().equals(id)) { + return b; + } + } + return null; + } + + private static void drag(ScrollSurface s, int fromX, int fromY, int toX, int toY) { + int ax = s.getAbsoluteX(); + int ay = s.getAbsoluteY(); + s.pointerPressed(ax + fromX, ay + fromY); + s.pointerDragged(ax + (fromX + toX) / 2, ay + (fromY + toY) / 2); + s.pointerDragged(ax + toX, ay + toY); + s.pointerReleased(ax + toX, ay + toY); + } + + private static void tap(ScrollSurface s, int x, int y) { + s.pointerPressed(s.getAbsoluteX() + x, s.getAbsoluteY() + y); + s.pointerReleased(s.getAbsoluteX() + x, s.getAbsoluteY() + y); + } + + @FormTest + void overlappingItemsShareTheColumn() { + DefaultScheduleModel model = new DefaultScheduleModel(); + model.addItem(item("a", at(5, 9, 0), at(5, 11, 0))); + model.addItem(item("b", at(5, 10, 0), at(5, 12, 0))); + model.addItem(item("c", at(5, 14, 0), at(5, 15, 0))); + DayGrid grid = (DayGrid) show(model, Scheduler.View.DAY).surface(); + + DayGrid.Box a = box(grid, "a"); + DayGrid.Box b = box(grid, "b"); + DayGrid.Box c = box(grid, "c"); + assertEquals(2, a.lanes); + assertEquals(2, b.lanes); + assertTrue(a.lane != b.lane, "two items at the same time are side by side"); + assertEquals(1, c.lanes, "an item alone has the whole column"); + + int[] ra = new int[4]; + int[] rb = new int[4]; + grid.boxRect(a, ra); + grid.boxRect(b, rb); + assertTrue(ra[0] + ra[2] <= rb[0] || rb[0] + rb[2] <= ra[0], "their rectangles do not overlap"); + } + + @FormTest + void anItemAcrossMidnightIsInBothDays() { + DefaultScheduleModel model = new DefaultScheduleModel(); + model.addItem(item("late", at(5, 22, 0), at(6, 2, 0))); + DayGrid grid = (DayGrid) show(model, Scheduler.View.WEEK).surface(); + + assertEquals(2, grid.boxes().size()); + DayGrid.Box first = grid.boxes().get(0).col == 0 ? grid.boxes().get(0) : grid.boxes().get(1); + DayGrid.Box second = first == grid.boxes().get(0) ? grid.boxes().get(1) : grid.boxes().get(0); + assertEquals(22 * 60, first.startMin); + assertEquals(TimeMath.MINUTES_PER_DAY, first.endMin); + assertTrue(first.endsAfter); + assertEquals(1, second.col); + assertEquals(0, second.startMin); + assertEquals(2 * 60, second.endMin); + assertTrue(second.startsBefore); + } + + @FormTest + void allDayAndLongItemsGoToTheLaneAbove() { + DefaultScheduleModel model = new DefaultScheduleModel(); + model.addItem(item("holiday", at(6, 0, 0), at(7, 0, 0)).setAllDay(true)); + model.addItem(item("trip", at(7, 8, 0), at(9, 18, 0))); + model.addItem(item("call", at(6, 9, 0), at(6, 10, 0))); + DayGrid grid = (DayGrid) show(model, Scheduler.View.WEEK).surface(); + + assertEquals(1, grid.boxes().size()); + assertEquals(2, grid.chips().size()); + for (DayGrid.Chip chip : grid.chips()) { + if (chip.item.getId().equals("holiday")) { + assertEquals(1, chip.fromCol); + assertEquals(1, chip.toCol, "a day that ends at midnight is not on the next one"); + } else { + assertEquals(2, chip.fromCol); + assertEquals(4, chip.toCol); + } + } + } + + @FormTest + void aDragMovesAnItemBySnappedTime() { + DefaultScheduleModel model = new DefaultScheduleModel(); + ScheduleItem a = item("a", at(5, 9, 0), at(5, 10, 0)); + model.addItem(a); + Scheduler s = show(model, Scheduler.View.DAY); + DayGrid grid = (DayGrid) s.surface(); + + int[] r = new int[4]; + grid.boxRect(box(grid, "a"), r); + int x = grid.headerW + r[0] + r[2] / 2 - grid.getOffX(); + int y = grid.headerH + r[1] + r[3] / 2 - grid.getOffY(); + // Two hours and ten minutes down snaps to two hours and a quarter. + int down = Math.round(grid.getHourHeight() * 130 / 60f); + drag(grid, x, y, x, y + down); + + assertEquals(at(5, 11, 15), a.getStart()); + assertEquals(at(5, 12, 15), a.getEnd()); + assertSame(a, s.getSelectedItem()); + } + + @FormTest + void aListenerCanRefuseAChange() { + DefaultScheduleModel model = new DefaultScheduleModel(); + ScheduleItem a = item("a", at(5, 9, 0), at(5, 10, 0)); + model.addItem(a); + Scheduler s = show(model, Scheduler.View.DAY); + final List asked = new ArrayList(); + s.addSchedulerListener(new SchedulerAdapter() { + @Override + public boolean itemChanging(Scheduler sc, ScheduleItem item, long start, long end, String resourceId) { + asked.add(item.getId()); + return false; + } + }); + DayGrid grid = (DayGrid) s.surface(); + int[] r = new int[4]; + grid.boxRect(box(grid, "a"), r); + int x = grid.headerW + r[0] + r[2] / 2 - grid.getOffX(); + int y = grid.headerH + r[1] + r[3] / 2 - grid.getOffY(); + drag(grid, x, y, x, y + Math.round(grid.getHourHeight() * 2)); + + assertEquals(1, asked.size()); + assertEquals(at(5, 9, 0), a.getStart(), "a refused change leaves the item where it was"); + } + + @FormTest + void theBottomEdgeResizes() { + DefaultScheduleModel model = new DefaultScheduleModel(); + ScheduleItem a = item("a", at(5, 9, 0), at(5, 11, 0)); + model.addItem(a); + DayGrid grid = (DayGrid) show(model, Scheduler.View.DAY).surface(); + int[] r = new int[4]; + grid.boxRect(box(grid, "a"), r); + int x = grid.headerW + r[0] + r[2] / 2 - grid.getOffX(); + int y = grid.headerH + r[1] + r[3] - 1 - grid.getOffY(); + drag(grid, x, y, x, y + Math.round(grid.getHourHeight())); + + assertEquals(at(5, 9, 0), a.getStart()); + assertEquals(at(5, 12, 0), a.getEnd()); + } + + @FormTest + void aDragOnEmptyTimeSelectsARangeAndCreates() { + DefaultScheduleModel model = new DefaultScheduleModel(); + model.setCreationEnabled(true); + Scheduler s = show(model, Scheduler.View.DAY); + final long[] range = new long[2]; + s.addSchedulerListener(new SchedulerAdapter() { + @Override + public void rangeSelected(Scheduler sc, long start, long end, String resourceId) { + range[0] = start; + range[1] = end; + } + }); + DayGrid grid = (DayGrid) s.surface(); + float hour = grid.getHourHeight(); + int x = grid.headerW + grid.bodyWidth() / 2; + int from = grid.headerH + Math.round(hour * 13) + 1 - grid.getOffY(); + int to = grid.headerH + Math.round(hour * 15) + 1 - grid.getOffY(); + drag(grid, x, from, x, to); + + assertEquals(at(5, 13, 0), range[0]); + assertEquals(at(5, 15, 0), range[1]); + assertEquals(1, model.getAllItems().size()); + assertSame(model.getAllItems().get(0), s.getSelectedItem()); + } + + @FormTest + void aTapSelectsAndADoubleTapOpens() { + DefaultScheduleModel model = new DefaultScheduleModel(); + ScheduleItem a = item("a", at(5, 9, 0), at(5, 11, 0)); + model.addItem(a); + Scheduler s = show(model, Scheduler.View.DAY); + final List events = new ArrayList(); + s.addSchedulerListener(new SchedulerAdapter() { + @Override + public void itemSelected(Scheduler sc, ScheduleItem item) { + events.add("selected " + (item == null ? null : item.getId())); + } + + @Override + public void itemOpened(Scheduler sc, ScheduleItem item) { + events.add("opened " + item.getId()); + } + }); + DayGrid grid = (DayGrid) s.surface(); + int[] r = new int[4]; + grid.boxRect(box(grid, "a"), r); + int x = grid.headerW + r[0] + r[2] / 2 - grid.getOffX(); + int y = grid.headerH + r[1] + r[3] / 2 - grid.getOffY(); + tap(grid, x, y); + assertEquals("[selected a]", events.toString()); + tap(grid, x, y); + assertEquals("[selected a, opened a]", events.toString()); + + // A tap on empty time lets go of the selection. + tap(grid, x, grid.headerH + Math.round(grid.getHourHeight() * 20) - grid.getOffY()); + assertNull(s.getSelectedItem()); + } + + @FormTest + void aReadOnlySchedulerDoesNotMoveItems() { + DefaultScheduleModel model = new DefaultScheduleModel(); + ScheduleItem a = item("a", at(5, 9, 0), at(5, 10, 0)); + model.addItem(a); + Scheduler s = show(model, Scheduler.View.DAY); + s.setEditable(false); + DayGrid grid = (DayGrid) s.surface(); + int[] r = new int[4]; + grid.boxRect(box(grid, "a"), r); + int x = grid.headerW + r[0] + r[2] / 2 - grid.getOffX(); + int y = grid.headerH + r[1] + r[3] / 2 - grid.getOffY(); + drag(grid, x, y, x, y + Math.round(grid.getHourHeight() * 2)); + assertEquals(at(5, 9, 0), a.getStart()); + } + + @FormTest + void stepsFollowTheView() { + DefaultScheduleModel model = new DefaultScheduleModel(); + Scheduler s = show(model, Scheduler.View.WEEK); + assertEquals(at(5, 0, 0), s.getVisibleStart()); + assertEquals(at(12, 0, 0), s.getVisibleEnd()); + final long[] seen = new long[2]; + s.addSchedulerListener(new SchedulerAdapter() { + @Override + public void visibleRangeChanged(Scheduler sc, long start, long end) { + seen[0] = start; + seen[1] = end; + } + }); + s.next(); + assertEquals(at(12, 0, 0), seen[0]); + assertEquals(at(19, 0, 0), seen[1]); + s.previous(); + s.setView(Scheduler.View.DAY); + assertEquals(at(5, 0, 0), s.getVisibleStart()); + assertEquals(at(6, 0, 0), s.getVisibleEnd()); + s.setView(Scheduler.View.MONTH); + // January 2026 starts on a Thursday: the grid opens on Monday the 29th. + assertEquals(TimeMath.startOfEpochDay(LocalDate.of(2025, 12, 29).toEpochDay(), UTC), s.getVisibleStart()); + s.next(); + assertEquals(TimeMath.startOfEpochDay(LocalDate.of(2026, 1, 26).toEpochDay(), UTC), s.getVisibleStart()); + } + + @FormTest + void theWorkWeekLeavesTheWeekendOut() { + DefaultScheduleModel model = new DefaultScheduleModel(); + model.addItem(item("sat", at(10, 9, 0), at(10, 10, 0))); + model.addItem(item("fri", at(9, 9, 0), at(9, 10, 0))); + DayGrid grid = (DayGrid) show(model, Scheduler.View.WORK_WEEK).surface(); + assertEquals(1, grid.boxes().size()); + assertEquals("fri", grid.boxes().get(0).item.getId()); + assertEquals(4, grid.boxes().get(0).col); + } + + @FormTest + void theMonthListsEachDay() { + DefaultScheduleModel model = new DefaultScheduleModel(); + model.addItem(item("a", at(5, 9, 0), at(5, 10, 0))); + model.addItem(item("b", at(5, 8, 0), at(5, 8, 30))); + model.addItem(item("trip", at(7, 8, 0), at(9, 18, 0))); + MonthGrid grid = (MonthGrid) show(model, Scheduler.View.MONTH).surface(); + List fifth = grid.itemsOn(day(5)); + assertEquals(2, fifth.size()); + assertEquals("b", fifth.get(0).getId(), "a day is in the order of its times"); + assertEquals(1, grid.itemsOn(day(7)).size()); + assertEquals(1, grid.itemsOn(day(8)).size()); + assertEquals(1, grid.itemsOn(day(9)).size()); + assertEquals(0, grid.itemsOn(day(10)).size()); + } + + @FormTest + void theAgendaSkipsEmptyDays() { + DefaultScheduleModel model = new DefaultScheduleModel(); + model.addItem(item("a", at(5, 9, 0), at(5, 10, 0))); + model.addItem(item("b", at(8, 9, 0), at(8, 10, 0))); + AgendaList list = (AgendaList) show(model, Scheduler.View.AGENDA).surface(); + assertEquals(4, list.rows().size()); + assertNull(list.rows().get(0).item); + assertEquals(day(5), list.rows().get(0).day); + assertEquals("a", list.rows().get(1).item.getId()); + assertEquals(day(8), list.rows().get(2).day); + assertEquals("b", list.rows().get(3).item.getId()); + } + + @FormTest + void theTimelineStacksWhatOverlapsAndMovesBetweenResources() { + DefaultScheduleModel model = new DefaultScheduleModel(); + model.addResource(new ScheduleResource("r1", "Room one")); + model.addResource(new ScheduleResource("r2", "Room two")); + ScheduleItem a = item("a", at(5, 9, 0), at(5, 11, 0)).setResourceId("r1"); + ScheduleItem b = item("b", at(5, 10, 0), at(5, 12, 0)).setResourceId("r1"); + ScheduleItem c = item("c", at(5, 9, 0), at(5, 10, 0)).setResourceId("r2"); + model.addItem(a).addItem(b).addItem(c); + Scheduler s = show(model, Scheduler.View.RESOURCE_TIMELINE); + ResourceTimeline line = (ResourceTimeline) s.surface(); + + int[] ra = new int[4]; + int[] rb = new int[4]; + int[] rc = new int[4]; + for (ResourceTimeline.Bar bar : line.bars()) { + if (bar.item == a) { + assertEquals(0, bar.lane); + line.barRect(bar, ra); + } else if (bar.item == b) { + assertEquals(1, bar.lane); + line.barRect(bar, rb); + } else { + assertEquals(1, bar.row); + line.barRect(bar, rc); + } + } + assertTrue(rb[1] >= ra[1] + ra[3], "the second of two at once is under the first"); + assertTrue(rc[1] > rb[1], "the second resource is below the whole of the first"); + + // Drag the item of the second room up into the first. + line.scrollTo(Math.max(0, rc[0] - 10), 0); + int x = line.headerW + rc[0] + rc[2] / 2 - line.getOffX(); + int y = line.headerH + rc[1] + rc[3] / 2 - line.getOffY(); + drag(line, x, y, x, line.headerH + ra[1] + 1 - line.getOffY()); + assertEquals("r1", c.getResourceId()); + assertEquals(at(5, 9, 0), c.getStart(), "a move straight up changes the resource and not the time"); + } + + @FormTest + void theResourceDayHasAColumnPerResource() { + DefaultScheduleModel model = new DefaultScheduleModel(); + model.addResource(new ScheduleResource("r1", "Ann")); + model.addResource(new ScheduleResource("r2", "Bob")); + ScheduleItem a = item("a", at(5, 9, 0), at(5, 10, 0)).setResourceId("r2"); + model.addItem(a); + model.addItem(item("other", at(5, 9, 0), at(5, 10, 0)).setResourceId("nobody")); + Scheduler s = show(model, Scheduler.View.RESOURCE_DAY); + DayGrid grid = (DayGrid) s.surface(); + assertEquals(1, grid.boxes().size()); + assertEquals(1, grid.boxes().get(0).col); + + int[] r = new int[4]; + grid.boxRect(grid.boxes().get(0), r); + int x = grid.headerW + r[0] + r[2] / 2 - grid.getOffX(); + int y = grid.headerH + r[1] + r[3] / 2 - grid.getOffY(); + drag(grid, x, y, grid.headerW + 2, y); + assertEquals("r1", a.getResourceId()); + assertEquals(at(5, 9, 0), a.getStart()); + } + + @FormTest + void theAxisPicksItsTicksFromTheScale() { + TimeAxis axis = new TimeAxis(UTC); + axis.setRange(at(5, 0, 0), at(12, 0, 0)); + axis.setScaleLimits(1000, 10 * TimeMath.DAY); + axis.setMillisPerPixel(TimeMath.HOUR / 50.0); + axis.pickUnits(40); + assertEquals(TimeAxis.HOUR, axis.getMinorUnit()); + assertFalse(axis.snapsToDays()); + assertEquals(50, axis.x(at(5, 1, 0))); + assertEquals(at(5, 2, 0), axis.time(100)); + + axis.setMillisPerPixel(TimeMath.DAY / 50.0); + axis.pickUnits(40); + assertEquals(TimeAxis.DAY, axis.getMinorUnit()); + assertTrue(axis.snapsToDays()); + + assertEquals(at(5, 0, 0), axis.floor(at(7, 13, 0), TimeAxis.WEEK, 1)); + assertEquals(at(1, 0, 0), axis.floor(at(7, 13, 0), TimeAxis.MONTH, 1)); + assertEquals(at(1, 0, 0), axis.floor(at(7, 13, 0), TimeAxis.QUARTER, 1)); + assertEquals(TimeMath.startOfEpochDay(LocalDate.of(2026, 4, 1).toEpochDay(), UTC), + axis.next(at(1, 0, 0), TimeAxis.QUARTER, 1)); + assertEquals(at(5, 12, 0), axis.floor(at(5, 13, 59), TimeAxis.HOUR, 6)); + assertEquals(at(6, 0, 0), axis.next(at(5, 18, 0), TimeAxis.HOUR, 6)); + assertNotNull(axis.label(at(5, 18, 0), TimeAxis.HOUR, 100, com.codename1.ui.Font.getDefaultFont())); + } +} From 4a48b886aa8684a61207af00877202e749070b0e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:55:23 +0200 Subject: [PATCH 03/19] Gantt: task table, bars, links, drag editing and resource load over a project model Co-Authored-By: Claude Fable 5.2 --- .../ui/scheduler/DefaultGanttBarRenderer.java | 96 ++ .../src/com/codename1/ui/scheduler/Gantt.java | 611 ++++++++++++ .../codename1/ui/scheduler/GanttAdapter.java | 50 + .../ui/scheduler/GanttBarRenderer.java | 46 + .../codename1/ui/scheduler/GanttChart.java | 912 ++++++++++++++++++ .../codename1/ui/scheduler/GanttListener.java | 56 ++ .../ui/scheduler/ResourceLoadView.java | 292 ++++++ .../com/codename1/ui/scheduler/GanttTest.java | 405 ++++++++ 8 files changed, 2468 insertions(+) create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/DefaultGanttBarRenderer.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/Gantt.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/GanttAdapter.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/GanttBarRenderer.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/GanttChart.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/GanttListener.java create mode 100644 CodenameOne/src/com/codename1/ui/scheduler/ResourceLoadView.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/ui/scheduler/GanttTest.java diff --git a/CodenameOne/src/com/codename1/ui/scheduler/DefaultGanttBarRenderer.java b/CodenameOne/src/com/codename1/ui/scheduler/DefaultGanttBarRenderer.java new file mode 100644 index 00000000000..db9d6875d52 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/DefaultGanttBarRenderer.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.scheduling.GanttTask; +import com.codename1.ui.Graphics; +import com.codename1.ui.plaf.Style; + +/// The renderer a `Gantt` starts with: a rounded bar with its progress +/// filled in, a bracket for a summary and a diamond for a milestone. +/// +/// The colours come from `GanttBar`, `GanttBarProgress`, `GanttBarCritical`, +/// `GanttSummaryBar` and `GanttMilestone`, or from the task's own UIID or +/// colour when it has one. +public class DefaultGanttBarRenderer implements GanttBarRenderer { + @Override + public void paintBar(Graphics g, Gantt gantt, GanttTask task, int x, int y, int width, int height, int state) { + boolean critical = (state & CRITICAL) != 0; + boolean aa = g.isAntiAliased(); + int alpha = g.getAlpha(); + g.setAntiAliased(true); + if ((state & DRAGGED) != 0) { + g.setAlpha(alpha * 170 / 255); + } + if (task.isSummary()) { + paintSummary(g, colorOf(task, "GanttSummaryBar", false), x, y, width, height); + } else if (task.isMilestone()) { + g.setColor(colorOf(task, critical ? "GanttBarCritical" : "GanttMilestone", false)); + int cx = x + width / 2; + int cy = y + height / 2; + int r = Math.min(width, height) / 2; + g.fillPolygon(new int[]{cx, cx + r, cx, cx - r}, new int[]{cy - r, cy, cy + r, cy}, 4); + } else { + int bg = colorOf(task, critical ? "GanttBarCritical" : "GanttBar", false); + int arc = Math.min(Math.min(width, height), Look.mm(1.2f)); + g.setColor(bg); + g.fillRoundRect(x, y, width, height, arc, arc); + int done = width * task.getPercentComplete() / 100; + if (done > 0) { + int cx = g.getClipX(); + int cy = g.getClipY(); + int cw = g.getClipWidth(); + int ch = g.getClipHeight(); + g.clipRect(x, y, done, height); + g.setColor(task.getColor() >= 0 || critical ? Look.mix(bg, 0, 70) + : Look.style("GanttBarProgress").getBgColor()); + g.fillRoundRect(x, y, width, height, arc, arc); + g.setClip(cx, cy, cw, ch); + } + if ((state & SELECTED) != 0) { + g.setColor(Look.style("GanttBar").getFgColor()); + g.drawRoundRect(x, y, width - 1, height - 1, arc, arc); + } + } + g.setAlpha(alpha); + g.setAntiAliased(aa); + } + + /// The colour of a task: its own, its UIID's, or the theme's for its kind. + protected int colorOf(GanttTask task, String uiid, boolean foreground) { + if (task.getColor() >= 0 && !foreground) { + return task.getColor(); + } + Style st = Look.style(task.getUIID() != null ? task.getUIID() : uiid); + return foreground ? st.getFgColor() : st.getBgColor(); + } + + private static void paintSummary(Graphics g, int color, int x, int y, int width, int height) { + int bar = Math.max(2, height * 2 / 5); + int tip = Math.min(height, Math.max(2, width / 2)); + g.setColor(color); + g.fillRect(x, y, width, bar); + g.fillPolygon(new int[]{x, x + tip, x}, new int[]{y, y, y + height}, 3); + g.fillPolygon(new int[]{x + width, x + width - tip, x + width}, new int[]{y, y, y + height}, 3); + } +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/Gantt.java b/CodenameOne/src/com/codename1/ui/scheduler/Gantt.java new file mode 100644 index 00000000000..51dfe42d912 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/Gantt.java @@ -0,0 +1,611 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.scheduling.GanttTask; +import com.codename1.scheduling.LevelingResult; +import com.codename1.scheduling.ProjectChangeListener; +import com.codename1.scheduling.ProjectModel; +import com.codename1.scheduling.ResourceLeveler; +import com.codename1.scheduling.ResourceOverallocation; +import com.codename1.ui.Button; +import com.codename1.ui.CheckBox; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.UIManager; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +/// A Gantt chart of a `ProjectModel`: the tree of tasks in a table on the +/// left, a bar for each against a time axis on the right, the links between +/// them, and under them how much of every resource the plan asks for. +/// +/// ```java +/// ProjectModel project = new ProjectModel(new WorkCalendar(), start); +/// GanttTask design = new GanttTask("design", "Design", 5 * day); +/// GanttTask build = new GanttTask("build", "Build", 10 * day); +/// project.addTask(design).addTask(build); +/// project.link(design, build); +/// project.update(); +/// form.add(BorderLayout.CENTER, new Gantt(project)); +/// ``` +/// +/// The chart draws its rows and keeps no component for each, so a plan of +/// thousands of tasks scrolls as one of ten does. Time zooms with a pinch, +/// with the wheel and the control key held, or with `zoomIn`, `zoomOut` and +/// `zoomToFit`. +/// +/// #### Editing +/// +/// A bar is dragged to move it and by its end to change its duration. With +/// a mouse that is a plain drag; on a touch screen a drag scrolls, so a long +/// press picks the bar up. The selected task shows a marker under its bar, +/// dragged to set how much of it is done, and a dot after its end, dragged +/// onto another bar to make that one depend on it. +/// +/// A task the project schedules is not placed by a drag: the drag sets a +/// "start no earlier than" constraint, and the task then starts there or +/// later if its predecessors say so. A manually scheduled task is given the +/// dates as they are. Every change is offered to the listeners first, which +/// may refuse it, and then the project is scheduled again and the bars slide +/// to where they now are. A summary task follows its children and is not +/// dragged. The table is not edited in place: a double tap is reported to +/// `GanttListener#taskOpened`, for an application's own editor. +/// +/// #### Resources +/// +/// `setLoadVisible(true)` shows the load of every resource under the chart. +/// A stretch over the resource's capacity is drawn in `GanttOverload`. +/// `level()` delays tasks until no resource is asked for more than it has, +/// as far as that can be done. +/// +/// #### Styling +/// +/// `GanttRow` is a row, with its selected style the selected one, and +/// `GanttSummaryRow` the row of a summary task; `GanttHeader` the time +/// axis and the titles of the table. The bars are `GanttBar`, with +/// `GanttBarProgress` the part done, `GanttBarCritical`, `GanttSummaryBar` +/// and `GanttMilestone`; `GanttBaseline` is the plan as it was captured. +/// `GanttLink` and `GanttLinkCritical` give the links their foreground +/// colour, `GanttLoad` and `GanttOverload` the resource load. A task that +/// names a UIID or a colour of its own is painted with that. +public class Gantt extends Container { + /// The columns the table can show after the name of a task. Those that + /// do not fit in less than half of the width are left out, last first. + public enum Column { + /// The day the task starts. + START, + /// The day the task ends. + END, + /// The working days the task takes. + DURATION, + /// How much of the task is done. + PROGRESS + } + + private ProjectModel model; + private final GanttChart chart; + private final ResourceLoadView load; + private final Container toolbar = new Container(new BorderLayout()); + private final HashSet collapsed = new HashSet(); + private final ArrayList listeners = new ArrayList(); + private Column[] columns = {Column.START, Column.END}; + private GanttTask selected; + private GanttBarRenderer renderer = new DefaultGanttBarRenderer(); + private boolean showCritical; + private boolean showBaseline; + private boolean showLinks = true; + private boolean editable = true; + private boolean loadVisible; + private boolean tableVisible = true; + private Boolean hours24; + private int firstDayOfWeek = 1; + private long fixedNow = -1; + private List overallocations; + private CheckBox criticalToggle; + private CheckBox baselineToggle; + private boolean listening; + + private final ProjectChangeListener modelListener = new ProjectChangeListener() { + @Override + public void projectChanged(ProjectModel m) { + changed(); + } + }; + + /// A chart with no project yet. + public Gantt() { + this(null); + } + + public Gantt(ProjectModel model) { + super(new BorderLayout()); + setUIID("Gantt"); + chart = new GanttChart(this); + load = new ResourceLoadView(this, chart); + chart.setLoadView(load); + toolbar.setUIID("SchedulerToolbar"); + buildToolbar(); + add(BorderLayout.NORTH, toolbar); + add(BorderLayout.CENTER, chart); + add(BorderLayout.SOUTH, load); + load.setHidden(true); + this.model = model; + } + + // ------------------------------------------------------------ toolbar + + private Button toolButton(String text, char icon, ActionListener action) { + Button b = new Button(text == null ? "" : text, "SchedulerViewButton"); + if (icon != 0) { + FontImage.setMaterialIcon(b, icon); + } + b.addActionListener(action); + return b; + } + + private CheckBox toggle(String text, ActionListener action) { + CheckBox c = new CheckBox(text); + c.setToggle(true); + c.setUIID("SchedulerViewButton"); + c.addActionListener(action); + return c; + } + + private void buildToolbar() { + Container zoom = new Container(BoxLayout.x()); + zoom.add(toolButton(null, FontImage.MATERIAL_ZOOM_OUT, new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + zoomOut(); + } + })); + zoom.add(toolButton(null, FontImage.MATERIAL_ZOOM_IN, new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + zoomIn(); + } + })); + zoom.add(toolButton(null, FontImage.MATERIAL_FIT_SCREEN, new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + zoomToFit(); + } + })); + Container options = new Container(BoxLayout.x()); + criticalToggle = toggle(localize("gantt.critical", "Critical path"), new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + setShowCriticalPath(criticalToggle.isSelected()); + } + }); + baselineToggle = toggle(localize("gantt.baseline", "Baseline"), new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + setShowBaseline(baselineToggle.isSelected()); + } + }); + options.add(criticalToggle); + options.add(baselineToggle); + options.add(toolButton(localize("gantt.level", "Level"), (char) 0, new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + level(); + } + })); + toolbar.add(BorderLayout.WEST, zoom); + toolbar.add(BorderLayout.EAST, options); + } + + /// Whether the bar of zoom buttons and options is shown above the chart. + public boolean isToolbarVisible() { + return !toolbar.isHidden(); + } + + public void setToolbarVisible(boolean v) { + toolbar.setHidden(!v); + revalidateLater(); + } + + // -------------------------------------------------------------- model + + public ProjectModel getModel() { + return model; + } + + /// Shows another project, or none when `m` is null. + public void setModel(ProjectModel m) { + if (model == m) { //NOPMD CompareObjectsWithEquals - the same model object, not an equal one + return; + } + if (model != null && listening) { + model.removeChangeListener(modelListener); + } + model = m; + if (model != null && listening) { + model.addChangeListener(modelListener); + } + selected = null; + collapsed.clear(); + changed(); + } + + /// Draws the project again, for a model changed in a way it could not + /// report itself. + public void refresh() { + changed(); + } + + private void changed() { + overallocations = null; + if (selected != null && (model == null || !model.getTasks().contains(selected))) { + selected = null; + } + chart.modelChanged(); + load.modelChanged(); + syncLoad(); + } + + // The load is given no room while there is no resource to show in it. + private void syncLoad() { + boolean show = loadVisible && model != null && !model.getResources().isEmpty(); + if (load.isHidden() == show) { + load.setHidden(!show); + load.setShouldCalcPreferredSize(true); + revalidateLater(); + } else if (show) { + load.setShouldCalcPreferredSize(true); + } + } + + // The listener is held only while the chart is on a form, so that a + // chart no longer shown is not kept alive by its project. + @Override + protected void initComponent() { + super.initComponent(); + if (!listening) { + listening = true; + if (model != null) { + model.addChangeListener(modelListener); + } + changed(); + } + } + + @Override + protected void deinitialize() { + if (listening) { + listening = false; + if (model != null) { + model.removeChangeListener(modelListener); + } + } + super.deinitialize(); + } + + boolean isOverallocated(String resourceId) { + if (model == null) { + return false; + } + if (overallocations == null) { + overallocations = ResourceLeveler.findOverallocations(model); + } + for (int i = 0; i < overallocations.size(); i++) { + if (overallocations.get(i).getResourceId().equals(resourceId)) { + return true; + } + } + return false; + } + + /// Delays tasks until no resource is asked for more than its capacity, + /// as far as the plan allows, and schedules the project again. What + /// could not be resolved is in the result, as is the finish of the + /// project before and after. Returns null when there is no project. + public LevelingResult level() { + if (model == null) { + return null; + } + LevelingResult result = ResourceLeveler.level(model); + if (result.isChanged()) { + chart.layoutNow(); + chart.beginReflow(); + result.apply(); + } + return result; + } + + // ------------------------------------------------------------ options + + /// Whether the tasks and links with no slack are drawn as critical. + public boolean isShowCriticalPath() { + return showCritical; + } + + public void setShowCriticalPath(boolean v) { + showCritical = v; + criticalToggle.setSelected(v); + chart.repaint(); + } + + /// Whether the captured baseline of every task is drawn under its bar. + public boolean isShowBaseline() { + return showBaseline; + } + + public void setShowBaseline(boolean v) { + showBaseline = v; + baselineToggle.setSelected(v); + chart.repaint(); + } + + /// Whether the links between tasks are drawn. They are by default. + public boolean isShowLinks() { + return showLinks; + } + + public void setShowLinks(boolean v) { + showLinks = v; + chart.repaint(); + } + + /// Whether bars can be dragged, progress set and links made. + public boolean isEditable() { + return editable; + } + + public void setEditable(boolean v) { + editable = v; + chart.cancelEdit(); + chart.repaint(); + } + + /// Whether a bar is moved by a plain drag, as with a mouse, or only + /// after a long press, which leaves a plain drag to scroll. The default + /// follows the device. + public void setDragEdits(boolean v) { + chart.setDragEdits(v); + } + + /// Whether the load of the resources is shown under the chart. + public boolean isLoadVisible() { + return loadVisible; + } + + public void setLoadVisible(boolean v) { + loadVisible = v; + syncLoad(); + } + + /// Whether the table of tasks is shown beside the chart. + public boolean isTableVisible() { + return tableVisible; + } + + public void setTableVisible(boolean v) { + tableVisible = v; + chart.modelChanged(); + load.modelChanged(); + } + + /// The columns of the table after the name, in order. + public void setColumns(Column... c) { + columns = new Column[c == null ? 0 : c.length]; + if (c != null) { + System.arraycopy(c, 0, columns, 0, c.length); + } + chart.modelChanged(); + } + + Column[] columns() { + return columns; + } + + public boolean is24Hours() { + return hours24 == null ? Dates.deviceUses24Hours() : hours24.booleanValue(); + } + + public void set24Hours(boolean v) { + hours24 = v ? Boolean.TRUE : Boolean.FALSE; + chart.modelChanged(); + } + + /// The ISO day weeks start on in the time axis: 1 is Monday. + public int getFirstDayOfWeek() { + return firstDayOfWeek; + } + + public void setFirstDayOfWeek(int isoDay) { + if (isoDay < 1 || isoDay > 7) { + throw new IllegalArgumentException("isoDay"); + } + firstDayOfWeek = isoDay; + chart.modelChanged(); + } + + public GanttBarRenderer getBarRenderer() { + return renderer; + } + + public void setBarRenderer(GanttBarRenderer r) { + if (r == null) { + throw new IllegalArgumentException("renderer"); + } + renderer = r; + chart.repaint(); + } + + /// Fixes what the chart takes the current time to be, for a screenshot + /// or a test. A negative value returns to the clock. + public void setCurrentTime(long millis) { + fixedNow = millis; + chart.modelChanged(); + } + + long now() { + return fixedNow >= 0 ? fixedNow : System.currentTimeMillis(); + } + + String localize(String key, String value) { + return UIManager.getInstance().localize(key, value); + } + + // --------------------------------------------------------------- tree + + /// Whether the children of a summary task are hidden. + public boolean isCollapsed(GanttTask task) { + return collapsed.contains(task); + } + + public void setCollapsed(GanttTask task, boolean v) { + boolean did = v ? collapsed.add(task) : collapsed.remove(task); + if (did) { + chart.modelChanged(); + } + } + + public void expandAll() { + collapsed.clear(); + chart.modelChanged(); + } + + public void collapseAll() { + if (model == null) { + return; + } + List all = model.getTasks(); + for (int i = 0; i < all.size(); i++) { + if (all.get(i).isSummary()) { + collapsed.add(all.get(i)); + } + } + chart.modelChanged(); + } + + // --------------------------------------------------------------- view + + public void zoomIn() { + chart.zoom(1.5f); + } + + public void zoomOut() { + chart.zoom(1 / 1.5f); + } + + /// Shows the whole project in the width there is. + public void zoomToFit() { + chart.zoomToFit(); + } + + /// Brings a task's row and the start of its bar into view, opening the + /// summary tasks it is under. + public void scrollToTask(GanttTask task) { + for (GanttTask p = task.getParent(); p != null; p = p.getParent()) { + collapsed.remove(p); + } + chart.modelChanged(); + chart.scrollToTask(task); + } + + public void scrollToDate(long time) { + chart.scrollToTime(time); + } + + // ---------------------------------------------------------- selection + + public GanttTask getSelectedTask() { + return selected; + } + + /// Selects a task, or nothing when `task` is null. The listeners are + /// not told: this is the application's own doing. + public void setSelectedTask(GanttTask task) { + selected = task; + chart.repaint(); + } + + public void addGanttListener(GanttListener l) { + if (l != null && !listeners.contains(l)) { + listeners.add(l); + } + } + + public void removeGanttListener(GanttListener l) { + listeners.remove(l); + } + + void viewSelected(GanttTask task) { + if (selected == task) { //NOPMD CompareObjectsWithEquals - the selection is this very object + return; + } + selected = task; + for (int i = 0; i < listeners.size(); i++) { + listeners.get(i).taskSelected(this, task); + } + chart.repaint(); + } + + void viewOpened(GanttTask task) { + for (int i = 0; i < listeners.size(); i++) { + listeners.get(i).taskOpened(this, task); + } + } + + boolean viewChanging(GanttTask task, long start, long end) { + for (int i = 0; i < listeners.size(); i++) { + if (!listeners.get(i).taskChanging(this, task, start, end)) { + return false; + } + } + return true; + } + + void viewChanged(GanttTask task) { + for (int i = 0; i < listeners.size(); i++) { + listeners.get(i).taskChanged(this, task); + } + } + + boolean viewLinking(GanttTask predecessor, GanttTask successor) { + for (int i = 0; i < listeners.size(); i++) { + if (!listeners.get(i).linkCreating(this, predecessor, successor)) { + return false; + } + } + return true; + } + + GanttChart surface() { + return chart; + } + + ResourceLoadView loadSurface() { + return load; + } +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/GanttAdapter.java b/CodenameOne/src/com/codename1/ui/scheduler/GanttAdapter.java new file mode 100644 index 00000000000..a102ab48ba7 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/GanttAdapter.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.scheduling.GanttTask; + +/// A `GanttListener` that does nothing and refuses nothing. +public class GanttAdapter implements GanttListener { + @Override + public void taskSelected(Gantt gantt, GanttTask task) { + } + + @Override + public void taskOpened(Gantt gantt, GanttTask task) { + } + + @Override + public boolean taskChanging(Gantt gantt, GanttTask task, long start, long end) { + return true; + } + + @Override + public void taskChanged(Gantt gantt, GanttTask task) { + } + + @Override + public boolean linkCreating(Gantt gantt, GanttTask predecessor, GanttTask successor) { + return true; + } +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/GanttBarRenderer.java b/CodenameOne/src/com/codename1/ui/scheduler/GanttBarRenderer.java new file mode 100644 index 00000000000..1eeaed0f42d --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/GanttBarRenderer.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.scheduling.GanttTask; +import com.codename1.ui.Graphics; + +/// Paints one task of a `Gantt` chart: a bar, a summary bracket or a +/// milestone. `DefaultGanttBarRenderer` is the one a chart starts with. +public interface GanttBarRenderer { + /// The task is the selected one. + int SELECTED = 1; + /// The task is on the critical path, and the chart is showing it. + int CRITICAL = 2; + /// The task is being dragged, and this is the copy under the pointer. + int DRAGGED = 4; + + /// Paints `task` into a rectangle: the span of its dates across and the + /// height of a bar. For a milestone the rectangle is the square its mark + /// is drawn in. + /// + /// #### Parameters + /// + /// - `state`: a combination of the constants of this interface + void paintBar(Graphics g, Gantt gantt, GanttTask task, int x, int y, int width, int height, int state); +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/GanttChart.java b/CodenameOne/src/com/codename1/ui/scheduler/GanttChart.java new file mode 100644 index 00000000000..ce454141298 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/GanttChart.java @@ -0,0 +1,912 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.scheduling.GanttTask; +import com.codename1.scheduling.ProjectModel; +import com.codename1.scheduling.ResourceAssignment; +import com.codename1.scheduling.ScheduleResource; +import com.codename1.scheduling.TaskDependency; +import com.codename1.scheduling.TimeMath; +import com.codename1.scheduling.WorkCalendar; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; + +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/// The chart of a `Gantt`: the table of tasks pinned on the left, and beside +/// it a row per task under a time axis, with the links between them. +final class GanttChart extends ScrollSurface { + private static final int MOVE = 1; + private static final int RESIZE_END = 2; + private static final int PROGRESS = 3; + private static final int LINK = 4; + private static final int REFLOW_MS = 260; + + private final Gantt owner; + private final TimeAxis axis; + private final ArrayList rows = new ArrayList(); + private final HashMap rowOf = new HashMap(); + private boolean dirty = true; + private boolean scaled; + private boolean fit; + private int laidW = -1; + private int laidH = -1; + private int laidGen = -1; + private int rowH; + private int barH; + private int nameW; + private int[] colW = new int[0]; + private Gantt.Column[] cols = new Gantt.Column[0]; + private final int[] rect = new int[4]; + private final int[] rect2 = new int[4]; + private ResourceLoadView load; + + // Where the bars were before the project was last scheduled, while they + // slide to where they are now. + private HashMap reflowFrom; + private long reflowStart; + + private int mode; + private GanttTask editTask; + private long editStart; + private long editEnd; + private int editPercent; + private long grabTime; + private int linkX; + private int linkY; + private GanttTask linkTarget; + + GanttChart(Gantt owner) { + this.owner = owner; + axis = new TimeAxis(ZoneId.systemDefault()); + setUIID("Gantt"); + } + + void setLoadView(ResourceLoadView v) { + load = v; + } + + TimeAxis axis() { + return axis; + } + + List rows() { + return rows; + } + + void modelChanged() { + dirty = true; + repaint(); + } + + /// Remembers where every bar is, so that the next layout slides them to + /// their new dates and does not jump. + void beginReflow() { + HashMap from = new HashMap(); + for (GanttTask t : rows) { + from.put(t, new long[]{t.getStart(), t.getEnd()}); + } + reflowFrom = from; + reflowStart = System.currentTimeMillis(); + requestAnimation(); + } + + /// Ends the slide at once, leaving every bar at its date. + void reflowDone() { + reflowFrom = null; + } + + @Override + boolean animateSurface() { + if (reflowFrom == null) { + return false; + } + if (System.currentTimeMillis() - reflowStart >= REFLOW_MS) { + reflowFrom = null; + } + repaint(); + return reflowFrom != null; + } + + private long shown(GanttTask t, boolean end) { + long now = end ? t.getEnd() : t.getStart(); + if (reflowFrom == null) { + return now; + } + long[] from = reflowFrom.get(t); + if (from == null) { + return now; + } + float p = Math.min(1f, (System.currentTimeMillis() - reflowStart) / (float) REFLOW_MS); + // Ease out: fast at first, settling at the end. + p = 1 - (1 - p) * (1 - p); + long was = from[end ? 1 : 0]; + return was + (long) ((now - was) * p); + } + + void zoomToFit() { + fit = true; + scaled = false; + laidW = -1; + repaint(); + } + + void zoom(float factor) { + zoomBy(factor, headerW + bodyWidth() / 2, headerH); + } + + void scrollToTime(long time) { + layoutNow(); + scrollTo(axis.x(time) - bodyWidth() / 4, getOffY()); + } + + void scrollToTask(GanttTask t) { + layoutNow(); + Integer row = rowOf.get(t); + int y = getOffY(); + if (row != null) { + int top = row.intValue() * rowH; + if (top < y || top + rowH > y + bodyHeight()) { + y = top - bodyHeight() / 3; + } + } + scrollTo(axis.x(t.getStart()) - bodyWidth() / 4, y); + } + + // ------------------------------------------------------------- layout + + private void addRows(List tasks) { + for (GanttTask t : tasks) { + rowOf.put(t, Integer.valueOf(rows.size())); + rows.add(t); + if (t.isSummary() && !owner.isCollapsed(t)) { + addRows(t.getChildren()); + } + } + } + + @Override + void prepare(int width, int height) { + int gen = UIManager.getThemeGeneration(); + if (!dirty && width == laidW && height == laidH && gen == laidGen) { + return; + } + laidW = width; + laidH = height; + laidGen = gen; + dirty = false; + ProjectModel model = owner.getModel(); + Style header = Look.style("GanttHeader"); + Style rowStyle = Look.style("GanttRow"); + Font f = rowStyle.getFont(); + rows.clear(); + rowOf.clear(); + long first = Long.MAX_VALUE; + long last = Long.MIN_VALUE; + ZoneId zone = ZoneId.systemDefault(); + if (model != null) { + zone = model.getCalendar().getZone(); + addRows(model.getRootTasks()); + first = model.getStart(); + last = model.getStart(); + for (GanttTask t : model.getTasks()) { + first = Math.min(first, t.getStart()); + last = Math.max(last, t.getEnd()); + if (t.hasBaseline()) { + first = Math.min(first, t.getBaselineStart()); + last = Math.max(last, t.getBaselineEnd()); + } + } + } + if (first == Long.MAX_VALUE) { + first = owner.now(); + last = first; + } + // Room on both sides: to drag a task earlier, and for the labels + // after the last bar. + long from = TimeMath.startOfDay(first, zone) - 3 * TimeMath.DAY; + long to = TimeMath.startOfDay(last, zone) + 12 * TimeMath.DAY; + long keep = scaled ? axis.time(getOffX()) : Long.MIN_VALUE; + axis.setZone(zone); + axis.setFirstDayOfWeek(owner.getFirstDayOfWeek()); + axis.set24Hours(owner.is24Hours()); + axis.setRange(from, to); + + rowH = Math.max(Look.mm(6.5f), f.getHeight() + Look.mm(2.6f)); + barH = Math.max(4, rowH * 11 / 20); + headerH = axis.headerHeight(header); + + // The table: the names, then as many of the columns as fit. + int indent = Look.mm(3); + int widest = header.getFont().stringWidth(owner.localize("gantt.task", "Task")); + for (GanttTask t : rows) { + widest = Math.max(widest, f.stringWidth(t.getName()) + t.getLevel() * indent); + } + nameW = Math.max(Look.mm(20), Math.min(Look.mm(52), widest + Look.mm(7))); + int cap = Math.max(Look.mm(18), width * 45 / 100); + nameW = Math.min(nameW, cap); + Gantt.Column[] wanted = owner.columns(); + ArrayList shownCols = new ArrayList(); + int[] widths = new int[wanted.length]; + int total = nameW; + for (Gantt.Column c : wanted) { + int w = Math.max(f.stringWidth(sample(c)), header.getFont().stringWidth(columnTitle(c))) + Look.mm(3); + if (total + w > cap) { + break; + } + widths[shownCols.size()] = w; + shownCols.add(c); + total += w; + } + cols = new Gantt.Column[shownCols.size()]; + shownCols.toArray(cols); + colW = widths; + headerW = owner.isTableVisible() ? total : 0; + + int bodyW = Math.max(1, width - headerW); + long span = to - from; + double dayScale = (double) TimeMath.DAY / Look.mm(7); + axis.setScaleLimits((double) TimeMath.HOUR / Look.mm(9), Math.max((double) span / bodyW, dayScale)); + if (!scaled && width > 0) { + scaled = true; + if (fit) { + axis.setMillisPerPixel((double) span / bodyW); + } else { + axis.setMillisPerPixel(Math.min(dayScale, Math.max((double) span / bodyW, + (double) TimeMath.DAY / Look.mm(14)))); + } + fit = false; + setOffsets(0, getOffY()); + } else if (keep != Long.MIN_VALUE) { + // The range may have grown at its start: what was at the left + // edge stays there. + setOffsets(axis.x(keep), getOffY()); + } + axis.pickUnits(header.getFont().stringWidth("Www 00") + Look.mm(2)); + if (load != null) { + load.chartChanged(); + } + } + + private String sample(Gantt.Column c) { + switch (c) { + case START: + case END: + return "Www 00"; + case DURATION: + return "000d"; + default: + return "100%"; + } + } + + private String columnTitle(Gantt.Column c) { + switch (c) { + case START: + return owner.localize("gantt.start", "Start"); + case END: + return owner.localize("gantt.end", "End"); + case DURATION: + return owner.localize("gantt.duration", "Days"); + default: + return "%"; + } + } + + private String columnText(Gantt.Column c, GanttTask t) { + ZoneId zone = axis.getZone(); + switch (c) { + case START: + return Dates.monthDay(TimeMath.epochDay(t.getStart(), zone)); + case END: + return Dates.monthDay(TimeMath.epochDay(Math.max(t.getStart(), t.getEnd() - 1), zone)); + case DURATION: { + WorkCalendar cal = owner.getModel().getCalendar(); + long work = t.isSummary() ? cal.workingTimeBetween(t.getStart(), t.getEnd()) : t.getDuration(); + long perDay = Math.max(1, cal.getMillisPerDay()); + long tenths = work * 10 / perDay; + return tenths % 10 == 0 ? String.valueOf(tenths / 10) : tenths / 10 + "." + tenths % 10; + } + default: + return t.getPercentComplete() + "%"; + } + } + + @Override + int contentWidth() { + return axis.width(); + } + + @Override + int contentHeight() { + return rows.size() * rowH; + } + + @Override + void scrolled() { + if (load != null) { + load.chartScrolled(); + } + } + + /// The rectangle of a task's bar in the content's coordinates. + void barRect(GanttTask t, int row, int[] out) { + int x0 = axis.x(shown(t, false)); + int x1 = axis.x(shown(t, true)); + int top = row * rowH + (rowH - barH) / 2; + if (t.isMilestone()) { + out[0] = x0 - barH / 2; + out[2] = barH; + } else { + out[0] = x0; + out[2] = Math.max(2, x1 - x0); + } + out[1] = top; + out[3] = barH; + } + + // -------------------------------------------------------------- paint + + @Override + void paintBody(Graphics g, int x, int y) { + Style rowStyle = Look.style("GanttRow"); + Style rowSel = Look.selected("GanttRow"); + Style summaryRow = Look.style("GanttSummaryRow"); + int cx = g.getClipX(); + int cy = g.getClipY(); + int cw = g.getClipWidth(); + int ch = g.getClipHeight(); + Look.fill(g, rowStyle, cx, cy, cw, ch); + ProjectModel model = owner.getModel(); + if (model == null) { + return; + } + int firstRow = Math.max(0, (cy - y) / rowH); + int lastRow = Math.min(rows.size() - 1, (cy + ch - y) / rowH); + GanttTask selected = owner.getSelectedTask(); + for (int r = firstRow; r <= lastRow; r++) { + GanttTask t = rows.get(r); + if (t == selected) { //NOPMD CompareObjectsWithEquals - the selection is this very object + Look.fill(g, rowSel, cx, y + r * rowH, cw, rowH); + } else if (t.isSummary()) { + Look.fill(g, summaryRow, cx, y + r * rowH, cw, rowH); + } + } + axis.paintOffHours(g, model.getCalendar(), Look.style("SchedulerOffHours"), x, cy, ch, cx, cx + cw); + int lineColor = Look.mix(rowStyle.getBgColor(), rowStyle.getFgColor(), 45); + axis.paintGrid(g, lineColor, rowStyle.getBgColor(), x, cy, ch, cx, cx + cw); + g.setColor(Look.mix(rowStyle.getBgColor(), rowStyle.getFgColor(), 25)); + for (int r = firstRow; r <= lastRow + 1; r++) { + g.drawLine(cx, y + r * rowH, cx + cw, y + r * rowH); + } + long now = owner.now(); + if (now >= axis.getStart() && now < axis.getEnd()) { + g.setColor(Look.style("SchedulerNow").getFgColor()); + g.fillRect(x + axis.x(now) - 1, cy, 2, ch); + } + + if (owner.isShowBaseline()) { + Style base = Look.style("GanttBaseline"); + int h = Math.max(2, barH / 3); + for (int r = firstRow; r <= lastRow; r++) { + GanttTask t = rows.get(r); + if (t.hasBaseline() && !t.isSummary()) { + int x0 = x + axis.x(t.getBaselineStart()); + int x1 = x + axis.x(t.getBaselineEnd()); + Look.fill(g, base, x0, y + r * rowH + rowH - h - 1, Math.max(2, x1 - x0), h); + } + } + } + if (owner.isShowLinks()) { + paintLinks(g, model, x, y, firstRow, lastRow); + } + + GanttBarRenderer renderer = owner.getBarRenderer(); + Font f = rowStyle.getFont(); + boolean showCritical = owner.isShowCriticalPath(); + int alpha = g.getAlpha(); + for (int r = firstRow; r <= lastRow; r++) { + GanttTask t = rows.get(r); + barRect(t, r, rect); + if (x + rect[0] > cx + cw) { + continue; + } + int state = 0; + if (t == selected) { //NOPMD CompareObjectsWithEquals - the selection is this very object + state |= GanttBarRenderer.SELECTED; + } + if (showCritical && t.isCritical() && !t.isSummary()) { + state |= GanttBarRenderer.CRITICAL; + } + if (x + rect[0] + rect[2] >= cx) { + renderer.paintBar(g, owner, t, x + rect[0], y + rect[1], rect[2], rect[3], state); + } + String label = labelOf(model, t); + if (label != null) { + g.setFont(f); + g.setColor(rowStyle.getFgColor()); + g.setAlpha(alpha * 170 / 255); + g.drawString(label, x + rect[0] + rect[2] + Look.mm(2.2f) + (t == selected ? Look.mm(3) : 0), //NOPMD CompareObjectsWithEquals + y + r * rowH + (rowH - f.getHeight()) / 2); + g.setAlpha(alpha); + } + } + if (selected != null && owner.isEditable() && !selected.isSummary() && mode == 0) { + Integer row = rowOf.get(selected); + if (row != null) { + paintHandles(g, selected, row.intValue(), x, y); + } + } + if (mode != 0) { + paintEdit(g, x, y); + } + } + + // Who a task is assigned to, written after its bar. + private static String labelOf(ProjectModel model, GanttTask t) { + List as = t.getAssignments(); + if (as.isEmpty()) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < as.size(); i++) { + ScheduleResource r = model.getResource(as.get(i).getResourceId()); + if (r == null) { + continue; + } + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(r.getName()); + float units = as.get(i).getUnits(); + if (Math.abs(units - 1f) > 0.001f) { + sb.append(" (").append(Math.round(units * 100)).append("%)"); + } + } + return sb.length() == 0 ? null : sb.toString(); + } + + private void paintHandles(Graphics g, GanttTask t, int row, int x, int y) { + barRect(t, row, rect); + Style st = Look.style("GanttBar"); + boolean aa = g.isAntiAliased(); + g.setAntiAliased(true); + // The dot a link is dragged from. + int r = Look.mm(1.1f); + int lx = x + rect[0] + rect[2] + Look.mm(2.2f); + int ly = y + rect[1] + rect[3] / 2; + g.setColor(Look.style("GanttRow").getBgColor()); + g.fillArc(lx - r, ly - r, 2 * r, 2 * r, 0, 360); + g.setColor(Look.style("GanttLink").getFgColor()); + g.drawArc(lx - r, ly - r, 2 * r, 2 * r, 0, 360); + if (!t.isMilestone()) { + // The marker progress is dragged by. + int px = x + rect[0] + rect[2] * t.getPercentComplete() / 100; + int py = y + rect[1] + rect[3]; + int s = Look.mm(1.2f); + g.setColor(st.getFgColor()); + g.fillPolygon(new int[]{px, px + s, px - s}, new int[]{py - s, py + s, py + s}, 3); + } + g.setAntiAliased(aa); + } + + private void paintEdit(Graphics g, int x, int y) { + Integer rowI = rowOf.get(editTask); + if (rowI == null) { + return; + } + int row = rowI.intValue(); + Style rowStyle = Look.style("GanttRow"); + if (mode == LINK) { + barRect(editTask, row, rect); + g.setColor(Look.style("GanttLink").getFgColor()); + g.drawLine(x + rect[0] + rect[2], y + rect[1] + rect[3] / 2, linkX, linkY); + if (linkTarget != null) { + Integer tr = rowOf.get(linkTarget); + if (tr != null) { + barRect(linkTarget, tr.intValue(), rect); + g.drawRect(x + rect[0] - 2, y + rect[1] - 2, rect[2] + 3, rect[3] + 3); + } + } + return; + } + if (mode == PROGRESS) { + barRect(editTask, row, rect); + Font f = rowStyle.getFont(); + g.setFont(f); + g.setColor(rowStyle.getFgColor()); + int px = x + rect[0] + rect[2] * editPercent / 100; + g.fillRect(px - 1, y + rect[1] - 2, 2, rect[3] + 4); + g.drawString(editPercent + "%", px + Look.mm(1), y + rect[1] - f.getHeight()); + return; + } + int x0 = x + axis.x(editStart); + int x1 = x + axis.x(editEnd); + int top = y + row * rowH + (rowH - barH) / 2; + if (editTask.isMilestone()) { + owner.getBarRenderer().paintBar(g, owner, editTask, x0 - barH / 2, top, barH, barH, + GanttBarRenderer.DRAGGED); + } else { + owner.getBarRenderer().paintBar(g, owner, editTask, x0, top, Math.max(2, x1 - x0), barH, + GanttBarRenderer.DRAGGED); + } + Font f = rowStyle.getFont(); + g.setFont(f); + g.setColor(rowStyle.getFgColor()); + ZoneId zone = axis.getZone(); + String s = Dates.monthDay(TimeMath.epochDay(editStart, zone)) + " - " + + Dates.monthDay(TimeMath.epochDay(Math.max(editStart, editEnd - 1), zone)); + g.drawString(s, x0, top - f.getHeight() > y + getOffY() ? top - f.getHeight() : top + barH); + } + + // A link leaves its predecessor, turns once or twice, and arrives at its + // successor from outside it, never across a bar it belongs to. + private void paintLinks(Graphics g, ProjectModel model, int x, int y, int firstRow, int lastRow) { + List all = model.getDependencies(); + int plain = Look.style("GanttLink").getFgColor(); + int hot = Look.style("GanttLinkCritical").getFgColor(); + boolean showCritical = owner.isShowCriticalPath(); + int stub = Look.mm(1.8f); + int head = Math.max(3, Look.mm(1)); + for (int i = 0; i < all.size(); i++) { + TaskDependency d = all.get(i); + Integer pr = rowOf.get(d.getPredecessor()); + Integer sr = rowOf.get(d.getSuccessor()); + if (pr == null || sr == null) { + continue; + } + int p = pr.intValue(); + int s = sr.intValue(); + if ((p < firstRow && s < firstRow) || (p > lastRow && s > lastRow)) { + continue; + } + barRect(d.getPredecessor(), p, rect); + barRect(d.getSuccessor(), s, rect2); + int dirF = d.fromFinish() ? 1 : -1; + int dirT = d.constrainsStart() ? 1 : -1; + int fx = x + (d.fromFinish() ? rect[0] + rect[2] : rect[0]); + int fy = y + rect[1] + rect[3] / 2; + int tx = x + (d.constrainsStart() ? rect2[0] : rect2[0] + rect2[2]); + int ty = y + rect2[1] + rect2[3] / 2; + int out = fx + dirF * stub; + int in = tx - dirT * stub; + g.setColor(showCritical && d.getPredecessor().isCritical() && d.getSuccessor().isCritical() ? hot : plain); + if (dirF == dirT && (in - out) * dirF >= 0) { + g.drawLine(fx, fy, in, fy); + g.drawLine(in, fy, in, ty); + } else { + int mid = y + (s > p ? (p + 1) * rowH : p * rowH); + g.drawLine(fx, fy, out, fy); + g.drawLine(out, fy, out, mid); + g.drawLine(out, mid, in, mid); + g.drawLine(in, mid, in, ty); + } + g.drawLine(in, ty, tx, ty); + g.fillPolygon(new int[]{tx, tx - dirT * head, tx - dirT * head}, new int[]{ty, ty - head, ty + head}, 3); + } + } + + @Override + void paintTopHeader(Graphics g, int x, int y) { + axis.paintHeader(g, Look.style("GanttHeader"), x, y, g.getClipX(), g.getClipX() + g.getClipWidth()); + } + + @Override + void paintLeftHeader(Graphics g, int x, int y) { + Style rowStyle = Look.style("GanttRow"); + Style rowSel = Look.selected("GanttRow"); + Style summaryRow = Look.style("GanttSummaryRow"); + int cy = g.getClipY(); + int ch = g.getClipHeight(); + Look.fill(g, rowStyle, x, cy, headerW, ch); + int firstRow = Math.max(0, (cy - y) / rowH); + int lastRow = Math.min(rows.size() - 1, (cy + ch - y) / rowH); + int indent = Look.mm(3); + int pad = Look.mm(1.2f); + int arrow = Look.mm(1.1f); + GanttTask selected = owner.getSelectedTask(); + int line = Look.mix(rowStyle.getBgColor(), rowStyle.getFgColor(), 25); + boolean aa = g.isAntiAliased(); + for (int r = firstRow; r <= lastRow; r++) { + GanttTask t = rows.get(r); + int top = y + r * rowH; + Style st = t.isSummary() ? summaryRow : rowStyle; + if (t == selected) { //NOPMD CompareObjectsWithEquals - the selection is this very object + Look.fill(g, rowSel, x, top, headerW, rowH); + } else if (t.isSummary()) { + Look.fill(g, summaryRow, x, top, headerW, rowH); + } + Font f = st.getFont(); + int tx = x + pad + t.getLevel() * indent; + int mid = top + rowH / 2; + g.setColor(st.getFgColor()); + if (t.isSummary()) { + g.setAntiAliased(true); + if (owner.isCollapsed(t)) { + g.fillPolygon(new int[]{tx, tx + arrow, tx}, new int[]{mid - arrow, mid, mid + arrow}, 3); + } else { + g.fillPolygon(new int[]{tx - arrow / 2, tx + arrow + arrow / 2, tx + arrow / 2}, + new int[]{mid - arrow / 2, mid - arrow / 2, mid + arrow}, 3); + } + g.setAntiAliased(aa); + } + tx += arrow + Look.mm(1.4f); + g.setFont(f); + g.drawString(Look.fit(t.getName(), f, x + nameW - tx - pad), tx, top + (rowH - f.getHeight()) / 2); + int cxp = x + nameW; + for (int c = 0; c < cols.length; c++) { + String s = columnText(cols[c], t); + g.drawString(s, cxp + colW[c] - f.stringWidth(s) - pad, top + (rowH - f.getHeight()) / 2); + cxp += colW[c]; + } + g.setColor(line); + g.drawLine(x, top + rowH, x + headerW, top + rowH); + } + g.setColor(Look.mix(rowStyle.getBgColor(), rowStyle.getFgColor(), 60)); + g.drawLine(x + headerW - 1, cy, x + headerW - 1, cy + ch); + } + + @Override + void paintCorner(Graphics g, int x, int y) { + Style header = Look.style("GanttHeader"); + Font f = header.getFont(); + Look.fill(g, header, x, y, headerW, headerH); + g.setFont(f); + g.setColor(header.getFgColor()); + int pad = Look.mm(1.2f); + int ty = y + headerH - f.getHeight() - Look.mm(0.7f); + g.drawString(Look.fit(owner.localize("gantt.task", "Task"), f, nameW - 2 * pad), x + pad, ty); + int cxp = x + nameW; + for (int c = 0; c < cols.length; c++) { + String s = columnTitle(cols[c]); + g.drawString(s, cxp + colW[c] - f.stringWidth(s) - pad, ty); + cxp += colW[c]; + } + g.setColor(Look.mix(header.getBgColor(), header.getFgColor(), 50)); + g.drawLine(x, y + headerH - 1, x + headerW, y + headerH - 1); + g.drawLine(x + headerW - 1, y, x + headerW - 1, y + headerH); + } + + // ------------------------------------------------------------ pointer + + private int rowAt(int py) { + int r = (py - headerH + getOffY()) / Math.max(1, rowH); + return py < headerH || r < 0 || r >= rows.size() ? -1 : r; + } + + /// The task whose bar is at a point of the component, or null. + GanttTask barAt(int px, int py) { + int r = rowAt(py); + if (r < 0 || px < headerW) { + return null; + } + GanttTask t = rows.get(r); + barRect(t, r, rect); + int cx = px - headerW + getOffX(); + int slack = Math.max(0, (Look.mm(2.5f) - rect[2]) / 2); + return cx >= rect[0] - slack && cx < rect[0] + rect[2] + slack ? t : null; + } + + private long snapStart(long time) { + ProjectModel model = owner.getModel(); + ZoneId zone = axis.getZone(); + long t; + if (axis.snapsToDays()) { + t = TimeMath.startOfDay(time + TimeMath.DAY / 2, zone); + } else { + long day = TimeMath.epochDay(time, zone); + int m = (TimeMath.minuteOfDay(time, zone) + 7) / 15 * 15; + t = TimeMath.at(day, Math.min(TimeMath.MINUTES_PER_DAY, m), zone); + } + return model.getCalendar().nextWorkingTime(t); + } + + private long snapEnd(long time) { + ProjectModel model = owner.getModel(); + ZoneId zone = axis.getZone(); + long t; + if (axis.snapsToDays()) { + t = TimeMath.startOfDay(time + TimeMath.DAY / 2, zone); + } else { + long day = TimeMath.epochDay(time, zone); + int m = (TimeMath.minuteOfDay(time, zone) + 7) / 15 * 15; + t = TimeMath.at(day, Math.min(TimeMath.MINUTES_PER_DAY, m), zone); + } + return model.getCalendar().previousWorkingTime(t); + } + + @Override + boolean beginEdit(int x, int y, boolean handlesOnly) { + ProjectModel model = owner.getModel(); + if (!owner.isEditable() || model == null || x < headerW || y < headerH) { + return false; + } + int cx = x - headerW + getOffX(); + int cy = y - headerH + getOffY(); + GanttTask sel = owner.getSelectedTask(); + Integer selRow = sel == null ? null : rowOf.get(sel); + if (sel != null && selRow != null && !sel.isSummary()) { + barRect(sel, selRow.intValue(), rect); + int reach = Look.mm(2.6f); + int lx = rect[0] + rect[2] + Look.mm(2.2f); + int ly = rect[1] + rect[3] / 2; + if (Math.abs(cx - lx) <= reach && Math.abs(cy - ly) <= reach) { + mode = LINK; + editTask = sel; + linkTarget = null; + return true; + } + int px = rect[0] + rect[2] * sel.getPercentComplete() / 100; + if (!sel.isMilestone() && Math.abs(cx - px) <= reach && cy >= rect[1] + rect[3] / 2 + && cy <= rect[1] + rect[3] + reach) { + mode = PROGRESS; + editTask = sel; + editPercent = sel.getPercentComplete(); + return true; + } + } + GanttTask t = barAt(x, y); + if (t == null || t.isSummary()) { + return false; + } + barRect(t, rowOf.get(t).intValue(), rect); + int edge = Math.min(rect[2] / 3, Look.mm(handlesOnly ? 3f : 1.6f)); + boolean right = !t.isMilestone() && rect[0] + rect[2] - cx <= edge; + if (handlesOnly && (t != sel || !right)) { //NOPMD CompareObjectsWithEquals - the selection is this very object + return false; + } + mode = right ? RESIZE_END : MOVE; + editTask = t; + editStart = t.getStart(); + editEnd = t.getEnd(); + grabTime = axis.time(cx); + owner.viewSelected(t); + return true; + } + + @Override + void editDragged(int x, int y) { + if (mode == 0) { + return; + } + ProjectModel model = owner.getModel(); + int cx = Math.max(0, Math.min(contentWidth(), x - headerW + getOffX())); + long pointer = axis.time(cx); + if (mode == MOVE) { + editStart = snapStart(editTask.getStart() + pointer - grabTime); + editEnd = model.getCalendar().addWorkingTime(editStart, editTask.getDuration()); + } else if (mode == RESIZE_END) { + long least = model.getCalendar().addWorkingTime(editStart, axis.snapsToDays() + ? Math.min(model.getCalendar().getMillisPerDay(), TimeMath.DAY) : 15 * TimeMath.MINUTE); + editEnd = Math.max(least, snapEnd(pointer)); + } else if (mode == PROGRESS) { + barRect(editTask, rowOf.get(editTask).intValue(), rect); + editPercent = Math.max(0, Math.min(100, (cx - rect[0]) * 100 / Math.max(1, rect[2]))); + // Whole fives are what a hand means. + editPercent = (editPercent + 2) / 5 * 5; + } else { + linkX = getX() + x; + linkY = getY() + y; + GanttTask t = barAt(x, y); + linkTarget = t != null && t != editTask && model.canLink(editTask, t) ? t : null; //NOPMD CompareObjectsWithEquals + } + } + + @Override + void endEdit(boolean commit) { + int m = mode; + GanttTask t = editTask; + mode = 0; + editTask = null; + ProjectModel model = owner.getModel(); + if (!commit || t == null || model == null) { + return; + } + if (m == LINK) { + if (linkTarget != null && owner.viewLinking(t, linkTarget)) { + beginReflow(); + model.link(t, linkTarget); + model.update(); + } + linkTarget = null; + } else if (m == PROGRESS) { + if (editPercent != t.getPercentComplete()) { + t.setPercentComplete(editPercent); + model.update(); + owner.viewChanged(t); + } + } else if (m == MOVE) { + if (editStart != t.getStart() && owner.viewChanging(t, editStart, editEnd)) { + beginReflow(); + if (t.isManuallyScheduled()) { + t.setDates(editStart, editEnd); + } else { + // A task that is scheduled goes where it is put only as + // far as its links allow, so the drag is a constraint. + t.setConstraint(GanttTask.Constraint.START_NO_EARLIER_THAN, editStart); + } + model.update(); + owner.viewChanged(t); + } + } else if (editEnd != t.getEnd() && owner.viewChanging(t, editStart, editEnd)) { + beginReflow(); + long work = Math.max(TimeMath.MINUTE, model.getCalendar().workingTimeBetween(t.getStart(), editEnd)); + if (t.isManuallyScheduled()) { + t.setDates(t.getStart(), editEnd); + } + t.setDuration(work); + model.update(); + owner.viewChanged(t); + } + } + + @Override + void tapped(int x, int y, boolean doubleTap) { + int r = rowAt(y); + if (r < 0) { + return; + } + GanttTask t = rows.get(r); + if (x < headerW) { + int toggle = Look.mm(1.2f) + t.getLevel() * Look.mm(3) + Look.mm(4.5f); + if (t.isSummary() && x <= toggle) { + owner.setCollapsed(t, !owner.isCollapsed(t)); + return; + } + owner.viewSelected(t); + if (doubleTap) { + scrollToTask(t); + owner.viewOpened(t); + } + return; + } + GanttTask bar = barAt(x, y); + owner.viewSelected(bar); + if (bar != null && doubleTap) { + owner.viewOpened(bar); + } + } + + @Override + boolean zoomBy(float factor, int x, int y) { + double before = axis.getMillisPerPixel(); + long t = axis.time(x - headerW + getOffX()); + axis.setMillisPerPixel(before / factor); + if (Math.abs(axis.getMillisPerPixel() - before) < before * 0.0001) { + return false; + } + axis.pickUnits(Look.style("GanttHeader").getFont().stringWidth("Www 00") + Look.mm(2)); + setOffsets(axis.x(t) - (x - headerW), getOffY()); + if (load != null) { + load.chartChanged(); + } + repaint(); + return true; + } +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/GanttListener.java b/CodenameOne/src/com/codename1/ui/scheduler/GanttListener.java new file mode 100644 index 00000000000..15a033936fe --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/GanttListener.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.scheduling.GanttTask; + +/// What a `Gantt` tells an application about. Extend `GanttAdapter` to +/// implement only what is needed. +public interface GanttListener { + /// The selection changed. `task` is null when nothing is selected now. + void taskSelected(Gantt gantt, GanttTask task); + + /// A task was double tapped, in the table or on the chart. + void taskOpened(Gantt gantt, GanttTask task); + + /// A task was dragged or resized and is about to be given new dates. The + /// project is scheduled again afterwards, so the dates it ends up with + /// are these only where its links and its calendar allow them. + /// + /// #### Returns + /// + /// false to refuse, which leaves the task as it was + boolean taskChanging(Gantt gantt, GanttTask task, long start, long end); + + /// A task was changed by the user and the project has been scheduled + /// again: moved, resized, or given a new progress. + void taskChanged(Gantt gantt, GanttTask task); + + /// A link was dragged from one task to another and is about to be made, + /// finish to start. + /// + /// #### Returns + /// + /// false to refuse + boolean linkCreating(Gantt gantt, GanttTask predecessor, GanttTask successor); +} diff --git a/CodenameOne/src/com/codename1/ui/scheduler/ResourceLoadView.java b/CodenameOne/src/com/codename1/ui/scheduler/ResourceLoadView.java new file mode 100644 index 00000000000..bddcd3fe018 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/ResourceLoadView.java @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.scheduling.ProjectModel; +import com.codename1.scheduling.ResourceLeveler; +import com.codename1.scheduling.ScheduleResource; +import com.codename1.scheduling.TimeMath; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.plaf.Style; + +import java.util.List; + +/// How much of each resource a project asks for over time, under the chart +/// of a `Gantt` and on the same time axis: a row per resource, a bar per +/// stretch of time as tall as the load in it, and the ones over the +/// resource's capacity in another colour. +final class ResourceLoadView extends ScrollSurface { + private final Gantt owner; + private final GanttChart chart; + private int rowH; + private boolean syncing; + + // The profile last worked out, and what it was worked out for. + private float[][] profile; + private long profileFrom; + private long profileBucket; + private int profileCount; + private boolean stale = true; + + ResourceLoadView(Gantt owner, GanttChart chart) { + this.owner = owner; + this.chart = chart; + setUIID("Gantt"); + } + + void modelChanged() { + stale = true; + repaint(); + } + + /// The chart was laid out again or changed its scale. + void chartChanged() { + stale = true; + chartScrolled(); + } + + void chartScrolled() { + if (!syncing) { + setOffsets(chart.getOffX(), getOffY()); + repaint(); + } + } + + int getRowHeight() { + return rowH; + } + + /// The load of a resource in the stretch of time at a point of the axis, + /// as last painted, or -1 when nothing was. + float loadAt(int resourceIndex, long time) { + if (profile == null || resourceIndex < 0 || resourceIndex >= profile.length) { + return -1; + } + long i = (time - profileFrom) / profileBucket; + return i < 0 || i >= profileCount ? -1 : profile[resourceIndex][(int) i]; + } + + @Override + void prepare(int width, int height) { + Style row = Look.style("GanttRow"); + rowH = Math.max(Look.mm(7), row.getFont().getHeight() * 2 + Look.mm(1.5f)); + headerW = chart.headerW; + headerH = 0; + // The chart is the one that knows where time is. + setOffsets(chart.getOffX(), getOffY()); + } + + @Override + int contentWidth() { + return chart.axis().width(); + } + + @Override + int contentHeight() { + ProjectModel model = owner.getModel(); + return model == null ? 0 : model.getResources().size() * rowH; + } + + @Override + protected Dimension calcPreferredSize() { + ProjectModel model = owner.getModel(); + int rows = model == null ? 0 : model.getResources().size(); + Style row = Look.style("GanttRow"); + int h = Math.max(Look.mm(7), row.getFont().getHeight() * 2 + Look.mm(1.5f)); + // Three rows and a part of the fourth, which says there are more. + return new Dimension(Look.mm(60), Math.min(rows * h, h * 7 / 2)); + } + + @Override + void scrolled() { + syncing = true; + chart.scrollTo(getOffX(), chart.getOffY()); + syncing = false; + } + + @Override + boolean zoomBy(float factor, int x, int y) { + return chart.zoomBy(factor, x, y); + } + + // A bucket is a day while a day is wide enough to see, then a week, then + // four of them. + private long bucketSize() { + double mpp = chart.axis().getMillisPerPixel(); + int least = Math.max(3, Look.mm(0.9f)); + if (TimeMath.DAY / mpp >= least) { + return TimeMath.DAY; + } + if (7 * TimeMath.DAY / mpp >= least) { + return 7 * TimeMath.DAY; + } + return 28 * TimeMath.DAY; + } + + private void profile(ProjectModel model, List resources, int left, int right) { + TimeAxis axis = chart.axis(); + long bucket = bucketSize(); + long from = TimeMath.startOfDay(axis.time(Math.max(0, left)), axis.getZone()); + if (bucket > TimeMath.DAY) { + // Whole buckets from the start of the axis, so that scrolling + // does not move their edges. + long first = TimeMath.startOfDay(axis.getStart(), axis.getZone()); + from = first + (from - first) / bucket * bucket; + } + int count = (int) ((axis.time(right) - from) / bucket) + 1; + if (!stale && profile != null && profile.length == resources.size() && from == profileFrom + && bucket == profileBucket && count == profileCount) { + return; + } + float[][] p = new float[resources.size()][]; + for (int i = 0; i < p.length; i++) { + p[i] = ResourceLeveler.loadProfile(model, resources.get(i).getId(), from, from + count * bucket, count); + } + profile = p; + profileFrom = from; + profileBucket = bucket; + profileCount = count; + stale = false; + } + + @Override + void paintBody(Graphics g, int x, int y) { + Style row = Look.style("GanttRow"); + Style loadStyle = Look.style("GanttLoad"); + Style over = Look.style("GanttOverload"); + int cx = g.getClipX(); + int cy = g.getClipY(); + int cw = g.getClipWidth(); + int ch = g.getClipHeight(); + Look.fill(g, row, cx, cy, cw, ch); + ProjectModel model = owner.getModel(); + if (model == null || rowH <= 0) { + return; + } + List resources = model.getResources(); + if (resources.isEmpty()) { + return; + } + TimeAxis axis = chart.axis(); + axis.paintOffHours(g, model.getCalendar(), Look.style("SchedulerOffHours"), x, cy, ch, cx, cx + cw); + axis.paintGrid(g, Look.mix(row.getBgColor(), row.getFgColor(), 45), row.getBgColor(), x, cy, ch, cx, cx + cw); + profile(model, resources, cx - x, cx + cw - x); + int firstRow = Math.max(0, (cy - y) / rowH); + int lastRow = Math.min(resources.size() - 1, (cy + ch - y) / rowH); + int pad = Math.max(1, Look.mm(0.6f)); + int line = Look.mix(row.getBgColor(), row.getFgColor(), 25); + int capColor = Look.mix(row.getBgColor(), row.getFgColor(), 110); + for (int r = firstRow; r <= lastRow; r++) { + ScheduleResource res = resources.get(r); + float capacity = res.getCapacity(); + float[] units = profile[r]; + float peak = capacity; + for (int i = 0; i < units.length; i++) { + peak = Math.max(peak, units[i]); + } + // The capacity is always at the same height unless the load + // goes over it by more than half, and then the row is the peak. + float top = Math.max(capacity * 1.5f, peak); + int base = y + (r + 1) * rowH - 1; + int room = rowH - 2 * pad - 1; + for (int i = 0; i < units.length; i++) { + if (units[i] <= 0) { + continue; + } + int x0 = x + axis.x(profileFrom + i * profileBucket); + int x1 = x + axis.x(profileFrom + (i + 1) * profileBucket); + int h = Math.max(1, Math.round(room * units[i] / top)); + boolean tooMuch = units[i] > capacity + 0.001f; + Look.fill(g, tooMuch ? over : loadStyle, x0, base - h, Math.max(1, x1 - x0 - 1), h); + } + int capY = base - Math.round(room * capacity / top); + g.setColor(capColor); + for (int dx = cx - (cx - x) % 6; dx < cx + cw; dx += 6) { + g.drawLine(dx, capY, dx + 2, capY); + } + g.setColor(line); + g.drawLine(cx, base, cx + cw, base); + if (headerW == 0) { + // No table to name the row in. + Font f = row.getFont(); + g.setFont(f); + g.setColor(row.getFgColor()); + g.drawString(res.getName(), cx + Look.mm(1), y + r * rowH + pad); + } + } + long now = owner.now(); + if (now >= axis.getStart() && now < axis.getEnd()) { + g.setColor(Look.style("SchedulerNow").getFgColor()); + g.fillRect(x + axis.x(now) - 1, cy, 2, ch); + } + } + + @Override + void paintLeftHeader(Graphics g, int x, int y) { + Style row = Look.style("GanttRow"); + Style over = Look.style("GanttOverload"); + int cy = g.getClipY(); + int ch = g.getClipHeight(); + Look.fill(g, row, x, cy, headerW, ch); + ProjectModel model = owner.getModel(); + if (model == null || rowH <= 0) { + return; + } + List resources = model.getResources(); + Font f = row.getFont(); + int pad = Look.mm(1.2f); + int firstRow = Math.max(0, (cy - y) / rowH); + int lastRow = Math.min(resources.size() - 1, (cy + ch - y) / rowH); + int line = Look.mix(row.getBgColor(), row.getFgColor(), 25); + int alpha = g.getAlpha(); + g.setFont(f); + for (int r = firstRow; r <= lastRow; r++) { + ScheduleResource res = resources.get(r); + int top = y + r * rowH; + boolean tooMuch = owner.isOverallocated(res.getId()); + String detail = res.getDetail(); + if (tooMuch) { + detail = owner.localize("gantt.overallocated", "Overallocated"); + } + int textH = detail == null ? f.getHeight() : 2 * f.getHeight(); + int ty = top + (rowH - textH) / 2; + g.setColor(row.getFgColor()); + g.drawString(Look.fit(res.getName(), f, headerW - 2 * pad), x + pad, ty); + if (detail != null) { + if (tooMuch) { + g.setColor(over.getBgColor()); + } else { + g.setAlpha(alpha * 150 / 255); + } + g.drawString(Look.fit(detail, f, headerW - 2 * pad), x + pad, ty + f.getHeight()); + g.setAlpha(alpha); + } + g.setColor(line); + g.drawLine(x, top + rowH - 1, x + headerW, top + rowH - 1); + } + g.setColor(Look.mix(row.getBgColor(), row.getFgColor(), 60)); + g.drawLine(x + headerW - 1, cy, x + headerW - 1, cy + ch); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/scheduler/GanttTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/scheduler/GanttTest.java new file mode 100644 index 00000000000..33e033e5104 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/scheduler/GanttTest.java @@ -0,0 +1,405 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.scheduler; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.scheduling.GanttTask; +import com.codename1.scheduling.LevelingResult; +import com.codename1.scheduling.ProjectModel; +import com.codename1.scheduling.ScheduleResource; +import com.codename1.scheduling.TimeMath; +import com.codename1.scheduling.WorkCalendar; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The Gantt chart through its surface: which rows it has, where the bars +/// are, and what a drag does to the project. January 2026 in UTC, where the +/// 5th is a Monday and a working day is 9 to 17. +class GanttTest extends UITestBase { + private static final ZoneId UTC = ZoneId.of("UTC"); + private static final long D = 8 * TimeMath.HOUR; + + private static long at(int day, int hour) { + return ZonedDateTime.of(2026, 1, day, hour, 0, 0, 0, UTC).toInstant().toEpochMilli(); + } + + private static ProjectModel project() { + return new ProjectModel(new WorkCalendar(UTC), at(5, 0)); + } + + private static GanttTask task(ProjectModel p, GanttTask parent, String id, int days) { + GanttTask t = new GanttTask(id, id, days * D); + if (parent == null) { + p.addTask(t); + } else { + p.addTask(parent, t); + } + return t; + } + + /// Shows a project at a scale of about 48 pixels a day, wide enough to + /// tell the end of a bar from its middle. + private Gantt show(ProjectModel model) { + Form form = Display.getInstance().getCurrent(); + form.setLayout(new BorderLayout()); + Gantt g = new Gantt(model); + g.set24Hours(true); + g.setCurrentTime(at(5, 10)); + g.setDragEdits(true); + form.add(BorderLayout.CENTER, g); + form.revalidate(); + GanttChart chart = g.surface(); + chart.layoutNow(); + double dayPx = TimeMath.DAY / chart.axis().getMillisPerPixel(); + chart.zoom((float) (48 / dayPx)); + chart.layoutNow(); + chart.scrollTo(0, 0); + return g; + } + + /// The rectangle of a task's bar in the chart's own coordinates. + private static int[] bar(GanttChart chart, GanttTask t) { + int[] r = new int[4]; + int row = chart.rows().indexOf(t); + assertTrue(row >= 0, t.getId() + " has a row"); + chart.barRect(t, row, r); + r[0] += chart.headerW - chart.getOffX(); + r[1] += chart.headerH - chart.getOffY(); + return r; + } + + private static void drag(ScrollSurface s, int fromX, int fromY, int toX, int toY) { + int ax = s.getAbsoluteX(); + int ay = s.getAbsoluteY(); + s.pointerPressed(ax + fromX, ay + fromY); + s.pointerDragged(ax + (fromX + toX) / 2, ay + (fromY + toY) / 2); + s.pointerDragged(ax + toX, ay + toY); + s.pointerReleased(ax + toX, ay + toY); + } + + private static void tap(ScrollSurface s, int x, int y) { + s.pointerPressed(s.getAbsoluteX() + x, s.getAbsoluteY() + y); + s.pointerReleased(s.getAbsoluteX() + x, s.getAbsoluteY() + y); + } + + private static int dayPixels(GanttChart chart) { + return chart.axis().x(at(6, 0)) - chart.axis().x(at(5, 0)); + } + + @FormTest + void rowsFollowTheTreeAndACollapsedSummaryHidesItsChildren() { + ProjectModel p = project(); + GanttTask phase = task(p, null, "phase", 0); + GanttTask a = task(p, phase, "a", 2); + GanttTask b = task(p, phase, "b", 1); + GanttTask c = task(p, null, "c", 1); + p.update(); + Gantt g = show(p); + GanttChart chart = g.surface(); + List rows = chart.rows(); + assertEquals(4, rows.size()); + assertSame(phase, rows.get(0)); + assertSame(a, rows.get(1)); + assertSame(b, rows.get(2)); + assertSame(c, rows.get(3)); + + // The triangle before the name of the summary task. + int[] r = bar(chart, phase); + tap(chart, 2, r[1] + r[3] / 2); + assertTrue(g.isCollapsed(phase)); + chart.layoutNow(); + assertEquals(2, chart.rows().size()); + assertSame(c, chart.rows().get(1)); + + g.scrollToTask(b); + assertFalse(g.isCollapsed(phase), "showing a task opens what it is under"); + chart.layoutNow(); + assertEquals(4, chart.rows().size()); + + g.collapseAll(); + chart.layoutNow(); + assertEquals(2, chart.rows().size()); + g.expandAll(); + chart.layoutNow(); + assertEquals(4, chart.rows().size()); + } + + @FormTest + void aBarSpansItsDatesAndAMilestoneIsCentredOnItsDate() { + ProjectModel p = project(); + GanttTask a = task(p, null, "a", 2); + GanttTask done = task(p, null, "done", 0); + p.link(a, done); + p.update(); + assertTrue(done.isMilestone()); + Gantt g = show(p); + GanttChart chart = g.surface(); + TimeAxis axis = chart.axis(); + int[] ra = new int[4]; + chart.barRect(a, 0, ra); + assertEquals(axis.x(at(5, 9)), ra[0]); + assertEquals(axis.x(at(6, 17)) - axis.x(at(5, 9)), ra[2]); + int[] rm = new int[4]; + chart.barRect(done, 1, rm); + assertEquals(axis.x(at(6, 17)), rm[0] + rm[2] / 2, 1); + assertTrue(rm[1] > ra[1], "a row each"); + assertTrue(axis.getStart() < at(5, 0), "room before the first task to drag it earlier"); + assertTrue(axis.getEnd() > at(6, 17)); + } + + @FormTest + void draggingABarConstrainsItsStartAndItsSuccessorFollows() { + ProjectModel p = project(); + GanttTask a = task(p, null, "a", 2); + GanttTask b = task(p, null, "b", 2); + p.link(a, b); + p.update(); + assertEquals(at(7, 9), b.getStart()); + Gantt g = show(p); + GanttChart chart = g.surface(); + final List log = new ArrayList(); + g.addGanttListener(new GanttAdapter() { + @Override + public boolean taskChanging(Gantt gantt, GanttTask task, long start, long end) { + log.add("changing " + task.getId() + " " + (start == at(6, 9)) + " " + (end == at(7, 17))); + return true; + } + + @Override + public void taskChanged(Gantt gantt, GanttTask task) { + log.add("changed " + task.getId()); + } + }); + int[] r = bar(chart, a); + int y = r[1] + r[3] / 4; + drag(chart, r[0] + r[2] / 2, y, r[0] + r[2] / 2 + dayPixels(chart), y); + assertEquals(at(6, 9), a.getStart()); + assertEquals(at(7, 17), a.getEnd()); + assertEquals(GanttTask.Constraint.START_NO_EARLIER_THAN, a.getConstraint()); + assertEquals(at(8, 9), b.getStart(), "the successor is scheduled after it again"); + assertSame(a, g.getSelectedTask()); + assertEquals(2, log.size()); + assertEquals("changing a true true", log.get(0)); + assertEquals("changed a", log.get(1)); + } + + @FormTest + void aListenerRefusesADragAndAReadOnlyChartOffersNone() { + ProjectModel p = project(); + GanttTask a = task(p, null, "a", 2); + p.update(); + Gantt g = show(p); + GanttChart chart = g.surface(); + final int[] asked = new int[1]; + g.addGanttListener(new GanttAdapter() { + @Override + public boolean taskChanging(Gantt gantt, GanttTask task, long start, long end) { + asked[0]++; + return false; + } + }); + int[] r = bar(chart, a); + int y = r[1] + r[3] / 4; + drag(chart, r[0] + r[2] / 2, y, r[0] + r[2] / 2 + dayPixels(chart), y); + assertEquals(1, asked[0]); + assertEquals(at(5, 9), a.getStart()); + assertEquals(GanttTask.Constraint.AS_SOON_AS_POSSIBLE, a.getConstraint()); + + g.setEditable(false); + chart.scrollTo(0, 0); + r = bar(chart, a); + drag(chart, r[0] + r[2] / 2, y, r[0] + r[2] / 2 + dayPixels(chart), y); + assertEquals(1, asked[0], "a read only chart scrolls under a drag"); + assertEquals(at(5, 9), a.getStart()); + } + + @FormTest + void draggingTheEndOfABarChangesItsDuration() { + ProjectModel p = project(); + GanttTask a = task(p, null, "a", 2); + GanttTask b = task(p, null, "b", 1); + p.link(a, b); + p.update(); + Gantt g = show(p); + GanttChart chart = g.surface(); + int[] r = bar(chart, a); + int y = r[1] + r[3] / 4; + drag(chart, r[0] + r[2] - 1, y, r[0] + r[2] - 1 + dayPixels(chart), y); + assertEquals(at(5, 9), a.getStart()); + assertEquals(at(7, 17), a.getEnd()); + assertEquals(3 * D, a.getDuration()); + assertEquals(at(8, 9), b.getStart()); + } + + @FormTest + void theMarkerOfTheSelectedTaskSetsItsProgress() { + ProjectModel p = project(); + GanttTask a = task(p, null, "a", 2); + p.update(); + Gantt g = show(p); + GanttChart chart = g.surface(); + g.setSelectedTask(a); + int[] r = bar(chart, a); + int y = r[1] + r[3]; + drag(chart, r[0], y, r[0] + r[2] / 2, y); + assertEquals(50, a.getPercentComplete()); + assertEquals(at(5, 9), a.getStart(), "progress does not move the task"); + } + + @FormTest + void draggingFromTheEndOfABarOntoAnotherLinksThem() { + ProjectModel p = project(); + GanttTask a = task(p, null, "a", 2); + GanttTask b = task(p, null, "b", 2); + p.update(); + assertEquals(at(5, 9), b.getStart()); + Gantt g = show(p); + GanttChart chart = g.surface(); + final List asked = new ArrayList(); + g.addGanttListener(new GanttAdapter() { + @Override + public boolean linkCreating(Gantt gantt, GanttTask predecessor, GanttTask successor) { + asked.add(predecessor.getId() + ">" + successor.getId()); + return true; + } + }); + g.setSelectedTask(a); + int[] ra = bar(chart, a); + int[] rb = bar(chart, b); + drag(chart, ra[0] + ra[2] + Look.mm(2.2f), ra[1] + ra[3] / 2, rb[0] + rb[2] / 2, rb[1] + rb[3] / 2); + assertEquals(1, asked.size()); + assertEquals("a>b", asked.get(0)); + assertEquals(1, p.getDependencies().size()); + assertSame(a, p.getDependencies().get(0).getPredecessor()); + assertSame(b, p.getDependencies().get(0).getSuccessor()); + assertEquals(at(7, 9), b.getStart()); + + // The other way round would be a cycle, and is not offered. + g.setSelectedTask(b); + chart.layoutNow(); + chart.reflowDone(); + ra = bar(chart, a); + rb = bar(chart, b); + drag(chart, rb[0] + rb[2] + Look.mm(2.2f), rb[1] + rb[3] / 2, ra[0] + ra[2] / 2, ra[1] + ra[3] / 2); + assertEquals(1, asked.size()); + assertEquals(1, p.getDependencies().size()); + } + + @FormTest + void tapsSelectAndADoubleTapOpens() { + ProjectModel p = project(); + GanttTask a = task(p, null, "a", 2); + GanttTask b = task(p, null, "b", 2); + p.update(); + Gantt g = show(p); + GanttChart chart = g.surface(); + final List log = new ArrayList(); + g.addGanttListener(new GanttAdapter() { + @Override + public void taskSelected(Gantt gantt, GanttTask task) { + log.add("selected " + (task == null ? "nothing" : task.getId())); + } + + @Override + public void taskOpened(Gantt gantt, GanttTask task) { + log.add("opened " + task.getId()); + } + }); + int[] rb = bar(chart, b); + tap(chart, rb[0] + rb[2] / 2, rb[1] + rb[3] / 2); + assertSame(b, g.getSelectedTask()); + tap(chart, rb[0] + rb[2] / 2, rb[1] + rb[3] / 2); + assertEquals(2, log.size()); + assertEquals("selected b", log.get(0)); + assertEquals("opened b", log.get(1)); + + // The name in the table selects as the bar does. + int[] ra = bar(chart, a); + tap(chart, chart.headerW / 2, ra[1] + ra[3] / 2); + assertSame(a, g.getSelectedTask()); + assertNotNull(chart.barAt(rb[0] + 1, rb[1] + 1)); + assertNull(chart.barAt(rb[0] + rb[2] + 3 * dayPixels(chart), rb[1] + 1)); + } + + @FormTest + void levelingDelaysTheSecondTaskOfOnePerson() { + ProjectModel p = project(); + p.addResource(new ScheduleResource("ann", "Ann")); + GanttTask a = task(p, null, "a", 2).assign("ann"); + GanttTask b = task(p, null, "b", 2).assign("ann"); + p.update(); + Gantt g = show(p); + assertTrue(g.isOverallocated("ann")); + LevelingResult r = g.level(); + assertTrue(r.isChanged()); + assertEquals(at(5, 9), a.getStart()); + assertEquals(at(7, 9), b.getStart()); + assertFalse(g.isOverallocated("ann")); + assertFalse(g.level().isChanged(), "nothing left to level"); + } + + @FormTest + void theLoadIsShownOnlyWithResourcesAndScrollsWithTheChart() { + ProjectModel p = project(); + GanttTask a = task(p, null, "a", 30); + p.update(); + Gantt g = show(p); + GanttChart chart = g.surface(); + ResourceLoadView load = g.loadSurface(); + assertTrue(load.isHidden()); + g.setLoadVisible(true); + assertTrue(load.isHidden(), "nothing to show without a resource"); + + p.addResource(new ScheduleResource("ann", "Ann")); + a.assign("ann"); + p.update(); + assertFalse(load.isHidden()); + Display.getInstance().getCurrent().revalidate(); + chart.layoutNow(); + load.layoutNow(); + assertEquals(chart.headerW, load.headerW, "one time axis for both"); + + chart.scrollTo(3 * dayPixels(chart), 0); + assertTrue(chart.getOffX() > 0); + assertEquals(chart.getOffX(), load.getOffX()); + load.scrollTo(dayPixels(chart), 0); + assertEquals(dayPixels(chart), chart.getOffX()); + + g.setLoadVisible(false); + assertTrue(load.isHidden()); + } +} From 26b11fbd186e6735375ebaeff99cbeeb93f49094 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:57:16 +0200 Subject: [PATCH 04/19] Scheduling: tests for the memory and calendar schedule models Co-Authored-By: Claude Fable 5.2 --- .../scheduling/ScheduleModelTest.java | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 maven/core-unittests/src/test/java/com/codename1/scheduling/ScheduleModelTest.java diff --git a/maven/core-unittests/src/test/java/com/codename1/scheduling/ScheduleModelTest.java b/maven/core-unittests/src/test/java/com/codename1/scheduling/ScheduleModelTest.java new file mode 100644 index 00000000000..c66455bd831 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/scheduling/ScheduleModelTest.java @@ -0,0 +1,330 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.scheduling; + +import com.codename1.calendar.CalendarDateTime; +import com.codename1.calendar.CalendarEvent; +import com.codename1.calendar.CalendarException; +import com.codename1.calendar.CalendarError; +import com.codename1.calendar.CalendarInfo; +import com.codename1.calendar.CalendarMutationScope; +import com.codename1.calendar.CalendarPage; +import com.codename1.calendar.CalendarQuery; +import com.codename1.calendar.CalendarSource; +import com.codename1.calendar.FreeBusyInterval; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.util.AsyncResource; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; + +import static com.codename1.scheduling.WorkCalendarTest.at; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The models a scheduler reads: the one in memory, and the one over a +/// calendar source. January 2026 in UTC. +class ScheduleModelTest extends UITestBase { + private static final ZoneId UTC = ZoneId.of("UTC"); + + private static List items(ScheduleModel m, long from, long to) { + List out = new ArrayList(); + m.getItems(from, to, out); + return out; + } + + private static final class Counter implements ScheduleChangeListener { + int count; + + @Override + public void scheduleChanged(ScheduleModel model) { + count++; + } + } + + @FormTest + void theMemoryModelAnswersARangeAndTellsItsListeners() { + DefaultScheduleModel m = new DefaultScheduleModel(); + Counter c = new Counter(); + m.addChangeListener(c); + ScheduleItem a = new ScheduleItem("a", "A", at(5, 9), at(5, 10)); + ScheduleItem b = new ScheduleItem("b", "B", at(6, 9), at(6, 10)); + m.addItem(a).addItem(b); + assertEquals(2, c.count); + assertEquals(1, items(m, at(5, 0), at(6, 0)).size()); + assertSame(a, items(m, at(5, 0), at(6, 0)).get(0)); + assertTrue(items(m, at(5, 10), at(6, 9)).isEmpty(), "an item ends before its end and starts at its start"); + assertEquals(2, items(m, at(5, 0), at(7, 0)).size()); + assertSame(b, m.getItem("b")); + + assertTrue(m.updateItem(a, at(6, 12), at(6, 13), "ann")); + assertEquals(at(6, 12), a.getStart()); + assertEquals("ann", a.getResourceId()); + assertEquals(3, c.count); + + m.setCreationEnabled(false); + assertNull(m.createItem(at(7, 9), at(7, 10), null), "a model can refuse new items"); + m.setCreationEnabled(true).setDefaultDuration(30 * TimeMath.MINUTE); + ScheduleItem made = m.createItem(at(7, 9), at(7, 9), "ann"); + assertNotNull(made); + assertEquals(at(7, 9) + 30 * TimeMath.MINUTE, made.getEnd()); + assertEquals("ann", made.getResourceId()); + assertEquals(3, m.getAllItems().size()); + + assertTrue(m.removeItem(made)); + assertFalse(m.removeItem(made)); + m.removeChangeListener(c); + int before = c.count; + m.clearItems(); + assertEquals(before, c.count); + } + + /// A source that keeps its events in a list, answers two to a page, and + /// fails its next save when told to. + private static final class ListSource extends CalendarSource { + final List events = new ArrayList(); + final List queries = new ArrayList(); + final List scopes = new ArrayList(); + final List deleted = new ArrayList(); + boolean failSave; + boolean failDelete; + int saves; + + ListSource() { + super("list", "List"); + } + + @Override + public AsyncResource> queryEvents(CalendarQuery q) { + queries.add(q); + List mine = new ArrayList(); + for (CalendarEvent e : events) { + if (q.getCalendarId().equals(e.getCalendarId())) { + mine.add(e); + } + } + int from = q.getPageToken() == null ? 0 : Integer.parseInt(q.getPageToken()); + int to = Math.min(mine.size(), from + 2); + AsyncResource> out = new AsyncResource>(); + out.complete(new CalendarPage(new ArrayList(mine.subList(from, to)), + to < mine.size() ? String.valueOf(to) : null, null)); + return out; + } + + @Override + public AsyncResource> queryFreeBusy(List ids, Instant from, Instant to) { + List list = new ArrayList(); + list.add(new FreeBusyInterval(Instant.ofEpochMilli(at(5, 13)), Instant.ofEpochMilli(at(5, 14)), + CalendarEvent.Availability.BUSY)); + list.add(new FreeBusyInterval(Instant.ofEpochMilli(at(5, 15)), Instant.ofEpochMilli(at(5, 16)), + CalendarEvent.Availability.FREE)); + AsyncResource> out = new AsyncResource>(); + out.complete(list); + return out; + } + + @Override + public AsyncResource saveEvent(CalendarEvent event, CalendarMutationScope scope) { + saves++; + scopes.add(scope); + AsyncResource out = new AsyncResource(); + if (failSave) { + out.error(new CalendarException(CalendarError.CONFLICT, "refused")); + } else { + if (event.getId() == null) { + event.setId("saved-" + saves); + events.add(event); + } + out.complete(event); + } + return out; + } + + @Override + public AsyncResource deleteEvent(String calendarId, String eventId, CalendarMutationScope scope, + String version) { + AsyncResource out = new AsyncResource(); + if (failDelete) { + out.error(new CalendarException(CalendarError.CONFLICT, "refused")); + } else { + deleted.add(eventId); + out.complete(Boolean.TRUE); + } + return out; + } + } + + private static CalendarEvent event(String calendar, String id, long start, long end) { + return new CalendarEvent().setCalendarId(calendar).setId(id).setTitle(id) + .setStart(CalendarDateTime.instant(Instant.ofEpochMilli(start), UTC)) + .setEnd(CalendarDateTime.instant(Instant.ofEpochMilli(end), UTC)); + } + + private static ScheduleItem find(List list, String id) { + for (ScheduleItem i : list) { + if (i.getId().equals(id)) { + return i; + } + } + return null; + } + + @FormTest + void theCalendarModelLoadsEveryPageOfEveryCalendar() { + ListSource source = new ListSource(); + source.events.add(event("work", "a", at(5, 9), at(5, 10))); + source.events.add(event("work", "b", at(5, 11), at(5, 12))); + source.events.add(event("work", "c", at(6, 9), at(6, 10))); + source.events.add(event("work", "gone", at(6, 11), at(6, 12)).setStatus(CalendarEvent.Status.CANCELED)); + source.events.add(new CalendarEvent().setCalendarId("home").setId("trip").setTitle("Trip") + .setStart(CalendarDateTime.allDay(LocalDate.of(2026, 1, 6))) + .setEnd(CalendarDateTime.allDay(LocalDate.of(2026, 1, 8)))); + source.events.add(event("home", "run", at(7, 7), at(7, 8)).setRecurringEventId("series")); + + CalendarScheduleModel m = new CalendarScheduleModel(source, UTC); + Counter c = new Counter(); + m.addChangeListener(c); + m.addCalendar(new CalendarInfo().setId("work").setName("Work").setColor(0xff112233)); + m.addCalendar(new CalendarInfo().setId("home").setName("Home").setReadOnly(true)); + m.addFreeBusyCalendar(new CalendarInfo().setId("boss").setName("Boss")); + assertTrue(source.queries.isEmpty(), "nothing is asked for before a range is shown"); + m.setVisibleRange(at(5, 0), at(8, 0)); + flushSerialCalls(); + assertTrue(c.count > 0); + assertNull(m.getLastError()); + + assertEquals(3, m.getResources().size()); + assertEquals("Work", m.getResources().get(0).getName()); + assertEquals(0x112233, m.getResources().get(0).getColor()); + CalendarQuery first = source.queries.get(0); + assertTrue(first.getStartTime().toEpochMilli() < at(5, 0), "more than the range is loaded"); + assertTrue(first.getEndTime().toEpochMilli() > at(8, 0)); + + List all = items(m, at(5, 0), at(8, 0)); + assertNotNull(find(all, "a")); + assertNotNull(find(all, "b")); + assertNotNull(find(all, "c"), "the second page"); + assertNull(find(all, "gone"), "a cancelled event is not shown"); + ScheduleItem trip = find(all, "trip"); + assertTrue(trip.isAllDay()); + assertEquals(at(6, 0), trip.getStart()); + assertEquals(at(8, 0), trip.getEnd()); + assertFalse(trip.isMovable(), "a read only calendar"); + ScheduleItem run = find(all, "run@" + at(7, 7)); + assertNotNull(run, "an occurrence is told from the others of its series by its start"); + assertTrue(run.isRecurring()); + ScheduleItem busy = null; + for (ScheduleItem i : all) { + if (i.getKind() == ScheduleItem.Kind.BLOCKED) { + assertNull(busy, "free time is not an item"); + busy = i; + } + } + assertNotNull(busy); + assertEquals("boss", busy.getResourceId()); + assertEquals(at(5, 13), busy.getStart()); + assertNull(CalendarScheduleModel.eventOf(busy)); + + // Inside what is loaded nothing is asked again. + int asked = source.queries.size(); + m.setVisibleRange(at(6, 0), at(7, 0)); + flushSerialCalls(); + assertEquals(asked, source.queries.size()); + m.setVisibleRange(at(20, 0), at(23, 0)); + flushSerialCalls(); + assertTrue(source.queries.size() > asked); + + m.removeCalendar("boss"); + assertEquals(2, m.getResources().size()); + m.dispose(); + } + + @FormTest + void anEditIsSavedAndPutBackWhenTheSourceRefuses() { + ListSource source = new ListSource(); + CalendarEvent a = event("work", "a", at(5, 9), at(5, 10)); + source.events.add(a); + CalendarScheduleModel m = new CalendarScheduleModel(source, UTC); + m.addCalendar(new CalendarInfo().setId("work").setName("Work")); + m.addCalendar(new CalendarInfo().setId("home").setName("Home")); + m.setVisibleRange(at(5, 0), at(6, 0)); + flushSerialCalls(); + ScheduleItem item = find(items(m, at(5, 0), at(6, 0)), "a"); + assertSame(a, CalendarScheduleModel.eventOf(item)); + + assertTrue(m.updateItem(item, at(5, 14), at(5, 16), "home")); + flushSerialCalls(); + assertEquals(1, source.saves); + assertEquals(CalendarMutationScope.THIS_INSTANCE, source.scopes.get(0)); + assertEquals(at(5, 14), a.getStart().getDateTime().toInstant().toEpochMilli()); + assertEquals(at(5, 16), a.getEnd().getDateTime().toInstant().toEpochMilli()); + assertEquals("home", a.getCalendarId()); + assertEquals("home", item.getResourceId()); + assertNull(m.getLastError()); + + source.failSave = true; + assertTrue(m.updateItem(item, at(5, 17), at(5, 18), "work")); + flushSerialCalls(); + assertNotNull(m.getLastError()); + assertEquals(at(5, 14), item.getStart(), "the item is where it was"); + assertEquals("home", item.getResourceId()); + assertEquals("home", a.getCalendarId()); + assertEquals(at(5, 14), a.getStart().getDateTime().toInstant().toEpochMilli()); + assertSame(item, find(items(m, at(5, 0), at(6, 0)), "a")); + + // A new event that is refused is taken out again. + assertNull(m.createItem(at(5, 11), at(5, 12), "work"), "creation is off until it is asked for"); + m.setCreationEnabled(true, "New"); + int before = items(m, at(5, 0), at(6, 0)).size(); + ScheduleItem refused = m.createItem(at(5, 11), at(5, 12), "work"); + flushSerialCalls(); + assertNotNull(refused); + assertEquals(before, items(m, at(5, 0), at(6, 0)).size()); + + source.failSave = false; + ScheduleItem made = m.createItem(at(5, 11), at(5, 12), "work"); + flushSerialCalls(); + assertEquals("New", made.getTitle()); + assertEquals(before + 1, items(m, at(5, 0), at(6, 0)).size()); + assertNotNull(CalendarScheduleModel.eventOf(made).getId()); + + source.failDelete = true; + assertTrue(m.removeItem(item)); + flushSerialCalls(); + assertNotNull(find(items(m, at(5, 0), at(6, 0)), "a"), "a refused delete leaves the item"); + source.failDelete = false; + assertTrue(m.removeItem(item)); + flushSerialCalls(); + assertNull(find(items(m, at(5, 0), at(6, 0)), "a")); + assertEquals("a", source.deleted.get(0)); + m.dispose(); + } +} From feb76778e6616234a6932e6db401ee20d4938948 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:04:59 +0200 Subject: [PATCH 05/19] Picker: Material 3 date and time pickers, chosen by the pickerStyle theme constant Co-Authored-By: Claude Fable 5.2 --- .../ui/spinner/MaterialDatePicker.java | 709 ++++++++++++++++++ .../ui/spinner/MaterialDateTimePicker.java | 118 +++ .../ui/spinner/MaterialTimePicker.java | 628 ++++++++++++++++ .../src/com/codename1/ui/spinner/Picker.java | 163 ++++ .../impl/android/AndroidImplementation.java | 7 +- .../ui/spinner/MaterialPickerTest.java | 350 +++++++++ 6 files changed, 1974 insertions(+), 1 deletion(-) create mode 100644 CodenameOne/src/com/codename1/ui/spinner/MaterialDatePicker.java create mode 100644 CodenameOne/src/com/codename1/ui/spinner/MaterialDateTimePicker.java create mode 100644 CodenameOne/src/com/codename1/ui/spinner/MaterialTimePicker.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/ui/spinner/MaterialPickerTest.java diff --git a/CodenameOne/src/com/codename1/ui/spinner/MaterialDatePicker.java b/CodenameOne/src/com/codename1/ui/spinner/MaterialDatePicker.java new file mode 100644 index 00000000000..9cb4175898e --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/spinner/MaterialDatePicker.java @@ -0,0 +1,709 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.spinner; + +import com.codename1.l10n.DateFormatSymbols; +import com.codename1.l10n.L10NManager; +import com.codename1.ui.Button; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.Font; +import com.codename1.ui.FontImage; +import com.codename1.ui.Graphics; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.events.DataChangedListener; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.util.EventDispatcher; + +import java.util.Calendar; +import java.util.Date; + +/// A date picker in the Material 3 style: the chosen date in large type over +/// a month of days, a grid of years behind the month's name, and a text +/// field for somebody who would sooner type. +/// +/// A `Picker` opens one in a dialog when the theme constant `pickerStyle` is +/// `material`. It is a component like any other and can be placed in a form +/// directly: +/// +/// ```java +/// MaterialDatePicker picker = new MaterialDatePicker(); +/// picker.setDate(new Date()); +/// picker.addActionListener(e -> show(picker.getDate())); +/// form.add(picker); +/// ``` +/// +/// The value is a `Date`. Its time of day is kept as it was given, so a date +/// chosen here does not move an appointment to midnight. +/// +/// #### Styling +/// +/// `PickerMaterialOverline` is the small title and `PickerMaterialHeadline` +/// the chosen date; `PickerMaterialButton` the name of the month, the arrows +/// and the pencil. `PickerMaterialWeekday` gives the initials of the days +/// their colour and `PickerMaterialDay` the days theirs: its selected style +/// is the circle of the chosen day, background and foreground, and its +/// disabled style a day outside the range. `PickerMaterialYear` is a year in +/// the grid, `PickerMaterialField` the text field and `PickerMaterialError` +/// what is said about a date that cannot be read. +public class MaterialDatePicker extends Container implements InternalPickerWidget { + private static final int DAYS = 0; + private static final int YEARS = 1; + private static final int INPUT = 2; + + private int year; + private int month; + private int day; + private int shownYear; + private int shownMonth; + // The time of day of the value, carried through a change of date. + private int hour; + private int minute; + private int second; + private int millis; + private int minKey = Integer.MIN_VALUE; + private int maxKey = Integer.MAX_VALUE; + private int firstDayOfWeek = Calendar.SUNDAY; + private int startYear = 1900; + private int endYear = 2100; + private int mode = DAYS; + private int[] fieldOrder; + private char separator = '/'; + + private final Label overline = new Label("", "PickerMaterialOverline"); + private final Label headline = new Label("", "PickerMaterialHeadline"); + private final Button modeButton = new Button("", "PickerMaterialButton"); + private final Button monthButton = new Button("", "PickerMaterialButton"); + private final Button previous = new Button("", "PickerMaterialButton"); + private final Button next = new Button("", "PickerMaterialButton"); + private final Container navigation = new Container(new BorderLayout()); + private final DayPane days = new DayPane(); + private Container years; + private final TextField input = new TextField(); + private final Label error = new Label("", "PickerMaterialError"); + private final Container inputPane = new Container(BoxLayout.y()); + private final Container body; + private final EventDispatcher listeners = new EventDispatcher(); + private boolean updating; + private final DateFormatSymbols symbols = new DateFormatSymbols(); + + /// A picker showing today. + public MaterialDatePicker() { + super(new BorderLayout()); + setUIID("PickerMaterial"); + overline.setText(getUIManager().localize("picker.selectDate", "Select date")); + Calendar c = Calendar.getInstance(); + read(c); + shownYear = year; + shownMonth = month; + + Container head = new Container(new BorderLayout()); + head.setUIID("PickerMaterialHeader"); + head.add(BorderLayout.NORTH, overline); + head.add(BorderLayout.CENTER, headline); + head.add(BorderLayout.EAST, modeButton); + modeButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + setInputMode(mode != INPUT); + } + }); + + monthButton.setTextPosition(Component.LEFT); + FontImage.setMaterialIcon(monthButton, FontImage.MATERIAL_ARROW_DROP_DOWN); + monthButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + show(mode == YEARS ? DAYS : YEARS); + } + }); + FontImage.setMaterialIcon(previous, FontImage.MATERIAL_CHEVRON_LEFT); + FontImage.setMaterialIcon(next, FontImage.MATERIAL_CHEVRON_RIGHT); + previous.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + page(-1); + } + }); + next.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + page(1); + } + }); + Container arrows = new Container(BoxLayout.x()); + arrows.add(previous).add(next); + navigation.add(BorderLayout.WEST, monthButton); + navigation.add(BorderLayout.EAST, arrows); + + // Whatever the body shows, it asks for the room the month takes, so + // that the dialog around it does not change its size. + body = new Container(new BorderLayout()) { + @Override + protected Dimension calcPreferredSize() { + Dimension d = days.getPreferredSize(); + return new Dimension(d.getWidth(), d.getHeight() + navigation.getPreferredH()); + } + }; + body.add(BorderLayout.NORTH, navigation); + body.add(BorderLayout.CENTER, days); + + input.setUIID("PickerMaterialField"); + input.setConstraint(TextArea.NUMERIC); + input.addDataChangedListener(new DataChangedListener() { + @Override + public void dataChanged(int type, int index) { + typed(); + } + }); + inputPane.add(input).add(error); + + add(BorderLayout.NORTH, head); + add(BorderLayout.CENTER, body); + refresh(); + } + + // -------------------------------------------------------------- value + + private void read(Calendar c) { + year = c.get(Calendar.YEAR); + month = c.get(Calendar.MONTH); + day = c.get(Calendar.DAY_OF_MONTH); + hour = c.get(Calendar.HOUR_OF_DAY); + minute = c.get(Calendar.MINUTE); + second = c.get(Calendar.SECOND); + millis = c.get(Calendar.MILLISECOND); + } + + private static int key(int y, int m, int d) { + return y * 10000 + m * 100 + d; + } + + private static int key(Date d) { + Calendar c = Calendar.getInstance(); + c.setTime(d); + return key(c.get(Calendar.YEAR), c.get(Calendar.MONTH), c.get(Calendar.DAY_OF_MONTH)); + } + + static int daysIn(int y, int m) { + if (m == 1) { + return y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) ? 29 : 28; + } + return m == 3 || m == 5 || m == 8 || m == 10 ? 30 : 31; + } + + /// The chosen date, at the time of day of the date last set. + public Date getDate() { + Calendar c = Calendar.getInstance(); + c.set(Calendar.YEAR, year); + c.set(Calendar.MONTH, month); + c.set(Calendar.DAY_OF_MONTH, day); + c.set(Calendar.HOUR_OF_DAY, hour); + c.set(Calendar.MINUTE, minute); + c.set(Calendar.SECOND, second); + c.set(Calendar.MILLISECOND, millis); + return c.getTime(); + } + + /// Chooses a date and shows its month. A date outside the range is + /// brought to the nearest end of it. A null date is ignored. + public void setDate(Date date) { + if (date == null) { + return; + } + Calendar c = Calendar.getInstance(); + c.setTime(date); + read(c); + clamp(); + shownYear = year; + shownMonth = month; + refresh(); + } + + private void clamp() { + int k = key(year, month, day); + if (k < minKey) { + k = minKey; + } else if (k > maxKey) { + k = maxKey; + } else { + return; + } + year = k / 10000; + month = k / 100 % 100; + day = k % 100; + } + + @Override + public Object getValue() { + return getDate(); + } + + @Override + public void setValue(Object value) { + if (value instanceof Date) { + setDate((Date) value); + } + } + + /// The first and the last day that can be chosen. Either may be null + /// for no limit on that side. + public void setDateRange(Date first, Date last) { + minKey = first == null ? Integer.MIN_VALUE : key(first); + maxKey = last == null ? Integer.MAX_VALUE : key(last); + if (maxKey < minKey) { + maxKey = minKey; + } + clamp(); + years = null; + refresh(); + } + + /// The years the grid of years offers, 1900 to 2100 unless set. A date + /// range narrows them further. + public void setYearRange(int first, int last) { + startYear = first; + endYear = Math.max(first, last); + years = null; + } + + /// The day a week starts on, as `java.util.Calendar` numbers them: + /// `Calendar.SUNDAY` by default, `Calendar.MONDAY` for most of Europe. + public void setFirstDayOfWeek(int calendarDay) { + if (calendarDay < Calendar.SUNDAY || calendarDay > Calendar.SATURDAY) { + throw new IllegalArgumentException("calendarDay"); + } + firstDayOfWeek = calendarDay; + days.repaint(); + } + + public int getFirstDayOfWeek() { + return firstDayOfWeek; + } + + /// The small title over the date: "Select date" unless set. + public void setTitle(String title) { + overline.setText(title == null ? "" : title); + } + + public String getTitle() { + return overline.getText(); + } + + /// Told when the user chooses another date. + public void addActionListener(ActionListener l) { + listeners.addListener(l); + } + + public void removeActionListener(ActionListener l) { + listeners.removeListener(l); + } + + boolean isEnabledDay(int y, int m, int d) { + int k = key(y, m, d); + return k >= minKey && k <= maxKey; + } + + /// Chooses a day as a tap on it does. + void choose(int y, int m, int d) { + if (!isEnabledDay(y, m, d)) { + return; + } + boolean differs = y != year || m != month || d != day; + year = y; + month = m; + day = d; + shownYear = y; + shownMonth = m; + refresh(); + if (differs) { + listeners.fireActionEvent(new ActionEvent(this, ActionEvent.Type.Other)); + } + } + + // --------------------------------------------------------------- view + + int getShownYear() { + return shownYear; + } + + int getShownMonth() { + return shownMonth; + } + + Component dayPane() { + return days; + } + + TextField inputField() { + return input; + } + + String errorText() { + return error.getText(); + } + + /// Shows the month before or after the one shown. + void page(int months) { + int m = shownYear * 12 + shownMonth + months; + int lo = Math.max(startYear * 12, minKey == Integer.MIN_VALUE ? Integer.MIN_VALUE : minKey / 10000 * 12 + minKey / 100 % 100); + int hi = Math.min(endYear * 12 + 11, maxKey == Integer.MAX_VALUE ? Integer.MAX_VALUE : maxKey / 10000 * 12 + maxKey / 100 % 100); + m = Math.max(lo, Math.min(hi, m)); + shownYear = m / 12; + shownMonth = m % 12; + refresh(); + } + + /// Whether the date is typed and not picked from the month. + public boolean isInputMode() { + return mode == INPUT; + } + + public void setInputMode(boolean v) { + show(v ? INPUT : DAYS); + } + + private void show(int m) { + if (m == mode) { + return; + } + mode = m; + body.removeAll(); + if (m == INPUT) { + body.add(BorderLayout.NORTH, inputPane); + error.setText(""); + updating = true; + input.setText(typedForm(year, month, day)); + input.setHint(hint()); + updating = false; + } else { + body.add(BorderLayout.NORTH, navigation); + if (m == YEARS) { + body.add(BorderLayout.CENTER, yearPane()); + } else { + body.add(BorderLayout.CENTER, days); + } + } + refresh(); + body.revalidateLater(); + } + + private void refresh() { + DateFormatSymbols s = symbols; + Calendar c = Calendar.getInstance(); + c.set(Calendar.YEAR, year); + c.set(Calendar.MONTH, month); + c.set(Calendar.DAY_OF_MONTH, day); + headline.setText(s.getShortWeekdays()[c.get(Calendar.DAY_OF_WEEK) - 1] + ", " + + s.getShortMonths()[month] + " " + day); + monthButton.setText(s.getMonths()[shownMonth] + " " + shownYear); + FontImage.setMaterialIcon(modeButton, mode == INPUT ? FontImage.MATERIAL_CALENDAR_TODAY : FontImage.MATERIAL_EDIT); + previous.setVisible(mode == DAYS); + next.setVisible(mode == DAYS); + days.repaint(); + } + + private Container yearPane() { + if (years == null) { + int first = Math.max(startYear, minKey == Integer.MIN_VALUE ? startYear : minKey / 10000); + int last = Math.min(endYear, maxKey == Integer.MAX_VALUE ? endYear : maxKey / 10000); + last = Math.max(first, last); + Container grid = new Container(new GridLayout((last - first + 3) / 3, 3)); + grid.setScrollableY(true); + for (int y = first; y <= last; y++) { + final int value = y; + Button b = new Button(String.valueOf(y), "PickerMaterialYear"); + b.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + shownYear = value; + page(0); + show(DAYS); + } + }); + grid.add(b); + } + years = grid; + } + // The year shown is the one to start from, not 1900. + int count = years.getComponentCount(); + for (int i = 0; i < count; i++) { + Button b = (Button) years.getComponentAt(i); + boolean current = b.getText().equals(String.valueOf(shownYear)); + b.setUIID(current ? "PickerMaterialYearSelected" : "PickerMaterialYear"); + if (current) { + final Button target = b; + final Container pane = years; + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + pane.scrollComponentToVisible(target); + } + }); + } + } + return years; + } + + // -------------------------------------------------------------- typed + + // The order a short date is written in here: which of the three numbers + // is the year, the month and the day. Read off a date whose three parts + // cannot be mistaken for one another. + private int[] order() { + if (fieldOrder == null) { + int[] o = {0, 1, 2}; + Calendar c = Calendar.getInstance(); + c.set(Calendar.YEAR, 1999); + c.set(Calendar.MONTH, 10); + c.set(Calendar.DAY_OF_MONTH, 22); + String s = L10NManager.getInstance().formatDateShortStyle(c.getTime()); + int y = s == null ? -1 : s.indexOf("99"); + int m = s == null ? -1 : s.indexOf("11"); + int d = s == null ? -1 : s.indexOf("22"); + if (y >= 0 && m >= 0 && d >= 0) { + // Each field's place among the three: 0 year, 1 month, 2 day. + o[0] = (y > m ? 1 : 0) + (y > d ? 1 : 0); + o[1] = (m > y ? 1 : 0) + (m > d ? 1 : 0); + o[2] = (d > y ? 1 : 0) + (d > m ? 1 : 0); + for (int i = Math.min(m, d); i < s.length(); i++) { + char ch = s.charAt(i); + if (ch < '0' || ch > '9') { + separator = ch; + break; + } + } + } + fieldOrder = o; + } + return fieldOrder; + } + + private static String two(int v) { + return v < 10 ? "0" + v : String.valueOf(v); + } + + String typedForm(int y, int m, int d) { + int[] o = order(); + String[] parts = new String[3]; + parts[o[0]] = String.valueOf(y); + parts[o[1]] = two(m + 1); + parts[o[2]] = two(d); + return parts[0] + separator + parts[1] + separator + parts[2]; + } + + private String hint() { + int[] o = order(); + String[] parts = new String[3]; + parts[o[0]] = "yyyy"; + parts[o[1]] = "mm"; + parts[o[2]] = "dd"; + return parts[0] + separator + parts[1] + separator + parts[2]; + } + + private void typed() { + if (updating || mode != INPUT) { + return; + } + String text = input.getText(); + int[] numbers = new int[3]; + int count = 0; + boolean inNumber = false; + boolean bad = false; + for (int i = 0; text != null && i < text.length(); i++) { + char ch = text.charAt(i); + if (ch >= '0' && ch <= '9') { + if (!inNumber) { + if (count == 3) { + bad = true; + break; + } + count++; + inNumber = true; + } + numbers[count - 1] = Math.min(100000, numbers[count - 1] * 10 + (ch - '0')); + } else { + inNumber = false; + } + } + int[] o = order(); + int y = numbers[o[0]]; + int m = numbers[o[1]] - 1; + int d = numbers[o[2]]; + if (bad || count != 3 || m < 0 || m > 11 || d < 1 || d > daysIn(y, m) || y < startYear || y > endYear) { + error.setText(getUIManager().localize("picker.invalidDate", "Invalid date") + ": " + hint()); + return; + } + if (!isEnabledDay(y, m, d)) { + error.setText(getUIManager().localize("picker.outOfRange", "Out of range")); + return; + } + error.setText(""); + updating = true; + choose(y, m, d); + updating = false; + } + + // ---------------------------------------------------------- the month + + /// The days of the month shown, painted and not built of components: a + /// row of initials and six weeks. + private final class DayPane extends Component { + private int pressedDay; + + DayPane() { + setUIID("PickerMaterialDays"); + setFocusable(false); + } + + @Override + protected Dimension calcPreferredSize() { + int cell = Math.max(Display.getInstance().convertToPixels(6.2f), + getUIManager().getComponentStyle("PickerMaterialDay").getFont().getHeight() * 2); + Style s = getStyle(); + return new Dimension(cell * 7 + s.getHorizontalPadding(), cell * 7 + s.getVerticalPadding()); + } + + // Which cell of the grid the first of the month is in. + private int lead() { + Calendar c = Calendar.getInstance(); + c.set(Calendar.DAY_OF_MONTH, 1); + c.set(Calendar.YEAR, shownYear); + c.set(Calendar.MONTH, shownMonth); + int l = c.get(Calendar.DAY_OF_WEEK) - firstDayOfWeek; + return l < 0 ? l + 7 : l; + } + + @Override + public void paint(Graphics g) { + Style st = getStyle(); + int x = getX() + st.getPaddingLeftNoRTL(); + int y = getY() + st.getPaddingTop(); + int w = getWidth() - st.getHorizontalPadding(); + int h = getHeight() - st.getVerticalPadding(); + int cw = w / 7; + int chh = h / 7; + x += (w - cw * 7) / 2; + Style dayStyle = getUIManager().getComponentStyle("PickerMaterialDay"); + Style chosen = getUIManager().getComponentSelectedStyle("PickerMaterialDay"); + Style off = getUIManager().getComponentCustomStyle("PickerMaterialDay", "dis"); + Style week = getUIManager().getComponentStyle("PickerMaterialWeekday"); + boolean aa = g.isAntiAliased(); + g.setAntiAliased(true); + String[] names = symbols.getShortWeekdays(); + Font wf = week.getFont(); + g.setFont(wf); + g.setColor(week.getFgColor()); + for (int i = 0; i < 7; i++) { + String n = names[(firstDayOfWeek - 1 + i) % 7]; + String initial = n == null || n.length() == 0 ? "" : n.substring(0, 1); + g.drawString(initial, x + i * cw + (cw - wf.stringWidth(initial)) / 2, y + (chh - wf.getHeight()) / 2); + } + Calendar now = Calendar.getInstance(); + int today = shownYear == now.get(Calendar.YEAR) && shownMonth == now.get(Calendar.MONTH) + ? now.get(Calendar.DAY_OF_MONTH) : -1; + int lead = lead(); + int count = daysIn(shownYear, shownMonth); + int r = Math.min(cw, chh) / 2 - 1; + Font f = dayStyle.getFont(); + g.setFont(f); + int alpha = g.getAlpha(); + for (int d = 1; d <= count; d++) { + int cell = lead + d - 1; + int cx = x + cell % 7 * cw + cw / 2; + int cy = y + (cell / 7 + 1) * chh + chh / 2; + boolean isChosen = d == day && shownMonth == month && shownYear == year; + boolean enabled = isEnabledDay(shownYear, shownMonth, d); + if (isChosen) { + g.setColor(chosen.getBgColor()); + g.fillArc(cx - r, cy - r, 2 * r, 2 * r, 0, 360); + g.setColor(chosen.getFgColor()); + } else if (d == today) { + g.setColor(chosen.getBgColor()); + g.drawArc(cx - r, cy - r, 2 * r - 1, 2 * r - 1, 0, 360); + } else if (!enabled) { + g.setColor(off.getFgColor()); + g.setAlpha(alpha * 97 / 255); + } else { + g.setColor(dayStyle.getFgColor()); + } + String s = String.valueOf(d); + g.drawString(s, cx - f.stringWidth(s) / 2, cy - f.getHeight() / 2); + g.setAlpha(alpha); + } + g.setAntiAliased(aa); + } + + private int dayAt(int px, int py) { + Style st = getStyle(); + int w = getWidth() - st.getHorizontalPadding(); + int h = getHeight() - st.getVerticalPadding(); + int cw = Math.max(1, w / 7); + int chh = Math.max(1, h / 7); + int x = px - getAbsoluteX() - st.getPaddingLeftNoRTL() - (w - cw * 7) / 2; + int y = py - getAbsoluteY() - st.getPaddingTop(); + if (x < 0 || y < chh || x >= cw * 7 || y >= chh * 7) { + return -1; + } + int d = (y / chh - 1) * 7 + x / cw - lead() + 1; + return d < 1 || d > daysIn(shownYear, shownMonth) ? -1 : d; + } + + @Override + public void pointerPressed(int x, int y) { + pressedDay = dayAt(x, y); + } + + @Override + public void pointerReleased(int x, int y) { + int d = dayAt(x, y); + if (d > 0 && d == pressedDay) { + choose(shownYear, shownMonth, d); + } + pressedDay = -1; + } + } + + /// The point of a day of the month shown, for a test to tap. + int[] pointOfDay(int d) { + Style st = days.getStyle(); + int w = days.getWidth() - st.getHorizontalPadding(); + int h = days.getHeight() - st.getVerticalPadding(); + int cw = w / 7; + int chh = h / 7; + int cell = days.lead() + d - 1; + return new int[]{ + days.getAbsoluteX() + st.getPaddingLeftNoRTL() + (w - cw * 7) / 2 + cell % 7 * cw + cw / 2, + days.getAbsoluteY() + st.getPaddingTop() + (cell / 7 + 1) * chh + chh / 2 + }; + } +} diff --git a/CodenameOne/src/com/codename1/ui/spinner/MaterialDateTimePicker.java b/CodenameOne/src/com/codename1/ui/spinner/MaterialDateTimePicker.java new file mode 100644 index 00000000000..ccdb9709201 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/spinner/MaterialDateTimePicker.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.spinner; + +import com.codename1.ui.ButtonGroup; +import com.codename1.ui.Container; +import com.codename1.ui.RadioButton; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.GridLayout; + +import java.util.Calendar; +import java.util.Date; + +/// A date and a time in one picker: the Material date picker and the +/// Material time picker behind two tabs. The value is a `Date`. +class MaterialDateTimePicker extends Container implements InternalPickerWidget { + private final MaterialDatePicker date = new MaterialDatePicker(); + private final MaterialTimePicker time = new MaterialTimePicker(); + private final RadioButton dateTab = new RadioButton(""); + private final RadioButton timeTab = new RadioButton(""); + + MaterialDateTimePicker() { + super(new BorderLayout()); + dateTab.setText(getUIManager().localize("picker.date", "Date")); + timeTab.setText(getUIManager().localize("picker.time", "Time")); + ButtonGroup g = new ButtonGroup(); + tab(dateTab, g); + tab(timeTab, g); + dateTab.setSelected(true); + dateTab.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + showTime(false); + } + }); + timeTab.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + showTime(true); + } + }); + add(BorderLayout.NORTH, GridLayout.encloseIn(2, dateTab, timeTab)); + add(BorderLayout.CENTER, date); + } + + private static void tab(RadioButton b, ButtonGroup g) { + b.setToggle(true); + b.setUIID("PickerMaterialTab"); + g.add(b); + } + + MaterialDatePicker datePicker() { + return date; + } + + MaterialTimePicker timePicker() { + return time; + } + + void showTime(boolean v) { + dateTab.setSelected(!v); + timeTab.setSelected(v); + if (v == contains(time)) { + return; + } + if (v) { + date.remove(); + add(BorderLayout.CENTER, time); + } else { + time.remove(); + add(BorderLayout.CENTER, date); + } + revalidateLater(); + } + + @Override + public Object getValue() { + Calendar c = Calendar.getInstance(); + c.setTime(date.getDate()); + c.set(Calendar.HOUR_OF_DAY, time.getHour()); + c.set(Calendar.MINUTE, time.getMinute()); + c.set(Calendar.SECOND, 0); + c.set(Calendar.MILLISECOND, 0); + return c.getTime(); + } + + @Override + public void setValue(Object value) { + if (value instanceof Date) { + Calendar c = Calendar.getInstance(); + c.setTime((Date) value); + date.setDate((Date) value); + time.setTime(c.get(Calendar.HOUR_OF_DAY), c.get(Calendar.MINUTE)); + } + } +} diff --git a/CodenameOne/src/com/codename1/ui/spinner/MaterialTimePicker.java b/CodenameOne/src/com/codename1/ui/spinner/MaterialTimePicker.java new file mode 100644 index 00000000000..d40e67b835c --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/spinner/MaterialTimePicker.java @@ -0,0 +1,628 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.spinner; + +import com.codename1.l10n.DateFormatSymbols; +import com.codename1.ui.Button; +import com.codename1.ui.ButtonGroup; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.Font; +import com.codename1.ui.FontImage; +import com.codename1.ui.Graphics; +import com.codename1.ui.Label; +import com.codename1.ui.RadioButton; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.events.DataChangedListener; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.util.EventDispatcher; +import com.codename1.util.MathUtil; + +/// A time picker in the Material 3 style: the hour and the minute in large +/// type over a clock face. The hand is dragged to the hour, the face then +/// turns to minutes, and a keyboard button swaps the face for two text +/// fields. +/// +/// A `Picker` opens one in a dialog when the theme constant `pickerStyle` is +/// `material`. It is a component like any other and can be placed in a form +/// directly. The value is an `Integer`, the minutes since midnight, as it is +/// for the time of a `Picker`. +/// +/// With `setShowMeridiem(true)` the face has twelve hours and AM and PM +/// beside the time; without, it has two rings, the afternoon inside. +/// +/// #### Styling +/// +/// `PickerMaterialOverline` is the small title. `PickerMaterialTimeField` is +/// the hour and the minute, its selected style the one the face is showing, +/// and `PickerMaterialTimeSeparator` the colon between them. +/// `PickerMaterialPeriod` is AM and PM. `PickerMaterialDial` is the face: +/// its background the disc and its foreground the numbers, and its selected +/// style the hand, with the foreground the number under it. Its disabled +/// style is an hour outside the range. `PickerMaterialButton` is the +/// keyboard button and `PickerMaterialField` a text field. +public class MaterialTimePicker extends Container implements InternalPickerWidget { + private int hour; + private int minute; + private boolean meridiem; + private int minuteStep = 1; + private int minHour = -1; + private int maxHour = -1; + private boolean minutesShown; + private boolean inputMode; + private boolean updating; + + private final Label overline = new Label("", "PickerMaterialOverline"); + private final RadioButton hourField = new RadioButton(""); + private final RadioButton minuteField = new RadioButton(""); + private final TextField hourInput = new TextField(); + private final TextField minuteInput = new TextField(); + private final RadioButton am = new RadioButton(""); + private final RadioButton pm = new RadioButton(""); + private final Container periods = new Container(BoxLayout.y()); + private final Container time = new Container(new FlowLayout(Component.CENTER, Component.CENTER)); + private final Dial dial = new Dial(); + private final Button modeButton = new Button("", "PickerMaterialButton"); + private final Container modeRow = new Container(new FlowLayout(Component.LEFT)); + private final EventDispatcher listeners = new EventDispatcher(); + + /// A picker showing midnight on a 24 hour face. + public MaterialTimePicker() { + super(new BorderLayout()); + setUIID("PickerMaterial"); + overline.setText(getUIManager().localize("picker.selectTime", "Select time")); + String[] names = new DateFormatSymbols().getAmPmStrings(); + am.setText(names != null && names.length > 1 ? names[0] : "AM"); + pm.setText(names != null && names.length > 1 ? names[1] : "PM"); + + ButtonGroup fields = new ButtonGroup(); + field(hourField, fields); + field(minuteField, fields); + hourField.setSelected(true); + hourField.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + showMinutes(false); + } + }); + minuteField.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + showMinutes(true); + } + }); + typedField(hourInput); + typedField(minuteInput); + + ButtonGroup half = new ButtonGroup(); + am.setToggle(true); + pm.setToggle(true); + am.setUIID("PickerMaterialPeriod"); + pm.setUIID("PickerMaterialPeriod"); + half.add(am); + half.add(pm); + ActionListener halfChosen = new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + int h = hour % 12 + (pm.isSelected() ? 12 : 0); + if (h != hour && isEnabledHour(h)) { + hour = h; + changed(); + } + refresh(); + } + }; + am.addActionListener(halfChosen); + pm.addActionListener(halfChosen); + periods.add(am).add(pm); + + Container head = new Container(new BorderLayout()); + head.setUIID("PickerMaterialHeader"); + head.add(BorderLayout.NORTH, overline); + head.add(BorderLayout.CENTER, time); + + FontImage.setMaterialIcon(modeButton, FontImage.MATERIAL_KEYBOARD); + modeButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + setInputMode(!inputMode); + } + }); + modeRow.add(modeButton); + + add(BorderLayout.NORTH, head); + add(BorderLayout.CENTER, dial); + add(BorderLayout.SOUTH, modeRow); + buildTime(); + refresh(); + } + + private void field(RadioButton b, ButtonGroup g) { + b.setToggle(true); + b.setUIID("PickerMaterialTimeField"); + g.add(b); + } + + private void typedField(TextField f) { + f.setUIID("PickerMaterialField"); + f.setConstraint(TextArea.NUMERIC); + f.setColumns(2); + f.setMaxSize(2); + f.addDataChangedListener(new DataChangedListener() { + @Override + public void dataChanged(int type, int index) { + typed(); + } + }); + } + + private void buildTime() { + time.removeAll(); + if (inputMode) { + time.add(hourInput); + } else { + time.add(hourField); + } + time.add(new Label(":", "PickerMaterialTimeSeparator")); + if (inputMode) { + time.add(minuteInput); + } else { + time.add(minuteField); + } + if (meridiem) { + time.add(periods); + } + } + + // -------------------------------------------------------------- value + + /// The hour of the day, 0 to 23. + public int getHour() { + return hour; + } + + public int getMinute() { + return minute; + } + + /// Sets the time. The hour is of the day, 0 to 23, whatever the face + /// shows; an hour outside the range is brought to the nearest one in it, + /// and the minute to a multiple of the step. + public void setTime(int hourOfDay, int minuteOfHour) { + int h = Math.max(0, Math.min(23, hourOfDay)); + if (minHour >= 0 && maxHour > minHour) { + h = Math.max(minHour, Math.min(maxHour - 1, h)); + } + hour = h; + minute = snap(Math.max(0, Math.min(59, minuteOfHour))); + refresh(); + } + + private int snap(int m) { + if (minuteStep <= 1) { + return m; + } + int s = (m + minuteStep / 2) / minuteStep * minuteStep; + return s >= 60 ? s - minuteStep : s; + } + + /// The minutes since midnight, as an `Integer`. + @Override + public Object getValue() { + return Integer.valueOf(hour * 60 + minute); + } + + /// Sets the time from the minutes since midnight. Anything that is not + /// an `Integer` is ignored. + @Override + public void setValue(Object value) { + if (value instanceof Integer) { + int v = ((Integer) value).intValue(); + setTime(v / 60, v % 60); + } + } + + /// Whether the face has twelve hours with AM and PM beside the time. + public boolean isShowMeridiem() { + return meridiem; + } + + public void setShowMeridiem(boolean v) { + if (meridiem != v) { + meridiem = v; + buildTime(); + refresh(); + time.revalidateLater(); + } + } + + /// The minutes the hand stops at: 5 for every five minutes, 1 for all. + public void setMinuteStep(int step) { + minuteStep = Math.max(1, Math.min(30, step)); + minute = snap(minute); + refresh(); + } + + public int getMinuteStep() { + return minuteStep; + } + + /// The hours that can be chosen: from `min` up to but not `max`. A + /// negative `min` removes the limit. + public void setHourRange(int min, int max) { + if (min < 0 || max <= min || max > 24) { + minHour = -1; + maxHour = -1; + } else { + minHour = min; + maxHour = max; + setTime(hour, minute); + } + dial.repaint(); + } + + boolean isEnabledHour(int h) { + return minHour < 0 || (h >= minHour && h < maxHour); + } + + /// The small title over the time: "Select time" unless set. + public void setTitle(String title) { + overline.setText(title == null ? "" : title); + } + + public String getTitle() { + return overline.getText(); + } + + /// Told when the user chooses another time. + public void addActionListener(ActionListener l) { + listeners.addListener(l); + } + + public void removeActionListener(ActionListener l) { + listeners.removeListener(l); + } + + private void changed() { + listeners.fireActionEvent(new ActionEvent(this, ActionEvent.Type.Other)); + } + + // --------------------------------------------------------------- view + + /// Whether the time is typed and not picked from the face. + public boolean isInputMode() { + return inputMode; + } + + public void setInputMode(boolean v) { + if (inputMode == v) { + return; + } + inputMode = v; + dial.setHidden(v); + buildTime(); + refresh(); + revalidateLater(); + } + + /// The button that swaps the face for text fields. It is under the face + /// until something takes it: a dialog puts it beside its own buttons. + public Button getInputModeButton() { + return modeButton; + } + + boolean isMinutesShown() { + return minutesShown; + } + + void showMinutes(boolean v) { + minutesShown = v; + refresh(); + } + + Component dialPane() { + return dial; + } + + TextField hourInputField() { + return hourInput; + } + + TextField minuteInputField() { + return minuteInput; + } + + private static String two(int v) { + return v < 10 ? "0" + v : String.valueOf(v); + } + + private String hourText() { + if (meridiem) { + int h = hour % 12; + return String.valueOf(h == 0 ? 12 : h); + } + return two(hour); + } + + private void refresh() { + hourField.setText(hourText()); + minuteField.setText(two(minute)); + hourField.setSelected(!minutesShown); + minuteField.setSelected(minutesShown); + am.setSelected(hour < 12); + pm.setSelected(hour >= 12); + FontImage.setMaterialIcon(modeButton, inputMode ? FontImage.MATERIAL_SCHEDULE : FontImage.MATERIAL_KEYBOARD); + if (inputMode && !updating) { + updating = true; + hourInput.setText(hourText()); + minuteInput.setText(two(minute)); + updating = false; + } + dial.repaint(); + } + + private static int number(String s) { + if (s == null || s.length() == 0 || s.length() > 2) { + return -1; + } + int v = 0; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c < '0' || c > '9') { + return -1; + } + v = v * 10 + (c - '0'); + } + return v; + } + + private void typed() { + if (updating || !inputMode) { + return; + } + int h = number(hourInput.getText()); + int m = number(minuteInput.getText()); + if (meridiem) { + if (h < 1 || h > 12) { + return; + } + h = h % 12 + (hour >= 12 ? 12 : 0); + } + if (h < 0 || h > 23 || m < 0 || m > 59 || !isEnabledHour(h)) { + return; + } + m = snap(m); + if (h != hour || m != minute) { + hour = h; + minute = m; + // The fields keep what was typed: writing "07" back over a "7" + // would move the caret under the hand that is typing. + updating = true; + refresh(); + updating = false; + changed(); + } + } + + // ----------------------------------------------------------- the face + + /// The clock face, painted. + private final class Dial extends Component { + Dial() { + setUIID("PickerMaterialDialPane"); + setFocusable(false); + } + + @Override + protected Dimension calcPreferredSize() { + int d = Display.getInstance().convertToPixels(40); + Style s = getStyle(); + return new Dimension(d + s.getHorizontalPadding(), d + s.getVerticalPadding()); + } + + private int radius() { + Style s = getStyle(); + return Math.min(getWidth() - s.getHorizontalPadding(), getHeight() - s.getVerticalPadding()) / 2; + } + + private int knob(int r) { + return Math.max(3, r * 19 / 100); + } + + @Override + public void paint(Graphics g) { + Style face = getUIManager().getComponentStyle("PickerMaterialDial"); + Style hand = getUIManager().getComponentSelectedStyle("PickerMaterialDial"); + Style off = getUIManager().getComponentCustomStyle("PickerMaterialDial", "dis"); + int r = radius(); + if (r <= 0) { + return; + } + int cx = getX() + getWidth() / 2; + int cy = getY() + getHeight() / 2; + int k = knob(r); + int outer = r - k - Math.max(1, r / 28); + int inner = outer - 2 * k + k / 5; + boolean aa = g.isAntiAliased(); + g.setAntiAliased(true); + g.setColor(face.getBgColor()); + g.fillArc(cx - r, cy - r, 2 * r, 2 * r, 0, 360); + + // The hand first, then the numbers over it. + int at; + int ring = outer; + if (minutesShown) { + at = minute * 6; + } else { + at = hour % 12 * 30; + if (!meridiem && (hour == 0 || hour > 12)) { + ring = inner; + } + } + double a = Math.toRadians(at); + int hx = cx + (int) Math.round(Math.sin(a) * ring); + int hy = cy - (int) Math.round(Math.cos(a) * ring); + g.setColor(hand.getBgColor()); + g.drawLine(cx, cy, hx, hy); + g.drawLine(cx + 1, cy, hx + 1, hy); + g.fillArc(cx - k / 4, cy - k / 4, k / 2, k / 2, 0, 360); + g.fillArc(hx - k, hy - k, 2 * k, 2 * k, 0, 360); + + Font f = face.getFont(); + g.setFont(f); + int alpha = g.getAlpha(); + boolean marked = false; + for (int i = 0; i < 12; i++) { + if (minutesShown) { + boolean under = minute == i * 5; + marked |= under; + number(g, f, two(i * 5), cx, cy, outer, i, under ? hand.getFgColor() : face.getFgColor()); + } else if (meridiem) { + int h = i + (hour >= 12 ? 12 : 0); + boolean ok = isEnabledHour(h); + g.setAlpha(ok ? alpha : alpha * 97 / 255); + number(g, f, String.valueOf(i == 0 ? 12 : i), cx, cy, outer, i, + hour % 12 == i ? hand.getFgColor() : ok ? face.getFgColor() : off.getFgColor()); + } else { + int morning = i == 0 ? 12 : i; + int evening = i == 0 ? 0 : i + 12; + g.setAlpha(isEnabledHour(morning) ? alpha : alpha * 97 / 255); + number(g, f, String.valueOf(morning), cx, cy, outer, i, + hour == morning ? hand.getFgColor() : isEnabledHour(morning) ? face.getFgColor() : off.getFgColor()); + g.setAlpha(isEnabledHour(evening) ? alpha : alpha * 97 / 255); + number(g, f, two(evening), cx, cy, inner, i, + hour == evening ? hand.getFgColor() : isEnabledHour(evening) ? face.getFgColor() : off.getFgColor()); + } + g.setAlpha(alpha); + } + if (minutesShown && !marked) { + // Between two numbers: a dot says where exactly. + g.setColor(hand.getFgColor()); + g.fillArc(hx - 2, hy - 2, 4, 4, 0, 360); + } + g.setAntiAliased(aa); + } + + private void number(Graphics g, Font f, String s, int cx, int cy, int ring, int i, int color) { + double a = Math.toRadians(i * 30); + int x = cx + (int) Math.round(Math.sin(a) * ring); + int y = cy - (int) Math.round(Math.cos(a) * ring); + g.setColor(color); + g.drawString(s, x - f.stringWidth(s) / 2, y - f.getHeight() / 2); + } + + private void point(int px, int py) { + int r = radius(); + if (r <= 0) { + return; + } + int dx = px - getAbsoluteX() - getWidth() / 2; + int dy = py - getAbsoluteY() - getHeight() / 2; + if (dx == 0 && dy == 0) { + return; + } + double deg = Math.toDegrees(MathUtil.atan2(dx, -dy)); + if (deg < 0) { + deg += 360; + } + if (minutesShown) { + // Round the face: past the last stop is the hour again, where + // a time that is set stays at the last stop of its hour. + int m = (int) Math.round(deg / 6) % 60; + m = (m + minuteStep / 2) / minuteStep * minuteStep % 60; + if (m != minute) { + minute = m; + refresh(); + changed(); + } + return; + } + int i = (int) Math.round(deg / 30) % 12; + int h; + if (meridiem) { + h = i + (hour >= 12 ? 12 : 0); + } else { + int k = knob(r); + int outer = r - k - Math.max(1, r / 28); + int inner = outer - 2 * k + k / 5; + boolean inside = dx * dx + dy * dy < (outer + inner) * (outer + inner) / 4; + h = inside ? (i == 0 ? 0 : i + 12) : (i == 0 ? 12 : i); + } + if (h != hour && isEnabledHour(h)) { + hour = h; + refresh(); + changed(); + } + } + + @Override + public void pointerPressed(int x, int y) { + point(x, y); + } + + @Override + public void pointerDragged(int x, int y) { + point(x, y); + } + + @Override + public void pointerReleased(int x, int y) { + point(x, y); + if (!minutesShown) { + // The hour is chosen; the minute is what is asked next. + showMinutes(true); + } + } + } + + /// The point of the face an hour of the day or a minute is at, for a + /// test to tap. + int[] pointOf(int value, boolean ofMinutes) { + int r = dial.radius(); + int k = dial.knob(r); + int outer = r - k - Math.max(1, r / 28); + int inner = outer - 2 * k + k / 5; + int ring = outer; + double a; + if (ofMinutes) { + a = Math.toRadians(value * 6); + } else { + a = Math.toRadians(value % 12 * 30); + if (!meridiem && (value == 0 || value > 12)) { + ring = inner; + } + } + return new int[]{ + dial.getAbsoluteX() + dial.getWidth() / 2 + (int) Math.round(Math.sin(a) * ring), + dial.getAbsoluteY() + dial.getHeight() / 2 - (int) Math.round(Math.cos(a) * ring) + }; + } +} diff --git a/CodenameOne/src/com/codename1/ui/spinner/Picker.java b/CodenameOne/src/com/codename1/ui/spinner/Picker.java index c01e543bdc0..c1b6ce6ae7f 100644 --- a/CodenameOne/src/com/codename1/ui/spinner/Picker.java +++ b/CodenameOne/src/com/codename1/ui/spinner/Picker.java @@ -140,6 +140,8 @@ public class Picker extends Button { /// so that setters propagate into the visible wheels and getters read the wheel /// position. Null whenever the popup is not showing. private InternalPickerWidget currentSpinner; + /// The Material dialog while it is open. See `showMaterialDialog`. + private Dialog materialDialog; /// Container for `currentSpinner` while the lightweight popup is visible. /// Public setters that stage a new live spinner value need to revalidate /// this container so Spinner3D recalculates and paints the new wheel state. @@ -305,6 +307,16 @@ public void actionPerformed(ActionEvent evt) { // which resolves its host from the component and lands in the right // one. boolean inWindow = getTopLevelContainer() instanceof com.codename1.ui.Window; + // A theme that asks for Material pickers gets a calendar or a clock + // face in a dialog where it would have got wheels in a sheet. Not in + // a desktop window: the dialog is a form of the main surface, which + // is the very thing the comment above keeps a window's picker off. + if (!inWindow && isMaterialType(type) && isMaterialStyle() + && (useLightweightPopup || !Display.getInstance().isNativePickerTypeSupported(type))) { + showMaterialDialog(); + evt.consume(); + return; + } if ((useLightweightPopup || inWindow || !Display.getInstance().isNativePickerTypeSupported(type)) && isLightweightModeSupportedForType(type)) { @@ -1027,6 +1039,151 @@ public void actionPerformed(ActionEvent evt) { updateValue(); } + // ------------------------------------------------------ Material style + + /// The theme constant that chooses what a lightweight date or time picker + /// looks like: `material` for a calendar and a clock face in a dialog, + /// anything else for wheels. + private boolean isMaterialStyle() { + return "material".equals(getUIManager().getThemeConstant("pickerStyle", "wheel")); + } + + private static boolean isMaterialType(int type) { + return type == Display.PICKER_TYPE_DATE || type == Display.PICKER_TYPE_TIME + || type == Display.PICKER_TYPE_DATE_AND_TIME || type == Display.PICKER_TYPE_CALENDAR; + } + + private void limit(MaterialDatePicker d) { + if (startDate != null || endDate != null) { + d.setDateRange(startDate, endDate); + } + } + + private void limit(MaterialTimePicker t) { + t.setShowMeridiem(showMeridiem); + t.setMinuteStep(minuteStep); + if (minHour >= 0 && minHour < 24 && maxHour > minHour) { + t.setHourRange(minHour, maxHour); + } + } + + private InternalPickerWidget createMaterialWidget() { + InternalPickerWidget out; + if (type == Display.PICKER_TYPE_TIME) { + MaterialTimePicker t = new MaterialTimePicker(); + limit(t); + out = t; + out.setValue(value instanceof Integer ? value : Integer.valueOf(0)); + } else { + if (type == Display.PICKER_TYPE_DATE_AND_TIME) { + MaterialDateTimePicker dt = new MaterialDateTimePicker(); + limit(dt.datePicker()); + limit(dt.timePicker()); + out = dt; + } else { + MaterialDatePicker d = new MaterialDatePicker(); + limit(d); + out = d; + } + out.setValue(value instanceof Date ? value : new Date()); + } + return out; + } + + /// The Material dialog while it is open, or null. + Dialog getMaterialDialog() { + return materialDialog; + } + + /// The widget of the popup or dialog that is open, or null. + InternalPickerWidget getOpenWidget() { + return currentSpinner; + } + + private void showMaterialDialog() { + final InternalPickerWidget widget = createMaterialWidget(); + final Dialog dlg = new Dialog(new BorderLayout()); + dlg.setDialogUIID("PickerMaterialDialog"); + dlg.getTitleArea().setHidden(true); + dlg.getTitleComponent().setHidden(true); + // Closed by its two buttons and by Back, each of which says whether the + // value is kept. A tap outside would close it saying neither. + dlg.setDisposeWhenPointerOutOfBounds(false); + dlg.add(BorderLayout.CENTER, (Component) widget); + Button cancel = new Button(getUIManager().localize("cancel", "Cancel"), "PickerMaterialButton"); + Button ok = new Button(getUIManager().localize("ok", "OK"), "PickerMaterialButton"); + cancel.setName("PickerMaterialCancel"); + ok.setName("PickerMaterialOk"); + cancel.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + endMaterialDialog(dlg, widget, false); + } + }); + ok.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + endMaterialDialog(dlg, widget, true); + } + }); + dlg.setBackCommand(new Command("") { + @Override + public void actionPerformed(ActionEvent evt) { + endMaterialDialog(dlg, widget, false); + } + }); + Container buttons = new Container(new BorderLayout()); + buttons.setUIID("PickerMaterialButtons"); + if (widget instanceof MaterialTimePicker) { + // Material puts the keyboard switch on the row of the buttons. + Button mode = ((MaterialTimePicker) widget).getInputModeButton(); + Container was = mode.getParent(); + mode.remove(); + if (was != null) { + was.setHidden(true); + } + buttons.add(BorderLayout.WEST, mode); + } + buttons.add(BorderLayout.EAST, BoxLayout.encloseX(cancel, ok)); + dlg.add(BorderLayout.SOUTH, buttons); + currentSpinner = widget; + materialDialog = dlg; + dlg.showPacked(BorderLayout.CENTER, false); + } + + private void endMaterialDialog(Dialog dlg, InternalPickerWidget widget, boolean commit) { + if (materialDialog != dlg) { //NOPMD CompareObjectsWithEquals - this very dialog, closed once + return; + } + materialDialog = null; + if (currentSpinner == widget) { //NOPMD CompareObjectsWithEquals + // Cleared before the value is read back, as endEditing does, so a + // listener sees the committed value and not the closing widget's. + currentSpinner = null; + } + if (commit) { + value = widget.getValue(); + markDateExplicitlySetIfDateType(); + } else { + value = preEditValue; + dateValueExplicitlySet = preEditDateValueExplicitlySet; + } + preEditValue = null; + preEditDateValueExplicitlySet = false; + updateValue(); + dlg.dispose(); + if (commit) { + // (-99, -99) tells the picker's own listener that this is the + // result and not another request to open. See ignoreActionEvent. + fireActionEvent(-99, -99); + } + if (stopEditingCallback != null) { + Runnable r = stopEditingCallback; + stopEditingCallback = null; + r.run(); + } + } + private Container createLightweightPopupButtonRow(int placement, boolean isTablet, Container spinnerContainer) { Container left = null; Container center = null; @@ -1344,6 +1501,12 @@ public void startEditingAsync() { @Override public void stopEditing(Runnable onFinish) { stopEditingCallback = onFinish; + if (materialDialog != null) { + // The Material dialog is no input device of the form; it is closed + // here, without keeping what it shows, and the callback run. + endMaterialDialog(materialDialog, currentSpinner, false); + return; + } // Through the top level, as registerAsInputDevice registers it: resolving a // Form here found nothing in a window, so stopEditing() did not close the // picker and never ran its callback. diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 36e6f1e0466..23311ba2382 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -16891,7 +16891,12 @@ public void onCancel(DialogInterface di) { public void run() { int hour = ((Integer)currentValue).intValue() / 60; int minute = ((Integer)currentValue).intValue() % 60; - TimePickerDialog tp = new TimePickerDialog(getActivity(), pickInstance, hour, minute, true){ + // The dialog follows the picker: a picker that writes its value + // with AM/PM gets a 12 hour dial. This was always 24 hours, so + // the dialog and the text it filled in could disagree. + boolean hours24 = !(source instanceof com.codename1.ui.spinner.Picker) + || !((com.codename1.ui.spinner.Picker) source).isShowMeridiem(); + TimePickerDialog tp = new TimePickerDialog(getActivity(), pickInstance, hour, minute, hours24){ @Override public void cancel() { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/spinner/MaterialPickerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/spinner/MaterialPickerTest.java new file mode 100644 index 00000000000..7305cd3cfb8 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/spinner/MaterialPickerTest.java @@ -0,0 +1,350 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.spinner; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.UIManager; + +import java.util.Calendar; +import java.util.Date; +import java.util.Hashtable; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The Material date and time pickers, by themselves and as what a `Picker` +/// opens when the theme asks for them. +class MaterialPickerTest extends UITestBase { + private static Date date(int year, int month, int day, int hour, int minute) { + Calendar c = Calendar.getInstance(); + c.set(Calendar.YEAR, year); + c.set(Calendar.MONTH, month); + c.set(Calendar.DAY_OF_MONTH, day); + c.set(Calendar.HOUR_OF_DAY, hour); + c.set(Calendar.MINUTE, minute); + c.set(Calendar.SECOND, 0); + c.set(Calendar.MILLISECOND, 0); + return c.getTime(); + } + + private static void tap(Component c, int[] at) { + c.pointerPressed(at[0], at[1]); + c.pointerReleased(at[0], at[1]); + } + + private static void place(Component c) { + Form form = Display.getInstance().getCurrent(); + form.setLayout(new BorderLayout()); + form.add(BorderLayout.CENTER, c); + form.revalidate(); + } + + private static final class Counter implements ActionListener { + int count; + + @Override + public void actionPerformed(ActionEvent evt) { + count++; + } + } + + @FormTest + void aTappedDayIsChosenAtTheTimeOfDayItHad() { + MaterialDatePicker p = new MaterialDatePicker(); + p.setDate(date(2026, 0, 5, 14, 30)); + Counter c = new Counter(); + p.addActionListener(c); + place(p); + tap(p.dayPane(), p.pointOfDay(20)); + assertEquals(date(2026, 0, 20, 14, 30), p.getDate()); + assertEquals(1, c.count); + tap(p.dayPane(), p.pointOfDay(20)); + assertEquals(1, c.count, "the day it already is tells nobody"); + + p.page(1); + assertEquals(1, p.getShownMonth()); + assertEquals(date(2026, 0, 20, 14, 30), p.getDate(), "another month shown is not another date"); + tap(p.dayPane(), p.pointOfDay(28)); + assertEquals(date(2026, 1, 28, 14, 30), p.getDate()); + p.page(-2); + assertEquals(2025, p.getShownYear()); + assertEquals(11, p.getShownMonth()); + + p.setFirstDayOfWeek(Calendar.MONDAY); + tap(p.dayPane(), p.pointOfDay(31)); + assertEquals(date(2025, 11, 31, 14, 30), p.getDate()); + assertEquals(29, MaterialDatePicker.daysIn(2024, 1)); + assertEquals(28, MaterialDatePicker.daysIn(2100, 1)); + } + + @FormTest + void daysOutsideTheRangeCannotBeChosen() { + MaterialDatePicker p = new MaterialDatePicker(); + p.setDate(date(2026, 0, 15, 9, 0)); + p.setDateRange(date(2026, 0, 10, 0, 0), date(2026, 0, 20, 23, 0)); + place(p); + tap(p.dayPane(), p.pointOfDay(5)); + assertEquals(date(2026, 0, 15, 9, 0), p.getDate()); + tap(p.dayPane(), p.pointOfDay(10)); + assertEquals(date(2026, 0, 10, 9, 0), p.getDate()); + p.page(3); + assertEquals(0, p.getShownMonth(), "there is no month to page to outside the range"); + p.setDate(date(2026, 5, 1, 9, 0)); + assertEquals(date(2026, 0, 20, 9, 0), p.getDate(), "a date past the range is its last day"); + } + + @FormTest + void aTypedDateIsChosenAndOneThatCannotBeReadIsSaidSo() { + MaterialDatePicker p = new MaterialDatePicker(); + p.setDate(date(2026, 0, 5, 8, 0)); + place(p); + p.setInputMode(true); + assertTrue(p.isInputMode()); + assertEquals(p.typedForm(2026, 0, 5), p.inputField().getText()); + p.inputField().setText(p.typedForm(2026, 2, 9)); + assertEquals(date(2026, 2, 9, 8, 0), p.getDate()); + assertEquals("", p.errorText()); + p.inputField().setText(p.typedForm(2026, 1, 30)); + assertEquals(date(2026, 2, 9, 8, 0), p.getDate(), "there is no 30th of February"); + assertTrue(p.errorText().length() > 0); + p.inputField().setText("12"); + assertTrue(p.errorText().length() > 0); + p.setInputMode(false); + assertEquals(2, p.getShownMonth()); + } + + @FormTest + void theFaceTakesAnHourThenAMinute() { + MaterialTimePicker p = new MaterialTimePicker(); + p.setTime(9, 15); + Counter c = new Counter(); + p.addActionListener(c); + place(p); + assertFalse(p.isMinutesShown()); + tap(p.dialPane(), p.pointOf(17, false)); + assertEquals(17, p.getHour(), "the inner ring is the afternoon"); + assertTrue(p.isMinutesShown(), "the hour chosen, the face turns to minutes"); + tap(p.dialPane(), p.pointOf(40, true)); + assertEquals(40, p.getMinute()); + assertEquals(Integer.valueOf(17 * 60 + 40), p.getValue()); + assertEquals(2, c.count); + + p.showMinutes(false); + tap(p.dialPane(), p.pointOf(0, false)); + assertEquals(0, p.getHour()); + p.showMinutes(false); + tap(p.dialPane(), p.pointOf(12, false)); + assertEquals(12, p.getHour()); + p.setValue(Integer.valueOf(23 * 60 + 59)); + assertEquals(23, p.getHour()); + assertEquals(59, p.getMinute()); + } + + @FormTest + void twelveHoursKeepTheirHalfOfTheDayAndMinutesKeepTheirStep() { + MaterialTimePicker p = new MaterialTimePicker(); + p.setShowMeridiem(true); + p.setMinuteStep(15); + p.setTime(15, 0); + place(p); + tap(p.dialPane(), p.pointOf(7, false)); + assertEquals(19, p.getHour(), "seven in the afternoon"); + tap(p.dialPane(), p.pointOf(22, true)); + assertEquals(15, p.getMinute()); + tap(p.dialPane(), p.pointOf(58, true)); + assertEquals(0, p.getMinute(), "the nearest stop to 58 is the hour"); + assertEquals(19, p.getHour()); + p.showMinutes(false); + tap(p.dialPane(), p.pointOf(12, false)); + assertEquals(12, p.getHour(), "twelve in the afternoon is noon"); + + p.setHourRange(9, 17); + assertEquals(12, p.getHour()); + p.showMinutes(false); + tap(p.dialPane(), p.pointOf(8, false)); + assertEquals(12, p.getHour(), "eight in the evening is outside the range"); + p.showMinutes(false); + tap(p.dialPane(), p.pointOf(4, false)); + assertEquals(16, p.getHour()); + p.setTime(22, 0); + assertEquals(16, p.getHour(), "a time past the range is its last hour"); + } + + @FormTest + void aTypedTimeIsChosen() { + MaterialTimePicker p = new MaterialTimePicker(); + p.setTime(9, 15); + place(p); + p.setInputMode(true); + assertEquals("09", p.hourInputField().getText()); + p.hourInputField().setText("21"); + p.minuteInputField().setText("5"); + assertEquals(21, p.getHour()); + assertEquals(5, p.getMinute()); + p.hourInputField().setText("31"); + assertEquals(21, p.getHour(), "no such hour"); + p.setInputMode(false); + assertFalse(p.dialPane().isHidden()); + } + + private static Component named(Container root, String name) { + for (int i = 0; i < root.getComponentCount(); i++) { + Component c = root.getComponentAt(i); + if (name.equals(c.getName())) { + return c; + } + if (c instanceof Container) { + Component found = named((Container) c, name); + if (found != null) { + return found; + } + } + } + return null; + } + + private static void style(String value) { + Hashtable props = new Hashtable(); + props.put("@pickerStyle", value); + UIManager.getInstance().addThemeProps(props); + } + + private Picker open(Picker picker) { + if (picker.getParent() == null) { + Form form = new Form(BoxLayout.y()); + form.add(picker); + form.show(); + flushSerialCalls(); + } + picker.pointerPressed(picker.getAbsoluteX() + 1, picker.getAbsoluteY() + 1); + picker.pointerReleased(picker.getAbsoluteX() + 1, picker.getAbsoluteY() + 1); + flushSerialCalls(); + return picker; + } + + @FormTest + void aPickerOpensTheMaterialDialogWhenTheThemeAsksForIt() { + style("material"); + try { + Picker picker = new Picker(); + picker.setUseLightweightPopup(true); + picker.setType(Display.PICKER_TYPE_DATE); + picker.setDate(date(2026, 0, 5, 0, 0)); + Counter c = new Counter(); + picker.addActionListener(c); + open(picker); + Dialog dlg = picker.getMaterialDialog(); + assertNotNull(dlg); + MaterialDatePicker widget = (MaterialDatePicker) picker.getOpenWidget(); + widget.choose(2026, 0, 22); + assertEquals(date(2026, 0, 22, 0, 0), picker.getDate(), "the picker answers with what the dialog shows"); + tapComponent(named(dlg, "PickerMaterialOk")); + flushSerialCalls(); + assertNull(picker.getMaterialDialog()); + assertNull(picker.getOpenWidget()); + assertEquals(date(2026, 0, 22, 0, 0), picker.getDate()); + assertEquals(1, c.count); + + // Cancel puts back what there was. + open(picker); + dlg = picker.getMaterialDialog(); + ((MaterialDatePicker) picker.getOpenWidget()).choose(2026, 0, 9); + tapComponent(named(dlg, "PickerMaterialCancel")); + flushSerialCalls(); + assertEquals(date(2026, 0, 22, 0, 0), picker.getDate()); + assertEquals(1, c.count); + + Picker time = new Picker(); + time.setUseLightweightPopup(true); + time.setType(Display.PICKER_TYPE_TIME); + time.setShowMeridiem(true); + time.setTime(8 * 60 + 30); + open(time); + MaterialTimePicker face = (MaterialTimePicker) time.getOpenWidget(); + assertTrue(face.isShowMeridiem()); + assertEquals(8, face.getHour()); + face.setTime(20, 45); + tapComponent(named(time.getMaterialDialog(), "PickerMaterialOk")); + flushSerialCalls(); + assertEquals(20 * 60 + 45, time.getTime()); + + Picker both = new Picker(); + both.setUseLightweightPopup(true); + both.setType(Display.PICKER_TYPE_DATE_AND_TIME); + both.setDate(date(2026, 0, 5, 10, 0)); + open(both); + MaterialDateTimePicker dt = (MaterialDateTimePicker) both.getOpenWidget(); + dt.datePicker().choose(2026, 0, 7); + dt.showTime(true); + dt.timePicker().setTime(11, 20); + // Closing from outside keeps nothing and runs what waits on it. + final boolean[] ran = new boolean[1]; + both.stopEditing(new Runnable() { + @Override + public void run() { + ran[0] = true; + } + }); + flushSerialCalls(); + assertTrue(ran[0]); + assertNull(both.getMaterialDialog()); + assertEquals(date(2026, 0, 5, 10, 0), both.getDate()); + open(both); + dt = (MaterialDateTimePicker) both.getOpenWidget(); + dt.datePicker().choose(2026, 0, 7); + dt.timePicker().setTime(11, 20); + tapComponent(named(both.getMaterialDialog(), "PickerMaterialOk")); + flushSerialCalls(); + assertEquals(date(2026, 0, 7, 11, 20), both.getDate()); + } finally { + style("wheel"); + } + } + + @FormTest + void withoutTheConstantAPickerKeepsItsWheels() { + Picker picker = new Picker(); + picker.setUseLightweightPopup(true); + picker.setType(Display.PICKER_TYPE_DATE); + picker.setDate(date(2026, 0, 5, 0, 0)); + open(picker); + assertNull(picker.getMaterialDialog()); + assertNotNull(picker.getOpenWidget()); + assertFalse(picker.getOpenWidget() instanceof MaterialDatePicker); + picker.stopEditing(null); + flushSerialCalls(); + } +} From 15c904a618a69cc51cb12316d61dd2a2853418b5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:18:08 +0200 Subject: [PATCH 06/19] Themes: scheduler, Gantt, Material picker and Calendar styles; today marker Core installs fallback styles for every Scheduler*, Gantt* and PickerMaterial* UIID the installed theme does not mention, light or dark, so a theme that predates them does not paint a white rectangle. android-material and ios-modern style all of them and the Calendar month grid. android-material sets pickerStyle to material, which now opens the Material date and time pickers by itself on a platform whose default is the native dialog; an explicit setUseLightweightPopup(false) keeps the platform's, and string and duration pickers are not affected. Calendar gains a today marker: a UIID from setTodayUIID or the calendarTodayUIID theme constant, which both modern themes set. Co-Authored-By: Claude Fable 5.2 --- .../src/com/codename1/ui/Calendar.java | 65 ++++- .../src/com/codename1/ui/plaf/UIManager.java | 206 ++++++++++++++++ .../src/com/codename1/ui/spinner/Picker.java | 18 +- Ports/Android/src/AndroidMaterialTheme.res | Bin 199579 -> 379564 bytes .../nativeSources/iOSModern27Theme.res | Bin 213386 -> 379231 bytes .../iOSPort/nativeSources/iOSModernTheme.res | Bin 212623 -> 378468 bytes Themes/AndroidMaterialTheme.res | Bin 199579 -> 379564 bytes Themes/iOSModern27Theme.res | Bin 213386 -> 379231 bytes Themes/iOSModernTheme.res | Bin 212623 -> 378468 bytes .../codename1/ui/CalendarTodayMarkerTest.java | 112 +++++++++ .../ui/plaf/SchedulingThemeTest.java | 161 +++++++++++++ .../ui/spinner/MaterialPickerTest.java | 30 +++ native-themes/COVERAGE.md | 6 +- native-themes/android-material/theme.css | 228 ++++++++++++++++++ native-themes/ios-modern/common.css | 223 +++++++++++++++++ 15 files changed, 1040 insertions(+), 9 deletions(-) create mode 100644 maven/core-unittests/src/test/java/com/codename1/ui/CalendarTodayMarkerTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/ui/plaf/SchedulingThemeTest.java diff --git a/CodenameOne/src/com/codename1/ui/Calendar.java b/CodenameOne/src/com/codename1/ui/Calendar.java index 1f9d54816ad..93cae7bc8a4 100644 --- a/CodenameOne/src/com/codename1/ui/Calendar.java +++ b/CodenameOne/src/com/codename1/ui/Calendar.java @@ -100,6 +100,8 @@ public class Calendar extends Container implements ActionSource { private long originalCurrentDay = -1; private boolean multipleSelectionEnabled = false; private String selectedDaysUIID = "CalendarMultipleDay"; + private String todayUIID; + private boolean todayUIIDSet; /// Creates a new instance of Calendar set to the given date based on time /// since epoch (the Date convention) @@ -771,6 +773,56 @@ public void setSelectedDaysUIID(String uiid) { this.selectedDaysUIID = uiid; } + /// The UIID today's cell is drawn with while it is not the selected day, + /// or null when today is drawn like any other day. + /// + /// Unless `setTodayUIID` was called this is the theme constant + /// `calendarTodayUIID`, which the modern native themes set to + /// `CalendarToday`. A theme without the constant marks nothing, so a + /// calendar in an existing application looks as it did. + /// + /// #### Returns + /// + /// the UIID of today's cell, or null + public String getTodayUIID() { + if (todayUIIDSet) { + return todayUIID; + } + return getUIManager().getThemeConstant("calendarTodayUIID", null); + } + + /// Sets the UIID today's cell is drawn with while it is not the selected + /// day. A highlighted or multiply selected day keeps its own UIID. + /// + /// #### Parameters + /// + /// - `uiid`: the UIID, or null to draw today like any other day + public void setTodayUIID(String uiid) { + todayUIID = uiid; + todayUIIDSet = true; + // restyle the month that is showing, which need not be the selected + // day's + mv.setCurrentDay(mv.currentDay, true); + } + + /// The UIID of a day of the shown month that is neither selected nor + /// highlighted. + private String plainDayUIID(long date) { + String today = getTodayUIID(); + if (today == null || today.length() == 0) { + return "CalendarDay"; + } + java.util.Calendar now = java.util.Calendar.getInstance(tmz); + java.util.Calendar day = java.util.Calendar.getInstance(tmz); + day.setTime(new Date(date)); + if (now.get(java.util.Calendar.YEAR) == day.get(java.util.Calendar.YEAR) + && now.get(java.util.Calendar.MONTH) == day.get(java.util.Calendar.MONTH) + && now.get(java.util.Calendar.DAY_OF_MONTH) == day.get(java.util.Calendar.DAY_OF_MONTH)) { + return today; + } + return "CalendarDay"; + } + /// Highlights a date on the calendar using the supplied uiid. (Selected /// dates uiid takes precedence over highlighted dates uiid) /// @@ -1142,7 +1194,7 @@ private void setCurrentDay(long day, boolean force) { setDayUIID(components[j], "CalendarSelectedDay"); selected = components[j]; } else { - setDayUIID(components[j], "CalendarDay"); + setDayUIID(components[j], plainDayUIID(dates[j])); } for (Map.Entry> entry : highlightGroup.entrySet()) { @@ -1321,7 +1373,7 @@ public void actionPerformed(ActionEvent evt) { if (SELECTED_DAY == dates[iter]) { setDayUIID(components[iter], "CalendarSelectedDay"); } else { - setDayUIID(components[iter], "CalendarDay"); + setDayUIID(components[iter], plainDayUIID(dates[iter])); } if (!highlightGroup.isEmpty()) { for (Map.Entry> entry : highlightGroup.entrySet()) { @@ -1339,7 +1391,14 @@ public void actionPerformed(ActionEvent evt) { } } else { if (selected != null) { - setDayUIID(selected, "CalendarDay"); + String was = "CalendarDay"; + for (int k = 0; k < components.length; k++) { + if (components[k] == selected) { //NOPMD CompareObjectsWithEquals + was = plainDayUIID(dates[k]); + break; + } + } + setDayUIID(selected, was); java.util.Calendar cal = java.util.Calendar.getInstance(tmz); cal.setTime(new Date(currentDay)); cal.set(java.util.Calendar.DAY_OF_MONTH, Integer.parseInt(getDayText(selected).trim())); diff --git a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java index 8fc4536e79e..4e8cb1da668 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java +++ b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java @@ -1415,6 +1415,17 @@ private void resetThemeProps(HashMap installedTheme) { themeProps.put("CalendarDay.sel#align", centerAlign); } + // Today's cell, for a Calendar whose todayUIID names this. A theme + // that says nothing draws it as a day with a line round it. + if (installedTheme == null || (!installedTheme.containsKey("CalendarToday.derive") + && !installedTheme.containsKey("CalendarToday.fgColor"))) { + themeProps.put("CalendarToday.derive", "CalendarDay"); + themeProps.put("CalendarToday.border", Border.getDefaultBorder()); + themeProps.put("CalendarToday.align", centerAlign); + themeProps.put("CalendarToday.sel#derive", "CalendarToday"); + themeProps.put("CalendarToday.press#derive", "CalendarToday"); + } + if (installedTheme == null || !installedTheme.containsKey("ComboBox.derive")) { themeProps.put("ComboBox.border", Border.getDefaultBorder()); } @@ -2093,7 +2104,202 @@ private void resetThemeProps(HashMap installedTheme) { themeProps.put("Picker.sel#border", Border.createLineBorder(1, 0x206afb)); } + installSchedulingDefaults(installedTheme, darkMode, lightFont, bold); + + + } + + /// Default styles for the scheduler, the Gantt chart and the Material + /// pickers, for every one of their UIIDs the installed theme says nothing + /// about. + /// + /// Most of those UIIDs are never set on a component: a painter reads the + /// colours out of the style. A theme that predates them would otherwise + /// give each one the default black on white, and a week view would be a + /// white rectangle. A theme that styles a UIID owns it whole, so nothing + /// here is merged into a UIID the theme mentions. + private void installSchedulingDefaults(HashMap installedTheme, boolean darkMode, + Font plain, Font bold) { + java.util.HashSet themed = new java.util.HashSet(); + boolean dark = darkMode; + if (installedTheme != null) { + for (String key : installedTheme.keySet()) { + if (key.startsWith("Scheduler") || key.startsWith("Gantt") || key.startsWith("PickerMaterial")) { + int dot = key.indexOf('.'); + if (dot > 0) { + themed.add(key.substring(0, dot)); + } + } + } + // A theme that is dark without the dark mode switch still needs + // the dark palette: judge by the background it gives everything. + Object base = installedTheme.get("bgColor"); + if (base instanceof String) { + try { + int rgb = Integer.parseInt((String) base, 16); + int luma = (((rgb >> 16) & 0xff) * 299 + ((rgb >> 8) & 0xff) * 587 + (rgb & 0xff) * 114) / 1000; + dark = dark || luma < 96; + } catch (NumberFormatException err) { + // not a colour this method can read; keep the light palette + dark = darkMode; + } + } + } + String surface = dark ? "1c1b1f" : "ffffff"; + String raised = dark ? "2b2930" : "f3edf7"; + String well = dark ? "36343b" : "e6e0e9"; + String onSurface = dark ? "e6e1e5" : "1d1b20"; + String variant = dark ? "cac4d0" : "49454f"; + String line = dark ? "3c4043" : "dadce0"; + String offHours = dark ? "26282b" : "f1f3f4"; + String primary = dark ? "8ab4f8" : "1a73e8"; + String onPrimary = dark ? "062e6f" : "ffffff"; + String tint = dark ? "0842a0" : "d3e3fd"; + String onTint = dark ? "d3e3fd" : "041e49"; + String error = dark ? "f28b82" : "d93025"; + Font large = plain; + Font small = plain; + if (Font.isNativeFontSchemeSupported()) { + large = plain.derive(Display.getInstance().convertToPixels(5f), Font.STYLE_PLAIN); + small = plain.derive(Display.getInstance().convertToPixels(2f), Font.STYLE_PLAIN); + } else { + large = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_LARGE); + small = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_SMALL); + } + + // painted surfaces: only the colours are read + schedulingStyle(themed, "Scheduler", onSurface, surface, "255", "0,0,0,0", null); + schedulingStyle(themed, "SchedulerGrid", line, surface, "0", null, null); + schedulingStyle(themed, "SchedulerOffHours", variant, offHours, "255", null, null); + schedulingStyle(themed, "SchedulerHeader", variant, surface, "255", null, small); + schedulingStyle(themed, "SchedulerHeaderToday", onPrimary, primary, "255", null, small); + schedulingStyle(themed, "SchedulerTimeLabel", variant, surface, "255", null, small); + schedulingStyle(themed, "SchedulerItem", onPrimary, primary, "255", null, small); + schedulingStyle(themed, "SchedulerBackgroundItem", onTint, tint, "120", null, small); + schedulingStyle(themed, "SchedulerBlocked", variant, line, "140", null, small); + schedulingStyle(themed, "SchedulerNow", error, error, "255", null, null); + schedulingStyle(themed, "SchedulerSelection", primary, primary, "60", null, null); + schedulingStyle(themed, "SchedulerMonthDay", onSurface, surface, "255", null, small); + schedulingStyle(themed, "SchedulerMonthOther", variant, offHours, "255", null, small); + schedulingStyle(themed, "SchedulerAgendaItem", onSurface, surface, "255", null, null); + schedulingStyle(themed, "Gantt", onSurface, surface, "255", "0,0,0,0", null); + schedulingStyle(themed, "GanttHeader", variant, surface, "255", null, small); + if (schedulingStyle(themed, "GanttRow", onSurface, surface, "255", null, null)) { + schedulingState("GanttRow", "sel#", onTint, tint, "255"); + } + schedulingStyle(themed, "GanttSummaryRow", onSurface, offHours, "255", null, bold); + schedulingStyle(themed, "GanttBar", onSurface, dark ? "0842a0" : "a8c7fa", "255", null, small); + schedulingStyle(themed, "GanttBarProgress", onPrimary, primary, "255", null, small); + schedulingStyle(themed, "GanttBarCritical", onSurface, error, "255", null, small); + schedulingStyle(themed, "GanttSummaryBar", surface, variant, "255", null, small); + schedulingStyle(themed, "GanttMilestone", surface, onSurface, "255", null, small); + schedulingStyle(themed, "GanttBaseline", variant, line, "255", null, null); + schedulingStyle(themed, "GanttLink", variant, surface, "0", null, null); + schedulingStyle(themed, "GanttLinkCritical", error, surface, "0", null, null); + schedulingStyle(themed, "GanttLoad", onPrimary, primary, "255", null, small); + schedulingStyle(themed, "GanttOverload", onPrimary, error, "255", null, small); + + // the toolbar the scheduler and the chart share + schedulingStyle(themed, "SchedulerToolbar", onSurface, surface, "255", "1,1,1,1", null); + schedulingStyle(themed, "SchedulerTitle", onSurface, surface, "0", "1,1,2,2", bold); + if (schedulingStyle(themed, "SchedulerViewButton", primary, surface, "0", "1,1,2,2", null)) { + themeProps.put("SchedulerViewButton.align", Integer.valueOf(Component.CENTER)); + schedulingState("SchedulerViewButton", "sel#", primary, surface, "0"); + // a toggle that is down draws its pressed style + schedulingState("SchedulerViewButton", "press#", onTint, tint, "255"); + } + + // Material pickers + schedulingStyle(themed, "PickerMaterial", onSurface, raised, "255", "0,0,0,0", null); + if (schedulingStyle(themed, "PickerMaterialDialog", onSurface, raised, "255", "2,2,2,2", null)) { + themeProps.put("PickerMaterialDialog.border", RoundRectBorder.create().cornerRadius(4f)); + } + schedulingStyle(themed, "PickerMaterialHeader", onSurface, raised, "0", "2,2,3,3", null); + schedulingStyle(themed, "PickerMaterialOverline", variant, raised, "0", "0,0,0,0", small); + schedulingStyle(themed, "PickerMaterialHeadline", onSurface, raised, "0", "1,0,0,0", large); + if (schedulingStyle(themed, "PickerMaterialButton", primary, raised, "0", "1.5,1.5,2,2", null)) { + themeProps.put("PickerMaterialButton.align", Integer.valueOf(Component.CENTER)); + schedulingState("PickerMaterialButton", "sel#", primary, raised, "0"); + schedulingState("PickerMaterialButton", "press#", onTint, tint, "255"); + } + schedulingStyle(themed, "PickerMaterialButtons", onSurface, raised, "0", "1,1,2,2", null); + schedulingStyle(themed, "PickerMaterialDays", onSurface, raised, "0", "0,1,2,2", null); + if (schedulingStyle(themed, "PickerMaterialDay", onSurface, raised, "0", null, null)) { + // the circle under the chosen day, and the number on it + schedulingState("PickerMaterialDay", "sel#", onPrimary, primary, "255"); + schedulingState("PickerMaterialDay", "dis#", dark ? "6f6a75" : "b0acb5", raised, "0"); + } + schedulingStyle(themed, "PickerMaterialWeekday", variant, raised, "0", null, small); + if (schedulingStyle(themed, "PickerMaterialYear", onSurface, raised, "0", "1.5,1.5,1,1", null)) { + themeProps.put("PickerMaterialYear.align", Integer.valueOf(Component.CENTER)); + schedulingState("PickerMaterialYear", "sel#", onSurface, raised, "0"); + schedulingState("PickerMaterialYear", "press#", onTint, tint, "255"); + } + if (schedulingStyle(themed, "PickerMaterialYearSelected", onPrimary, primary, "255", "1.5,1.5,1,1", null)) { + themeProps.put("PickerMaterialYearSelected.align", Integer.valueOf(Component.CENTER)); + schedulingState("PickerMaterialYearSelected", "sel#", onPrimary, primary, "255"); + schedulingState("PickerMaterialYearSelected", "press#", onPrimary, primary, "255"); + } + if (!themed.contains("PickerMaterialField")) { + themeProps.put("PickerMaterialField.derive", "TextField"); + themeProps.put("PickerMaterialField.sel#derive", "TextField.sel"); + themeProps.put("PickerMaterialField.press#derive", "TextField.press"); + themeProps.put("PickerMaterialField.dis#derive", "TextField.dis"); + } + schedulingStyle(themed, "PickerMaterialError", error, raised, "0", "0,1,3,3", small); + if (schedulingStyle(themed, "PickerMaterialTimeField", onSurface, well, "255", "2,2,3,3", large)) { + themeProps.put("PickerMaterialTimeField.align", Integer.valueOf(Component.CENTER)); + schedulingState("PickerMaterialTimeField", "sel#", onSurface, well, "255"); + schedulingState("PickerMaterialTimeField", "press#", onTint, tint, "255"); + } + schedulingStyle(themed, "PickerMaterialTimeSeparator", onSurface, raised, "0", "0,0,1,1", large); + if (schedulingStyle(themed, "PickerMaterialPeriod", variant, well, "255", "1,1,2,2", null)) { + themeProps.put("PickerMaterialPeriod.align", Integer.valueOf(Component.CENTER)); + schedulingState("PickerMaterialPeriod", "sel#", variant, well, "255"); + schedulingState("PickerMaterialPeriod", "press#", onTint, tint, "255"); + } + schedulingStyle(themed, "PickerMaterialDialPane", onSurface, raised, "0", "1,1,2,2", null); + if (schedulingStyle(themed, "PickerMaterialDial", onSurface, well, "255", null, null)) { + // the hand and the number under it + schedulingState("PickerMaterialDial", "sel#", onPrimary, primary, "255"); + schedulingState("PickerMaterialDial", "dis#", dark ? "6f6a75" : "b0acb5", well, "255"); + } + if (schedulingStyle(themed, "PickerMaterialTab", variant, raised, "0", "1.5,1.5,2,2", null)) { + themeProps.put("PickerMaterialTab.align", Integer.valueOf(Component.CENTER)); + schedulingState("PickerMaterialTab", "sel#", variant, raised, "0"); + schedulingState("PickerMaterialTab", "press#", onTint, tint, "255"); + } + } + + /// One default style, unless the theme has the UIID. `padding` is in + /// millimetres, top, bottom, left, right; null leaves the theme's default. + private boolean schedulingStyle(java.util.HashSet themed, String id, String fg, String bg, + String transparency, String padding, Font font) { + if (themed.contains(id)) { + return false; + } + themeProps.put(id + ".fgColor", fg); + themeProps.put(id + ".bgColor", bg); + themeProps.put(id + ".transparency", transparency); + themeProps.put(id + ".margin", "0,0,0,0"); + if (padding != null) { + themeProps.put(id + ".padding", padding); + themeProps.put(id + ".padUnit", new byte[]{Style.UNIT_TYPE_DIPS, Style.UNIT_TYPE_DIPS, + Style.UNIT_TYPE_DIPS, Style.UNIT_TYPE_DIPS}); + } + if (font != null) { + themeProps.put(id + ".font", font); + } + return true; + } + /// A state of a default style. It derives the unselected one, so it keeps + /// the padding, alignment and font and changes the colours alone. + private void schedulingState(String id, String state, String fg, String bg, String transparency) { + themeProps.put(id + "." + state + "derive", id); + themeProps.put(id + "." + state + "fgColor", fg); + themeProps.put(id + "." + state + "bgColor", bg); + themeProps.put(id + "." + state + "transparency", transparency); } /// Adds the given theme properties on top of the existing properties without diff --git a/CodenameOne/src/com/codename1/ui/spinner/Picker.java b/CodenameOne/src/com/codename1/ui/spinner/Picker.java index c1b6ce6ae7f..496d17a43f2 100644 --- a/CodenameOne/src/com/codename1/ui/spinner/Picker.java +++ b/CodenameOne/src/com/codename1/ui/spinner/Picker.java @@ -133,6 +133,11 @@ public class Picker extends Button { /// Flag to indicate that the picker should prefer lightweight components /// rather than native components. private boolean useLightweightPopup; + /// Whether the application chose lightweight or native itself. A theme + /// that asks for the Material style gets it on a platform whose default + /// is the native dialog, but never over an explicit + /// `setUseLightweightPopup(false)`. + private boolean lightweightPopupSet; private Runnable stopEditingCallback; private boolean suppressPaint; private final ArrayList lightweightPopupButtons = new ArrayList(); @@ -312,7 +317,8 @@ public void actionPerformed(ActionEvent evt) { // a desktop window: the dialog is a form of the main surface, which // is the very thing the comment above keeps a window's picker off. if (!inWindow && isMaterialType(type) && isMaterialStyle() - && (useLightweightPopup || !Display.getInstance().isNativePickerTypeSupported(type))) { + && (useLightweightPopup || !lightweightPopupSet + || !Display.getInstance().isNativePickerTypeSupported(type))) { showMaterialDialog(); evt.consume(); return; @@ -1041,9 +1047,12 @@ public void actionPerformed(ActionEvent evt) { // ------------------------------------------------------ Material style - /// The theme constant that chooses what a lightweight date or time picker - /// looks like: `material` for a calendar and a clock face in a dialog, - /// anything else for wheels. + /// The theme constant that chooses what a date or time picker looks like: + /// `material` for a lightweight calendar and clock face in a dialog, + /// anything else for the platform's own picker or the wheels. The + /// constant alone is enough on a platform that defaults to its native + /// dialog; `lightweightPickerBool` is not needed and would also move the + /// string and duration pickers off the native ones. private boolean isMaterialStyle() { return "material".equals(getUIManager().getThemeConstant("pickerStyle", "wheel")); } @@ -1480,6 +1489,7 @@ public boolean isUseLightweightPopup() { /// - `useLightweightPopup` public void setUseLightweightPopup(boolean useLightweightPopup) { this.useLightweightPopup = useLightweightPopup; + lightweightPopupSet = true; } /// Check to see if the built-in action listener should ignore a given diff --git a/Ports/Android/src/AndroidMaterialTheme.res b/Ports/Android/src/AndroidMaterialTheme.res index b500d0eee2126e80781f5bef08e806705799d476..21ab5d8ddd3d8873fde23c9eda933f5d49131aed 100644 GIT binary patch literal 379564 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 u+Yjk+Vg|EodK`#2cz+&ZwcA6880S32cJOQ=} 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

9!my<#*!g`#hO4!h>XH<(*&fq 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

q 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

P6 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 diff --git a/Themes/AndroidMaterialTheme.res b/Themes/AndroidMaterialTheme.res index b500d0eee2126e80781f5bef08e806705799d476..21ab5d8ddd3d8873fde23c9eda933f5d49131aed 100644 GIT binary patch literal 379564 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 u+Yjk+Vg|EodK`#2cz+&ZwcA6880S32cJOQ=} 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

9!my<#*!g`#hO4!h>XH<(*&fq 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

q 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

P6 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 diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/CalendarTodayMarkerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/CalendarTodayMarkerTest.java new file mode 100644 index 00000000000..3bbe4dcbdea --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/CalendarTodayMarkerTest.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.ui.layouts.BorderLayout; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/// Today's cell in the month grid: marked only when a UIID is named for it, +/// and only while today is not the selected day. +public class CalendarTodayMarkerTest extends UITestBase { + + private static java.util.Calendar today() { + return java.util.Calendar.getInstance(); + } + + /// A day of this month that is not today. + private static Date anotherDay() { + java.util.Calendar c = today(); + c.set(java.util.Calendar.DAY_OF_MONTH, c.get(java.util.Calendar.DAY_OF_MONTH) == 1 ? 2 : 1); + return c.getTime(); + } + + private static List