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..f2790fd08fc --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/CalendarScheduleModel.java @@ -0,0 +1,645 @@ +/* + * 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 +/// LocalCalendarSource source = LocalCalendarSource.getInstance(); +/// CalendarScheduleModel model = new CalendarScheduleModel(source, ZoneId.systemDefault()); +/// source.listCalendars(CalendarInfo.ContentType.EVENTS, null).ready(page -> { +/// for (CalendarInfo calendar : page.getItems()) { +/// model.addCalendar(calendar); +/// } +/// }); +/// Scheduler scheduler = new Scheduler(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 int created; + private int loads; + private int pending; + 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 last failure of a load, a save or a delete, or null. It stays + /// until `reload` is called or a later save or delete works: one + /// calendar that loads says nothing of another that did not. + 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 (ScheduleResource resource : resources) { + List list = map.get(resource.getId()); + if (list == null) { + continue; + } + for (ScheduleItem item : list) { + if (item.intersects(from, to)) { + out.add(item); + } + } + } + } + + @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() { + // A failure is reported until something is asked for again. + lastError = null; + load(); + } + + private void load() { + 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; + pending = resources.size(); + for (ScheduleResource resource : resources) { + String id = resource.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 || !calendars.containsKey(calendarId)) { + // Answered after a newer question, or for a calendar + // that was taken away while it was asked. + answered(asked); + return; + } + List events = page.getItems(); + for (CalendarEvent event : events) { + ScheduleItem item = toItem(event, 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; + } + // One calendar that answered says nothing about another that + // failed in the same load, so the error is left as it is. + items.put(calendarId, into); + answered(asked); + loads++; + fireChanged(); + } + }).except(failed(asked, calendarId)); + } + + 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) { + answered(asked); + if (asked != generation || !calendars.containsKey(calendarId)) { + return; + } + List list = new ArrayList(); + for (FreeBusyInterval f : intervals) { + 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, calendarId)); + } + + private SuccessCallback failed(final int asked, final String calendarId) { + return new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + answered(asked); + if (asked == generation && calendars.containsKey(calendarId)) { + lastError = err; + Log.e(err); + // Nothing is loaded for this window, so the next range + // asked for, even the same one, asks the source again. + loadedFrom = 0; + loadedTo = 0; + 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(ScheduleItem given, long start, long end, String resourceId) { + // A load since the caller was given its item made another object + // for the same event, and that one is what the views draw. + final ScheduleItem item = held(given); + final CalendarEvent e = item == null ? null : eventOf(item); + if (e == null || !item.isMovable()) { + return false; + } + final int seen = loads; + 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); + final CalendarDateTime newS = e.getStart(); + 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(); + } else { + loadIfOvertaken(seen); + } + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + lastError = err; + Log.e(err); + // The item goes back at once, but only if this was the last + // edit of it and it is still what the model shows: a later + // edit has its own answer coming, and after a reload the + // item is a copy nobody draws. Either way the source is then + // asked what it has, which is the only thing known for sure. + List shown = items.get(target); + if (e.getStart() == newS && shown != null && shown.contains(item)) { //NOPMD CompareObjectsWithEquals - this edit's own value + e.setStart(oldS).setEnd(oldE).setCalendarId(oldResource); + item.setTime(oldStart, oldEnd); + move(item, target, oldResource); + } + fireChanged(); + load(); + } + }); + return true; + } + + // The item the model holds for the event of `item`: the same object, + // or the one a later load made for the same id. Null when it is gone. + private ScheduleItem held(ScheduleItem item) { + if (item == null) { + return null; + } + // In its own calendar first: a source may give one meeting the same + // id in two calendars that are both shown, and the copy that was + // dragged is the one to change, not whichever comes first. + ScheduleItem own = sameId(items.get(item.getResourceId()), item); + if (own != null) { + return own; + } + // Then in every other, since the event may have been moved to + // another since; and failing its id by the event behind it: an item + // that was created here has an id of its own making, and the load + // after it was saved has the source's. + CalendarEvent e = eventOf(item); + String eventId = e == null ? null : e.getId(); + // Its own calendar first here too, for the same reason. + ScheduleItem sameEvent = sameEvent(items.get(item.getResourceId()), eventId); + for (List list : items.values()) { + ScheduleItem found = sameId(list, item); + if (found != null) { + return found; + } + if (sameEvent == null) { + sameEvent = sameEvent(list, eventId); + } + } + return sameEvent; + } + + // The item of the list that is the event with this id, and not one + // occurrence of many. + private static ScheduleItem sameEvent(List list, String eventId) { + if (list == null || eventId == null) { + return null; + } + for (ScheduleItem each : list) { + if (!each.isRecurring()) { + CalendarEvent other = eventOf(each); + if (other != null && eventId.equals(other.getId())) { + return each; + } + } + } + return null; + } + + // The item itself when the list has it, or else the one with its id. + private static ScheduleItem sameId(List list, ScheduleItem item) { + if (list == null) { + return null; + } + ScheduleItem byId = null; + for (ScheduleItem each : list) { + if (each == item) { //NOPMD CompareObjectsWithEquals - the very item is the common case + return each; + } + if (byId == null && each.getId().equals(item.getId())) { + byId = each; + } + } + return byId; + } + + // An answer to a load that was asked before an edit was made, and came + // after it, drew the event where it was. The edit has now been saved, + // so what the source holds is asked for once more. + private void loadIfOvertaken(int seen) { + // One that has answered, or one that is still to answer: either + // way what it says was read before the edit. + if (loads != seen || pending > 0) { + load(); + } + } + + // One calendar of the load that is under way has given its last answer. + private void answered(int asked) { + if (asked == generation && pending > 0) { + pending--; + } + } + + 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-" + (++created) + "-" + start, newEventTitle, start, end) + .setResourceId(calendarId).setUserObject(e); + move(item, null, calendarId); + fireChanged(); + final String cal = calendarId; + final int seen = loads; + source.saveEvent(e, CalendarMutationScope.ALL).ready(new SuccessCallback() { + @Override + public void onSucess(CalendarEvent saved) { + lastError = null; + if (saved != null) { + item.setUserObject(saved); + } + loadIfOvertaken(seen); + } + }).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(ScheduleItem given) { + // The held item has the version the source gave last. + final ScheduleItem found = held(given); + final ScheduleItem item = found == null ? given : found; + final CalendarEvent e = eventOf(item); + if (e == null || !item.isMovable()) { + return false; + } + final int seen = loads; + final String cal = item.getResourceId(); + final List list = items.get(cal); + // A load since the caller was given this item made another object + // for the same event, so the one to take out is found by its id. + ScheduleItem held = null; + if (list != null) { + for (ScheduleItem each : list) { + if (each.getId().equals(item.getId())) { + held = each; + break; + } + } + if (held != null) { + list.remove(held); + } + } + final ScheduleItem taken = held; + fireChanged(); + source.deleteEvent(cal, e.getId(), mutationScope, e.getVersion()).ready(new SuccessCallback() { + @Override + public void onSucess(Boolean done) { + lastError = null; + loadIfOvertaken(seen); + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + lastError = err; + Log.e(err); + // Back at once if nothing was loaded in between, and then + // whatever the source really holds. + if (taken != null && items.get(cal) == list && !list.contains(taken)) { //NOPMD CompareObjectsWithEquals - the same list, not an equal one + list.add(taken); + } + fireChanged(); + load(); + } + }); + 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..b8652dce28f --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/DefaultScheduleModel.java @@ -0,0 +1,156 @@ +/* + * 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 (ScheduleItem item : items) { + if (item.getId().equals(id)) { + return item; + } + } + 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 (ScheduleItem it : items) { + 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; + } + // The application chooses its own ids and may have chosen this one. + String id; + do { + created++; + id = "new-" + created; + } while (getItem(id) != null); + ScheduleItem item = new ScheduleItem(id, "", 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..c75aaf36dbb --- /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 uiid; + 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 (ResourceAssignment assignment : assignments) { + if (assignment.getResourceId().equals(resourceId)) { + return assignment.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; + } + + /// The UIID the bar is drawn with in place of `GanttBar`, or null. + public String getUIID() { + return uiid; + } + + public GanttTask setUIID(String v) { + uiid = 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..96d748b9517 --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/LevelingResult.java @@ -0,0 +1,108 @@ +/* + * 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. + /// + /// The proposal describes the project as it was when it was made. If + /// tasks were added, removed or changed since, the delays are still + /// written for the tasks that remain and every other task gets none, but + /// `getFinishAfter` and `getUnresolved` no longer say what comes out: + /// level again to know. + public void apply() { + model.clearLeveling(); + for (int i = 0; i < tasks.size(); i++) { + GanttTask t = tasks.get(i); + if (model.contains(t)) { + t.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..62eaa8e8d1d --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/ProjectModel.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.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. A task that is in the project + /// already is moved. A task belongs to one project: one that is a task + /// of another is to be removed from that project first, with + /// `removeTask`, which also drops the links that project has to it. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when the task would go under itself, + /// when the parent is not in this project, or when its links would + /// then run in a loop through the summary + 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"); + } + if (parent != null && !contains(parent)) { + throw new IllegalArgumentException("The parent is not a task of this project"); + } + if (dependencies.isEmpty()) { + // Nothing is linked, so no place in the tree can close a loop. + place(parent, index, task); + return this; + } + boolean was = contains(task); + GanttTask oldParent = task.parent; + int oldIndex = was ? (oldParent == null ? roots : oldParent.children).indexOf(task) : -1; + place(parent, index, task); + if (ProjectScheduler.order(this) == null) { + // A link into a summary holds its children, so a task that is + // linked to its new parent would wait for itself. + if (was) { + place(oldParent, oldIndex, task); + } else { + detach(task); + task.parent = null; + flat = null; + } + throw new IllegalArgumentException("The links of " + task + " would run in a loop under " + parent); + } + return this; + } + + /// Whether a task is in this project's tree. + public boolean contains(GanttTask task) { + if (task == null) { + return false; + } + GanttTask top = task; + while (top.parent != null) { + top = top.parent; + } + return roots.contains(top); + } + + private void place(GanttTask parent, int index, GanttTask task) { + 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; + } + + /// Moves a task, with everything under it, to another place in the tree. + /// + /// #### Returns + /// + /// false when the move would put the task under itself, under a task + /// that is not in this project, 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; + } + if (newParent != null && !contains(newParent)) { + return false; + } + GanttTask oldParent = task.parent; + List from = oldParent == null ? roots : oldParent.children; + int oldIndex = from.indexOf(task); + place(newParent, index, task); + if (ProjectScheduler.order(this) == null) { + place(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 (GanttTask t : level) { + out.add(t); + if (!t.children.isEmpty()) { + flatten(t.children, out); + } + } + } + + public GanttTask getTask(String id) { + List all = getTasks(); + for (GanttTask candidate : all) { + if (candidate.getId().equals(id)) { + return candidate; + } + } + 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 of this project, 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; + } + // The engine finds a task by its place in the outline, which a task + // outside the project does not have. + if (!contains(predecessor) || !contains(successor)) { + 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 (TaskDependency d : dependencies) { + 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 (TaskDependency d : dependencies) { + if (d.getSuccessor() == task) { //NOPMD CompareObjectsWithEquals + out.add(d); + } + } + return out; + } + + /// The links that start at `task`. + public List getSuccessors(GanttTask task) { + List out = new ArrayList(); + for (TaskDependency d : dependencies) { + if (d.getPredecessor() == task) { //NOPMD CompareObjectsWithEquals + out.add(d); + } + } + 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 (ScheduleResource resource : resources) { + if (resource.getId().equals(id)) { + return resource; + } + } + return null; + } + + /// Records every task's current dates as its baseline. + public ProjectModel captureBaseline() { + List all = getTasks(); + for (GanttTask t : all) { + t.setBaseline(t.getStart(), t.getEnd()); + } + return this; + } + + /// Removes every leveling delay. Call `update` after it. + public ProjectModel clearLeveling() { + List all = getTasks(); + for (GanttTask each : all) { + each.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..607933ba629 --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/ProjectScheduler.java @@ -0,0 +1,424 @@ +/* + * 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 (TaskDependency d : deps) { + 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 (TaskDependency d : deps) { + GanttTask s = d.getSuccessor(); + if (s.isSummary()) { + leaves.clear(); + leavesOf(s, leaves); + for (GanttTask leaf : leaves) { + from.add(d.getPredecessor().index); + to.add(leaf.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++) { + int next = target[e]; + degree[next]--; + if (degree[next] == 0) { + out[tail++] = next; + } + } + } + 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 (TaskDependency d : in) { + 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 (TaskDependency d : out) { + // 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 (GanttTask leaf : leaves) { + lf = Math.min(lf, lateBound(d, leaf.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 (TaskDependency d : out) { + 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 (GanttTask leaf : leaves) { + long allowed = cal.subtractWorkingTime(leaf.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..d02c56b9e8b --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/ResourceLeveler.java @@ -0,0 +1,300 @@ +/* + * 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 (ScheduleResource resource : resources) { + scan(resource, 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 (GanttTask t : leaves) { + 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 (GanttTask o : leaves) { + 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. + long steps = 200L + 4L * leaves.size() * leaves.size(); + int limit = steps > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) steps; + for (int step = 0; step < limit; step++) { + ResourceOverallocation first = null; + for (ScheduleResource resource : resources) { + found.clear(); + scan(resource, 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 (GanttTask t : leaves) { + 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 (GanttTask o : conflict) { + 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 (GanttTask t : conflict) { + 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 (GanttTask task : from) { + if (!into.contains(task)) { + into.add(task); + } + } + } + + private static List leaves(ProjectModel model) { + List all = model.getTasks(); + List out = new ArrayList(); + for (GanttTask t : all) { + 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 (GanttTask t : leaves) { + if (t.getUnits(id) > 0 && t.getEnd() > t.getStart()) { + count++; + } + } + if (count == 0) { + return; + } + long[] bounds = new long[count * 2]; + int b = 0; + for (GanttTask t : leaves) { + 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 (GanttTask t : leaves) { + 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..c6fcca70bb7 --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/ScheduleItem.java @@ -0,0 +1,203 @@ +/* + * 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 uiid; + 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; + } + + /// 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 setUIID(String v) { + uiid = 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..c7fb42f7a5f --- /dev/null +++ b/CodenameOne/src/com/codename1/scheduling/WorkCalendar.java @@ -0,0 +1,293 @@ +/* + * 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); + int[] own = checked(startEndMinutes); + // No hours is a day off and not a day with hours of its own, so the + // day takes the default hours if it is made a working day again. + dayHours[isoDay] = own.length > 0 ? own : null; + workingDays[isoDay] = own.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/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..88c309790ac 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,212 @@ 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; + // A theme that carries its dark styles as `$Dark` entries turns dark + // with the device and has no `darkModeBool` to say so. The defaults + // here are looked up under the light name when a `$Dark` one is + // missing, so they have to be the dark palette while that is on, or + // a week view is a white sheet in a dark form. + Boolean deviceDark = CN.isDarkMode(); + boolean followsDevice = deviceDark != null && deviceDark.booleanValue(); + if (installedTheme != null) { + for (String key : installedTheme.keySet()) { + if (followsDevice && key.startsWith("$Dark")) { + dark = true; + } + 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; + Font small; + 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/scheduler/AgendaList.java b/CodenameOne/src/com/codename1/ui/scheduler/AgendaList.java new file mode 100644 index 00000000000..63b4c2cf622 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/AgendaList.java @@ -0,0 +1,294 @@ +/* + * 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.line(grid, 200); + int pad = Look.mm(1.5f); + int alpha = g.getAlpha(); + boolean lit = anyHover(); + for (Row r : rows) { + int top = y + r.top; + if (top > cy + ch || top + r.height < cy) { + continue; + } + if (lit) { + Look.wash(g, grid.getFgColor(), x, top, w, r.height, hoverLevel(Integer.valueOf(r.top))); + } + 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 hoverAt(int x, int y) { + // Every row answers a click: an item opens, a day is gone to. + Row r = x < 0 || x >= getWidth() || y < 0 || y >= getHeight() ? null : rowAt(y); + hover(r == null ? null : Integer.valueOf(r.top), r == null ? DEFAULT_CURSOR : HAND_CURSOR); + } + + @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/BarLayout.java b/CodenameOne/src/com/codename1/ui/scheduler/BarLayout.java new file mode 100644 index 00000000000..5f450211abf --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/BarLayout.java @@ -0,0 +1,148 @@ +/* + * 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.Container; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.Layout; +import com.codename1.ui.plaf.Style; + +/// The layout of the bar over a schedule or a chart: what leads on the +/// left, what trails on the right and, when there are three parts, a middle +/// that takes the room between them. +/// +/// On a phone the parts are wider than the bar. A row would then lay the +/// buttons of one part over the buttons of the other, so the bar becomes +/// two rows: the trailing part moves under the rest. +/// +/// A layout is asked for its preferred size before its container has a +/// width, so it cannot decide by itself which of the two it is. The owner +/// of the bar says, from its own layout, how wide the bar will be. +final class BarLayout extends Layout { + private boolean stacked; + + /// Chooses one row or two for a bar that is about to be `width` wide. + /// True when that changed, and the bar has to be measured again. + boolean fit(Container bar, int width) { + int row = 0; + int count = bar.getComponentCount(); + for (int i = 0; i < count; i++) { + Component c = bar.getComponentAt(i); + if (!c.isHidden()) { + row += outerWidth(c); + } + } + Style st = bar.getStyle(); + boolean now = width > 0 && row > width - st.getHorizontalPadding(); + if (now == stacked) { + return false; + } + stacked = now; + return true; + } + + boolean isStacked() { + return stacked; + } + + private static int outerWidth(Component c) { + return c.getPreferredW() + c.getStyle().getHorizontalMargins(); + } + + private static int outerHeight(Component c) { + return c.getPreferredH() + c.getStyle().getVerticalMargins(); + } + + private static void place(Component c, int x, int y, int w, int h) { + Style st = c.getStyle(); + c.setX(x + st.getMarginLeftNoRTL()); + c.setY(y + st.getMarginTop()); + c.setWidth(Math.max(0, w - st.getHorizontalMargins())); + c.setHeight(Math.max(0, h - st.getVerticalMargins())); + } + + @Override + public void layoutContainer(Container parent) { + Style st = parent.getStyle(); + int x = st.getPaddingLeftNoRTL(); + int y = st.getPaddingTop(); + int w = parent.getLayoutWidth() - parent.getSideGap() - st.getHorizontalPadding(); + int h = parent.getLayoutHeight() - parent.getBottomGap() - st.getVerticalPadding(); + int count = parent.getComponentCount(); + if (count == 0) { + return; + } + Component lead = parent.getComponentAt(0); + Component trail = count > 1 ? parent.getComponentAt(count - 1) : null; + Component middle = count > 2 ? parent.getComponentAt(1) : null; + boolean rtl = parent.isRTL(); + int rowH = h; + int rowW = w; + if (stacked && trail != null) { + int trailH = Math.min(h, outerHeight(trail)); + rowH = h - trailH; + // Centred under the row when it fits, and from the leading edge + // when it does not: a part that scrolls starts at its start. + int tw = Math.min(w, outerWidth(trail)); + place(trail, x + (w - tw) / 2, y + rowH, tw, trailH); + } else if (trail != null) { + int tw = Math.min(w, outerWidth(trail)); + place(trail, rtl ? x : x + w - tw, y, tw, h); + rowW = w - tw; + if (rtl) { + x += tw; + } + } + int lw = Math.min(rowW, outerWidth(lead)); + place(lead, rtl ? x + rowW - lw : x, y, lw, rowH); + if (middle != null) { + place(middle, rtl ? x : x + lw, y, rowW - lw, rowH); + } + } + + @Override + public Dimension getPreferredSize(Container parent) { + Style st = parent.getStyle(); + int count = parent.getComponentCount(); + int rowW = 0; + int rowH = 0; + int trailW = 0; + int trailH = 0; + for (int i = 0; i < count; i++) { + Component c = parent.getComponentAt(i); + if (c.isHidden()) { + continue; + } + if (stacked && count > 1 && i == count - 1) { + trailW = outerWidth(c); + trailH = outerHeight(c); + } else { + rowW += outerWidth(c); + rowH = Math.max(rowH, outerHeight(c)); + } + } + return new Dimension(Math.max(rowW, trailW) + st.getHorizontalPadding(), + rowH + trailH + st.getVerticalPadding()); + } +} 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..dc6e260800c --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/Dates.java @@ -0,0 +1,155 @@ +/* + * 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 { + // The names are asked for with every header painted, so one set is kept + // for as long as the application's bundle stays the one it was made from. + private static final SymbolCache CACHE = new SymbolCache(); + + private Dates() { + } + + private static DateFormatSymbols symbols() { + return CACHE.get(UIManager.getInstance().getBundle()); + } + + /// `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); + return L10NManager.getInstance().formatTime(c.getTime()).indexOf("13") >= 0; + } + + private static final class SymbolCache { + private DateFormatSymbols symbols; + private Map bundle; + + DateFormatSymbols get(Map current) { + if (symbols == null || current != bundle) { //NOPMD CompareObjectsWithEquals - the bundle's identity is the point + DateFormatSymbols s = new DateFormatSymbols(); + if (current instanceof Hashtable) { + s.setResourceBundle((Hashtable) current); + } + symbols = s; + bundle = current; + } + return symbols; + } + } +} 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..9cefe0a635e --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/DayGrid.java @@ -0,0 +1,1190 @@ +/* + * 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; + // A slot of the grid as a key of the hover: the column times this, and the minute. + private static final long SLOT_KEY = 10000L; + // 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 ? 18 : 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) { + // Half a label higher than the hour asked for: its label is + // centred on its line, and would be cut in two by the header. + int room = Look.style("SchedulerTimeLabel").getFont().getHeight() / 2 + Look.mm(0.5f); + setOffsets(getOffX(), Math.max(0, Math.round(pendingScrollMinute * hourH / 60f) - room)); + pendingScrollMinute = -1; + } + } + + @Override + double[] saveZoom() { + return new double[]{hourH}; + } + + @Override + void restoreZoom(double[] saved) { + hourH = (float) saved[0]; + } + + @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); + } + + // The working hours of a calendar as minutes of a day of this view. They + // are the calendar's own when it keeps the scheduler's zone. Somebody + // who works in another zone has hours that fall elsewhere on this + // column, and partly on the days either side of it. + private int[] workingMinutes(WorkCalendar cal, long day) { + ZoneId zone = owner.getZone(); + ZoneId theirs = cal.getZone(); + if (theirs == null || theirs.equals(zone)) { + return cal.getWorkingHours(day); + } + long from = TimeMath.startOfEpochDay(day, zone); + long to = TimeMath.startOfEpochDay(day + 1, zone); + long first = TimeMath.epochDay(from, theirs); + long last = TimeMath.epochDay(to - 1, theirs); + int[] out = new int[0]; + for (long d = first; d <= last; d++) { + int[] iv = cal.getWorkingHours(d); + for (int i = 0; i < iv.length; i += 2) { + long a = Math.max(from, TimeMath.at(d, iv[i], theirs)); + long b = Math.min(to, TimeMath.at(d, iv[i + 1], theirs)); + if (b <= a) { + continue; + } + // Minutes of this column's own clock, which is what the + // column is laid out in: on a day the clock changes they are + // not the minutes that have passed since midnight. + int start = a <= from ? 0 : TimeMath.minuteOfDay(a, zone); + int end = b >= to ? TimeMath.MINUTES_PER_DAY : TimeMath.minuteOfDay(b, zone); + if (end <= start) { + continue; + } + if (out.length > 0 && start <= out[out.length - 1]) { + out[out.length - 1] = Math.max(out[out.length - 1], end); + } else { + int[] grown = new int[out.length + 2]; + System.arraycopy(out, 0, grown, 0, out.length); + grown[out.length] = start; + grown[out.length + 1] = end; + out = grown; + } + } + } + return out; + } + + 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 = workingMinutes(cal, 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)); + } + } + } + + paintSlot(g, x, y, hovered(), grid.getFgColor()); + paintSlot(g, x, y, fading(), grid.getFgColor()); + + // A background band is part of the grid, as the shading of the hours + // off is: the hour lines are drawn over it, so an hour can still be + // read across a column that is all one band. + 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); + } + + int line = Look.line(grid, 255); + int faint = Look.line(grid, 110); + 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); + } + + ScheduleItem selected = owner.getSelectedItem(); + Box selectedBox = null; + boolean lit = anyHover(); + float lift = mode == MOVE || mode == RESIZE_START || mode == RESIZE_END ? liftLevel() : 0; + int alpha = g.getAlpha(); + for (Box b : boxes) { + boxRect(b, rect); + settle(b.item.getId(), rect); + if (y + rect[1] > cy + ch || y + rect[1] + rect[3] < cy) { + continue; + } + int state = 0; + float over = lit ? hoverLevel(b.item.getId()) : 0; + if (over > 0) { + state |= ScheduleItemRenderer.HOVERED; + } + boolean held = lift > 0 && b.item == editItem; //NOPMD CompareObjectsWithEquals - the very item that is held + if (held) { + // What is left behind while the item is carried: where it + // was, and where it returns to if it is not let go. + g.setAlpha(Math.round(alpha * (1 - 0.6f * lift))); + } + 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 (held) { + g.setAlpha(alpha); + } else { + Look.glow(g, grid.getFgColor(), x + rect[0], y + rect[1], rect[2], rect[3], over); + } + } + 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(Look.under(grid)); + 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); + // The size of the hours beside the grid: a theme that gives + // the selection no font of its own would write the range + // in its default one, larger than anything else here. + Font f = Look.style("SchedulerTimeLabel").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 { + Look.shadow(g, x0, y + top, w, h, liftLevel()); + owner.getItemRenderer().paintItem(g, owner, editItem, x0, y + top, w, h, + ScheduleItemRenderer.DRAGGED | ScheduleItemRenderer.SELECTED); + // The time it would land on, over the item: in the colour + // and the size of the hours beside the grid, which it is + // read against. The colour of the grid's lines is too + // pale to read as text. + Style label = Look.style("SchedulerTimeLabel"); + Font f = label.getFont(); + g.setFont(f); + g.setColor(label.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; + } + float over = hoverLevel(c.item.getId()); + if (over > 0) { + state |= ScheduleItemRenderer.HOVERED; + } + renderer.paintItem(g, owner, c.item, x + rect[0], y + rect[1], rect[2], rect[3], state); + Look.glow(g, header.getFgColor(), x + rect[0], y + rect[1], rect[2], rect[3], over); + } + 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; + if (item != null && editRect(rect)) { + // From where it was let go to where it belongs, which is the + // place it came from when the move is refused or given up. + settleFrom(item.getId(), rect[0], rect[1], rect[2], rect[3]); + } + 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); + } + } + + // Where the item that is being dragged is drawn, in the content: the + // first column it shows in. + boolean editRect(int[] out) { + ZoneId zone = owner.getZone(); + int col = -1; + long ds = 0; + long de = 0; + for (int c = 0; c < days.length && col < 0; c++) { + if (resources != null && c != resourceColumn(editResource)) { + continue; + } + ds = TimeMath.startOfEpochDay(days[c], zone); + de = TimeMath.startOfEpochDay(days[c] + 1, zone); + if (editEnd > ds && editStart < de) { + col = c; + } + } + if (col < 0) { + return false; + } + int top = editStart <= ds ? 0 : minuteY(TimeMath.minuteOfDay(editStart, zone)); + int bottom = editEnd >= de ? contentHeight() : minuteY(TimeMath.minuteOfDay(editEnd, zone)); + out[0] = colX(col) + 1; + out[1] = top; + out[2] = colX(col + 1) - colX(col) - 2; + out[3] = Math.max(Look.mm(2.6f), bottom - top); + return true; + } + + // The slot of the grid a key of `hoverAt` names, lit. + private void paintSlot(Graphics g, int x, int y, Object key, int rgb) { + if (!(key instanceof Long) || days.length == 0) { + return; + } + long v = ((Long) key).longValue(); + int col = (int) (v / SLOT_KEY); + int minute = (int) (v % SLOT_KEY); + if (col >= days.length) { + return; + } + int top = minuteY(minute); + Look.wash(g, rgb, x + colX(col) + 1, y + top, colX(col + 1) - colX(col) - 1, + minuteY(Math.min(TimeMath.MINUTES_PER_DAY, minute + slotMinutes())) - top, hoverLevel(key)); + } + + // A slot is a step of the snap, and half an hour when the steps are + // too fine to point at. + private int slotMinutes() { + return Math.max(30, owner.getSnapMinutes()); + } + + @Override + void hoverAt(int x, int y) { + if (days.length == 0 || x < headerW) { + hover(null, DEFAULT_CURSOR); + return; + } + if (y < headerH) { + Chip chip = chipAt(x, y); + hover(chip == null ? null : chip.item.getId(), chip == null ? DEFAULT_CURSOR : HAND_CURSOR); + tip(chip == null ? null : chip.item.getTitle()); + return; + } + int cy = y - headerH + getOffY(); + Box b = boxAt(x, y); + if (b != null) { + int cursor = HAND_CURSOR; + if (owner.isEditable() && isDragEdits()) { + boxRect(b, rect); + int edge = Math.min(rect[3] / 3, Look.mm(1.6f)); + boolean bottom = !b.endsAfter && rect[1] + rect[3] - cy <= edge; + boolean top = !bottom && !b.startsBefore && cy - rect[1] <= edge; + if ((top || bottom) && b.item.isResizable()) { + cursor = top ? N_RESIZE_CURSOR : S_RESIZE_CURSOR; + } else if (b.item.isMovable()) { + cursor = MOVE_CURSOR; + } + } + hover(b.item.getId(), cursor); + tip(b.item.getTitle() + ", " + owner.formatRange(b.item.getStart(), b.item.getEnd())); + return; + } + tip(null); + if (!owner.isEditable()) { + hover(null, DEFAULT_CURSOR); + return; + } + // An empty slot, which a drag or a double click would fill. + int col = colAt(Math.max(0, Math.min(contentW - 1, x - headerW + getOffX()))); + int slot = slotMinutes(); + int minute = Math.max(0, Math.min(TimeMath.MINUTES_PER_DAY - 1, minuteAt(cy))) / slot * slot; + hover(Long.valueOf(col * SLOT_KEY + minute), DEFAULT_CURSOR); + } + + 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/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/DefaultScheduleItemRenderer.java b/CodenameOne/src/com/codename1/ui/scheduler/DefaultScheduleItemRenderer.java new file mode 100644 index 00000000000..deb3c28a979 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/DefaultScheduleItemRenderer.java @@ -0,0 +1,163 @@ +/* + * 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 (own >= 0 && item.getKind() == ScheduleItem.Kind.BACKGROUND) { + // A band behind a day's items is a tint of the colour, not the + // colour: at full strength a person's working hours were the + // loudest thing in the view and hid the items over them. + opacity = Math.min(opacity, 48); + fg = st.getFgColor(); + } + 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) { + // The time goes first where there is room for both. In a + // narrow cell it would be all that fits, and a month of chips + // that say "09:00" tells nobody what is on. + String timed = scheduler.formatTime(item.getStart()) + " " + title; + if (font.stringWidth(timed) <= tw) { + title = timed; + } + } + 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/Gantt.java b/CodenameOne/src/com/codename1/ui/scheduler/Gantt.java new file mode 100644 index 00000000000..0be55673d74 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/Gantt.java @@ -0,0 +1,668 @@ +/* + * 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 +/// WorkCalendar calendar = new WorkCalendar(); +/// // A duration is working time: a day of it is the calendar's hours. +/// long day = calendar.getMillisPerDay(); +/// ProjectModel project = new ProjectModel(calendar, System.currentTimeMillis()); +/// 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 form = new Form("Plan", new BorderLayout()); +/// form.add(BorderLayout.CENTER, new Gantt(project)); +/// form.show(); +/// ``` +/// +/// ![A Gantt chart with its critical path](https://www.codenameone.com/developer-guide/img/generated/gantt-critical-path.png) +/// +/// 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. +/// +/// Under a mouse the bar the pointer is over lights up together with the +/// links into and out of it, the row is marked in the table and in the +/// chart, the cursor says what a drag would do and a tooltip gives the +/// task's dates and progress. A bar that is picked up lifts over a shadow. +/// +/// ![A bar moved and the tasks after it following, as four stopped frames](https://www.codenameone.com/developer-guide/img/generated/gantt-drag-stages.png) +/// +/// `setAnimationTime` stops the clock these run by, for a screenshot or a +/// test of a moment in the middle of one. +/// +/// #### 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. +/// +/// ![The load of each resource under the chart](https://www.codenameone.com/developer-guide/img/generated/gantt-load.png) +/// +/// #### 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 BarLayout bar = new BarLayout(); + private final Container toolbar = new Container(bar); + 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(zoom); + toolbar.add(options); + } + + /// The bar is a row where there is room for one, and two rows on a + /// screen as narrow as a phone's. It is decided here because this is + /// the first place the width is known. + @Override + public void layoutContainer() { + if (bar.fit(toolbar, getWidth() - getStyle().getHorizontalPadding())) { + toolbar.setShouldCalcPreferredSize(true); + } + super.layoutContainer(); + } + + /// 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; + } + // A bar that is held belongs to the project that is leaving. + chart.cancelEdit(); + 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 (ResourceOverallocation over : overallocations) { + if (over.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. + @Override + 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(); + } + + /// Stops the clock the chart animates by at a time, for a screenshot of + /// an animation or a test of one: the light of a hover, the rise of + /// an item that is picked up and the slide of + /// the bars to their new dates are drawn as far along as that time + /// makes them. A negative value returns to the clock. + /// + /// ```java + /// gantt.setAnimationTime(1000); // nothing has started + /// // ... press, drag, release ... + /// gantt.setAnimationTime(1100); // 100 milliseconds into what followed + /// ``` + public void setAnimationTime(long millis) { + chart.setFixedClock(millis); + load.setFixedClock(millis); + } + + 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 (GanttTask each : all) { + if (each.isSummary()) { + collapsed.add(each); + } + } + 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); + } + + // The listeners as they are now: one that removes itself while it is + // told must not make the next one miss the event, or its say on it. + private GanttListener[] told() { + return listeners.toArray(new GanttListener[listeners.size()]); + } + + void viewSelected(GanttTask task) { + if (selected == task) { //NOPMD CompareObjectsWithEquals - the selection is this very object + return; + } + selected = task; + for (GanttListener l : told()) { + l.taskSelected(this, task); + } + chart.repaint(); + } + + void viewOpened(GanttTask task) { + for (GanttListener l : told()) { + l.taskOpened(this, task); + } + } + + boolean viewChanging(GanttTask task, long start, long end) { + for (GanttListener l : told()) { + if (!l.taskChanging(this, task, start, end)) { + return false; + } + } + return true; + } + + void viewChanged(GanttTask task) { + for (GanttListener l : told()) { + l.taskChanged(this, task); + } + } + + boolean viewLinking(GanttTask predecessor, GanttTask successor) { + for (GanttListener l : told()) { + if (!l.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..e50d2fd226c --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/GanttBarRenderer.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.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; + /// The pointer of a desktop rests on the bar. The chart lights the + /// bar itself; a renderer that wants more can tell by this. + int HOVERED = 8; + + /// 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..00ba5d8496b --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/GanttChart.java @@ -0,0 +1,1111 @@ +/* + * 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 GanttTask linkProbe; + 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() { + // What could be linked a moment ago is asked again. + linkProbe = null; + if (mode == LINK) { + linkTarget = null; + } + 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() { + beginReflow(null, 0, 0); + } + + /// As `beginReflow()`, with one bar starting from where a hand left it + /// and not from its dates: the project may put it somewhere else again, + /// and it slides there from under the hand, not from where it was. + void beginReflow(GanttTask held, long heldStart, long heldEnd) { + HashMap from = new HashMap(); + for (GanttTask t : rows) { + if (t == held) { //NOPMD CompareObjectsWithEquals - the very task that was dragged + from.put(t, new long[]{heldStart, heldEnd}); + } else { + from.put(t, new long[]{t.getStart(), t.getEnd()}); + } + } + reflowFrom = from; + reflowStart = tick(); + 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 (tick() - 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, (tick() - 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 + double[] saveZoom() { + return new double[]{axis.getMillisPerPixel()}; + } + + @Override + void restoreZoom(double[] saved) { + axis.setMillisPerPixel(saved[0]); + } + + @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); + // A page shows the project, not what a hand was doing to it. + GanttTask selected = isPrinting() ? null : 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); + } + } + paintRowHover(g, cx, y, cw, rowStyle.getFgColor()); + 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(); + boolean lit = anyHover(); + float lift = mode == MOVE || mode == RESIZE_END ? liftLevel() : 0; + 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; + float over = lit ? hoverLevel(t) : 0; + if (over > 0) { + state |= GanttBarRenderer.HOVERED; + } + boolean held = lift > 0 && t == editTask; //NOPMD CompareObjectsWithEquals - the very task that is held + if (held) { + // What is left behind while the bar is carried. + g.setAlpha(Math.round(alpha * (1 - 0.6f * lift))); + } + 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); + if (!held && !t.isMilestone()) { + Look.glow(g, rowStyle.getFgColor(), x + rect[0], y + rect[1], rect[2], rect[3], over); + } + } + g.setAlpha(alpha); + 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 && !isPrinting()) { + paintEdit(g, x, y); + } + } + + // A page ends between two rows, so that no row is cut in half by it. + @Override + int pageStep(int bodyH) { + return rowH > 0 && bodyH >= rowH ? bodyH - bodyH % rowH : bodyH; + } + + // A drag is of a task that is in the project and on a row. One that was + // removed, or whose parent was folded, while it was held has no bar left + // to follow the pointer. + private boolean editGone() { + ProjectModel model = owner.getModel(); + return editTask == null || model == null || !model.contains(editTask) || rowOf.get(editTask) == null; + } + + // The start a move by `delta` gives. The distance is what snaps, not the + // start: a task that begins at one in the afternoon stays at one, and a + // press that goes nowhere moves nothing. + private long movedStart(GanttTask t, long delta) { + ProjectModel model = owner.getModel(); + ZoneId zone = axis.getZone(); + long start = t.getStart(); + long to; + if (axis.snapsToDays()) { + long days = Math.round((double) delta / TimeMath.DAY); + if (days == 0) { + return start; + } + to = TimeMath.at(TimeMath.epochDay(start, zone) + days, TimeMath.minuteOfDay(start, zone), zone); + } else { + long step = 15 * TimeMath.MINUTE; + long steps = Math.round((double) delta / step); + if (steps == 0) { + return start; + } + to = start + steps * step; + } + return model.getCalendar().nextWorkingTime(to); + } + + // 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++) { //NOPMD ForLoopCanBeForeach - painted for every row of every frame; a for-each allocates an iterator + 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()) { + Look.shadow(g, x0 - barH / 2, top, barH, barH, liftLevel()); + owner.getBarRenderer().paintBar(g, owner, editTask, x0 - barH / 2, top, barH, barH, + GanttBarRenderer.DRAGGED); + } else { + Look.shadow(g, x0, top, Math.max(2, x1 - x0), barH, liftLevel()); + 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)); + Object over = isPrinting() ? null : hovered(); + int strong = Look.style("GanttRow").getFgColor(); + for (TaskDependency d : all) { + 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 (over != null && (over == d.getPredecessor() || over == d.getSuccessor())) { //NOPMD CompareObjectsWithEquals - the very task the pointer is on + // What the task under the pointer waits for, and what waits for it. + g.setColor(strong); + } + 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); + // A page shows the project, not what a hand was doing to it. + GanttTask selected = isPrinting() ? null : owner.getSelectedTask(); + int line = Look.mix(rowStyle.getBgColor(), rowStyle.getFgColor(), 25); + boolean aa = g.isAntiAliased(); + boolean lit = anyHover(); + int hot = lit ? hoverRow(hovered()) : -1; + int cold = lit ? hoverRow(fading()) : -1; + 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); + } + if (r == hot) { + Look.wash(g, rowStyle.getFgColor(), x, top, headerW, rowH, hoverLevel(hovered())); + } else if (r == cold) { + Look.wash(g, rowStyle.getFgColor(), x, top, headerW, rowH, hoverLevel(fading())); + } + 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 + + // The row the pointer is in, whether it is on the bar or beside it. + private int hoverRow(Object key) { + if (key instanceof Integer) { + return ((Integer) key).intValue(); + } + if (key instanceof GanttTask) { + Integer r = rowOf.get(key); + return r == null ? -1 : r.intValue(); + } + return -1; + } + + // That row lit from edge to edge: the table and the chart are one row. + private void paintRowHover(Graphics g, int x, int y, int w, int rgb) { + if (!anyHover()) { + return; + } + Object now = hovered(); + Object was = fading(); + int r = hoverRow(now); + if (r >= 0 && r < rows.size()) { + Look.wash(g, rgb, x, y + r * rowH, w, rowH, hoverLevel(now)); + } + int old = hoverRow(was); + if (old >= 0 && old < rows.size() && old != r) { + Look.wash(g, rgb, x, y + old * rowH, w, rowH, hoverLevel(was)); + } + } + + @Override + void hoverAt(int x, int y) { + int r = rowAt(y); + if (r < 0 || x < 0 || x >= getWidth()) { + hover(null, DEFAULT_CURSOR); + return; + } + if (x < headerW) { + // A click on the table picks the task or folds its children. + hover(Integer.valueOf(r), HAND_CURSOR); + return; + } + int cx = x - headerW + getOffX(); + int cy = y - headerH + getOffY(); + boolean edits = owner.isEditable(); + GanttTask sel = owner.getSelectedTask(); + Integer selRow = sel == null ? null : rowOf.get(sel); + if (edits && sel != null && selRow != null && !sel.isSummary()) { + // The two handles of the selected task, as beginEdit reads them. + barRect(sel, selRow.intValue(), rect); + int reach = Look.mm(2.6f); + if (Math.abs(cx - (rect[0] + rect[2] + Look.mm(2.2f))) <= reach + && Math.abs(cy - (rect[1] + rect[3] / 2)) <= reach) { + hover(sel, CROSSHAIR_CURSOR); + return; + } + 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) { + hover(sel, E_RESIZE_CURSOR); + return; + } + } + GanttTask t = barAt(x, y); + if (t == null) { + hover(Integer.valueOf(r), DEFAULT_CURSOR); + tip(null); + return; + } + ZoneId zone = axis.getZone(); + tip(t.getName() + ", " + Dates.monthDay(TimeMath.epochDay(t.getStart(), zone)) + " - " + + Dates.monthDay(TimeMath.epochDay(Math.max(t.getStart(), t.getEnd() - 1), zone)) + ", " + + t.getPercentComplete() + "%"); + int cursor = HAND_CURSOR; + if (edits && isDragEdits() && !t.isSummary()) { + barRect(t, r, rect); + int edge = Math.min(rect[2] / 3, Look.mm(1.6f)); + cursor = !t.isMilestone() && rect[0] + rect[2] - cx <= edge ? E_RESIZE_CURSOR : MOVE_CURSOR; + } + hover(t, cursor); + } + + 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 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; + linkProbe = 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; + } + if (editGone()) { + cancelEdit(); + 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 = movedStart(editTask, pointer - grabTime); + // A task with dates of its own keeps the span it was given; it + // has no duration the calendar could lay out again. + editEnd = editStart == editTask.getStart() ? editTask.getEnd() + : editTask.isManuallyScheduled() ? editStart + (editTask.getEnd() - editTask.getStart()) + : 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); + // Asking whether a link would close a loop orders the whole + // project, so it is asked once for each bar the pointer comes to. + if (t != linkProbe) { //NOPMD CompareObjectsWithEquals - the bar under the pointer last time + linkProbe = t; + 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 || !model.contains(t)) { + linkTarget = null; + return; + } + if (m == LINK) { + // Asked once more, and before the listeners are: the project + // changing under the pointer may have made the link one that + // cannot be, and a listener is not asked about one of those. + if (linkTarget != null && model.canLink(t, linkTarget) && owner.viewLinking(t, linkTarget)) { + beginReflow(); + model.link(t, linkTarget); + model.update(); + } + linkTarget = null; + } else if (m == PROGRESS) { + // Progress is a change like a new date, so it is offered the + // same way, with the dates the task keeps. + if (editPercent != t.getPercentComplete() && owner.viewChanging(t, t.getStart(), t.getEnd())) { + t.setPercentComplete(editPercent); + model.update(); + owner.viewChanged(t); + } + } else if (m == MOVE) { + if (editStart != t.getStart() && owner.viewChanging(t, editStart, editEnd)) { + beginReflow(t, editStart, editEnd); + 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. + // A constraint that places the task keeps its kind and + // takes the new date. One that only says how late the + // task may be places nothing, so a task that has it, or + // none, is held from starting earlier: the bar has to + // end up where it was put. + GanttTask.Constraint c = t.getConstraint(); + if (c == GanttTask.Constraint.MUST_START_ON) { + t.setConstraint(c, editStart); + } else if (c == GanttTask.Constraint.MUST_FINISH_ON + || c == GanttTask.Constraint.FINISH_NO_EARLIER_THAN) { + t.setConstraint(c, editEnd); + } else { + 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(t, t.getStart(), editEnd); + 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..cb589fbafc9 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/GanttListener.java @@ -0,0 +1,57 @@ +/* + * 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, or + /// its progress was dragged, which offers the dates it already has. 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/Look.java b/CodenameOne/src/com/codename1/ui/scheduler/Look.java new file mode 100644 index 00000000000..12111a221b8 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/Look.java @@ -0,0 +1,200 @@ +/* + * 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 shadow of something held above the surface, `level` of the way + /// up from 0 to 1. + static void shadow(Graphics g, int x, int y, int w, int h, float level) { + if (level <= 0 || w <= 0 || h <= 0) { + return; + } + int a = g.getAlpha(); + int reach = Math.max(2, mm(1.1f)); + int drop = Math.round(mm(0.6f) * level); + int arc = mm(1.2f); + g.setColor(0); + // Each ring is drawn over the ones outside it, which is what makes + // the shadow darker towards the thing that casts it. + g.setAlpha(Math.max(1, Math.round(a * 0.2f * level / reach))); + for (int i = reach; i >= 1; i--) { + g.fillRoundRect(x - i, y - i + drop, w + 2 * i, h + 2 * i, arc + 2 * i, arc + 2 * i); + } + g.setAlpha(a); + } + + /// The light of the pointer on what it is over: a wash of `rgb` and + /// a line around it, `level` of the way up from 0 to 1. + static void glow(Graphics g, int rgb, int x, int y, int w, int h, float level) { + if (level <= 0 || w <= 0 || h <= 0) { + return; + } + int a = g.getAlpha(); + int arc = mm(0.8f); + g.setColor(rgb); + g.setAlpha(Math.round(a * 0.16f * level)); + g.fillRoundRect(x, y, w, h, arc, arc); + g.setAlpha(Math.round(a * 0.55f * level)); + g.drawRoundRect(x, y, w - 1, h - 1, arc, arc); + g.setAlpha(a); + } + + /// The same wash with no line, for an empty place a press would fill. + static void wash(Graphics g, int rgb, int x, int y, int w, int h, float level) { + if (level <= 0 || w <= 0 || h <= 0) { + return; + } + int a = g.getAlpha(); + g.setColor(rgb); + g.setAlpha(Math.round(a * 0.09f * level)); + 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) + "..."; + } + + /// The colour a style's lines are drawn over. A grid style usually has + /// no background of its own, and the colour a theme leaves in a + /// transparent one is black: mixed with that, a pale line came out + /// nearly black. What is under such a grid is the component. + static int under(Style st) { + if ((st.getBgTransparency() & 0xff) == 0) { + return style("Scheduler").getBgColor(); + } + return st.getBgColor(); + } + + /// A line of a grid, as strong as `amount` of 255. + static int line(Style grid, int amount) { + return mix(under(grid), grid.getFgColor(), amount); + } + + /// 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..7c1577f3f4e --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/MonthGrid.java @@ -0,0 +1,429 @@ +/* + * 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.line(grid, 255); + int lo = Math.min(anchorCell, editCell); + int hi = Math.max(anchorCell, editCell); + Object over = hovered(); + Object left = fading(); + float lift = mode == MOVE ? liftLevel() : 0; + int alpha = g.getAlpha(); + 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); + } + if (over instanceof Integer && ((Integer) over).intValue() == cell) { + Look.wash(g, grid.getFgColor(), x0, y0, x1 - x0, y1 - y0, hoverLevel(over)); + } else if (left instanceof Integer && ((Integer) left).intValue() == cell) { + Look.wash(g, grid.getFgColor(), x0, y0, x1 - x0, y1 - y0, hoverLevel(left)); + } + 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; + } + float lit = over != null || left != null ? hoverLevel(item.getId()) : 0; + if (lit > 0) { + state |= ScheduleItemRenderer.HOVERED; + } + boolean held = lift > 0 && item == editItem; //NOPMD CompareObjectsWithEquals - the very item that is held + if (held) { + // What is left behind while the item is carried. + g.setAlpha(Math.round(alpha * (1 - 0.6f * lift))); + } + renderer.paintItem(g, owner, item, x0 + 1, cy, x1 - x0 - 2, chipH - 1, state); + if (held) { + g.setAlpha(alpha); + } else { + Look.glow(g, grid.getFgColor(), x0 + 1, cy, x1 - x0 - 2, chipH - 1, lit); + } + 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)); + } + if (mode == MOVE && editItem != null && editCell != grabCell && capacity > 0) { + // The item itself, carried to the day it would land on. + int x0 = x + colX(editCell % 7) + 1; + int w = colX(editCell % 7 + 1) - colX(editCell % 7) - 2; + int top = y + rowY(editCell / 7) + numberH; + Look.shadow(g, x0, top, w, chipH - 1, lift); + renderer.paintItem(g, owner, editItem, x0, top, w, chipH - 1, + ScheduleItemRenderer.COMPACT | ScheduleItemRenderer.DRAGGED | ScheduleItemRenderer.SELECTED); + } + } + + @Override + void hoverAt(int x, int y) { + if (y < headerH || cells == null || x < 0 || x >= getWidth() || y >= getHeight()) { + hover(null, DEFAULT_CURSOR); + return; + } + ScheduleItem item = itemAt(x, y); + if (item != null) { + hover(item.getId(), owner.isEditable() && isDragEdits() && item.isMovable() ? MOVE_CURSOR : HAND_CURSOR); + tip(item.getTitle()); + } else { + // The day a click opens and a drag starts a range from. + hover(Integer.valueOf(cellAt(x, y)), HAND_CURSOR); + tip(null); + } + } + + @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) { + // Both ends keep their time of day and move the same number of + // dates. Adding the length in milliseconds would end an all-day + // item an hour into the next day when a clock change is crossed. + int by = editCell - grabCell; + long start = TimeMath.at(TimeMath.epochDay(item.getStart(), zone) + by, + TimeMath.minuteOfDay(item.getStart(), zone), zone); + long end = TimeMath.at(TimeMath.epochDay(item.getEnd(), zone) + by, + TimeMath.minuteOfDay(item.getEnd(), zone), zone); + owner.viewChanged(item, start, Math.max(end, start), 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/ResourceLoadView.java b/CodenameOne/src/com/codename1/ui/scheduler/ResourceLoadView.java new file mode 100644 index 00000000000..0c703fce15b --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/ResourceLoadView.java @@ -0,0 +1,296 @@ +/* + * 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; + float[] row = profile[resourceIndex]; + return row == null || i < 0 || i >= profileCount ? -1 : row[(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(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; + } + // A row's load is worked out when the row is first painted: a + // project has more resources than a screen has rows. + profile = new float[resources.size()][]; + 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(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]; + if (units == null) { + units = ResourceLeveler.loadProfile(model, res.getId(), profileFrom, + profileFrom + profileCount * profileBucket, profileCount); + profile[r] = units; + } + float peak = capacity; + for (float unit : units) { + peak = Math.max(peak, unit); + } + // 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/CodenameOne/src/com/codename1/ui/scheduler/ResourceTimeline.java b/CodenameOne/src/com/codename1/ui/scheduler/ResourceTimeline.java new file mode 100644 index 00000000000..b1809f36a30 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/ResourceTimeline.java @@ -0,0 +1,711 @@ +/* + * 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); + // A drag under way keeps a row that exists. + editRow = Math.min(editRow, rowCount - 1); + 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 + double[] saveZoom() { + return new double[]{axis.getMillisPerPixel()}; + } + + @Override + void restoreZoom(double[] saved) { + axis.setMillisPerPixel(saved[0]); + } + + @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); + } + // Background bands go under the grid lines, as the hours off do. + 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); + } + axis.paintGrid(g, Look.line(grid, 255), Look.under(grid), x, cy, ch, cx, + cx + cw); + g.setColor(Look.line(grid, 255)); + for (int r = 1; r <= rowCount; r++) { + int ly = y + rowTop[r]; + g.drawLine(cx, ly, cx + cw, ly); + } + paintRowHover(g, y, cx, cw, hovered(), grid.getFgColor()); + paintRowHover(g, y, cx, cw, fading(), grid.getFgColor()); + ScheduleItem selected = owner.getSelectedItem(); + boolean lit = anyHover(); + float lift = mode == MOVE || mode == RESIZE_START || mode == RESIZE_END ? liftLevel() : 0; + int alpha = g.getAlpha(); + for (Bar b : bars) { + barRect(b, rect); + settle(b.item.getId(), rect); + if (x + rect[0] > cx + cw || x + rect[0] + rect[2] < cx) { + continue; + } + int state = ScheduleItemRenderer.HORIZONTAL | ScheduleItemRenderer.COMPACT; + float over = lit ? hoverLevel(b.item.getId()) : 0; + if (over > 0) { + state |= ScheduleItemRenderer.HOVERED; + } + boolean held = lift > 0 && b.item == editItem; //NOPMD CompareObjectsWithEquals - the very item that is held + if (held) { + // What is left behind while the item is carried. + g.setAlpha(Math.round(alpha * (1 - 0.6f * lift))); + } + 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 (held) { + g.setAlpha(alpha); + } else { + Look.glow(g, grid.getFgColor(), Math.max(x + rect[0], cx - Look.mm(2)), y + rect[1], + x + rect[0] + rect[2] - Math.max(x + rect[0], cx - Look.mm(2)), rect[3], over); + } + } + 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 { + Look.shadow(g, x0, y + rowTop[editRow] + rowPad, w, laneH - 1, liftLevel()); + 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; + } + if (resources.length > 0 && cy >= rowTop[rowCount]) { + // Under the last row there is nobody to book. + return false; + } + mode = RANGE; + editItem = null; + editRow = rowAt(cy); + anchorTime = snap(axis.time(cx) - step() / 2); + editStart = anchorTime; + editEnd = anchorTime + defaultLength(); + return true; + } + + // The row a key of `hoverAt` names, lit from edge to edge. + private void paintRowHover(Graphics g, int y, int cx, int cw, Object key, int rgb) { + if (!(key instanceof Integer)) { + return; + } + int r = ((Integer) key).intValue(); + if (r >= 0 && r < rowCount) { + Look.wash(g, rgb, cx, y + rowTop[r] + 1, cw, rowTop[r + 1] - rowTop[r] - 1, hoverLevel(key)); + } + } + + @Override + void hoverAt(int x, int y) { + if (x < headerW || y < headerH) { + hover(null, DEFAULT_CURSOR); + return; + } + int cx = x - headerW + getOffX(); + int cy = y - headerH + getOffY(); + Bar b = barAt(x, y); + if (b != null) { + int cursor = HAND_CURSOR; + if (owner.isEditable() && isDragEdits()) { + barRect(b, rect); + int edge = Math.min(rect[2] / 3, Look.mm(1.6f)); + boolean right = rect[0] + rect[2] - cx <= edge; + boolean left = !right && cx - rect[0] <= edge; + if ((left || right) && b.item.isResizable()) { + cursor = left ? W_RESIZE_CURSOR : E_RESIZE_CURSOR; + } else if (b.item.isMovable()) { + cursor = MOVE_CURSOR; + } + } + hover(b.item.getId(), cursor); + tip(b.item.getTitle() + ", " + owner.formatRange(b.item.getStart(), b.item.getEnd())); + return; + } + tip(null); + // The row a drag or a double click would book. + if (rowCount > 0 && cy < rowTop[rowCount]) { + hover(Integer.valueOf(rowAt(cy)), DEFAULT_CURSOR); + } else { + hover(null, DEFAULT_CURSOR); + } + } + + 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) { + if (axis.snapsToDays()) { + // Whole days, at the hour it had: a booking at ten dragged to + // another row or another day is still at ten. + ZoneId zone = owner.getZone(); + long delta = pointer - grabTime; + long days = (delta >= 0 ? delta + TimeMath.DAY / 2 : delta - TimeMath.DAY / 2) / TimeMath.DAY; + editStart = TimeMath.at(TimeMath.epochDay(editItem.getStart(), zone) + days, + TimeMath.minuteOfDay(editItem.getStart(), zone), zone); + editEnd = TimeMath.at(TimeMath.epochDay(editItem.getEnd(), zone) + days, + TimeMath.minuteOfDay(editItem.getEnd(), zone), zone); + } else { + 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; + if (item != null && editRow < rowCount) { + // From where it was let go to where it belongs, which is the + // place it came from when the move is refused or given up. + int x0 = axis.x(editStart); + settleFrom(item.getId(), x0, rowTop[editRow] + rowPad, Math.max(Look.mm(1.5f), axis.x(editEnd) - x0), + laneH - 1); + } + mode = 0; + editItem = null; + if (!commit) { + return; + } + // The model may have lost a resource while this was dragged. + String res = resources.length > 0 ? resources[Math.min(editRow, resources.length - 1)].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..0a6e10957b6 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/ScheduleItemRenderer.java @@ -0,0 +1,57 @@ +/* + * 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; + /// The pointer of a desktop rests on the item. The view lights the + /// item itself; a renderer that wants more can tell by this. + int HOVERED = 64; + + /// 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/SchedulePrinter.java b/CodenameOne/src/com/codename1/ui/scheduler/SchedulePrinter.java new file mode 100644 index 00000000000..4b7506172e4 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/SchedulePrinter.java @@ -0,0 +1,527 @@ +/* + * 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.io.FileSystemStorage; +import com.codename1.io.Log; +import com.codename1.printing.PrintResult; +import com.codename1.printing.PrintResultListener; +import com.codename1.printing.Printer; +import com.codename1.scheduling.TimeMath; +import com.codename1.ui.Component; +import com.codename1.ui.Display; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.util.ImageIO; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +/// Prints a `Scheduler` or a `Gantt` chart, or exports it as images or a PDF. +/// +/// What is on screen is a window onto the view: a week scrolls under its +/// header, a project runs off the right edge. A printer lays the whole of it +/// out on pages instead. The view is painted off screen at the page's size, +/// one page of content at a time, and every page gets the headers that +/// belong to its part: the days or the time scale across the top, the hours, +/// the resources or the task table down the side. +/// +/// ```java +/// Scheduler scheduler = new Scheduler(); +/// SchedulePrinter printer = new SchedulePrinter(scheduler); +/// printer.setTitle("This week"); +/// printer.print(new PrintResultListener() { +/// public void onResult(PrintResult result) { +/// if (result.isFailed()) { +/// ToastBar.showErrorMessage(result.getError()); +/// } +/// } +/// }); +/// ``` +/// +/// Pages run left to right, then top to bottom. `getPageCount`, `renderPage` +/// and `renderImage` give the pages to an application that would sooner +/// share or store them, and `writePdf` writes them as one document. +/// +/// A scheduler prints the view and the dates it is showing; a Gantt chart +/// prints the whole project at the zoom it is showing, so zooming out before +/// printing is how a long project is fitted to fewer pages. The resource +/// load under a chart is not printed. +/// +/// A page is an image as large as the page, so rendering is done a page at a +/// time and the pages are not kept. Everything here runs on the EDT. +public final class SchedulePrinter { + /// The most pages one print makes. A chart zoomed to hours over a year + /// is thousands of pages nobody asked for. + private static final int MAX_PAGES = 400; + private static final String TEMP_PREFIX = "cn1-schedule-print-"; + private int[] held; + + private final Scheduler scheduler; + private final Gantt gantt; + private int pageWidth; + private int pageHeight; + private String title; + private boolean titleSet; + private boolean pageNumbers = true; + + /// A printer of the view and the dates `scheduler` is showing. + public SchedulePrinter(Scheduler scheduler) { + if (scheduler == null) { + throw new IllegalArgumentException("scheduler"); + } + this.scheduler = scheduler; + this.gantt = null; + defaultPage(); + } + + /// A printer of the whole of the project `gantt` shows. + public SchedulePrinter(Gantt gantt) { + if (gantt == null) { + throw new IllegalArgumentException("gantt"); + } + this.scheduler = null; + this.gantt = gantt; + defaultPage(); + } + + // A landscape sheet about 250 mm of screen wide: text the size it is on + // screen stays readable on paper, and a page stays a few megabytes. + private void defaultPage() { + int w = Display.getInstance().convertToPixels(250f); + pageWidth = Math.max(1000, Math.min(2400, w)); + pageHeight = pageWidth * 210 / 297; + } + + /// Sets the size of a page in pixels. The default is a landscape sheet + /// in the proportions of A4. Text and rows keep the size they have on + /// screen, so a larger page holds more of the view and prints it + /// smaller. + public void setPageSize(int widthPixels, int heightPixels) { + if (widthPixels < 100 || heightPixels < 100) { + throw new IllegalArgumentException("page size"); + } + pageWidth = widthPixels; + pageHeight = heightPixels; + } + + public int getPageWidth() { + return pageWidth; + } + + public int getPageHeight() { + return pageHeight; + } + + /// The line at the top of every page. Unless one is set, a scheduler + /// prints the range its toolbar names and a chart prints none. + public String getTitle() { + if (titleSet) { + return title; + } + return scheduler != null ? scheduler.titleText() : null; + } + + /// Sets the line at the top of every page; null or empty prints none. + public void setTitle(String title) { + this.title = title; + titleSet = true; + } + + /// Whether every page says which page of how many it is. On by default; + /// a single page is never numbered. + public boolean isPageNumbers() { + return pageNumbers; + } + + public void setPageNumbers(boolean pageNumbers) { + this.pageNumbers = pageNumbers; + } + + private ScrollSurface surface() { + Component c = scheduler != null ? scheduler.surface() : gantt.surface(); + return c instanceof ScrollSurface ? (ScrollSurface) c : null; + } + + private static Style headingStyle() { + return Look.style("SchedulerTitle"); + } + + // The height of the band the title and the page number are printed in. + private int bandHeight(int pages) { + String t = getTitle(); + boolean hasTitle = t != null && t.length() > 0; + if (!hasTitle && !(pageNumbers && pages > 1)) { + return 0; + } + return headingStyle().getFont().getHeight() + Look.mm(2); + } + + // Columns and rows of pages, and the content one page holds. The band + // depends on whether pages are numbered, which depends on the count, so + // the count is taken with a band and taken again without one only when a + // single untitled page turns out not to need it. + private int[] grid() { + if (held != null) { + return held; + } + ScrollSurface s = surface(); + if (s == null) { + return new int[]{0, 0, 0, 0, 0}; + } + int band = bandHeight(2); + int[] g = s.pageGrid(pageWidth, Math.max(1, pageHeight - band)); + if (band > 0 && g[0] * g[1] == 1 && bandHeight(1) == 0) { + band = 0; + g = s.pageGrid(pageWidth, pageHeight); + } + return new int[]{g[0], g[1], g[2], g[3], band}; + } + + /// The number of pages the view takes at this page size. + /// + /// #### Throws + /// + /// - `IllegalStateException`: if it is more than a print can sensibly + /// be; zoom the view out or set a larger page + public int getPageCount() { + int[] g = grid(); + long n = (long) g[0] * g[1]; + if (n > MAX_PAGES) { + throw new IllegalStateException("The view is " + n + " pages; zoom out or use a larger page"); + } + return (int) n; + } + + /// Renders one page, counted from 0, left to right and then top to + /// bottom. + public Image renderPage(int page) { + int[] g = grid(); + long count = (long) g[0] * g[1]; + if (count > MAX_PAGES) { + throw new IllegalStateException("The view is " + count + " pages; zoom out or use a larger page"); + } + if (page < 0 || page >= count) { + throw new IllegalArgumentException("page " + page + " of " + count); + } + Style paper = Look.style(scheduler != null ? "Scheduler" : "Gantt"); + Image img = Image.createImage(pageWidth, pageHeight, 0xff000000 | paper.getBgColor()); + Graphics gr = img.getGraphics(); + int band = g[4]; + if (band > 0) { + Style st = headingStyle(); + Font f = st.getFont(); + gr.setFont(f); + gr.setColor(st.getFgColor()); + int ty = (band - f.getHeight()) / 2; + String number = pageNumbers && count > 1 ? (page + 1) + " / " + count : null; + int numberW = number == null ? 0 : f.stringWidth(number) + Look.mm(2); + String t = getTitle(); + if (t != null && t.length() > 0) { + gr.drawString(Look.fit(t, f, pageWidth - numberW - Look.mm(2)), Look.mm(1), ty); + } + if (number != null) { + gr.drawString(number, pageWidth - numberW, ty); + } + } + int col = page % g[0]; + int row = page / g[0]; + surface().paintPage(gr, 0, band, pageWidth, pageHeight - band, col * g[2], row * g[3]); + return img; + } + + /// Renders the whole view as one image, its headers once: the picture + /// to share or to put in a report. It is as large as the content, so a + /// long project at a close zoom is a very large image; `renderPage` is + /// the one for those. + /// + /// #### Throws + /// + /// - `IllegalStateException`: if either side would pass `maxSide` pixels + public Image renderImage(int maxSide) { + ScrollSurface s = surface(); + if (s == null) { + throw new IllegalStateException("nothing to render"); + } + // Lay out at the page's size to learn how much content there is, + // then at the size that holds all of it. + int[] g = s.pageGrid(pageWidth, pageHeight); + int w = pageWidth - g[2] + g[0] * g[2]; + int h = pageHeight - g[3] + g[1] * g[3]; + if (w > maxSide || h > maxSide) { + throw new IllegalStateException("The view is " + w + " by " + h + " pixels; zoom out or render it in pages"); + } + Style paper = Look.style(scheduler != null ? "Scheduler" : "Gantt"); + Image img = Image.createImage(w, h, 0xff000000 | paper.getBgColor()); + s.paintPage(img.getGraphics(), 0, 0, w, h, 0, 0); + return img; + } + + /// Whether `writePdf` works here: it needs the platform to encode JPEG. + public static boolean isPdfSupported() { + ImageIO io = ImageIO.getImageIO(); + return io != null && io.isFormatSupported(ImageIO.FORMAT_JPEG); + } + + /// Writes every page into one PDF document at `path`, a path of + /// `FileSystemStorage`. Each page of the document is a picture of the + /// page, so its text cannot be selected or searched. + /// + /// #### Throws + /// + /// - `IOException`: if the file cannot be written, or the platform + /// cannot encode JPEG (see `isPdfSupported`) + public void writePdf(String path) throws IOException { + ImageIO io = ImageIO.getImageIO(); + if (io == null || !io.isFormatSupported(ImageIO.FORMAT_JPEG)) { + throw new IOException("JPEG encoding is not available on this platform"); + } + int count = getPageCount(); + // The view does not change between one page and the next, so the + // pages are counted once for the document and not once for each. + int[] counted = grid(); + OutputStream out = FileSystemStorage.getInstance().openOutputStream(path); + held = counted; + try { + PdfPages pdf = new PdfPages(out, count); + for (int i = 0; i < count; i++) { + Image page = renderPage(i); + ByteArrayOutputStream jpeg = new ByteArrayOutputStream(); + io.save(page, jpeg, ImageIO.FORMAT_JPEG, 0.92f); + pdf.page(jpeg.toByteArray(), page.getWidth(), page.getHeight()); + } + pdf.finish(); + } finally { + held = null; + out.close(); + } + } + + /// Prints every page through the platform's print dialog. + /// + /// The pages go to the printer as one PDF document. On a platform that + /// cannot write one, a single page is printed as an image, and more + /// than one is reported as a failure, since the platform prints one + /// image at a time and a dialog per page is not a print. + /// + /// #### Parameters + /// + /// - `listener`: told how it ended, on the EDT; may be null + public void print(final PrintResultListener listener) { + if (!Printer.isPrintingSupported()) { + report(listener, PrintResult.failed("Printing is not supported on this platform")); + return; + } + int count; + try { + count = getPageCount(); + } catch (IllegalStateException tooMany) { + report(listener, PrintResult.failed(tooMany.getMessage())); + return; + } + if (count == 0) { + report(listener, PrintResult.failed("Nothing to print")); + return; + } + if (!isPdfSupported()) { + if (count == 1) { + Printer.printImage(renderPage(0), listener); + } else { + report(listener, PrintResult.failed("This platform prints one page at a time and the view is " + + count + " pages; zoom out or use a larger page")); + } + return; + } + final FileSystemStorage fs = FileSystemStorage.getInstance(); + // A name of its own: a document that is still at the printer is not + // written over, or deleted, by the next one. + sweep(fs); + final String path = fs.getAppHomePath() + TEMP_PREFIX + System.currentTimeMillis() + ".pdf"; + boolean written = false; + try { + writePdf(path); + written = true; + } catch (IOException err) { + report(listener, PrintResult.failed(err.toString())); + } finally { + // Half a document is nobody's: whatever stopped the writing, + // the file does not stay behind in the application's home. + if (!written && fs.exists(path)) { + fs.delete(path); + } + } + if (!written) { + return; + } + Printer.printPDF(path, new DeleteThenReport(fs, path, listener)); + } + + // A document whose print never reported back, because the application + // was closed with the dialog up, is taken away by a later print. One + // from the last day is left: it may be at the printer now. + private static void sweep(FileSystemStorage fs) { + String home = fs.getAppHomePath(); + long now = System.currentTimeMillis(); + try { + String[] names = fs.listFiles(home); + for (int i = 0; names != null && i < names.length; i++) { + String name = names[i]; + if (name == null || !name.startsWith(TEMP_PREFIX) || !name.endsWith(".pdf")) { + continue; + } + long made = 0; + boolean number = name.length() > TEMP_PREFIX.length() + 4; + for (int c = TEMP_PREFIX.length(); number && c < name.length() - 4; c++) { + char ch = name.charAt(c); + number = ch >= '0' && ch <= '9' && made < Long.MAX_VALUE / 100; + made = made * 10 + (ch - '0'); + } + if (number && now - made > TimeMath.DAY) { + fs.delete(home + name); + } + } + } catch (IOException err) { + // Nothing to clean, or no way to look; the print goes on. + Log.e(err); + } + } + + // The file is the printer's for as long as it prints, and nobody's after. + private static final class DeleteThenReport implements PrintResultListener { + private final FileSystemStorage fs; + private final String path; + private final PrintResultListener listener; + + DeleteThenReport(FileSystemStorage fs, String path, PrintResultListener listener) { + this.fs = fs; + this.path = path; + this.listener = listener; + } + + @Override + public void onResult(PrintResult result) { + fs.delete(path); + report(listener, result); + } + } + + private static void report(PrintResultListener listener, PrintResult result) { + if (listener != null) { + listener.onResult(result); + } + } + + /// The smallest PDF that holds a JPEG per page: a catalog, the page + /// tree, and for each page the page, a content stream that draws its + /// image over the whole of it, and the image. Objects are written as + /// they come, so a document of many pages holds one page in memory. + static final class PdfPages { + // A landscape A4 sheet in points; a page of other proportions is + // fitted to the long side. + private static final int SHEET = 842; + private final OutputStream out; + private final int count; + private final int[] offsets; + private int written; + private int position; + + PdfPages(OutputStream out, int count) throws IOException { + this.out = out; + this.count = count; + // 1 catalog, 2 page tree, then page, content and image per page + offsets = new int[3 + count * 3]; + text("%PDF-1.4\n"); + object(1, "<< /Type /Catalog /Pages 2 0 R >>"); + StringBuilder kids = new StringBuilder(); + for (int i = 0; i < count; i++) { + kids.append(3 + i * 3).append(" 0 R "); + } + object(2, "<< /Type /Pages /Count " + count + " /Kids [ " + kids + "] >>"); + } + + void page(byte[] jpeg, int width, int height) throws IOException { + int id = 3 + written * 3; + int w; + int h; + if (width >= height) { + w = SHEET; + h = Math.max(1, SHEET * height / width); + } else { + h = SHEET; + w = Math.max(1, SHEET * width / height); + } + object(id, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + w + " " + h + "] /Resources << /XObject << /Im0 " + + (id + 2) + " 0 R >> >> /Contents " + (id + 1) + " 0 R >>"); + String draw = "q " + w + " 0 0 " + h + " 0 0 cm /Im0 Do Q\n"; + offsets[id + 1] = position; + text((id + 1) + " 0 obj\n<< /Length " + draw.length() + " >>\nstream\n" + draw + "endstream\nendobj\n"); + offsets[id + 2] = position; + text((id + 2) + " 0 obj\n<< /Type /XObject /Subtype /Image /Width " + width + " /Height " + height + + " /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length " + jpeg.length + + " >>\nstream\n"); + out.write(jpeg); + position += jpeg.length; + text("\nendstream\nendobj\n"); + written++; + } + + void finish() throws IOException { + if (written != count) { + throw new IOException("PDF has " + written + " of " + count + " pages"); + } + int xref = position; + StringBuilder b = new StringBuilder(); + b.append("xref\n0 ").append(offsets.length).append("\n0000000000 65535 f \n"); + for (int i = 1; i < offsets.length; i++) { + String n = String.valueOf(offsets[i]); + for (int pad = n.length(); pad < 10; pad++) { + b.append('0'); + } + b.append(n).append(" 00000 n \n"); + } + b.append("trailer\n<< /Size ").append(offsets.length).append(" /Root 1 0 R >>\nstartxref\n") + .append(xref).append("\n%%EOF\n"); + text(b.toString()); + out.flush(); + } + + private void object(int id, String body) throws IOException { + offsets[id] = position; + text(id + " 0 obj\n" + body + "\nendobj\n"); + } + + // Everything but the image data is ASCII, so a character is a byte. + private void text(String s) throws IOException { + int n = s.length(); + byte[] b = new byte[n]; + for (int i = 0; i < n; i++) { + b[i] = (byte) s.charAt(i); + } + out.write(b); + position += n; + } + } +} 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..6d00104c149 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/Scheduler.java @@ -0,0 +1,983 @@ +/* + * 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 +/// long start = System.currentTimeMillis(); +/// DefaultScheduleModel model = new DefaultScheduleModel(); +/// model.addItem(new ScheduleItem("1", "Design review", start, start + TimeMath.HOUR)); +/// Scheduler scheduler = new Scheduler(model); +/// scheduler.setView(Scheduler.View.WEEK); +/// +/// Form form = new Form("Schedule", new BorderLayout()); +/// form.add(BorderLayout.CENTER, scheduler); +/// form.show(); +/// ``` +/// +/// ![A week of a schedule](https://www.codenameone.com/developer-guide/img/generated/scheduler-week.png) +/// +/// The same items by the day, by the month and against the people they are +/// booked for: +/// +/// ![The day view](https://www.codenameone.com/developer-guide/img/generated/scheduler-day.png) +/// ![The month view](https://www.codenameone.com/developer-guide/img/generated/scheduler-month.png) +/// ![The resource timeline](https://www.codenameone.com/developer-guide/img/generated/scheduler-resource-timeline.png) +/// +/// 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. +/// +/// #### Pointer and motion +/// +/// Under a mouse the item the pointer is over lights up, free time shows +/// the slot a drag would start in, the cursor says what a drag would do, +/// and a tooltip gives the item's title and time. An item that is picked +/// up lifts: a shadow comes up under it and the place it left dims. Let go, +/// it glides from where it was dropped to where it belongs. Each takes +/// under a quarter of a second. +/// +/// ![An item picked up, carried and dropped, as five stopped frames](https://www.codenameone.com/developer-guide/img/generated/scheduler-drag-stages.png) +/// +/// `setAnimationTime` stops the clock these run by, for a screenshot or a +/// test of a moment in the middle of one. +/// +/// #### 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. +/// +/// ![A schedule restyled from CSS](https://www.codenameone.com/developer-guide/img/generated/scheduler-css.png) +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 animationTime = -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 BarLayout bar = new BarLayout(); + private final Container toolbar = new Container(bar); + 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) { + // A model that loads again makes new items for the same things: + // the selection follows the id, or is gone with the item. + if (selected != null) { + ScheduleItem now = again(m, selected); + if (now == null) { + // Gone with the item, and the listeners hear of it as + // they hear of any other change of the selection. + viewSelected(null); + } else { + selected = now; + } + } + refresh(); + } + + private ScheduleItem again(ScheduleModel m, ScheduleItem was) { + List near = new ArrayList(); + long span = Math.max(TimeMath.DAY, visibleEnd - visibleStart); + m.getItems(Math.min(visibleStart, was.getStart()) - span, Math.max(visibleEnd, was.getEnd()) + span, near); + for (ScheduleItem item : near) { + if (item == was || item.getId().equals(was.getId())) { //NOPMD CompareObjectsWithEquals - the very item is the common case + return item; + } + } + return null; + } + }; + + /// 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(); + } + })); + // In this order: BarLayout takes the first as what leads, the last + // as what trails and the one between as what fills. + toolbar.add(nav); + toolbar.add(title); + toolbar.add(viewButtons); + // More views than a phone is wide are scrolled through. + viewButtons.setScrollableX(true); + viewButtons.setScrollVisible(false); + buildViewButtons(); + } + + /// The bar is a row where there is room for one, and two rows on a + /// screen as narrow as a phone's. It is decided here because this is + /// the first place the width is known. + @Override + public void layoutContainer() { + if (bar.fit(toolbar, getWidth() - getStyle().getHorizontalPadding())) { + toolbar.setShouldCalcPreferredSize(true); + } + super.layoutContainer(); + } + + 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(); + // Placed against the button that opens it. In a window of its own it + // would come out centred, wherever the theme's default points. + menu.setNativeWindowMode(false); + 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(); + if (model != null && visibleEnd > visibleStart) { + // The range has not moved, so apply told nobody; but this model + // has never heard it, and one that loads would load nothing. + model.setVisibleRange(visibleStart - (visibleEnd - visibleStart), visibleEnd + (visibleEnd - visibleStart)); + } + } + + /// Reads the model again. A model that fires its change listeners needs + /// no call to this. + public void refresh() { + if (current == dayGrid && dayGrid != null) { //NOPMD CompareObjectsWithEquals - the same component, not an equal one + if (view == View.RESOURCE_DAY) { + // The resources themselves may have changed. + apply(); + } else { + dayGrid.modelChanged(); + } + } else if (current == monthGrid && monthGrid != null) { //NOPMD CompareObjectsWithEquals - the same component, not an equal one + monthGrid.modelChanged(); + } else if (current == agenda && agenda != null) { //NOPMD CompareObjectsWithEquals - the same component, not an equal one + agenda.modelChanged(); + } else if (current == timeline && timeline != null) { //NOPMD CompareObjectsWithEquals - the same component, not an equal one + 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 (ScheduleResource resource : all) { + if (id.equals(resource.getId())) { + return resource; + } + } + 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 += 7L * direction; + break; + case MONTH: + anchorDay = TimeMath.plusMonths(anchorDay, direction); + break; + case AGENDA: + anchorDay += (long) agendaDays * direction; + break; + default: + anchorDay += (long) 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 (animationTime >= 0 && c instanceof ScrollSurface) { + // A view made after the clock was stopped stands at that time too. + ((ScrollSurface) c).setFixedClock(animationTime); + } + if (current == c) { //NOPMD CompareObjectsWithEquals - the same component, not an equal one + 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 (SchedulerListener l : told()) { + l.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"); + } + // The same date in the new zone. The instant that date started at + // in the old zone may be the day before in the new one, and going by + // it would step back a day with every change to a zone further west. + zone = z; + 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(); + } + + @Override + 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(); + } + + /// Stops the clock the scheduler animates by at a time, for a screenshot of + /// an animation or a test of one: the light of a hover, the rise of + /// an item that is picked up and the travel of one + /// that was dropped are drawn as far along as that time + /// makes them. A negative value returns to the clock. + /// + /// ```java + /// scheduler.setAnimationTime(1000); // nothing has started + /// // ... press, drag, release ... + /// scheduler.setAnimationTime(1100); // 100 milliseconds into what followed + /// ``` + public void setAnimationTime(long millis) { + animationTime = millis; + if (dayGrid != null) { + dayGrid.setFixedClock(millis); + } + if (monthGrid != null) { + monthGrid.setFixedClock(millis); + } + if (agenda != null) { + agenda.setFixedClock(millis); + } + if (timeline != null) { + timeline.setFixedClock(millis); + } + } + + 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); + } + + // The listeners as they are now: one that removes itself, or another, + // while it is told does not make the loop skip the one after it, which + // for `itemChanging` would be a refusal nobody heard. + private SchedulerListener[] told() { + return listeners.toArray(new SchedulerListener[listeners.size()]); + } + + void viewSelected(ScheduleItem item) { + if (selected == item) { //NOPMD CompareObjectsWithEquals - the selection is this very object + return; + } + selected = item; + for (SchedulerListener l : told()) { + l.itemSelected(this, item); + } + repaint(); + } + + void viewOpened(ScheduleItem item) { + for (SchedulerListener l : told()) { + l.itemOpened(this, item); + } + } + + void viewRange(long start, long end, String resourceId) { + for (SchedulerListener l : told()) { + l.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 (SchedulerListener l : told()) { + if (!l.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 range the toolbar names, for a printed page's heading. + String titleText() { + return title.getText(); + } + + /// 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..3f44f943371 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/scheduler/ScrollSurface.java @@ -0,0 +1,948 @@ +/* + * 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(); + if (hoverX >= 0 && !pressed) { + // What is under a pointer that rests has changed. + hoverAt(hoverX, hoverY); + } + } + } + + /// 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(); + } + } + + // ---- what the pointer is over, what is held, what was dropped ---- + + /// How long a hover takes to come up and to go, a held item to rise, + /// and a dropped one to reach its place. + static final int HOVER_MS = 140; + static final int LIFT_MS = 160; + static final int SETTLE_MS = 220; + + private long fixedClock = -1; + private Object hoverKey; + private long hoverSince; + private Object leftKey; + private long leftSince; + private int hoverX = -1; + private int hoverY = -1; + private long liftSince; + private Object settleKey; + private final int[] settleFrom = new int[4]; + private long settleSince; + + /// The time the surface animates by: the clock, or the one it was + /// given to stand still at. + long tick() { + return fixedClock >= 0 ? fixedClock : System.currentTimeMillis(); + } + + void setFixedClock(long millis) { + fixedClock = millis; + repaint(); + } + + /// The pointer rests at a point of the surface without being pressed. + /// A view answers with `hover`, naming what is there and the cursor + /// that says what a press would do. + void hoverAt(int x, int y) { + hover(null, DEFAULT_CURSOR); + } + + /// The pointer left, or nothing is known of it any more. + void hoverGone() { + hoverX = -1; + hoverY = -1; + hover(null, DEFAULT_CURSOR); + } + + final void hover(Object key, int cursor) { + if (key == null ? hoverKey != null : !key.equals(hoverKey)) { + long now = tick(); + if (hoverKey != null) { + leftKey = hoverKey; + leftSince = now; + } + if (key != null && key.equals(leftKey)) { + leftKey = null; + } + hoverKey = key; + hoverSince = now; + if (key == null) { + tip(null); + } + requestAnimation(); + repaint(); + } + if (getCursor() != cursor) { + setCursor(cursor); + } + } + + /// What a tooltip says of the thing the pointer is on, for an + /// application that shows tooltips; null for nothing. + void tip(String text) { + String was = getTooltip(); + if (text == null ? was != null : !text.equals(was)) { + setTooltip(text); + } + } + + /// What the pointer has just left, while its light goes out. + Object fading() { + return printing ? null : leftKey; + } + + /// What the pointer is over, as the view named it. + Object hovered() { + return hoverKey; + } + + /// Whether anything is lit by the pointer or still fading: a view + /// that paints many items asks once before asking for each. + boolean anyHover() { + return !printing && (hoverKey != null || leftKey != null); + } + + /// How far the hover of `key` has come up, from 0 to 1. + float hoverLevel(Object key) { + if (key == null || printing) { + return 0; + } + if (key.equals(hoverKey)) { + return ease(tick() - hoverSince, HOVER_MS); + } + if (key.equals(leftKey)) { + return 1 - ease(tick() - leftSince, HOVER_MS); + } + return 0; + } + + /// How far the item that is being dragged has risen off the surface. + float liftLevel() { + return gesture == EDIT ? ease(tick() - liftSince, LIFT_MS) : 0; + } + + /// An item was let go at a rectangle of the content, which is not + /// where it belongs: it is drawn travelling from there to its place. + void settleFrom(Object key, int x, int y, int w, int h) { + if (key == null || printing) { + return; + } + settleKey = key; + settleFrom[0] = x; + settleFrom[1] = y; + settleFrom[2] = w; + settleFrom[3] = h; + settleSince = tick(); + requestAnimation(); + } + + /// Moves the rectangle of an item, in the content, to where it is on + /// its way from the place it was dropped. True while it travels. + boolean settle(Object key, int[] rect) { + if (settleKey == null || printing || !settleKey.equals(key)) { + return false; + } + long t = tick() - settleSince; + if (t >= SETTLE_MS) { + return false; + } + // Fast at first and slow into place, as a thing put down is. + float p = 1 - t / (float) SETTLE_MS; + p = 1 - p * p * p; + for (int i = 0; i < 4; i++) { + rect[i] = settleFrom[i] + Math.round((rect[i] - settleFrom[i]) * p); + } + return true; + } + + private static float ease(long elapsed, int duration) { + if (elapsed >= duration) { + return 1; + } + if (elapsed <= 0) { + return 0; + } + float p = elapsed / (float) duration; + return p * p * (3 - 2 * p); + } + + // Whether any of the three is still on its way. Never with a clock + // that stands still: nothing would ever arrive. + private boolean feedbackBusy() { + if (fixedClock >= 0) { + return false; + } + long now = System.currentTimeMillis(); + boolean busy = false; + if (leftKey != null) { + if (now - leftSince >= HOVER_MS) { + leftKey = null; + } else { + busy = true; + } + } + if (hoverKey != null && now - hoverSince < HOVER_MS) { + busy = true; + } + if (settleKey != null) { + if (now - settleSince >= SETTLE_MS) { + settleKey = null; + } else { + busy = true; + } + } + if (gesture == EDIT && now - liftSince < LIFT_MS) { + busy = true; + } + return busy; + } + + @Override + public void pointerHover(int[] x, int[] y) { + if (pressed || x == null || x.length == 0) { + return; + } + layoutNow(); + hoverX = x[0] - getAbsoluteX(); + hoverY = y[0] - getAbsoluteY(); + hoverAt(hoverX, hoverY); + } + + @Override + public void setHovered(boolean hovered) { + super.setHovered(hovered); + if (!hovered) { + hoverGone(); + } + } + + @Override + protected void initComponent() { + super.initComponent(); + if (Display.getInstance().isDesktop()) { + // The cursor says what a press would do: move, stretch, link. + // A form shows the cursors of its components only when asked. + Form f = getComponentForm(); + if (f != null && !f.isEnableCursors()) { + f.setEnableCursors(true); + } + } + } + + 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 (feedbackBusy()) { + busy = true; + repaint(); + } + if (!busy && registered) { + Form f = getComponentForm(); + if (f != null) { + f.deregisterAnimated(this); + } + registered = false; + } + return busy; + } + + @Override + protected void deinitialize() { + stopFling(); + if (gesture == EDIT) { + // Taken off the form in the middle of a drag: nothing is + // committed, and the view does not keep drawing what was dragged. + endEdit(false); + } + gesture = NONE; + pressed = false; + still = false; + pinching = false; + pinchBase = 0; + 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); + } + + // -------------------------------------------------------------- pages + + /// How the content divides into pages of `w` by `h` pixels, headers + /// included on each: columns of pages, rows of pages, and the width and + /// height of content one page holds. + /// + /// The view is laid out at the page's size for the count, as it is for + /// the painting, and put back as it was. + int[] pageGrid(int w, int h) { + int ow = getWidth(); + int oh = getHeight(); + int sx = offX; + int sy = offY; + double[] zoom = saveZoom(); + setWidth(w); + setHeight(h); + try { + prepare(w, h); + int bw = Math.max(1, bodyWidth()); + int bh = Math.max(1, pageStep(bodyHeight())); + int across = Math.max(1, (contentWidth() + bw - 1) / bw); + int down = Math.max(1, (contentHeight() + bh - 1) / bh); + return new int[]{across, down, bw, bh}; + } finally { + setWidth(ow); + setHeight(oh); + backToScreen(ow, oh, zoom); + offX = sx; + offY = sy; + } + } + + /// The zoom a layout at another size may change: a view holds its scale + /// inside limits that depend on the room it has, so laying it out for a + /// page can move the scale, and laying it out for the screen again does + /// not move it back. Null for a view with no zoom. + double[] saveZoom() { + return null; + } + + /// How far down a page of body height `bodyH` goes before the next one + /// starts. A view of rows answers a whole number of them, so that a page + /// does not end in the middle of one. + int pageStep(int bodyH) { + return bodyH; + } + + /// Whether this is a page being painted and not the screen. + boolean isPrinting() { + return printing; + } + + /// Puts back what `saveZoom` returned. Called with the view laid out at + /// its own size, so the limits are the screen's again. + void restoreZoom(double[] saved) { + } + + private void backToScreen(int w, int h, double[] zoom) { + prepare(w, h); + if (zoom != null) { + restoreZoom(zoom); + prepare(w, h); + } + } + + /// Paints one page: the content from `contentX`, `contentY` on, under + /// the headers that belong to it, at `px`, `py` in a page of `w` by `h`. + /// + /// This is the screen's own painting at another size and another scroll + /// position, so what prints is what the view draws. The overlay is left + /// out: it holds what a drag in progress shows. The body is clipped to + /// the content, so the last page of a row or column ends where the + /// content does and is not filled out with empty grid. + void paintPage(Graphics g, int px, int py, int w, int h, int contentX, int contentY) { + int ox = getX(); + int oy = getY(); + int ow = getWidth(); + int oh = getHeight(); + int sx = offX; + int sy = offY; + int cx = g.getClipX(); + int cy = g.getClipY(); + int cw = g.getClipWidth(); + int ch = g.getClipHeight(); + double[] zoom = saveZoom(); + setX(px); + setY(py); + setWidth(w); + setHeight(h); + printing = true; + try { + prepare(w, h); + offX = contentX; + offY = contentY; + int bx = px + headerW; + int by = py + headerH; + int bw = Math.min(bodyWidth(), Math.max(0, contentWidth() - contentX)); + int bh = Math.min(pageStep(bodyHeight()), Math.max(0, contentHeight() - contentY)); + g.clipRect(bx, by, bw, bh); + paintBody(g, bx - contentX, by - contentY); + g.setClip(cx, cy, cw, ch); + if (headerH > 0) { + g.clipRect(bx, py, bw, headerH); + paintTopHeader(g, bx - contentX, py); + g.setClip(cx, cy, cw, ch); + } + if (headerW > 0) { + g.clipRect(px, by, headerW, bh); + paintLeftHeader(g, px, by - contentY); + g.setClip(cx, cy, cw, ch); + } + if (headerW > 0 && headerH > 0) { + g.clipRect(px, py, headerW, headerH); + paintCorner(g, px, py); + g.setClip(cx, cy, cw, ch); + } + } finally { + printing = false; + g.setClip(cx, cy, cw, ch); + setX(ox); + setY(oy); + setWidth(ow); + setHeight(oh); + backToScreen(ow, oh, zoom); + offX = sx; + offY = sy; + } + } + + @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); + if (gesture == EDIT) { + // The release of the last drag went somewhere else: a listener + // showed a dialog over this while it was held. + endEdit(false); + } + // A pinch whose end went elsewhere is over as well. + pinching = false; + pinchBase = 0; + still = false; + 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) { + liftSince = tick(); + requestAnimation(); + } + } + 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; + liftSince = tick(); + requestAnimation(); + 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; + boolean rest = still; + still = false; + if (g == EDIT) { + endEdit(true); + repaint(); + } else if (g == PAN) { + // A pinch or a cancelled edit ends where it is: the speed of the + // finger was not a scroll. + if (!rest) { + 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; + still = true; + 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; + // The gesture under way ends without a fling. + private boolean still; + private boolean printing; + + @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; + if (gesture == EDIT) { + // A second finger on a drag makes it a zoom; the drag is + // dropped, not left half made. + autoX = 0; + autoY = 0; + endEdit(false); + } + gesture = PAN; + still = true; + 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/CodenameOne/src/com/codename1/ui/spinner/MaterialDatePicker.java b/CodenameOne/src/com/codename1/ui/spinner/MaterialDatePicker.java new file mode 100644 index 00000000000..54018152426 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/spinner/MaterialDatePicker.java @@ -0,0 +1,967 @@ +/* + * 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.Form; +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 -> Log.p("Chosen: " + picker.getDate())); +/// +/// Form form = new Form("Date", new BorderLayout()); +/// form.add(BorderLayout.CENTER, picker); +/// form.show(); +/// ``` +/// +/// ![The month of a Material date picker](https://www.codenameone.com/developer-guide/img/generated/picker-material-date.png) +/// ![The years](https://www.codenameone.com/developer-guide/img/generated/picker-material-date-years.png) +/// ![The date typed](https://www.codenameone.com/developer-guide/img/generated/picker-material-date-input.png) +/// +/// Under a mouse the day the pointer is over is marked, and a month asked +/// for with the arrows slides in over the one shown. +/// +/// 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. +/// +/// ![A date picker restyled from CSS](https://www.codenameone.com/developer-guide/img/generated/picker-material-date-css.png) +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 Component revealYear; + 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); + // In line with the headline over it, not against the dialog's edge. + inputPane.getAllStyles().setPaddingUnit(Style.UNIT_TYPE_DIPS); + inputPane.getAllStyles().setPadding(1, 2, 3, 3); + + 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(); + // The years on offer reach the date that is set, or its own month + // could not be paged to and its own text would not be a date. + shownYear = year; + shownMonth = month; + reachYear(); + 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(); + newYears(); + refresh(); + } + + private void reachYear() { + if (year < startYear) { + startYear = year; + newYears(); + } else if (year > endYear) { + endYear = year; + newYears(); + } + } + + // The grid of years is built for the limits it had; one that is on + // screen is built again for the new ones. + private void newYears() { + years = null; + if (mode == YEARS) { + mode = DAYS; + show(YEARS); + } + } + + /// Whether what is typed is a date that can be chosen. When it is not, + /// the message under the field says so and the date is the one from + /// before, which is not what the field reads. + boolean acceptInput() { + return mode != INPUT || error.getText().length() == 0; + } + + /// 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); + reachYear(); + shownYear = Math.max(startYear, Math.min(endYear, shownYear)); + newYears(); + refresh(); + } + + /// 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; + } + + /// How far a month asked for has slid in, from 0 to 1. + float slideAt() { + return days.slide(); + } + + /// The day the pointer is over, or -1. + int overDay() { + return days.overDay; + } + + TextField inputField() { + return input; + } + + String errorText() { + return error.getText(); + } + + private static final int SLIDE_MS = 220; + private long fixedClock = -1; + + /// Stops the clock the months slide by at a time, for a screenshot of a + /// month coming in or a test of it. A negative value returns to the clock. + public void setAnimationTime(long millis) { + fixedClock = millis; + days.repaint(); + } + + private long clock() { + return fixedClock >= 0 ? fixedClock : System.currentTimeMillis(); + } + + /// 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)); + int was = shownYear * 12 + shownMonth; + if (m != was) { + days.slideFrom(shownYear, shownMonth, m > was ? 1 : -1); + } + 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)) { + @Override + protected void laidOut() { + super.laidOut(); + // Open on the year shown. This is the first moment the + // buttons have a place to scroll to. + if (revealYear != null) { + Component target = revealYear; + revealYear = null; + scrollComponentToVisible(target); + } + } + }; + grid.setScrollableY(true); + // The list opens at the year, it does not travel there from + // the first one, a century of rows away. + grid.setSmoothScrolling(false); + grid.setScrollVisible(false); + 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) { + revealYear = b; + years.setShouldCalcPreferredSize(true); + Display.getInstance().callSerially(new Reveal(years, b)); + } + } + 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.indexOf("99"); + int m = s.indexOf("11"); + int d = 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(); + if (!bad && count == 1 && text.length() == 8) { + // A keyboard of digits only has no separator to type: eight + // digits are the three parts, the year four of them. + boolean digits = true; + for (int i = 0; i < 8; i++) { + char ch = text.charAt(i); + digits = digits && ch >= '0' && ch <= '9'; + } + if (digits) { + int at = 0; + for (int part = 0; part < 3; part++) { + int width = part == o[0] ? 4 : 2; + int v = 0; + for (int i = 0; i < width; i++) { + v = v * 10 + (text.charAt(at + i) - '0'); + } + numbers[part] = v; + at += width; + } + count = 3; + } + } + 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; + private int overDay = -1; + + 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() { + return lead(shownYear, shownMonth); + } + + private int lead(int yr, int mo) { + Calendar c = Calendar.getInstance(); + c.set(Calendar.DAY_OF_MONTH, 1); + c.set(Calendar.YEAR, yr); + c.set(Calendar.MONTH, mo); + 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 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); + } + int r = Math.min(cw, chh) / 2 - 1; + float p = slide(); + if (p >= 1) { + paintMonth(g, shownYear, shownMonth, x, y, cw, chh, r, overDay); + } else { + // The month left goes out the way the one asked for comes in. + int cx0 = g.getClipX(); + int cy0 = g.getClipY(); + int cw0 = g.getClipWidth(); + int ch0 = g.getClipHeight(); + g.clipRect(x, y + chh, cw * 7, chh * 6); + int travel = Math.round(cw * 7 * p); + paintMonth(g, slideYear, slideMonth, x - slideWay * travel, y, cw, chh, r, -1); + paintMonth(g, shownYear, shownMonth, x + slideWay * (cw * 7 - travel), y, cw, chh, r, -1); + g.setClip(cx0, cy0, cw0, ch0); + } + g.setAntiAliased(aa); + } + + private void paintMonth(Graphics g, int yr, int mo, int x, int y, int cw, int chh, int r, int over) { + Style dayStyle = getUIManager().getComponentStyle("PickerMaterialDay"); + Style chosen = getUIManager().getComponentSelectedStyle("PickerMaterialDay"); + Style off = getUIManager().getComponentCustomStyle("PickerMaterialDay", "dis"); + Calendar now = Calendar.getInstance(); + int today = yr == now.get(Calendar.YEAR) && mo == now.get(Calendar.MONTH) + ? now.get(Calendar.DAY_OF_MONTH) : -1; + int lead = lead(yr, mo); + int count = daysIn(yr, mo); + 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 && mo == month && yr == year; + boolean enabled = isEnabledDay(yr, mo, d); + if (d == over && enabled && !isChosen) { + // The day a press would choose. + g.setColor(dayStyle.getFgColor()); + g.setAlpha(alpha * 28 / 255); + g.fillArc(cx - r, cy - r, 2 * r, 2 * r, 0, 360); + g.setAlpha(alpha); + } + 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); + } + } + + // ---- the month before or after slides in ---- + + private int slideYear; + private int slideMonth; + private int slideWay; + private long slideSince = -1; + private boolean registered; + + // How far the month asked for has come in, from 0 to 1. + private float slide() { + if (slideSince < 0) { + return 1; + } + long t = clock() - slideSince; + if (t >= SLIDE_MS) { + return 1; + } + float p = t <= 0 ? 0 : t / (float) SLIDE_MS; + return 1 - (1 - p) * (1 - p) * (1 - p); + } + + void slideFrom(int yr, int mo, int way) { + if (!isInitialized()) { + return; + } + slideYear = yr; + slideMonth = mo; + slideWay = way; + slideSince = clock(); + overDay = -1; + Form f = getComponentForm(); + if (f != null && !registered) { + f.registerAnimated(this); + registered = true; + } + } + + private void still() { + if (registered) { + Form f = getComponentForm(); + if (f != null) { + f.deregisterAnimated(this); + } + registered = false; + } + } + + @Override + public boolean animate() { + boolean busy = slideSince >= 0 && fixedClock < 0 && clock() - slideSince < SLIDE_MS; + if (!busy) { + still(); + } + return busy; + } + + @Override + protected void deinitialize() { + still(); + slideSince = -1; + overDay = -1; + super.deinitialize(); + } + + 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 + protected void initComponent() { + super.initComponent(); + if (Display.getInstance().isDesktop()) { + Form f = getComponentForm(); + if (f != null && !f.isEnableCursors()) { + f.setEnableCursors(true); + } + } + } + + @Override + public void pointerHover(int[] x, int[] y) { + if (x == null || x.length == 0) { + return; + } + int d = dayAt(x[0], y[0]); + if (d > 0 && !isEnabledDay(shownYear, shownMonth, d)) { + d = -1; + } + if (d != overDay) { + overDay = d; + setCursor(d > 0 ? HAND_CURSOR : DEFAULT_CURSOR); + repaint(); + } + } + + @Override + public void setHovered(boolean hovered) { + super.setHovered(hovered); + if (!hovered && overDay > 0) { + overDay = -1; + repaint(); + } + } + + @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 + }; + } + + // Scrolls the year shown into view once the grid of years has a layout. + private static final class Reveal implements Runnable { + private final Container pane; + private final Component target; + + Reveal(Container pane, Component target) { + this.pane = pane; + this.target = target; + } + + @Override + public void run() { + pane.scrollComponentToVisible(target); + } + } +} 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..ffb4dda1412 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/spinner/MaterialDateTimePicker.java @@ -0,0 +1,135 @@ +/* + * 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(); + } + + /// Whether what is typed in the date and in the time can be confirmed; + /// the part that cannot is the one shown. + boolean acceptInput() { + if (!date.acceptInput()) { + showTime(false); + return false; + } + if (!time.acceptInput()) { + showTime(true); + return false; + } + return true; + } + + // A wall-clock time that does not exist on the day, the hour a clock + // skips in spring, becomes the instant the platform's calendar makes of + // it. That is the platform's rule for every date in the application. + @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..e0208d744f1 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/spinner/MaterialTimePicker.java @@ -0,0 +1,1073 @@ +/* + * 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.Form; +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.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.layouts.Layout; +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. +/// +/// ![The hours of a Material time picker](https://www.codenameone.com/developer-guide/img/generated/picker-material-time.png) +/// ![The minutes](https://www.codenameone.com/developer-guide/img/generated/picker-material-time-minutes.png) +/// ![Twenty four hours](https://www.codenameone.com/developer-guide/img/generated/picker-material-time-24h.png) +/// +/// Under a mouse the number the pointer is over is marked. The hand follows +/// a finger as it is dragged, and turns by the shorter way round to a time +/// that is set any other way: the minutes after an hour is chosen, a time +/// typed in, a call to `setTime`. +/// +/// ![The hand turning from ten to two, as four stopped frames](https://www.codenameone.com/developer-guide/img/generated/picker-material-hand-stages.png) +/// +/// #### 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 typedWrong; + private boolean pointedHour; + 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(""); + // Two rows of one height each, so AM and PM together are as tall as + // the hour beside them whatever a theme pads them with. + private final Container periods = new Container(new GridLayout(2, 1)); + private final Container time = new Container(); + private final Colon separator = new Colon(); + private final Label hourCaption = new Label("", "PickerMaterialOverline"); + private final Label minuteCaption = new Label("", "PickerMaterialOverline"); + 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")); + hourCaption.setText(getUIManager().localize("picker.hour", "Hour")); + minuteCaption.setText(getUIManager().localize("picker.minute", "Minute")); + time.setLayout(new TimeRow()); + 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); + // Two digits in a box made for them: there is nothing to scroll to, + // and the bar of a text area was drawn down the side of the box. + f.setScrollVisible(false); + 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(separator); + if (inputMode) { + time.add(minuteInput); + } else { + time.add(minuteField); + } + if (meridiem) { + time.add(periods); + } + if (inputMode) { + time.add(hourCaption); + time.add(minuteCaption); + } + } + + // The row of the time: the hour and the minute in two boxes of one + // size, the colon between them and AM and PM beside them, all of one + // height and centred as a group. + // + // A flow of the four gave each its own preferred size. The hour was + // narrower than the minute whenever it had one digit, the two periods + // were as tall as their paddings added up to, and the colon sat + // wherever the font put it. Here the box of the hour sets the size and + // everything else is measured against it. + private final class TimeRow extends Layout { + private int boxWidth() { + return Math.max(hourField.getPreferredW(), minuteField.getPreferredW()); + } + + private int boxHeight() { + return Math.max(hourField.getPreferredH(), minuteField.getPreferredH()); + } + + private int captionGap() { + return Display.getInstance().convertToPixels(0.7f); + } + + private int periodsWidth() { + return meridiem ? periods.getPreferredW() + Display.getInstance().convertToPixels(2f) : 0; + } + + @Override + public void layoutContainer(Container parent) { + Style s = parent.getStyle(); + int fw = boxWidth(); + int fh = boxHeight(); + int sw = separator.getPreferredW(); + int pw = periodsWidth(); + int room = parent.getWidth() - s.getHorizontalPadding(); + int x = s.getPaddingLeftNoRTL() + Math.max(0, (room - 2 * fw - sw - pw) / 2); + int y = s.getPaddingTop(); + if (inputMode) { + padTyped(fw, fh); + } + place(inputMode ? (Component) hourInput : hourField, x, y, fw, fh); + place(separator, x + fw, y, sw, fh); + place(inputMode ? (Component) minuteInput : minuteField, x + fw + sw, y, fw, fh); + if (meridiem) { + int gap = pw - periods.getPreferredW(); + // The outer edges of the two are the edges of the row: what + // a theme puts around each as a margin stays between them. + int over = am.getStyle().getMarginTop(); + int under = pm.getStyle().getMarginBottom(); + place(periods, x + 2 * fw + sw + gap, y - over, pw - gap, fh + over + under); + } + if (inputMode) { + int cy = y + fh + captionGap(); + place(hourCaption, x, cy, fw, hourCaption.getPreferredH()); + place(minuteCaption, x + fw + sw, cy, fw, minuteCaption.getPreferredH()); + } + } + + private void place(Component c, int x, int y, int w, int h) { + c.setX(x); + c.setY(y); + c.setWidth(w); + c.setHeight(h); + } + + @Override + public Dimension getPreferredSize(Container parent) { + Style s = parent.getStyle(); + int h = boxHeight(); + if (inputMode) { + h += captionGap() + Math.max(hourCaption.getPreferredH(), minuteCaption.getPreferredH()); + } + return new Dimension(2 * boxWidth() + separator.getPreferredW() + periodsWidth() + + s.getHorizontalPadding(), h + s.getVerticalPadding()); + } + } + + // A typed field is the box of the hour with a caret in it: the same + // type, and two digits in the middle of it. A text field draws its text + // from its padding and has no centred alignment, so the padding is what + // puts the digits there. + private void padTyped(int fw, int fh) { + Font f = getUIManager().getComponentStyle("PickerMaterialTimeField").getFont(); + int px = Math.max(0, (fw - f.stringWidth("00")) / 2); + int py = Math.max(0, (fh - f.getHeight()) / 2); + padTyped(hourInput, f, px, py); + padTyped(minuteInput, f, px, py); + } + + // Asked of the style every time and written only when it differs: a + // theme that is refreshed gives the field a new style, and a number + // remembered here would say the padding was still on it. + private static void padTyped(TextField t, Font f, int px, int py) { + Style now = t.getUnselectedStyle(); + if (now.getFont() == f && now.getPaddingLeftNoRTL() == px && now.getPaddingTop() == py) { //NOPMD CompareObjectsWithEquals - the very font + return; + } + Style all = t.getAllStyles(); + all.setFont(f); + all.setPaddingUnit(Style.UNIT_TYPE_PIXELS); + all.setPadding(py, py, px, px); + all.setMarginUnit(Style.UNIT_TYPE_PIXELS); + all.setMargin(0, 0, 0, 0); + } + + // The colon between the hour and the minute: two dots about the middle + // of the row. The glyph of a font sits between the baseline and the + // height of a lower case letter, which is under the middle of the + // digits beside it. + private static final class Colon extends Component { + Colon() { + setUIID("PickerMaterialTimeSeparator"); + setFocusable(false); + } + + @Override + protected Dimension calcPreferredSize() { + Style s = getStyle(); + int h = s.getFont().getHeight(); + return new Dimension(Math.max(h / 3, Display.getInstance().convertToPixels(3f)) + s.getHorizontalPadding(), + h); + } + + @Override + public void paint(Graphics g) { + Style s = getStyle(); + int d = Math.max(3, s.getFont().getHeight() / 7); + int cx = getX() + getWidth() / 2; + int cy = getY() + getHeight() / 2; + int off = d * 2; + boolean aa = g.isAntiAliased(); + g.setAntiAliased(true); + g.setColor(s.getFgColor()); + g.fillArc(cx - d / 2, cy - off - d / 2, d, d, 0, 360); + g.fillArc(cx - d / 2, cy + off - d / 2, d, d, 0, 360); + g.setAntiAliased(aa); + } + } + + // -------------------------------------------------------------- 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. + /// The minute is kept as it is given: the step is where the hand stops + /// when it is turned, and a time that is only looked at is not changed + /// by having been shown. + 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 = Math.max(0, Math.min(59, minuteOfHour)); + typedWrong = false; + refresh(); + } + + /// Whether what is typed is a time. When it is not, the fields are + /// written again with the time that is set, so that what is confirmed + /// next is what is read there and not something that was never shown. + boolean acceptInput() { + if (!inputMode || !typedWrong) { + return true; + } + typedWrong = false; + refresh(); + return false; + } + + /// 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)); + 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; + // The fields are written again with the time that is set. + typedWrong = false; + 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; + } + + /// Where the hand points now, in degrees clockwise from twelve. + float handAt() { + return dial.handAt(); + } + + /// The number of the face the pointer is over, or -1. + int overNumber() { + return dial.overIndex; + } + + Container timeRow() { + return time; + } + + 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; + // Two digits, as the minute has: the hour is typed over, and a + // box with one digit off its middle reads as a broken one. + int h = meridiem ? (hour % 12 == 0 ? 12 : hour % 12) : hour; + hourInput.setText(two(h)); + 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()); + typedWrong = true; + 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; + } + // A minute that is typed is the minute that is meant; the step is + // for the hand. + typedWrong = false; + 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 + + private static final int SWEEP_MS = 220; + private long fixedClock = -1; + + /// Stops the clock the hand turns by at a time, for a screenshot of + /// the turn or a test of it. A negative value returns to the clock. + public void setAnimationTime(long millis) { + // What changed before this is a turn that starts now. + dial.sync(); + fixedClock = millis; + dial.sync(); + dial.repaint(); + } + + private long clock() { + return fixedClock >= 0 ? fixedClock : System.currentTimeMillis(); + } + + /// 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; + } + + // The ring of the hours after noon, inside the ring at `outer`. + // Half way to the centre, as on the platform's own dial: any + // closer in and twelve two-digit numbers run into one another. + private int innerRing(int outer, int k) { + return outer - k * 3 / 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 = innerRing(outer, k); + 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. + sync(); + float p = sweep(); + double a = Math.toRadians(fromAt + turn(fromAt, toAt) * p); + float inward = (fromIn ? 1 : 0) + ((toIn ? 1 : 0) - (fromIn ? 1 : 0)) * p; + int ring = outer - Math.round((outer - inner) * inward); + int hx = cx + (int) Math.round(Math.sin(a) * ring); + int hy = cy - (int) Math.round(Math.cos(a) * ring); + if (overIndex >= 0) { + // Where a press would put the hand. + double oa = Math.toRadians(overIndex * 30); + int or = overIn ? inner : outer; + int alpha0 = g.getAlpha(); + g.setColor(face.getFgColor()); + g.setAlpha(alpha0 * 28 / 255); + g.fillArc(cx + (int) Math.round(Math.sin(oa) * or) - k, cy - (int) Math.round(Math.cos(oa) * or) - k, + 2 * k, 2 * k, 0, 360); + g.setAlpha(alpha0); + } + 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); + // A number is written in the hand's colour while the hand is + // on it, which during a sweep is not the number that is set: + // that one would be white on the pale face until the hand + // arrived. + knobX = hx; + knobY = hy; + knobNear = k * k * 2 / 5; + litColor = hand.getFgColor(); + plainColor = face.getFgColor(); + + Font f = face.getFont(); + // The inner ring is shorter round, and its numbers smaller. + Font small = meridiem || minutesShown ? f : innerFont(f); + 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, small, 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 int knobX; + private int knobY; + private int knobNear; + private int litColor; + private int plainColor; + private Font innerOf; + private Font innerFont; + + private Font innerFont(Font f) { + if (innerOf != f) { //NOPMD CompareObjectsWithEquals - the font the cached one was made from + innerOf = f; + innerFont = f.isTTFNativeFont() ? f.derive(f.getPixelSize() * 0.85f, f.getStyle()) : f; + } + return innerFont; + } + + // ---- the hand turns to a time it was not dragged to ---- + + private boolean aimed; + private int aimedAt; + private boolean held; + private float fromAt; + private float toAt; + private boolean fromIn; + private boolean toIn; + private long sweepSince = -1; + private boolean registered; + private int overIndex = -1; + private boolean overIn; + + // The shorter way round from one angle to another. + private float turn(float from, float to) { + float d = (to - from) % 360; + if (d > 180) { + d -= 360; + } else if (d < -180) { + d += 360; + } + return d; + } + + // Points the hand at the time the picker has now. + void sync() { + if (minutesShown) { + aim(minute * 6, false); + } else { + aim(hour * 30 % 360, !meridiem && (hour == 0 || hour > 12)); + } + } + + float handAt() { + sync(); + float a = (fromAt + turn(fromAt, toAt) * sweep()) % 360; + return a < 0 ? a + 360 : a; + } + + // How far the hand has come, from 0 to 1. + private float sweep() { + if (sweepSince < 0) { + return 1; + } + long t = clock() - sweepSince; + if (t >= SWEEP_MS) { + return 1; + } + float p = t <= 0 ? 0 : t / (float) SWEEP_MS; + return p * p * (3 - 2 * p); + } + + // The time the face shows has changed. Under a finger the hand is + // where the finger is; set any other way it turns there: the hour + // was chosen and the minutes are asked, a field was typed in, the + // half of the day was changed. + private void aim(int at, boolean in) { + if (aimed && at == aimedAt && in == toIn) { + return; + } + if (!aimed || held || !isInitialized()) { + sweepSince = -1; + } else { + float p = sweep(); + fromAt = fromAt + turn(fromAt, toAt) * p; + // Halfway between the rings it starts from the nearer one. + fromIn = p < 0.5f ? fromIn : toIn; + sweepSince = clock(); + Form f = getComponentForm(); + if (f != null && !registered) { + f.registerAnimated(this); + registered = true; + } + } + if (sweepSince < 0) { + fromAt = at; + fromIn = in; + } + // The angle as it was given, to compare the next one with: two + // floats are not compared for equality. + aimedAt = at; + toAt = at; + toIn = in; + aimed = true; + } + + @Override + public boolean animate() { + boolean busy = sweepSince >= 0 && fixedClock < 0 && clock() - sweepSince < SWEEP_MS; + if (!busy && registered) { + Form f = getComponentForm(); + if (f != null) { + f.deregisterAnimated(this); + } + registered = false; + } + return busy; + } + + @Override + protected void deinitialize() { + if (registered) { + Form f = getComponentForm(); + if (f != null) { + f.deregisterAnimated(this); + } + registered = false; + } + sweepSince = -1; + held = false; + overIndex = -1; + super.deinitialize(); + } + + @Override + protected void initComponent() { + super.initComponent(); + if (Display.getInstance().isDesktop()) { + Form f = getComponentForm(); + if (f != null && !f.isEnableCursors()) { + f.setEnableCursors(true); + } + setCursor(HAND_CURSOR); + } + } + + @Override + public void pointerHover(int[] x, int[] y) { + int r = radius(); + if (r <= 0 || x == null || x.length == 0) { + return; + } + int dx = x[0] - getAbsoluteX() - getWidth() / 2; + int dy = y[0] - getAbsoluteY() - getHeight() / 2; + int index = -1; + boolean in = false; + if (dx * dx + dy * dy <= r * r && (dx != 0 || dy != 0)) { + double deg = Math.toDegrees(MathUtil.atan2(dx, -dy)); + if (deg < 0) { + deg += 360; + } + index = (int) Math.round(deg / 30) % 12; + if (!minutesShown) { + int h; + if (meridiem) { + h = index + (hour >= 12 ? 12 : 0); + } else { + int k = knob(r); + int outer = r - k - Math.max(1, r / 28); + int inner = innerRing(outer, k); + in = dx * dx + dy * dy < (outer + inner) * (outer + inner) / 4; + h = in ? (index == 0 ? 0 : index + 12) : (index == 0 ? 12 : index); + } + if (!isEnabledHour(h)) { + index = -1; + } + } + } + if (index != overIndex || in != overIn) { + overIndex = index; + overIn = in; + repaint(); + } + } + + @Override + public void setHovered(boolean hovered) { + super.setHovered(hovered); + if (!hovered && overIndex >= 0) { + overIndex = -1; + repaint(); + } + } + + 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); + int dx = x - knobX; + int dy = y - knobY; + boolean under = dx * dx + dy * dy <= knobNear; + int shown = color; + if (under) { + shown = litColor; + } else if (color == litColor) { + shown = plainColor; + } + g.setColor(shown); + g.setFont(f); + 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 = innerRing(outer, k); + boolean inside = dx * dx + dy * dy < (outer + inner) * (outer + inner) / 4; + h = inside ? (i == 0 ? 0 : i + 12) : (i == 0 ? 12 : i); + } + pointedHour = isEnabledHour(h); + if (h != hour && pointedHour) { + hour = h; + refresh(); + changed(); + } + } + + @Override + public void pointerPressed(int x, int y) { + held = true; + overIndex = -1; + point(x, y); + } + + @Override + public void pointerDragged(int x, int y) { + point(x, y); + } + + @Override + public void pointerReleased(int x, int y) { + pointedHour = false; + point(x, y); + // The hand is where the finger left it before anything turns it. + sync(); + held = false; + if (!minutesShown && pointedHour) { + // The hour is chosen; the minute is what is asked next. An + // hour that cannot be chosen leaves the question open. + 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 = dial.innerRing(outer, k); + int ring = outer; + double a; + if (ofMinutes) { + a = Math.toRadians(value * 6); + } else { + a = Math.toRadians(value * 30 % 360); + 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..5277970454c 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(); @@ -140,6 +145,11 @@ 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; + // Whether that dialog has been on screen: until its opening has played + // it is not the current form, and it is not one that was covered either. + private boolean materialSeen; /// 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 +315,17 @@ 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 || !lightweightPopupSet + || !Display.getInstance().isNativePickerTypeSupported(type))) { + showMaterialDialog(); + evt.consume(); + return; + } if ((useLightweightPopup || inWindow || !Display.getInstance().isNativePickerTypeSupported(type)) && isLightweightModeSupportedForType(type)) { @@ -1027,6 +1048,218 @@ public void actionPerformed(ActionEvent evt) { updateValue(); } + // ------------------------------------------------------ Material style + + /// 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")); + } + + 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) { + // The picker's last hour is one that can be chosen, as it is on + // the wheels; the face takes the hour after it. + t.setHourRange(minHour, Math.min(23, maxHour) + 1); + } + } + + 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 static boolean acceptsInput(InternalPickerWidget widget) { + if (widget instanceof MaterialDatePicker) { + return ((MaterialDatePicker) widget).acceptInput(); + } + if (widget instanceof MaterialTimePicker) { + return ((MaterialTimePicker) widget).acceptInput(); + } + if (widget instanceof MaterialDateTimePicker) { + return ((MaterialDateTimePicker) widget).acceptInput(); + } + return true; + } + + private void showMaterialDialog() { + if (materialDialog != null) { + if (Display.getInstance().getCurrent() == materialDialog) { //NOPMD CompareObjectsWithEquals - this very dialog + // Asked to open while it is open: one dialog, one answer. + return; + } + // The application showed another form over it: it was never + // answered and is not coming back, which is a cancel. + // The value was never taken from it, and what Cancel puts back + // is what was noted when this opening was asked for, so there + // is nothing to restore; whoever waited for it to close is told. + materialDialog = null; + currentSpinner = null; + if (stopEditingCallback != null) { + Runnable r = stopEditingCallback; + stopEditingCallback = null; + r.run(); + } + } + final InternalPickerWidget widget = createMaterialWidget(); + final Dialog dlg = new Dialog(new BorderLayout()); + // The picker's own surface over the form it belongs to, as the wheel + // popup and the modal picker are: never an operating system window. + dlg.setNativeWindowMode(false); + 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) { + // Text that is not a date or a time is not confirmed: the + // value behind it is one the user is not looking at. + if (acceptsInput(widget)) { + 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; + materialSeen = false; + dlg.addShowListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + if (materialDialog == dlg) { //NOPMD CompareObjectsWithEquals - this very dialog + materialSeen = true; + } + } + }); + dlg.showPacked(BorderLayout.CENTER, false); + } + + private void endMaterialDialog(Dialog dlg, InternalPickerWidget widget, boolean commit) { + if (materialDialog != dlg) { //NOPMD CompareObjectsWithEquals - this very dialog, closed once + // One that was given up for a later opening while a dialog of + // the application lay over it, and is on screen again now that + // the others closed: its buttons close it and decide nothing. + if (Display.getInstance().getCurrent() == dlg) { //NOPMD CompareObjectsWithEquals - this very dialog + dlg.dispose(); + } + 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(); + if (type == Display.PICKER_TYPE_DATE_AND_TIME && value instanceof Date) { + // The month limits the day; the instant is limited here. + Date chosen = (Date) value; + // The last day is one that can be chosen, all of it, as it + // is in the month; only the first day has a first moment. + if (startDate != null && chosen.getTime() < startDate.getTime()) { + value = new Date(startDate.getTime()); + } + } + 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; @@ -1323,6 +1556,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 @@ -1344,6 +1578,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. @@ -1428,12 +1668,24 @@ public void setType(int type) { /// Returns the value currently visible to the user: the live spinner wheel value while /// the lightweight popup is showing, otherwise the committed `value` field. private Object currentValue() { - if (currentSpinner != null) { - return currentSpinner.getValue(); + InternalPickerWidget shown = shownSpinner(); + if (shown != null) { + return shown.getValue(); } return value; } + // The widget the user is choosing in. A Material dialog the application + // covered with something of its own is not that: nobody is looking at + // it, and what it showed was never confirmed. + private InternalPickerWidget shownSpinner() { + if (materialDialog != null && materialSeen + && Display.getInstance().getCurrent() != materialDialog) { //NOPMD CompareObjectsWithEquals - this very dialog + return null; + } + return currentSpinner; + } + /// Returns the date, this value is used both for type date/date and time. Notice that this /// value isn't used for time. /// @@ -1450,8 +1702,9 @@ public Date getDate() { // If the popup is showing, the live wheel position wins regardless of // whether a default is configured - the user is in the middle of // editing and should see the in-flight value. - if (currentSpinner != null) { - return (Date) currentSpinner.getValue(); + InternalPickerWidget shown = shownSpinner(); + if (shown != null) { + return (Date) shown.getValue(); } // No explicit setDate yet -> let the default getter take over so the // value returned here matches what the picker would show if opened now. diff --git a/Ports/Android/src/AndroidMaterialTheme.res b/Ports/Android/src/AndroidMaterialTheme.res index b500d0eee21..21ab5d8ddd3 100644 Binary files a/Ports/Android/src/AndroidMaterialTheme.res and b/Ports/Android/src/AndroidMaterialTheme.res differ 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/Ports/iOSPort/nativeSources/iOSModern27Theme.res b/Ports/iOSPort/nativeSources/iOSModern27Theme.res index be993f1d39d..e871e0fe96f 100644 Binary files a/Ports/iOSPort/nativeSources/iOSModern27Theme.res and b/Ports/iOSPort/nativeSources/iOSModern27Theme.res differ diff --git a/Ports/iOSPort/nativeSources/iOSModernTheme.res b/Ports/iOSPort/nativeSources/iOSModernTheme.res index c4eccd7f439..1b2eaed89e9 100644 Binary files a/Ports/iOSPort/nativeSources/iOSModernTheme.res and b/Ports/iOSPort/nativeSources/iOSModernTheme.res differ diff --git a/Themes/AndroidMaterialTheme.res b/Themes/AndroidMaterialTheme.res index b500d0eee21..21ab5d8ddd3 100644 Binary files a/Themes/AndroidMaterialTheme.res and b/Themes/AndroidMaterialTheme.res differ diff --git a/Themes/iOSModern27Theme.res b/Themes/iOSModern27Theme.res index be993f1d39d..e871e0fe96f 100644 Binary files a/Themes/iOSModern27Theme.res and b/Themes/iOSModern27Theme.res differ diff --git a/Themes/iOSModernTheme.res b/Themes/iOSModernTheme.res index c4eccd7f439..1b2eaed89e9 100644 Binary files a/Themes/iOSModernTheme.res and b/Themes/iOSModernTheme.res differ diff --git a/docs/demos/common/src/main/css/guide-snippets-theme.css b/docs/demos/common/src/main/css/guide-snippets-theme.css index 48ccbe0ea2b..596f249f529 100644 --- a/docs/demos/common/src/main/css/guide-snippets-theme.css +++ b/docs/demos/common/src/main/css/guide-snippets-theme.css @@ -862,3 +862,97 @@ Button.hover { --radius: 1.5mm; } /* end::wayline-css-brand[] */ + +/* tag::scheduling-css-scheduler[] */ +SchedulerItem { + background-color: #0f766e; + color: #ffffff; +} + +SchedulerBackgroundItem { + background-color: #f0fdfa; + color: #115e59; +} + +SchedulerHeaderToday { + background-color: #0f766e; + color: #ffffff; +} + +SchedulerNow { + background-color: #e11d48; + color: #e11d48; +} + +/* A UIID of the application's own, for the items that ask for it. */ +TentativeBooking { + background-color: #fef3c7; + color: #92400e; + font-family: "native:MainRegular"; + font-size: 1.4mm; +} +/* end::scheduling-css-scheduler[] */ + +/* tag::scheduling-css-gantt[] */ +GanttBar { + background-color: #93c5fd; + color: #0f172a; +} + +GanttBarProgress { + background-color: #1d4ed8; + color: #ffffff; +} + +GanttBarCritical { + background-color: #f97316; + color: #0f172a; +} + +GanttSummaryBar { + background-color: #0f172a; + color: #ffffff; +} + +GanttMilestone { + background-color: #7c3aed; + color: #ffffff; +} + +GanttLinkCritical { + color: #f97316; +} +/* end::scheduling-css-gantt[] */ + +/* tag::picker-material-constant[] */ +#Constants { + pickerStyle: "material"; +} +/* end::picker-material-constant[] */ + +/* tag::picker-material-css[] */ +PickerMaterial { + background-color: #f0fdfa; +} + +PickerMaterialButton { + color: #0f766e; +} + +/* The chosen day. */ +PickerMaterialDay.selected { + background-color: #0f766e; + color: #ffffff; +} + +/* The clock face, and the hand with the number under it. */ +PickerMaterialDial { + background-color: #ccfbf1; + color: #134e4a; +} + +PickerMaterialDial.selected { + background-color: #0f766e; + color: #ffffff; +} +/* end::picker-material-css[] */ diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/scheduling/CardItemRenderer.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/scheduling/CardItemRenderer.java new file mode 100644 index 00000000000..ec36d0e78a7 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/scheduling/CardItemRenderer.java @@ -0,0 +1,59 @@ +/* + * 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.codenameone.developerguide.scheduling; + +import com.codename1.scheduling.ScheduleItem; +import com.codename1.ui.Graphics; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.scheduler.DefaultScheduleItemRenderer; +import com.codename1.ui.scheduler.Scheduler; + +// tag::scheduler-renderer[] +/// Draws an event as a card: a pale body with a stripe in the item's +/// colour, which deepens under the pointer and when it is selected. +public class CardItemRenderer extends DefaultScheduleItemRenderer { + @Override + public void paintItem(Graphics g, Scheduler scheduler, ScheduleItem item, + int x, int y, int width, int height, int state) { + if (item.getKind() != ScheduleItem.Kind.EVENT) { + // Working time and blocked time keep the theme's look. + super.paintItem(g, scheduler, item, x, y, width, height, state); + return; + } + int color = item.getColor() >= 0 ? item.getColor() : 0x0f766e; + boolean lit = (state & (HOVERED | SELECTED | DRAGGED)) != 0; + int alpha = g.getAlpha(); + g.setColor(0xffffff); + g.fillRect(x, y, width, height); + g.setColor(color); + g.setAlpha(alpha * (lit ? 90 : 45) / 255); + g.fillRect(x, y, width, height); + g.setAlpha(alpha); + int stripe = Math.min(width, 4); + g.fillRect(x, y, stripe, height); + paintText(g, scheduler, item, + UIManager.getInstance().getComponentStyle("SchedulerItem").getFont(), + 0x1d1b20, x + stripe, y, width - stripe, height, state); + } +} +// end::scheduler-renderer[] diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/scheduling/SchedulingSnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/scheduling/SchedulingSnippets.java new file mode 100644 index 00000000000..65432e8dfe6 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/scheduling/SchedulingSnippets.java @@ -0,0 +1,328 @@ +/* + * 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.codenameone.developerguide.scheduling; + +import com.codename1.calendar.CalendarInfo; +import com.codename1.calendar.LocalCalendarSource; +import com.codename1.printing.PrintResult; +import com.codename1.printing.PrintResultListener; +import com.codename1.scheduling.CalendarScheduleModel; +import com.codename1.scheduling.DefaultScheduleModel; +import com.codename1.scheduling.GanttTask; +import com.codename1.scheduling.LevelingResult; +import com.codename1.scheduling.ProjectModel; +import com.codename1.scheduling.ResourceLeveler; +import com.codename1.scheduling.ScheduleItem; +import com.codename1.scheduling.ScheduleResource; +import com.codename1.scheduling.TaskDependency; +import com.codename1.scheduling.TimeMath; +import com.codename1.scheduling.WorkCalendar; +import com.codename1.ui.Dialog; +import com.codename1.ui.Form; +import com.codename1.ui.Image; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.scheduler.Gantt; +import com.codename1.ui.scheduler.GanttAdapter; +import com.codename1.ui.scheduler.SchedulePrinter; +import com.codename1.ui.scheduler.Scheduler; +import com.codename1.ui.scheduler.SchedulerAdapter; +import com.codename1.ui.spinner.Picker; +import java.time.ZoneId; +import java.util.Date; + +public class SchedulingSnippets { + public void firstScheduler() { + // tag::scheduler-first[] + ZoneId zone = ZoneId.systemDefault(); + long today = TimeMath.epochDay(System.currentTimeMillis(), zone); + + DefaultScheduleModel model = new DefaultScheduleModel(); + model.addItem(new ScheduleItem("standup", "Stand-up", + TimeMath.at(today, 9 * 60, zone), TimeMath.at(today, 9 * 60 + 30, zone))); + model.addItem(new ScheduleItem("review", "Design review", + TimeMath.at(today, 13 * 60, zone), TimeMath.at(today, 14 * 60 + 30, zone)) + .setDetail("Room 2")); + model.addItem(new ScheduleItem("offsite", "Offsite", + TimeMath.startOfEpochDay(today + 1, zone), TimeMath.startOfEpochDay(today + 2, zone)) + .setAllDay(true)); + + Scheduler scheduler = new Scheduler(model); + scheduler.setView(Scheduler.View.WEEK); + + Form form = new Form("Schedule", new BorderLayout()); + form.add(BorderLayout.CENTER, scheduler); + form.show(); + // end::scheduler-first[] + } + + public void resources() { + // tag::scheduler-resources[] + ZoneId zone = ZoneId.systemDefault(); + long today = TimeMath.epochDay(System.currentTimeMillis(), zone); + + DefaultScheduleModel model = new DefaultScheduleModel(); + model.addResource(new ScheduleResource("ana", "Ana").setDetail("Stylist")); + model.addResource(new ScheduleResource("ben", "Ben").setDetail("Colourist")); + + // The hours Ana works, drawn as a band under her bookings. + model.addItem(new ScheduleItem("ana-shift", "", + TimeMath.at(today, 9 * 60, zone), TimeMath.at(today, 17 * 60, zone)) + .setResourceId("ana").setKind(ScheduleItem.Kind.BACKGROUND)); + model.addItem(new ScheduleItem("cut", "Cut and finish", + TimeMath.at(today, 10 * 60, zone), TimeMath.at(today, 11 * 60, zone)) + .setResourceId("ana")); + // Time taken by another calendar: hatched, and nobody's to move. + model.addItem(new ScheduleItem("dentist", "Busy", + TimeMath.at(today, 12 * 60, zone), TimeMath.at(today, 13 * 60, zone)) + .setResourceId("ben").setKind(ScheduleItem.Kind.BLOCKED)); + + Scheduler scheduler = new Scheduler(model); + scheduler.setViews(Scheduler.View.RESOURCE_DAY, Scheduler.View.RESOURCE_TIMELINE); + scheduler.setView(Scheduler.View.RESOURCE_DAY); + + Form form = new Form("Team", new BorderLayout()); + form.add(BorderLayout.CENTER, scheduler); + form.show(); + // end::scheduler-resources[] + } + + public void events() { + // tag::scheduler-events[] + DefaultScheduleModel model = new DefaultScheduleModel(); + // The application shows its own editor for a new item, so the model + // does not make one by itself. + model.setCreationEnabled(false); + + Scheduler scheduler = new Scheduler(model); + scheduler.setSnapMinutes(15); + scheduler.addSchedulerListener(new SchedulerAdapter() { + @Override + public void itemOpened(Scheduler s, ScheduleItem item) { + Dialog.show(item.getTitle(), s.formatRange(item.getStart(), item.getEnd()), "OK", null); + } + + @Override + public void rangeSelected(Scheduler s, long start, long end, String resourceId) { + model.addItem(new ScheduleItem("new-" + start, "New booking", start, end) + .setResourceId(resourceId)); + s.refresh(); + } + + @Override + public boolean itemChanging(Scheduler s, ScheduleItem item, long start, long end, + String resourceId) { + // Nothing is moved into the past. + return start >= System.currentTimeMillis(); + } + }); + + Form form = new Form("Bookings", new BorderLayout()); + form.add(BorderLayout.CENTER, scheduler); + form.show(); + // end::scheduler-events[] + } + + public void workingHours() { + // tag::scheduler-work-calendar[] + ZoneId zone = ZoneId.systemDefault(); + WorkCalendar hours = new WorkCalendar(zone); + // 08:30 to 12:00 and 13:00 to 17:30, Monday to Friday. + hours.setWorkingHours(8 * 60 + 30, 12 * 60, 13 * 60, 17 * 60 + 30); + // Saturday mornings. + hours.setWorkingDay(6, true); + hours.setWorkingHours(6, new int[] {9 * 60, 13 * 60}); + hours.addHoliday(TimeMath.epochDay(System.currentTimeMillis(), zone) + 3); + + Scheduler scheduler = new Scheduler(); + scheduler.setWorkCalendar(hours); + scheduler.setView(Scheduler.View.WORK_WEEK); + + Form form = new Form("Working hours", new BorderLayout()); + form.add(BorderLayout.CENTER, scheduler); + form.show(); + // end::scheduler-work-calendar[] + } + + public void deviceCalendar() { + // tag::scheduler-calendar-source[] + LocalCalendarSource source = LocalCalendarSource.getInstance(); + CalendarScheduleModel model = new CalendarScheduleModel(source, ZoneId.systemDefault()); + source.listCalendars(CalendarInfo.ContentType.EVENTS, null).ready(page -> { + for (CalendarInfo calendar : page.getItems()) { + model.addCalendar(calendar); + } + }); + + Scheduler scheduler = new Scheduler(model); + Form form = new Form("My calendars", new BorderLayout()); + form.add(BorderLayout.CENTER, scheduler); + form.show(); + // end::scheduler-calendar-source[] + } + + public void firstGantt() { + // tag::gantt-first[] + long day = TimeMath.DAY; + WorkCalendar calendar = new WorkCalendar(ZoneId.systemDefault()); + ProjectModel project = new ProjectModel(calendar, + TimeMath.startOfDay(System.currentTimeMillis(), calendar.getZone())); + project.addResource(new ScheduleResource("dev", "Developer")); + project.addResource(new ScheduleResource("qa", "Tester")); + + // A duration is working time: three days of the calendar's hours. + long workDay = calendar.getMillisPerDay(); + GanttTask build = new GanttTask("build", "Build"); + GanttTask api = new GanttTask("api", "API", 3 * workDay).assign("dev"); + GanttTask screens = new GanttTask("screens", "Screens", 5 * workDay).assign("dev"); + GanttTask testing = new GanttTask("test", "Testing", 2 * workDay).assign("qa"); + GanttTask release = new GanttTask("release", "Release", 0); + + project.addTask(build); + project.addTask(build, api); + project.addTask(build, screens); + project.addTask(testing); + project.addTask(release); + + project.link(api, screens); + project.link(screens, testing); + // The release follows testing by a day. + project.link(testing, release, TaskDependency.Type.FINISH_TO_START, day); + project.update(); + + Gantt gantt = new Gantt(project); + gantt.setShowCriticalPath(true); + + Form form = new Form("Plan", new BorderLayout()); + form.add(BorderLayout.CENTER, gantt); + form.show(); + // end::gantt-first[] + } + + public void ganttEvents() { + // tag::gantt-events[] + ProjectModel project = new ProjectModel(new WorkCalendar(), System.currentTimeMillis()); + Gantt gantt = new Gantt(project); + gantt.addGanttListener(new GanttAdapter() { + @Override + public boolean taskChanging(Gantt g, GanttTask task, long start, long end) { + // A task that is finished stays where it is. + return task.getPercentComplete() < 100; + } + + @Override + public boolean linkCreating(Gantt g, GanttTask predecessor, GanttTask successor) { + return !successor.isSummary(); + } + }); + + // What the plan was, drawn as a ghost bar under what it becomes. + project.captureBaseline(); + gantt.setShowBaseline(true); + // end::gantt-events[] + } + + public void leveling() { + // tag::gantt-leveling[] + WorkCalendar calendar = new WorkCalendar(); + ProjectModel project = new ProjectModel(calendar, System.currentTimeMillis()); + project.addResource(new ScheduleResource("dev", "Developer")); + // Two tasks at once for somebody there is one of. + project.addTask(new GanttTask("api", "API", 3 * calendar.getMillisPerDay()).assign("dev")); + project.addTask(new GanttTask("screens", "Screens", 2 * calendar.getMillisPerDay()).assign("dev")); + project.update(); + + if (!ResourceLeveler.findOverallocations(project).isEmpty()) { + LevelingResult proposal = ResourceLeveler.level(project); + long slip = proposal.getFinishAfter() - proposal.getFinishBefore(); + if (proposal.isChanged() && Dialog.show("Level resources", + proposal.getChangedTasks().size() + " tasks move; the project ends " + + slip / TimeMath.DAY + " days later.", "Apply", "Cancel")) { + proposal.apply(); + } + } + // end::gantt-leveling[] + } + + public void print() { + // tag::schedule-print[] + Scheduler scheduler = new Scheduler(); + SchedulePrinter printer = new SchedulePrinter(scheduler); + printer.setTitle("Week 12"); + + // One picture of all of it, to share. + Image picture = printer.renderImage(2000); + + // Or page by page. + for (int page = 0; page < printer.getPageCount(); page++) { + Image sheet = printer.renderPage(page); + } + + // Or to the platform's print dialog. + printer.print(new PrintResultListener() { + @Override + public void onResult(PrintResult result) { + if (result.isFailed()) { + Dialog.show("Print", result.getError(), "OK", null); + } + } + }); + // end::schedule-print[] + } + + public void customRenderer(Scheduler scheduler) { + // tag::scheduler-renderer-use[] + scheduler.setItemRenderer(new CardItemRenderer()); + // end::scheduler-renderer-use[] + } + + public Image animationFrame(Scheduler scheduler, Runnable drag) { + // tag::scheduler-animation-time[] + // Stop the clock, do what starts the animation, then move the clock + // to the moment wanted: 80 milliseconds into picking an item up. + scheduler.setAnimationTime(1000); + drag.run(); + scheduler.setAnimationTime(1080); + + Image frame = Image.createImage(scheduler.getWidth(), scheduler.getHeight()); + scheduler.paintComponent(frame.getGraphics(), true); + + // Back to the real clock. + scheduler.setAnimationTime(-1); + // end::scheduler-animation-time[] + return frame; + } + + public void materialPicker() { + // tag::picker-material[] + Picker date = new Picker(); + date.setType(com.codename1.ui.Display.PICKER_TYPE_DATE); + date.setDate(new Date()); + + // An application that wants the platform's own dialog on this one + // field says so; the theme decides for every other. + Picker time = new Picker(); + time.setType(com.codename1.ui.Display.PICKER_TYPE_TIME); + time.setUseLightweightPopup(false); + // end::picker-material[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/GuideFigure.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/GuideFigure.java index 2360da3fef9..18a96efdad1 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/GuideFigure.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/GuideFigure.java @@ -73,6 +73,16 @@ default boolean fillsViewport() { return false; } + /// Whether building this figure changes the installed theme. + /// + /// A figure about styling lays rules of its own over the native theme, + /// and the theme is global: without this the figures after it in the + /// manifest would be drawn in its colours. The renderer installs the + /// device's theme again after a figure that answers true. + default boolean changesTheme() { + return false; + } + /// Runs the event thread for a while so animations finish. /// /// Several things a figure wants to show are only reachable through an diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/GuideFigureRenderer.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/GuideFigureRenderer.java index c158ab73ed6..5ffd32e085c 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/GuideFigureRenderer.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/GuideFigureRenderer.java @@ -127,6 +127,9 @@ public static void render(ScreenshotSink sink, FigureDevice device, FigureVarian .append(err.getClass().getName()).append(": ").append(err.getMessage()); } finally { Dialog.setDefaultBlurBackgroundRadius(blurRadius); + if (variant.figure().changesTheme()) { + installTheme(device.themeResource()); + } } } System.out.println("Rendered " + rendered + " figure(s) for " + device.key()); diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/GuideFigures.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/GuideFigures.java index 0970542c2e5..49add40ac34 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/GuideFigures.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/GuideFigures.java @@ -36,6 +36,15 @@ private GuideFigures() { public static FigureVariant[] variants() { GuideFigure button = new ButtonFigure(); GuideFigure appearance = new AppearanceFigure(); + FigureVariant[] general = general(button, appearance); + FigureVariant[] scheduling = SchedulingFigures.variants(); + FigureVariant[] all = new FigureVariant[general.length + scheduling.length]; + System.arraycopy(general, 0, all, 0, general.length); + System.arraycopy(scheduling, 0, all, general.length, scheduling.length); + return all; + } + + private static FigureVariant[] general(GuideFigure button, GuideFigure appearance) { return new FigureVariant[] { // The Components chapter shows the platforms side by side. new FigureVariant(button, FigureDevice.IOS, false, "components-button-ios.png"), diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/SchedulingFigures.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/SchedulingFigures.java new file mode 100644 index 00000000000..c11851a58ca --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/SchedulingFigures.java @@ -0,0 +1,1011 @@ +/* + * 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.codenameone.developerguide.screenshots; + +import com.codename1.scheduling.DefaultScheduleModel; +import com.codename1.scheduling.GanttTask; +import com.codename1.scheduling.ProjectModel; +import com.codename1.scheduling.ScheduleItem; +import com.codename1.scheduling.ScheduleResource; +import com.codename1.scheduling.TaskDependency; +import com.codename1.scheduling.TimeMath; +import com.codename1.scheduling.WorkCalendar; +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.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.Label; +import com.codename1.ui.Painter; +import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.scheduler.Gantt; +import com.codename1.ui.scheduler.Scheduler; +import com.codename1.ui.scheduler.SchedulerAdapter; +import com.codename1.ui.spinner.MaterialDatePicker; +import com.codename1.ui.spinner.MaterialTimePicker; +import com.codename1.ui.util.Resources; +import com.codenameone.developerguide.scheduling.CardItemRenderer; + +import java.io.IOException; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Date; +import java.util.Hashtable; +import java.util.List; + +/// The figures of the scheduling chapter and of the Material pickers: every +/// view of a `Scheduler`, a `Gantt` in its variations, the pickers, what CSS +/// does to each, and the animations as rows of stopped frames. +/// +/// Everything is on fixed dates in a fixed zone with a fixed "now", and the +/// animated ones stop the components' clock with `setAnimationTime`, so a +/// frame from the middle of an animation is the same picture every run. +public final class SchedulingFigures { + /// Monday 2026-03-09, as a count of days since 1970-01-01. + private static final long MONDAY = 20521L; + + /// 2024-06-15T12:00:00Z, the day the date pickers open on. Not the month + /// the figures are generated in, so no day is ever ringed as today. + private static final long PICKER_DAY = 1718452800000L; + + /// When the stopped clock starts, and when everything that was started + /// before it has long finished. + private static final long T0 = 1000; + private static final long T1 = 9000; + + private SchedulingFigures() { + } + + /// What builds a figure's form. + private interface Scene { + Form build(); + } + + /// What a figure does to its form once it is on screen. + private interface Act { + void run(Form form); + } + + private static final class Fig implements GuideFigure { + private final String id; + private final Scene scene; + private final Act act; + private final boolean fills; + private final boolean themed; + + Fig(String id, Scene scene, Act act, boolean fills, boolean themed) { + this.id = id; + this.scene = scene; + this.act = act; + this.fills = fills; + this.themed = themed; + } + + @Override + public String id() { + return id; + } + + @Override + public Form build() { + return scene.build(); + } + + @Override + public void afterShow(Form form) { + if (act != null) { + act.run(form); + } + } + + @Override + public boolean fillsViewport() { + return fills; + } + + @Override + public boolean changesTheme() { + return themed; + } + } + + private static FigureVariant portrait(String name, Scene scene) { + return new FigureVariant(new Fig(name, scene, null, true, false), FigureDevice.ANDROID, false, name + ".png"); + } + + private static FigureVariant landscape(String name, Scene scene) { + return new FigureVariant(new Fig(name, scene, null, true, false), FigureDevice.ANDROID, false, true, + name + ".png"); + } + + private static FigureVariant staged(String name, boolean wide, Scene scene, Act act) { + return new FigureVariant(new Fig(name, scene, act, true, false), FigureDevice.ANDROID, false, wide, + name + ".png"); + } + + private static FigureVariant cropped(String name, Scene scene, Act act) { + return new FigureVariant(new Fig(name, scene, act, false, false), FigureDevice.ANDROID, false, name + ".png"); + } + + public static FigureVariant[] variants() { + List v = new ArrayList(); + + // ---- every view of a Scheduler + v.add(portrait("scheduler-day", () -> schedulerForm(Scheduler.View.DAY))); + v.add(landscape("scheduler-week", () -> schedulerForm(Scheduler.View.WEEK))); + v.add(landscape("scheduler-work-week", () -> schedulerForm(Scheduler.View.WORK_WEEK))); + v.add(portrait("scheduler-month", () -> schedulerForm(Scheduler.View.MONTH))); + v.add(portrait("scheduler-agenda", () -> schedulerForm(Scheduler.View.AGENDA))); + v.add(landscape("scheduler-resource-timeline", () -> schedulerForm(Scheduler.View.RESOURCE_TIMELINE))); + v.add(portrait("scheduler-resource-day", () -> schedulerForm(Scheduler.View.RESOURCE_DAY))); + // ---- the same schedule in the other appearance and on the other platform + GuideFigure day = new Fig("scheduler-day-appearance", () -> schedulerForm(Scheduler.View.DAY), null, true, + false); + v.add(new FigureVariant(day, FigureDevice.ANDROID, true, "scheduler-day-dark.png")); + v.add(new FigureVariant(day, FigureDevice.IOS, false, "scheduler-day-ios.png")); + GuideFigure month = new Fig("scheduler-month-appearance", () -> schedulerForm(Scheduler.View.MONTH), null, + true, false); + v.add(new FigureVariant(month, FigureDevice.ANDROID, true, "scheduler-month-dark.png")); + // ---- restyled + v.add(new FigureVariant(new Fig("scheduler-css", SchedulingFigures::styledScheduler, null, true, true), + FigureDevice.ANDROID, false, "scheduler-css.png")); + v.add(new FigureVariant(new Fig("scheduler-item-colors", SchedulingFigures::coloredScheduler, null, true, + false), FigureDevice.ANDROID, false, true, "scheduler-item-colors.png")); + v.add(portrait("scheduler-renderer", SchedulingFigures::renderedScheduler)); + // ---- in motion + DayStages drag = new DayStages(5, 9 * 60); + v.add(staged("scheduler-drag-stages", true, drag::build, drag::drag)); + DayStages resize = new DayStages(4, 13 * 60); + v.add(staged("scheduler-resize-stages", true, resize::build, resize::resize)); + DayStages range = new DayStages(3, 13 * 60); + v.add(staged("scheduler-range-stages", true, range::build, range::range)); + MonthStages monthDrag = new MonthStages(); + v.add(staged("scheduler-month-drag-stages", true, monthDrag::build, monthDrag::drag)); + + // ---- a Gantt + v.add(landscape("gantt-chart", () -> ganttForm(gantt(project(), false, false)))); + v.add(landscape("gantt-critical-path", () -> ganttForm(gantt(project(), true, false)))); + v.add(landscape("gantt-baseline", () -> ganttForm(gantt(slipped(), true, true)))); + v.add(landscape("gantt-collapsed", SchedulingFigures::collapsedGantt)); + v.add(staged("gantt-load", false, () -> loadForm(false), SchedulingFigures::fitChart)); + v.add(staged("gantt-leveled", false, () -> loadForm(true), SchedulingFigures::fitChart)); + v.add(new FigureVariant(new Fig("gantt-dark", () -> ganttForm(gantt(project(), true, false)), null, true, + false), FigureDevice.ANDROID, true, true, "gantt-dark.png")); + v.add(new FigureVariant(new Fig("gantt-ios", () -> ganttForm(gantt(project(), true, false)), null, true, + false), FigureDevice.IOS, false, true, "gantt-ios.png")); + v.add(new FigureVariant(new Fig("gantt-css", SchedulingFigures::styledGantt, null, true, true), + FigureDevice.ANDROID, false, true, "gantt-css.png")); + GanttStages ganttDrag = new GanttStages(4); + v.add(staged("gantt-drag-stages", false, ganttDrag::build, ganttDrag::drag)); + GanttStages ganttLink = new GanttStages(3); + v.add(staged("gantt-link-stages", false, ganttLink::build, ganttLink::link)); + + // ---- the Material pickers + v.add(cropped("picker-material-date", () -> pickerForm("Date", datePicker()), null)); + v.add(cropped("picker-material-date-years", () -> pickerForm("Date", datePicker()), + f -> press(f, "June 2024"))); + v.add(cropped("picker-material-date-input", () -> { + MaterialDatePicker p = datePicker(); + p.setInputMode(true); + return pickerForm("Date", p); + }, null)); + v.add(cropped("picker-material-date-range", () -> { + MaterialDatePicker p = datePicker(); + p.setDateRange(new Date(PICKER_DAY - 5 * TimeMath.DAY), new Date(PICKER_DAY + 9 * TimeMath.DAY)); + return pickerForm("Date", p); + }, null)); + v.add(cropped("picker-material-time", () -> pickerForm("Time", timePicker(true, 9, 25)), null)); + v.add(cropped("picker-material-time-minutes", () -> pickerForm("Time", timePicker(true, 9, 25)), + f -> press(f, "25"))); + v.add(cropped("picker-material-time-24h", () -> pickerForm("Time", timePicker(false, 17, 25)), null)); + v.add(cropped("picker-material-time-input", () -> { + MaterialTimePicker p = timePicker(true, 9, 25); + p.setInputMode(true); + return pickerForm("Time", p); + }, null)); + GuideFigure dateLook = new Fig("picker-material-date-look", () -> pickerForm("Date", datePicker()), null, + false, false); + v.add(new FigureVariant(dateLook, FigureDevice.ANDROID, true, "picker-material-date-dark.png")); + v.add(new FigureVariant(dateLook, FigureDevice.IOS, false, "picker-material-date-ios.png")); + GuideFigure timeLook = new Fig("picker-material-time-look", () -> pickerForm("Time", timePicker(true, 9, 25)), + null, false, false); + v.add(new FigureVariant(timeLook, FigureDevice.ANDROID, true, "picker-material-time-dark.png")); + v.add(new FigureVariant(new Fig("picker-material-date-css", () -> { + overlay("PickerMaterial"); + return pickerForm("Date", datePicker()); + }, null, false, true), FigureDevice.ANDROID, false, "picker-material-date-css.png")); + v.add(new FigureVariant(new Fig("picker-material-time-css", () -> { + overlay("PickerMaterial"); + return pickerForm("Time", timePicker(true, 9, 25)); + }, null, false, true), FigureDevice.ANDROID, false, "picker-material-time-css.png")); + PickerStages hand = new PickerStages(); + v.add(new FigureVariant(new Fig("picker-material-hand-stages", hand::buildTime, hand::sweep, false, false), + FigureDevice.ANDROID, false, true, "picker-material-hand-stages.png")); + PickerStages slide = new PickerStages(); + v.add(new FigureVariant(new Fig("picker-material-month-stages", slide::buildDate, slide::slide, false, + false), FigureDevice.ANDROID, false, true, "picker-material-month-stages.png")); + return v.toArray(new FigureVariant[0]); + } + + // ------------------------------------------------------------ the data + + private static ZoneId zone() { + return ZoneId.of("UTC"); + } + + private static long at(int day, int hour, int minute) { + return TimeMath.at(MONDAY + day, hour * 60 + minute, zone()); + } + + private static long allDay(int day) { + return TimeMath.startOfEpochDay(MONDAY + day, zone()); + } + + /// Three people's month: a full Monday with two meetings at once, a band + /// of working time, a blocked hour, days off and something in every week. + private static DefaultScheduleModel team() { + DefaultScheduleModel m = new DefaultScheduleModel(); + m.addResource(new ScheduleResource("ana", "Ana").setDetail("Design")); + m.addResource(new ScheduleResource("ben", "Ben").setDetail("Build")); + m.addResource(new ScheduleResource("cy", "Cy").setDetail("Test")); + m.addItem(new ScheduleItem("shift", "", at(0, 8, 0), at(0, 16, 0)) + .setResourceId("ana").setKind(ScheduleItem.Kind.BACKGROUND)); + m.addItem(new ScheduleItem("standup", "Stand-up", at(0, 9, 0), at(0, 9, 30)).setResourceId("ana")); + m.addItem(new ScheduleItem("review", "Design review", at(0, 10, 0), at(0, 11, 30)) + .setResourceId("ana").setDetail("Room 2")); + m.addItem(new ScheduleItem("pairing", "Pairing", at(0, 10, 30), at(0, 12, 0)).setResourceId("ben")); + m.addItem(new ScheduleItem("busy", "Busy", at(0, 13, 0), at(0, 14, 0)) + .setResourceId("cy").setKind(ScheduleItem.Kind.BLOCKED)); + m.addItem(new ScheduleItem("demo", "Demo", at(0, 14, 30), at(0, 15, 30)).setResourceId("ben")); + m.addItem(new ScheduleItem("planning", "Planning", at(1, 9, 0), at(1, 10, 30)).setResourceId("ana")); + m.addItem(new ScheduleItem("release", "Release", at(1, 11, 0), at(1, 12, 0)).setResourceId("ben")); + m.addItem(new ScheduleItem("workshop", "Workshop", at(2, 9, 0), at(2, 12, 30)).setResourceId("cy")); + m.addItem(new ScheduleItem("sync", "Sync", at(2, 14, 0), at(2, 14, 30)).setResourceId("ana")); + m.addItem(new ScheduleItem("interview", "Interview", at(3, 10, 0), at(3, 11, 0)).setResourceId("ben")); + m.addItem(new ScheduleItem("retro", "Retrospective", at(3, 14, 0), at(3, 15, 0)).setResourceId("ana")); + m.addItem(new ScheduleItem("offsite", "Offsite", allDay(4), allDay(5)).setAllDay(true).setResourceId("ben")); + m.addItem(new ScheduleItem("kickoff", "Kick-off", at(7, 10, 0), at(7, 11, 0)).setResourceId("ana")); + m.addItem(new ScheduleItem("audit", "Audit", at(9, 13, 0), at(9, 15, 0)).setResourceId("cy")); + m.addItem(new ScheduleItem("conference", "Conference", allDay(10), allDay(12)).setAllDay(true) + .setResourceId("cy")); + m.addItem(new ScheduleItem("launch", "Launch", at(15, 9, 0), at(15, 10, 0)).setResourceId("ben")); + m.addItem(new ScheduleItem("budget", "Budget", at(-5, 11, 0), at(-5, 12, 0)).setResourceId("ana")); + m.addItem(new ScheduleItem("training", "Training", at(-4, 9, 0), at(-4, 12, 0)).setResourceId("cy")); + return m; + } + + private static Scheduler scheduler(DefaultScheduleModel model, Scheduler.View view) { + Scheduler s = new Scheduler(model); + s.setZone(zone()); + s.setWorkCalendar(new WorkCalendar(zone())); + s.set24Hours(true); + s.setCurrentTime(at(0, 10, 10)); + s.setInitialMinute(8 * 60); + s.setView(view); + s.setDate(at(0, 0, 0)); + return s; + } + + private static Form schedulerForm(Scheduler.View view) { + return fill("Schedule", scheduler(team(), view)); + } + + private static Form fill(String title, Component c) { + Form f = new Form(title, new BorderLayout()); + f.add(BorderLayout.CENTER, c); + return f; + } + + private static Form styledScheduler() { + overlay("Scheduler", "TentativeBooking"); + DefaultScheduleModel m = team(); + m.getItem("pairing").setUIID("TentativeBooking"); + m.getItem("demo").setUIID("TentativeBooking"); + return fill("Schedule", scheduler(m, Scheduler.View.DAY)); + } + + /// A colour for each person, and one item with a colour of its own. + private static Form coloredScheduler() { + DefaultScheduleModel m = team(); + m.getResources().get(0).setColor(0x7c3aed); + m.getResources().get(1).setColor(0x0369a1); + m.getResources().get(2).setColor(0xb45309); + m.getItem("release").setColor(0x15803d); + return fill("Schedule", scheduler(m, Scheduler.View.WEEK)); + } + + private static Form renderedScheduler() { + DefaultScheduleModel m = team(); + m.getResources().get(1).setColor(0x0369a1); + Scheduler s = scheduler(m, Scheduler.View.DAY); + s.setItemRenderer(new CardItemRenderer()); + return fill("Schedule", s); + } + + /// A small project: a summary over three linked tasks, a task the same + /// person is double booked on, testing and a milestone at the end. + private static ProjectModel project() { + WorkCalendar calendar = new WorkCalendar(zone()); + ProjectModel p = new ProjectModel(calendar, at(0, 0, 0)); + p.addResource(new ScheduleResource("dev", "Developer")); + p.addResource(new ScheduleResource("qa", "Tester")); + long day = calendar.getMillisPerDay(); + GanttTask build = new GanttTask("build", "Build"); + GanttTask design = new GanttTask("design", "Design", 2 * day).assign("dev").setPercentComplete(100); + GanttTask api = new GanttTask("api", "API", 3 * day).assign("dev").setPercentComplete(60); + GanttTask screens = new GanttTask("screens", "Screens", 4 * day).assign("dev"); + GanttTask docs = new GanttTask("docs", "Docs", 2 * day).assign("dev"); + GanttTask testing = new GanttTask("test", "Testing", 2 * day).assign("qa"); + GanttTask release = new GanttTask("release", "Release", 0); + p.addTask(build); + p.addTask(build, design); + p.addTask(build, api); + p.addTask(build, screens); + p.addTask(docs); + p.addTask(testing); + p.addTask(release); + p.link(design, api); + p.link(api, screens); + p.link(screens, testing); + p.link(testing, release, TaskDependency.Type.FINISH_TO_START, 0); + p.update(); + return p; + } + + /// The project with a plan kept and the API then taking two days more. + private static ProjectModel slipped() { + ProjectModel p = project(); + p.captureBaseline(); + GanttTask api = p.getTask("api"); + api.setDuration(api.getDuration() + 2 * p.getCalendar().getMillisPerDay()); + p.update(); + return p; + } + + private static Gantt gantt(ProjectModel p, boolean critical, boolean baseline) { + Gantt g = new Gantt(p); + g.set24Hours(true); + g.setCurrentTime(at(2, 10, 0)); + g.setShowCriticalPath(critical); + g.setShowBaseline(baseline); + return g; + } + + private static Form ganttForm(Gantt g) { + return fill("Plan", g); + } + + private static Form collapsedGantt() { + ProjectModel p = project(); + Gantt g = gantt(p, false, false); + g.setCollapsed(p.getTask("build"), true); + return ganttForm(g); + } + + private static Form loadForm(boolean leveled) { + ProjectModel p = project(); + Gantt g = gantt(p, false, false); + g.setLoadVisible(true); + g.setColumns(); + if (leveled) { + g.level().apply(); + } + return ganttForm(g); + } + + /// Zooms the chart of a form until the whole project is in view, which + /// can only be asked once the chart has a width. + private static void fitChart(Form form) { + Gantt g = chartIn(form); + if (g != null) { + g.zoomToFit(); + } + } + + private static Gantt chartIn(Container root) { + for (int i = 0; i < root.getComponentCount(); i++) { + Component c = root.getComponentAt(i); + if (c instanceof Gantt) { + return (Gantt) c; + } + if (c instanceof Container) { + Gantt g = chartIn((Container) c); + if (g != null) { + return g; + } + } + } + return null; + } + + private static Form styledGantt() { + overlay("Gantt"); + return ganttForm(gantt(slipped(), true, false)); + } + + private static MaterialDatePicker datePicker() { + MaterialDatePicker p = new MaterialDatePicker(); + p.setDate(new Date(PICKER_DAY)); + return p; + } + + private static MaterialTimePicker timePicker(boolean meridiem, int hour, int minute) { + MaterialTimePicker p = new MaterialTimePicker(); + // Said outright, so the figure does not follow the host's setting. + p.setShowMeridiem(meridiem); + p.setTime(hour, minute); + return p; + } + + private static Form pickerForm(String title, Component picker) { + Form f = new Form(title, new BorderLayout()); + f.add(BorderLayout.NORTH, picker); + return f; + } + + // ------------------------------------------------------------- styling + + /// Lays the rules the guide's own stylesheet has for some UIIDs over the + /// installed theme, so a styled figure is drawn by the CSS the chapter + /// prints and not by a copy of its colours. + private static void overlay(String... prefixes) { + try { + Resources res = Resources.open("/guide-snippets-theme.res"); + Hashtable theme = res.getTheme(res.getThemeResourceNames()[0]); + Hashtable rules = new Hashtable(); + for (Object k : theme.keySet()) { + String key = (String) k; + for (String prefix : prefixes) { + if (key.startsWith(prefix)) { + rules.put(key, theme.get(k)); + } + } + } + if (rules.isEmpty()) { + throw new IllegalStateException("the guide's stylesheet has no rule for " + prefixes[0]); + } + UIManager.getInstance().addThemeProps(rules); + } catch (IOException err) { + throw new IllegalStateException("the guide's stylesheet did not load", err); + } + } + + // --------------------------------------------------------- the pointer + + /// Where on screen something is, found by asking the components the way + /// a mouse would: a hover over every few pixels, and the tooltip or the + /// cursor the component answers with. + private static final class Hit { + private Component c; + private int x0 = Integer.MAX_VALUE; + private int y0 = Integer.MAX_VALUE; + private int x1 = -1; + private int y1 = -1; + + boolean found() { + return c != null; + } + + int cx() { + return (x0 + x1) / 2; + } + + int cy() { + return (y0 + y1) / 2; + } + + int width() { + return x1 - x0; + } + + int height() { + return y1 - y0; + } + } + + private interface Match { + boolean is(Component c); + } + + private static Hit find(Container root, Rectangle area, Match match) { + Hit hit = new Hit(); + List touched = new ArrayList(); + int[] px = new int[1]; + int[] py = new int[1]; + for (int y = area.getY(); y < area.getY() + area.getHeight(); y += 2) { + for (int x = area.getX(); x < area.getX() + area.getWidth(); x += 2) { + Component c = root.getComponentAt(x, y); + if (c == null || c instanceof Container) { + continue; + } + px[0] = x; + py[0] = y; + c.pointerHover(px, py); + if (!touched.contains(c)) { + touched.add(c); + } + if (match.is(c)) { + hit.c = c; + hit.x0 = Math.min(hit.x0, x); + hit.y0 = Math.min(hit.y0, y); + hit.x1 = Math.max(hit.x1, x); + hit.y1 = Math.max(hit.y1, y); + } + } + } + for (Component c : touched) { + c.setHovered(false); + } + return hit; + } + + private static Rectangle bounds(Component c) { + return new Rectangle(c.getAbsoluteX(), c.getAbsoluteY(), c.getWidth(), c.getHeight()); + } + + /// The part of a component whose tooltip starts with a text: an item of + /// a schedule or the bar of a task, by its name. + private static Hit named(Container root, String name) { + Hit hit = find(root, bounds(root), c -> c.getTooltip() != null && c.getTooltip().startsWith(name)); + if (!hit.found()) { + throw new IllegalStateException("nothing on screen answers to " + name); + } + return hit; + } + + /// The part of an area over which a component asks for a cursor. + private static Hit cursor(Container root, Rectangle area, int cursor) { + Hit hit = find(root, area, c -> c.getCursor() == cursor); + if (!hit.found()) { + throw new IllegalStateException("nothing on screen asks for cursor " + cursor); + } + return hit; + } + + private static void hover(Hit hit, int x, int y) { + hit.c.pointerHover(new int[]{x}, new int[]{y}); + } + + private static void pull(Hit hit, int fromX, int fromY, int toX, int toY, boolean release) { + hit.c.pointerPressed(fromX, fromY); + for (int i = 1; i <= 6; i++) { + hit.c.pointerDragged(fromX + (toX - fromX) * i / 6, fromY + (toY - fromY) * i / 6); + } + if (release) { + hit.c.pointerReleased(toX, toY); + } + } + + /// Presses the button of a form that shows a text. + private static void press(Form form, String text) { + Button b = button(form, text); + if (b == null) { + throw new IllegalStateException("no button shows " + text); + } + b.pressed(); + b.released(); + } + + private static Button button(Container root, String text) { + for (int i = 0; i < root.getComponentCount(); i++) { + Component c = root.getComponentAt(i); + if (c instanceof Button && text.equals(((Button) c).getText())) { + return (Button) c; + } + if (c instanceof Container) { + Button b = button((Container) c, text); + if (b != null) { + return b; + } + } + } + return null; + } + + /// The buttons under a container that have a UIID, in the order they + /// were added. + private static void buttons(Container root, String uiid, List