diff --git a/.github/workflows/mavenCi.yml b/.github/workflows/mavenCi.yml
index e3d7aace..24f2194a 100644
--- a/.github/workflows/mavenCi.yml
+++ b/.github/workflows/mavenCi.yml
@@ -44,12 +44,6 @@ jobs:
name: KeepTime-${{ env.version }}
path: /home/runner/work/KeepTime/KeepTime/target/*-bin.zip
- - name: Analyze
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- run: mvn -V -B sonar:sonar -Dsonar.host.url=${{ secrets.HOST_URL }} -Dsonar.organization=${{ secrets.ORGANIZATION_NAME }} -Dsonar.projectKey=${{ secrets.PROJECT_KEY }} -Dsonar.java.binaries=. -Dsonar.qualitygate.wait=false
-
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
diff --git a/README.md b/README.md
index 89fccd79..9b2483a1 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,8 @@ Application to track your time spent on different projects each day. Aim was to
Create projects and choose if they are counted as 'work time'. Select the project you work on. Before you switch the project, write a comment on what u did. Change the project. Repeat.
+KeepTime can connect to [Heimat](https://heimat-software.com/) so you can import Heimat projects and sync your tracked time back to Heimat. Details: [heimat.md](readme/heimat.md).
+
## Usage
### Main view:
@@ -29,9 +31,25 @@ Create projects and choose if they are counted as 'work time'. Select the projec
+ Export: export database for backup and later import (import currently not yet implemented)
### Reports:
-
-+ the report screen gives you a summary for every day
+KeepTime’s reporting workflow works by tracking daily work and synchronizing project activities with external task management. Here’s how you use it:
+
+1. **Track Your Work**
+ Throughout your workday, log activities and assign them to projects. Each entry includes a timeslot, duration, and optional notes.
+
+2. **Review Your Daily Report**
+ At any time, open the report view to see an overview of your day, including all logged activities, their durations, and project associations.
+
+3. **Edit and Manage Entries**
+ Use the controls in the report to copy, edit, or delete individual work entries as needed. You can also copy notes or summaries for external use.
+
+4. **Select the Day to View**
+ Use the calendar widget to select which day’s report you want to review. Only days with recorded work are available.
+
+5. **Synchronize Projects with Heimat**
+ If you want you can syncronize the current date with Heimat. See [heimat.md](readme/heimat.md)
+
+
## Install
@@ -73,6 +91,6 @@ You should put the .jar in an extra folder as a *logs* and a *db* folder will be
## Requirements
* Operating System
* Windows 7, 10, 11
- * Linux (tested on Ubuntu 18.04)
+ * Linux (tested on Ubuntu 18.04, Fedora Workstation 43)
* Mac (tested on MacBook M2 Pro (ARM based CPU))
* Java >= 17
diff --git a/keeptime.bat b/keeptime.bat
index f557fc3c..f1e9ef14 100644
--- a/keeptime.bat
+++ b/keeptime.bat
@@ -1 +1,27 @@
-start "" "javaw" -Dprism.order=sw -jar keeptime.jar
\ No newline at end of file
+@echo off
+setlocal
+
+cd /d "%~dp0"
+
+where javaw >nul 2>&1
+if errorlevel 1 (
+ echo ERROR: Java was not found.
+ echo.
+ echo Please install Java.
+ echo See installation requirements: https://github.com/doubleSlashde/KeepTime#requirements
+ echo.
+ pause
+ exit /b 1
+)
+
+if not exist "keeptime.jar" (
+ echo ERROR: keeptime.jar was not found in this folder.
+ echo.
+ echo Please download and extract KeepTime from the releases page.
+ echo See installation instructions: https://github.com/doubleSlashde/KeepTime#install
+ echo.
+ pause
+ exit /b 1
+)
+
+start "" "javaw" -Dprism.order=sw -jar keeptime.jar
diff --git a/pom.xml b/pom.xml
index dd649d92..cb50b6a2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
org.springframework.boot
spring-boot-starter-parent
- 3.4.4
+ 3.5.14
@@ -39,7 +39,7 @@
17
22
- 12.0.1
+ 12.2.2
ALL
true
@@ -103,7 +103,18 @@
org.springdoc
springdoc-openapi-starter-webmvc-ui
- 2.8.5
+ 2.8.17
+
+
+
+ org.apache.tomcat.embed
+ tomcat-embed-core
+ 10.1.55
+
+
+ org.apache.tomcat.embed
+ tomcat-embed-websocket
+ 10.1.55
org.springframework.boot
@@ -147,14 +158,9 @@
org.apache.maven.plugins
maven-assembly-plugin
- 3.7.1
+ 3.8.0
maven-plugin
-
- org.sonarsource.scanner.maven
- sonar-maven-plugin
- 3.11.0.3922
-
org.hamcrest
hamcrest-library
@@ -165,7 +171,7 @@
com.fasterxml.jackson.datatype
jackson-datatype-jsr310
- 2.18.2
+ 2.22.0
@@ -208,7 +214,7 @@
org.apache.maven.plugins
maven-site-plugin
- 3.12.1
+ 3.22.0
@@ -267,13 +273,13 @@
org.apache.maven.plugins
maven-compiler-plugin
- 3.11.0
+ 3.15.0
org.mapstruct
mapstruct-processor
- 1.5.5.Final
+ 1.6.3
@@ -281,7 +287,7 @@
org.jacoco
jacoco-maven-plugin
- 0.8.10
+ 0.8.14
prepare-agent
diff --git a/readme/heimat.md b/readme/heimat.md
new file mode 100644
index 00000000..6639205c
--- /dev/null
+++ b/readme/heimat.md
@@ -0,0 +1,53 @@
+# Heimat integration
+
+[Heimat](https://heimat-software.com/) is external project and time-tracking software. KeepTime can connect to it so you can:
+
+- **Import** projects from Heimat into KeepTime, or **link** Heimat projects to projects you already have in KeepTime.
+- **Push** the time you logged in KeepTime for a given day into Heimat when you choose to sync.
+
+**Important:** KeepTime never writes to Heimat on its own. Data is sent to Heimat only when you run the sync from the report view.
+
+---
+
+## 1. Connect KeepTime to Heimat
+
+1. Open **Settings** and go to the **HEIMAT** section.
+
+ 
+
+2. Fill in:
+
+ | Field | What to enter |
+ | ---------------- | -------------------------------------------------------------------------------------------------------------------- |
+ | **URL** | Your Heimat instance URL (the same base URL you use in the browser), for example `https://your-company.example.com`. |
+ | **Access token** | Create or copy a token in Heimat (under your account or API settings) and paste it here. |
+
+ 
+
+3. Click **Validate connection**. If it succeeds, the integration is ready to use.
+
+---
+
+## 2. Match Heimat projects to KeepTime
+
+After a successful connection, use **Map projects** (button in Heimat settings dialog) to either:
+
+- Map each Heimat project to an existing KeepTime project, or
+- Import Heimat projects as new KeepTime projects.
+
+Do this once (or whenever your project lists change) so KeepTime knows where each Heimat project should go.
+
+---
+
+## 3. Sync a day’s work to Heimat
+
+When you want to upload time for the **currently selected day** in the report:
+
+1. Open the **report** view for that day.
+2. Click the **sync** button (Heimat / external sync).
+3. In the dialog, choose which projects to include and add any **note** you want stored with the sync.
+4. Click **Sync** to send that day’s tracked time to Heimat.
+
+
+
+Until you complete step 4, nothing is written to Heimat.
diff --git a/readme/images/externalProjectDialog.png b/readme/images/externalProjectDialog.png
new file mode 100644
index 00000000..77c28fb8
Binary files /dev/null and b/readme/images/externalProjectDialog.png differ
diff --git a/readme/images/heimatWebsite.png b/readme/images/heimatWebsite.png
new file mode 100644
index 00000000..45ed9a5d
Binary files /dev/null and b/readme/images/heimatWebsite.png differ
diff --git a/readme/images/reportDescription.png b/readme/images/reportDescription.png
index bda4588a..8bb86ac3 100644
Binary files a/readme/images/reportDescription.png and b/readme/images/reportDescription.png differ
diff --git a/readme/images/settingsHeimat.png b/readme/images/settingsHeimat.png
new file mode 100644
index 00000000..a0bee6de
Binary files /dev/null and b/readme/images/settingsHeimat.png differ
diff --git a/src/main/java/de/doubleslash/keeptime/App.java b/src/main/java/de/doubleslash/keeptime/App.java
index 2c3ff0a3..fc48185c 100644
--- a/src/main/java/de/doubleslash/keeptime/App.java
+++ b/src/main/java/de/doubleslash/keeptime/App.java
@@ -21,6 +21,7 @@
import java.io.StringWriter;
import java.time.LocalDate;
import java.util.List;
+import java.util.Locale;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -84,6 +85,9 @@ public class App extends Application {
@Override
public void init() throws Exception {
LOG.info("Starting KeepTime.");
+
+ setLocaleToEnglish();
+
final DefaultExceptionHandler defaultExceptionHandler = new DefaultExceptionHandler();
defaultExceptionHandler.register();
@@ -102,6 +106,22 @@ public void init() throws Exception {
model.setSpringContext(springContext);
}
+ private void setLocaleToEnglish() {
+ final Locale systemDefaultLocale = Locale.getDefault();
+ final Locale wantedApplicationLocale = Locale.ENGLISH;
+
+ if (systemDefaultLocale.getLanguage().equals(wantedApplicationLocale.getLanguage())) {
+ LOG.debug("Application locale already is '{}'. Nothing to do.", wantedApplicationLocale);
+ return;
+ }
+
+ LOG.info("Setting application locale to '{}', was '{}'.", wantedApplicationLocale, systemDefaultLocale);
+ Locale.setDefault(wantedApplicationLocale);
+ Locale.setDefault(Locale.Category.DISPLAY, wantedApplicationLocale);
+ // keep system locale for format conversions (date, currency, numbers)
+ Locale.setDefault(Locale.Category.FORMAT, systemDefaultLocale);
+ }
+
@Override
public void start(final Stage primaryStage) {
LOG.info("Initialising the UI");
@@ -117,12 +137,13 @@ public void start(final Stage primaryStage) {
}
}
- public static void showErrorDialogAndWait(String title, String header, String content, final Exception e, Window window) {
+ public static void showErrorDialogAndWait(String title, String header, String content, final Exception e,
+ Window window) {
final Alert alert = new Alert(AlertType.ERROR);
alert.setTitle(title);
alert.setHeaderText(header);
alert.setContentText(content);
- if(window != null) {
+ if (window != null) {
alert.initOwner(window);
}
final StringWriter sw = new StringWriter();
diff --git a/src/main/java/de/doubleslash/keeptime/common/FileOpenHelper.java b/src/main/java/de/doubleslash/keeptime/common/FileOpenHelper.java
index 03406f2b..a3613d48 100644
--- a/src/main/java/de/doubleslash/keeptime/common/FileOpenHelper.java
+++ b/src/main/java/de/doubleslash/keeptime/common/FileOpenHelper.java
@@ -32,7 +32,7 @@ public static boolean openFile(final String filePath) {
final File file = new File(filePath);
final Runtime rt = Runtime.getRuntime();
- if (!file.exists() || file.isFile()) {
+ if (!file.exists() || !file.isFile()) {
LOG.warn("Filepath does not seem to exist or does not point to a file: '{}'.", filePath);
return false;
}
diff --git a/src/main/java/de/doubleslash/keeptime/common/Resources.java b/src/main/java/de/doubleslash/keeptime/common/Resources.java
index da245988..1724d111 100644
--- a/src/main/java/de/doubleslash/keeptime/common/Resources.java
+++ b/src/main/java/de/doubleslash/keeptime/common/Resources.java
@@ -81,6 +81,8 @@ public enum RESOURCE {
SVG_ROTATE_ICON("/svgs/rotate-solid.svg"),
+ SVG_PLUS_SOLID("/svgs/plus-solid.svg"),
+
ICON_MAIN("/icons/icon.png"),
/** CSS **/
diff --git a/src/main/java/de/doubleslash/keeptime/controller/Controller.java b/src/main/java/de/doubleslash/keeptime/controller/Controller.java
index afe02d9d..c3b40add 100644
--- a/src/main/java/de/doubleslash/keeptime/controller/Controller.java
+++ b/src/main/java/de/doubleslash/keeptime/controller/Controller.java
@@ -128,6 +128,16 @@ public Project addNewProject(final Project project) {
return project;
}
+ public Work addWork(final Work work) {
+ LOG.info("Adding work '{}'", work);
+ final Work saved = model.getWorkRepository().save(work);
+ // show in report if it belongs to today
+ final LocalDate today = dateProvider.dateTimeNow().toLocalDate();
+ if (today.equals(saved.getStartTime().toLocalDate())) {
+ model.getPastWorkItems().add(saved);
+ }
+ return saved;
+ }
public void updateColorSettings(final Color hoverBackgroundColor,final Color hoverFontColor,final Color defaultBackgroundColor,final Color defaultFontColor,final Color taskBarColor) {
settings.setTaskBarColor(taskBarColor);
diff --git a/src/main/java/de/doubleslash/keeptime/controller/HeimatController.java b/src/main/java/de/doubleslash/keeptime/controller/HeimatController.java
index cd6f7aaa..eb5e6f53 100644
--- a/src/main/java/de/doubleslash/keeptime/controller/HeimatController.java
+++ b/src/main/java/de/doubleslash/keeptime/controller/HeimatController.java
@@ -16,8 +16,19 @@
package de.doubleslash.keeptime.controller;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.stream.Collectors;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
+
import de.doubleslash.keeptime.model.*;
import de.doubleslash.keeptime.model.repos.ExternalProjectsMappingsRepository;
import de.doubleslash.keeptime.model.settings.HeimatSettings;
@@ -26,15 +37,6 @@
import de.doubleslash.keeptime.rest.integration.heimat.model.HeimatTask;
import de.doubleslash.keeptime.rest.integration.heimat.model.HeimatTime;
import de.doubleslash.keeptime.view.ProjectReport;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.stereotype.Service;
-
-import java.time.LocalDate;
-import java.time.format.DateTimeFormatter;
-import java.util.*;
-import java.util.stream.Collectors;
@Service
public class HeimatController {
@@ -85,7 +87,7 @@ public void tryLogin() {
try {
heimatAPI.isLoginValid();
} catch (Exception e) {
- throw new SecurityException("Could not connect to HEIMAT API. Maybe wrong configuration?", e);
+ throw new SecurityException("Could not connect to Heimat API. Maybe wrong configuration?", e);
}
}
@@ -110,17 +112,25 @@ public List getTableRows(final LocalDate currentReportDate, final List<
String heimatNotes = "";
long heimatTimeSeconds = 0;
boolean isMappedInHeimat = false;
+ String bookingHint = "";
final Optional optHeimatMapping = mappedProjects.stream()
- .filter(mp -> mp.getProject().getId()
- == project.getId())
+ .filter(
+ mp -> mp.getProject()
+ .getId() == project.getId())
.findAny();
List optionalAlreadyBookedTimes = new ArrayList<>();
Optional optionalExistingMapping = Optional.empty();
if (optHeimatMapping.isPresent()) {
isMappedInHeimat = true;
+ bookingHint = heimatTasks.stream()
+ .filter(ht -> ht.id() == optHeimatMapping.get().getExternalTaskId())
+ .map(HeimatTask::bookingHint)
+ .findAny()
+ .orElseGet(String::new);
optionalExistingMapping = list.stream()
- .filter(mapping -> mapping.heimatTaskId == optHeimatMapping.get()
- .getExternalTaskId())
+ .filter(
+ mapping -> mapping.heimatTaskId == optHeimatMapping.get()
+ .getExternalTaskId())
.findAny();
final List heimatTimesForTaskId = taskIdToHeimatTimesMap.get(
@@ -145,16 +155,20 @@ public List getTableRows(final LocalDate currentReportDate, final List<
pr.appendToWorkNotes(currentWorkNote);
}
final String keeptimeNotes = pr.getNotes();
- String canBeSyncedMessage;
+ StyledMessage canBeSyncedMessage;
+
if (!isMappedInHeimat) {
- canBeSyncedMessage = "Not mapped to Heimat task.\nMap in settings dialog.";
+ canBeSyncedMessage = StyledMessage.of(
+ new StyledMessage.TextSegment("Not mapped to Heimat task.\nMap in settings dialog."));
} else if (heimatTasks.stream().noneMatch(ht -> ht.id() == optHeimatMapping.get().getExternalTaskId())) {
- canBeSyncedMessage = "Heimat Task is not available (anymore).\nPlease check mappings in settings dialog.";
+ canBeSyncedMessage = StyledMessage.of(new StyledMessage.TextSegment(
+ "Heimat Task is not available (anymore).\nPlease check mappings in settings dialog."));
isMappedInHeimat = false;
} else {
final ExternalProjectMapping externalProjectMapping = optHeimatMapping.get();
- canBeSyncedMessage = "Sync to " + externalProjectMapping.getExternalTaskName() + "\n("
- + externalProjectMapping.getExternalProjectName() + ")";
+ canBeSyncedMessage = StyledMessage.of(new StyledMessage.TextSegment("Sync to "),
+ new StyledMessage.TextSegment(externalProjectMapping.getExternalTaskName(), true),
+ new StyledMessage.TextSegment("\n(" + externalProjectMapping.getExternalProjectName() + ")"));
}
if (optionalExistingMapping.isPresent()) {
@@ -163,21 +177,21 @@ public List getTableRows(final LocalDate currentReportDate, final List<
projects.add(project);
final long keepTimeSeconds = existingMapping.keeptimeSeconds() + projectWorkSeconds;
final long heimatSeconds = existingMapping.heimatSeconds();
- final boolean shouldBeSynced =
- isMappedInHeimat && differenceGreaterOrEqual15Minutes(heimatSeconds, keepTimeSeconds);
+ final boolean shouldBeSynced = isMappedInHeimat
+ && differenceGreaterOrEqual15Minutes(heimatSeconds, keepTimeSeconds);
final Mapping mapping = new Mapping(isMappedInHeimat ? optHeimatMapping.get().getExternalTaskId() : -1,
- isMappedInHeimat, shouldBeSynced, canBeSyncedMessage, existingMapping.existingTimes(), projects,
- existingMapping.heimatNotes(), existingMapping.keeptimeNotes() + ". " + keeptimeNotes, heimatSeconds,
- keepTimeSeconds);
+ isMappedInHeimat, shouldBeSynced, canBeSyncedMessage, bookingHint, existingMapping.existingTimes(),
+ projects, existingMapping.heimatNotes(), existingMapping.keeptimeNotes() + ". " + keeptimeNotes,
+ heimatSeconds, keepTimeSeconds);
list.remove(existingMapping);
list.add(mapping);
} else {
- final boolean shouldBeSynced =
- isMappedInHeimat && differenceGreaterOrEqual15Minutes(heimatTimeSeconds, projectWorkSeconds);
+ final boolean shouldBeSynced = isMappedInHeimat
+ && differenceGreaterOrEqual15Minutes(heimatTimeSeconds, projectWorkSeconds);
final List projects = Collections.singletonList(project);
final Mapping mapping = new Mapping(isMappedInHeimat ? optHeimatMapping.get().getExternalTaskId() : -1,
- isMappedInHeimat, shouldBeSynced, canBeSyncedMessage, optionalAlreadyBookedTimes, projects,
- heimatNotes, keeptimeNotes, heimatTimeSeconds, projectWorkSeconds);
+ isMappedInHeimat, shouldBeSynced, canBeSyncedMessage, bookingHint, optionalAlreadyBookedTimes,
+ projects, heimatNotes, keeptimeNotes, heimatTimeSeconds, projectWorkSeconds);
list.add(mapping);
}
}
@@ -193,42 +207,52 @@ public List getTableRows(final LocalDate currentReportDate, final List<
long heimatTimeSeconds = times.stream()
.reduce(0L, (subtotal, element) -> subtotal + element.durationInMinutes() * 60L,
Long::sum);
- final HeimatTask heimatTask = heimatTasks.stream()
- .filter(t -> t.id() == times.get(0).taskId())
- .findAny()
- .orElseThrow();
- final Mapping mapping = new Mapping(id, true, false,
- "Not mapped in KeepTime\n\n" + heimatTask.name() + "\n" + heimatTask.taskHolderName(), times,
- new ArrayList<>(0), heimatNotes, "", heimatTimeSeconds, 0);
+
+ final Optional optionalHeimatTask = heimatTasks.stream().filter(t -> t.id() == id).findAny();
+ String taskName = "Cannot resolve Heimat Task Id: " + id + " to name\nPlease check in Heimat";
+ if (optionalHeimatTask.isPresent()) {
+ final HeimatTask heimatTask = optionalHeimatTask.get();
+ taskName = heimatTask.name() + "\n" + heimatTask.taskHolderName();
+ }
+ StyledMessage syncMessage = StyledMessage.of(new StyledMessage.TextSegment("Not mapped in KeepTime\n\n"),
+ new StyledMessage.TextSegment(taskName, true));
+ final Mapping mapping = new Mapping(id, true, false, syncMessage, "", times, new ArrayList<>(0), heimatNotes,
+ "", heimatTimeSeconds, 0);
list.add(mapping);
});
taskIdToHeimatTimesMap.forEach((id, times) -> {
- final Optional mapping = mappedProjects.stream()
- .filter(mp -> mp.getExternalTaskId() == id)
- .findAny();
- if (mapping.isEmpty())
+ final List mappings = mappedProjects.stream()
+ .filter(mp -> mp.getExternalTaskId() == id)
+ .toList();
+ if (mappings.isEmpty())
return;
- final ExternalProjectMapping externalProjectMapping = mapping.get();
- final Optional optionalProject = workedProjectsSet.stream()
- .filter(wp -> wp.getId()
- == externalProjectMapping.getProject()
- .getId())
- .findAny();
- if (optionalProject.isPresent()) {
+
+ Optional anyMatch = mappings.stream().filter(externalProjectMapping -> {
+ final List optionalProject = workedProjectsSet.stream()
+ .filter(
+ wp -> wp.getId() == externalProjectMapping.getProject()
+ .getId())
+ .toList();
+ return !optionalProject.isEmpty();
+ }).findAny();
+
+ if (anyMatch.isPresent()) {
return;
}
String heimatNotes = addHeimatNotes(times);
long heimatTimeSeconds = addHeimatTimes(times);
- final Mapping mapping2 = new Mapping(id, true, false,
- "Present in HEIMAT but not KeepTime\n\nSync to " + externalProjectMapping.getExternalTaskName() + "\n("
- + externalProjectMapping.getExternalProjectName() + ")", times, mappedProjects.stream()
- .filter(
- mp -> mp.getExternalTaskId()
- == id)
- .map(ExternalProjectMapping::getProject)
- .toList(),
+ StyledMessage syncMessage = StyledMessage.of(
+ new StyledMessage.TextSegment("Present in Heimat but not KeepTime\n\nSync to "),
+ new StyledMessage.TextSegment(mappings.get(0).getExternalTaskName(), true),
+ new StyledMessage.TextSegment("\n(" + mappings.get(0).getExternalProjectName() + ")"));
+
+ final Mapping mapping2 = new Mapping(id, true, false, syncMessage, "", times,
+ mappedProjects.stream()
+ .filter(mp -> mp.getExternalTaskId() == id)
+ .map(ExternalProjectMapping::getProject)
+ .toList(),
heimatNotes, "", heimatTimeSeconds, 0);
list.add(mapping2);
});
@@ -280,8 +304,8 @@ public List saveDay(final List items, LocalDate date)
}
public String getUrlForDay(final LocalDate currentReportDate) {
- return heimatSettings.getHeimatUrl() + "/core/heimat/time/main/day/" + currentReportDate.format(
- DateTimeFormatter.ofPattern("yyyy/M/d"));
+ return heimatSettings.getHeimatUrl() + "/core/heimat/time/main/day/"
+ + currentReportDate.format(DateTimeFormatter.ofPattern("yyyy/M/d"));
}
public List getTasks(final LocalDate forDate) {
@@ -309,15 +333,13 @@ public void updateMappings(final List newMappings) {
final Optional any = alreadyMappedProjects.stream()
.filter(
pm -> pm.getProject()
- .getId()
- == projectMapping.getProject()
- .getId())
+ .getId() == projectMapping.getProject()
+ .getId())
.findAny();
final HeimatTask heimatTask = projectMapping.getHeimatTask();
if (any.isPresent()) {
final ExternalProjectMapping projectMapping1 = any.get();
- if (projectMapping1.getExternalTaskId()
- == heimatTask.id()) {
+ if (projectMapping1.getExternalTaskId() == heimatTask.id()) {
// mapping did not change
return null;
}
@@ -347,16 +369,13 @@ public void updateMappings(final List newMappings) {
// remove mappings which were removed also from database
final ArrayList mappingsToRemove = alreadyMappedProjects.stream()
- .filter(em -> newMappings.stream()
- .anyMatch(
- wantedMapping ->
- wantedMapping.getProject()
- .getId()
- == em.getProject()
- .getId()
- &&
- wantedMapping.getHeimatTask()
- == null))
+ .filter(
+ em -> newMappings.stream()
+ .anyMatch(
+ wantedMapping -> wantedMapping.getProject()
+ .getId() == em.getProject()
+ .getId()
+ && wantedMapping.getHeimatTask() == null))
.collect(Collectors.toCollection(
ArrayList::new));
// remove mappings of projects which were 'deleted'
@@ -380,60 +399,73 @@ public ExistingAndInvalidMappings getExistingProjectMappings(List ex
final List validProjectMappings = model.getSortedAvailableProjects().stream().map(p -> {
final Optional mapping = alreadyMappedProjects.stream()
- .filter(mp -> mp.getProject().getId()
- == p.getId())
+ .filter(mp -> mp.getProject()
+ .getId() == p.getId())
.findAny();
if (mapping.isEmpty()) {
- return new ProjectMapping(p, null);
+ return new ProjectMapping(p, null, false);
}
+
final Optional any = externalProjects.stream()
.filter(ep -> ep.id() == mapping.get().getExternalTaskId())
.findAny();
if (any.isEmpty()) {
- LOG.warn("A mapping exists but task does not exist anymore in HEIMAT! '{}'->'{}'.",
- mapping.get().getProject(), mapping.get().getExternalTaskId());
- invalidExternalMappings.add(mapping.get());
- return new ProjectMapping(p, null);
+ ExternalProjectMapping existingMapping = mapping.get();
+ LOG.warn("A mapping exists but task does not exist anymore in Heimat! '{}'->'{}'.",
+ existingMapping.getProject(), existingMapping.getExternalTaskId());
+ invalidExternalMappings.add(existingMapping);
+ return new ProjectMapping(p, getHeimatTaskFromMapping(existingMapping), true);
}
- return new ProjectMapping(p, any.get());
+ return new ProjectMapping(p, any.get(), false);
}).toList();
final List invalidMappingsAsString = invalidExternalMappings.stream()
.map(em -> "Task no longer exists: "
+ em.getExternalProjectName() + " - "
+ em.getExternalTaskName()
- + "'. Was mapped to '" + em.getProject()
- .getName()
- + "'.")
+ + "'. Was mapped to '"
+ + em.getProject().getName() + "'.")
.collect(Collectors.toCollection(
ArrayList::new));
/*
- // I do not have all external projects here :( only already filtered ones
- allExternalProjects.stream()
- .filter(HeimatTask::isStartAndEndTimeRequired)
- .map(p -> "Task " + p.taskHolderName() + " - " + p.name()
- + " requires start+end time which is not supported.")
- .forEach(invalidMappingsAsString::add);
- */
+ * // I do not have all external projects here :( only already filtered ones allExternalProjects.stream()
+ * .filter(HeimatTask::isStartAndEndTimeRequired) .map(p -> "Task " + p.taskHolderName() + " - " + p.name() +
+ * " requires start+end time which is not supported.") .forEach(invalidMappingsAsString::add);
+ */
return new ExistingAndInvalidMappings(validProjectMappings, invalidMappingsAsString);
}
+ private HeimatTask getHeimatTaskFromMapping(ExternalProjectMapping mapping) {
+ try {
+ String json = mapping.getExternalTaskMetadata();
+ if (json == null || json.isEmpty()) {
+ return null;
+ }
+ return objectMapper.readValue(json, HeimatTask.class);
+ } catch (Exception e) {
+ LOG.warn("Unable to deserialize HeimatTask from mapping metadata", e);
+ return null;
+ }
+ }
+
public record UserMapping(Mapping mapping, boolean shouldSync, String userNotes, int userMinutes) {}
- public record Mapping(long heimatTaskId, boolean canBeSynced, boolean shouldBeSynced, String syncMessage,
- List existingTimes, List projects, String heimatNotes,
- String keeptimeNotes, long heimatSeconds, long keeptimeSeconds) {}
+ public record Mapping(long heimatTaskId, boolean canBeSynced, boolean shouldBeSynced, StyledMessage syncMessage,
+ String bookingHint, List existingTimes, List projects, String heimatNotes,
+ String keeptimeNotes, long heimatSeconds, long keeptimeSeconds) {}
public record HeimatErrors(UserMapping mapping, String errorMessage) {}
public static class ProjectMapping {
private Project project;
private HeimatTask heimatTask;
+ private boolean pendingRemoval;
- public ProjectMapping(final Project project, final HeimatTask heimatTask) {
+ public ProjectMapping(final Project project, final HeimatTask heimatTask, boolean pendingRemoval) {
this.project = project;
this.heimatTask = heimatTask;
+ this.pendingRemoval = pendingRemoval;
}
public Project getProject() {
@@ -451,5 +483,13 @@ public HeimatTask getHeimatTask() {
public void setHeimatTask(final HeimatTask heimatTask) {
this.heimatTask = heimatTask;
}
+
+ public boolean isPendingRemoval() {
+ return pendingRemoval;
+ }
+
+ public void setPendingRemoval(boolean pendingRemoval) {
+ this.pendingRemoval = pendingRemoval;
+ }
}
}
diff --git a/src/main/java/de/doubleslash/keeptime/model/StyledMessage.java b/src/main/java/de/doubleslash/keeptime/model/StyledMessage.java
new file mode 100644
index 00000000..ac8471f6
--- /dev/null
+++ b/src/main/java/de/doubleslash/keeptime/model/StyledMessage.java
@@ -0,0 +1,70 @@
+// Copyright 2025 doubleSlash Net Business GmbH
+//
+// This file is part of KeepTime.
+// KeepTime is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program 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 for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package de.doubleslash.keeptime.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents a styled text message composed of multiple text segments. This class provides a UI-agnostic way to
+ * represent formatted text, allowing separation of business logic from UI components.
+ */
+public class StyledMessage {
+
+ /**
+ * Represents a single text segment with optional styling.
+ *
+ * @param text
+ * The text content
+ * @param bold
+ * Whether the text should be displayed in bold
+ */
+ public record TextSegment(String text, boolean bold) {
+ public TextSegment(String text) {
+ this(text, false);
+ }
+ }
+
+ private final List segments;
+
+ public StyledMessage(List segments) {
+ this.segments = new ArrayList<>(segments);
+ }
+
+ /**
+ * Creates a StyledMessage from a variable number of text segments.
+ *
+ * @param segments
+ * The text segments to include in the message
+ * @return A new StyledMessage containing the provided segments
+ */
+ public static StyledMessage of(TextSegment... segments) {
+ return new StyledMessage(List.of(segments));
+ }
+
+ public List getSegments() {
+ return new ArrayList<>(segments);
+ }
+
+ /**
+ * Returns the message as plain text without styling.
+ */
+ public String toPlainText() {
+ return segments.stream().map(TextSegment::text).reduce("", String::concat);
+ }
+}
+
diff --git a/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/JwtDecoder.java b/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/JwtDecoder.java
index c5a903d4..3eca3430 100644
--- a/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/JwtDecoder.java
+++ b/src/main/java/de/doubleslash/keeptime/rest/integration/heimat/JwtDecoder.java
@@ -30,7 +30,7 @@ public record JWTTokenAttributes(
String header,
String payload,
LocalDateTime expiration
- ) {}
+ ) { }
public static JWTTokenAttributes parse(String bearerToken) {
@@ -57,6 +57,10 @@ public static JWTTokenAttributes parse(String bearerToken) {
return new JWTTokenAttributes(header, payload, expiration);
}
+ public static boolean isExpired(JWTTokenAttributes token, LocalDateTime localDateTimeNow) {
+ return localDateTimeNow.isAfter(token.expiration);
+ }
+
private static String removeBearerPrefix(String token) {
return token.startsWith("Bearer ") ? token.substring(7) : token;
}
diff --git a/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsMapController.java b/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsMapController.java
index 53a94f64..62d6bb26 100644
--- a/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsMapController.java
+++ b/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsMapController.java
@@ -24,6 +24,11 @@
import de.doubleslash.keeptime.model.Project;
import de.doubleslash.keeptime.rest.integration.heimat.model.ExistingAndInvalidMappings;
import de.doubleslash.keeptime.rest.integration.heimat.model.HeimatTask;
+import de.doubleslash.keeptime.viewpopup.SearchCombobox;
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
import javafx.application.Platform;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
@@ -33,18 +38,28 @@
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.fxml.FXML;
-import javafx.scene.control.*;
+import javafx.scene.control.Button;
+import javafx.scene.control.ButtonBar;
+import javafx.scene.control.ButtonType;
+import javafx.scene.control.DatePicker;
+import javafx.scene.control.Dialog;
+import javafx.scene.control.Label;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.control.SelectionMode;
+import javafx.scene.control.TableCell;
+import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableView;
+import javafx.scene.control.TextField;
+import javafx.scene.control.Tooltip;
import javafx.scene.control.cell.CheckBoxTableCell;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
-import java.time.LocalDate;
-import java.util.List;
-import java.util.Optional;
-
@Component
public class ExternalProjectsMapController {
@@ -86,15 +101,27 @@ private void initialize() {
tasksForDateDatePicker.setValue(LocalDate.now());
tasksForDateDatePicker.setDisable(true);
// TODO add listener on this thing
- // but what happens with mapped projects not existing at that date? but actually not related to this feature alone
final List externalProjects = heimatController.getTasks(tasksForDateDatePicker.getValue());
- final ExistingAndInvalidMappings existingAndInvalidMappings = heimatController.getExistingProjectMappings(
- externalProjects);
- final List previousProjectMappings = existingAndInvalidMappings.validMappings();
+ final ExistingAndInvalidMappings existingAndInvalidMappings = heimatController.getExistingProjectMappings(externalProjects);
+
+ final List previousProjectMappings = existingAndInvalidMappings.validMappings();
final ObservableList newProjectMappings = FXCollections.observableArrayList(
previousProjectMappings);
+
+ Platform.runLater(() -> {
+ List warnings = existingAndInvalidMappings.invalidMappingsAsString();
+ if (!warnings.isEmpty()) {
+ if (showInvalidMappingsDialog(warnings)) {
+ newProjectMappings.stream()
+ .filter(HeimatController.ProjectMapping::isPendingRemoval)
+ .forEach(pm -> pm.setHeimatTask(null));
+ mappingTableView.refresh();
+ }
+ }
+ });
+
final FilteredList value = new FilteredList<>(newProjectMappings,
pm -> pm.getProject().isWork());
mappingTableView.setItems(value);
@@ -103,58 +130,62 @@ private void initialize() {
TableColumn keepTimeColumn = new TableColumn<>("KeepTime project");
keepTimeColumn.setCellValueFactory(data -> new SimpleStringProperty(data.getValue().getProject().getName()));
+ keepTimeColumn.setCellFactory(col -> new TableCell<>() {
+ @Override
+ protected void updateItem(String item, boolean empty) {
+ super.updateItem(item, empty);
+ if (empty || item == null) {
+ setText(null);
+ setTooltip(null);
+ } else {
+ setText(item);
+ Tooltip tooltip = new Tooltip(item);
+ setTooltip(tooltip);
+ }
+ }
+ });
+
// External Project column with dropdown
final ObservableList externalProjectsObservableList = FXCollections.observableArrayList(
externalProjects);
- externalProjectsObservableList.add(0, null); // option to clear selection
-
- TableColumn externalColumn = new TableColumn<>("HEIMAT project");
+ externalProjectsObservableList.add(0,null);
+ TableColumn externalColumn = new TableColumn<>("Heimat project");
externalColumn.setCellValueFactory(data -> new SimpleObjectProperty<>(data.getValue().getHeimatTask()));
externalColumn.setCellFactory(col -> new TableCell<>() {
- // TODO search in box would be nice
- private final ComboBox comboBox = new ComboBox<>(externalProjectsObservableList);
+ private final SearchCombobox searchPopup = new SearchCombobox<>(externalProjectsObservableList);
+
+ {
+ searchPopup.setDisplayTextFunction(ht -> ht == null ? "" : ht.taskHolderName() + " - " + ht.name());
+ searchPopup.setClearFieldAfterSelection(false);
+ searchPopup.setPromptText("Search Project...");
+ searchPopup.setOnItemSelected((selectedTask, popup) -> {
+ HeimatController.ProjectMapping mapping = getTableView().getItems().get(getIndex());
+ mapping.setHeimatTask(selectedTask);
+ if(selectedTask != null)
+ searchPopup.setComboBoxTooltip(selectedTask.name() + " - " + selectedTask.id());
+ updateItem(selectedTask, false);
+ });
+ }
@Override
protected void updateItem(HeimatTask item, boolean empty) {
super.updateItem(item, empty);
- // selected item
- comboBox.setButtonCell(new ListCell<>() {
- @Override
- protected void updateItem(HeimatTask item, boolean empty) {
- super.updateItem(item, empty);
- if (empty || item == null) {
- setText(null);
- } else {
- setText(item.taskHolderName() + " - " + item.name());
- }
- }
- });
-
- // Dropdown
- comboBox.setCellFactory(param -> new ListCell<>() {
- @Override
- protected void updateItem(HeimatTask item, boolean empty) {
- super.updateItem(item, empty);
- if (item == null || empty) {
- setGraphic(null);
- setText(null);
- } else {
- // TODO maybe show if the project was already mapped
- setText(item.taskHolderName() + " - " + item.name());
- }
- }
- });
-
if (empty) {
setGraphic(null);
setText(null);
+ setStyle(null);
} else {
- comboBox.setValue(getTableView().getItems().get(getIndex()).getHeimatTask());
- comboBox.setOnAction(e -> {
- HeimatController.ProjectMapping mapping = getTableView().getItems().get(getIndex());
- mapping.setHeimatTask(comboBox.getValue());
- });
- setGraphic(comboBox);
+ searchPopup.setSelectedItem(item);
+ if (item != null) {
+ searchPopup.setComboBoxTooltip(item.name() + " - " + item.id());
+ } else {
+ searchPopup.setComboBoxTooltip("");
+ }
+
+ // highlight mappings which do not exist anymore
+ final String highlightStyle = item != null && !externalProjects.contains(item) ? "-fx-background-color: lightsalmon;" : null;
+ setStyle(highlightStyle);
+ setGraphic(searchPopup.getComboBox());
setText(null);
}
}
@@ -179,7 +210,7 @@ protected void updateItem(HeimatTask item, boolean empty) {
final Project project = controller.addNewProject(
new Project(toBeCreatedHeimatTask.name() + " - " + toBeCreatedHeimatTask.taskHolderName(),
toBeCreatedHeimatTask.bookingHint(), ColorHelper.randomColor(), true, sortIndex));
- newProjectMappings.add(new HeimatController.ProjectMapping(project, toBeCreatedHeimatTask));
+ newProjectMappings.add(new HeimatController.ProjectMapping(project, toBeCreatedHeimatTask, false));
}
});
@@ -189,18 +220,13 @@ protected void updateItem(HeimatTask item, boolean empty) {
});
cancelButton.setOnAction(ae -> thisStage.close());
-
- List warnings = existingAndInvalidMappings.invalidMappingsAsString();
- if (!warnings.isEmpty()) {
- Platform.runLater(() -> showInvalidMappingsDialog(warnings));
- }
}
private List showMultiSelectDialog(final List externalProjects,
List unmappedHeimatTasks) {
Dialog> dialog = new Dialog<>();
- dialog.setTitle("Import HEIMAT projects");
- dialog.setHeaderText("You can select mutliple items");
+ dialog.setTitle("Import Heimat projects");
+ dialog.setHeaderText("You can select multiple items");
dialog.initOwner(this.thisStage);
dialog.setWidth(600);
dialog.setHeight(500);
@@ -210,8 +236,13 @@ private List showMultiSelectDialog(final List externalPr
ButtonType cancelButtonType = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialog.getDialogPane().getButtonTypes().addAll(okButtonType, cancelButtonType);
+ // Observable and filtered list
+ ObservableList baseList = FXCollections.observableArrayList(externalProjects);
+ FilteredList filteredList = new FilteredList<>(baseList, t -> true);
+
+ // Name Column
TableView tableView = new TableView<>();
- TableColumn nameColumn = new TableColumn<>("HEIMAT project");
+ TableColumn nameColumn = new TableColumn<>("Heimat project");
nameColumn.setCellValueFactory(data -> new SimpleObjectProperty<>(data.getValue()));
nameColumn.setCellFactory(param -> new TableCell<>() {
@Override
@@ -238,9 +269,10 @@ protected void updateItem(HeimatTask item, boolean empty) {
tableView.setEditable(false);
tableView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
- tableView.setItems(FXCollections.observableArrayList(externalProjects));
+ tableView.setItems(filteredList);
- Button selectAllUnmappedButton = new Button("Select unmapped projects (" + unmappedHeimatTasks.size() + ")");
+ Button selectAllUnmappedButton = new Button("Select unmapped projects ("
+ + unmappedHeimatTasks.size() + ")");
selectAllUnmappedButton.getStyleClass().add("secondary-button");
selectAllUnmappedButton.setOnAction(e -> {
tableView.getSelectionModel().clearSelection();
@@ -250,7 +282,27 @@ protected void updateItem(HeimatTask item, boolean empty) {
tableView.requestFocus();
});
- VBox content = new VBox(10, selectAllUnmappedButton, tableView);
+ TextField searchField = new TextField();
+ searchField.setPromptText("Search...");
+ searchField.textProperty().addListener((obs, oldText, newText) -> {
+ String filter = newText == null ? "" : newText.trim().toLowerCase();
+ filteredList.setPredicate(task -> {
+ if (filter.isEmpty()) return true;
+ return task.taskHolderName().toLowerCase().contains(filter)
+ || task.name().toLowerCase().contains(filter);
+ });
+
+ long visibleUnmapped = filteredList.stream().filter(unmappedHeimatTasks::contains).count();
+ selectAllUnmappedButton.setText("Select unmapped projects ("
+ + visibleUnmapped + ")");
+ });
+ searchField.getStyleClass().add("text-field");
+ searchField.setMaxWidth(Double.MAX_VALUE);
+ HBox.setHgrow(searchField, Priority.ALWAYS);
+
+ HBox headContent = new HBox(50, selectAllUnmappedButton, searchField);
+
+ VBox content = new VBox(10, headContent, tableView);
dialog.getDialogPane().setContent(content);
final List emptyList = List.of();
dialog.setResultConverter(dialogButton -> {
@@ -279,11 +331,17 @@ protected void updateItem(HeimatTask item, boolean empty) {
return result.orElse(emptyList);
}
- private void showInvalidMappingsDialog(final List warnings) {
- Dialog dialog = new Dialog<>();
+ private boolean showInvalidMappingsDialog(final List warnings) {
+ Dialog dialog = new Dialog<>();
+
dialog.initOwner(this.thisStage);
+
+ Stage dialogStage = (Stage) dialog.getDialogPane().getScene().getWindow();
+ dialogStage.getIcons().addAll(this.thisStage.getIcons());
+
dialog.setTitle("Invalid mappings");
- dialog.setHeaderText("Please note to following issue:");
+ dialog.setHeaderText("The following projects are no longer available.\n"
+ + "Would you like to remove them from your mapping list?");
VBox warningBox = new VBox(10);
for (String warning : warnings) {
@@ -299,10 +357,11 @@ private void showInvalidMappingsDialog(final List warnings) {
dialog.getDialogPane().setContent(scrollPane);
dialog.getDialogPane().setMinWidth(400);
- // Add OK button
- ButtonType okButton = new ButtonType("OK", ButtonBar.ButtonData.OK_DONE);
- dialog.getDialogPane().getButtonTypes().add(okButton);
+ ButtonType removeButton = new ButtonType("Remove", ButtonBar.ButtonData.NO);
+ ButtonType keepButton = new ButtonType("Keep", ButtonBar.ButtonData.YES);
+ dialog.getDialogPane().getButtonTypes().setAll(removeButton, keepButton);
- dialog.showAndWait();
+ Optional result = dialog.showAndWait();
+ return result.isPresent() && result.get() == removeButton;
}
}
diff --git a/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java b/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java
index 0f6dac2f..a7e9855a 100644
--- a/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java
+++ b/src/main/java/de/doubleslash/keeptime/view/ExternalProjectsSyncController.java
@@ -22,8 +22,10 @@
import de.doubleslash.keeptime.common.SvgNodeProvider;
import de.doubleslash.keeptime.controller.HeimatController;
import de.doubleslash.keeptime.model.Project;
+import de.doubleslash.keeptime.model.StyledMessage;
import de.doubleslash.keeptime.model.Work;
import de.doubleslash.keeptime.rest.integration.heimat.model.HeimatTask;
+import de.doubleslash.keeptime.viewpopup.SearchCombobox;
import javafx.animation.Animation;
import javafx.animation.KeyFrame;
import javafx.animation.RotateTransition;
@@ -41,13 +43,18 @@
import javafx.concurrent.Task;
import javafx.fxml.FXML;
import javafx.geometry.Pos;
+import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.effect.GaussianBlur;
+import javafx.scene.input.KeyCode;
+import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.shape.SVGPath;
+import javafx.scene.text.Text;
+import javafx.scene.text.TextFlow;
import javafx.stage.Stage;
import javafx.util.Duration;
import javafx.util.converter.LocalTimeStringConverter;
@@ -64,6 +71,7 @@
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Predicate;
+import java.util.stream.Collectors;
import static de.doubleslash.keeptime.view.ReportController.copyToClipboard;
@@ -110,9 +118,7 @@ public class ExternalProjectsSyncController {
private Region syncingIconRegion;
@FXML
- private ComboBox heimatTaskComboBox;
- @FXML
- private Button addHeimatTaskButton;
+ private HBox heimatTaskSearchContainer;
private final SVGPath loadingSpinner = SvgNodeProvider.getSvgNodeWithScale(Resources.RESOURCE.SVG_SPINNER_SOLID, 0.1,
0.1);
@@ -124,11 +130,15 @@ public class ExternalProjectsSyncController {
private final Color colorLoadingSuccess = Color.valueOf("#74a317");
private final Color colorLoadingFailure = Color.valueOf("#c63329");
+ private boolean shiftDown = false;
+
private final LocalTimeStringConverter localTimeStringConverter = new LocalTimeStringConverter(FormatStyle.MEDIUM);
+
private ObservableList items;
private LocalDate currentReportDate;
private Stage thisStage;
+ private Timeline closingTimeline;
private final HeimatController heimatController;
private final RotateTransition loadingSpinnerAnimation = new RotateTransition(Duration.seconds(1),
syncingIconRegion);
@@ -158,9 +168,9 @@ public void initForDate(LocalDate currentReportDate, List currentWorkItems
mappingTableView.setItems(items);
- ObservableList items2 = FXCollections.observableArrayList(
+ ObservableList itemsForBindings = FXCollections.observableArrayList(
item -> new javafx.beans.Observable[] { item.userTimeSeconds, item.shouldSyncCheckBox, item.userNotes });
- items2.addAll(items);
+ itemsForBindings.addAll(items);
StringBinding totalSum = Bindings.createStringBinding(() -> localTimeStringConverter.toString(
LocalTime.ofSecondOfDay(
items.stream().filter(item -> item.mapping.heimatTaskId() != -1L) // if its bookable in heimat
@@ -169,7 +179,7 @@ public void initForDate(LocalDate currentReportDate, List currentWorkItems
return item.userTimeSeconds.getValue();
else
return item.heimatTimeSeconds.get();
- }).sum())), items2);
+ }).sum())), itemsForBindings);
sumTimeLabel.textProperty().bind(totalSum);
keepTimeTimeLabel.setText(localTimeStringConverter.toString(
@@ -177,19 +187,31 @@ public void initForDate(LocalDate currentReportDate, List currentWorkItems
heimatTimeLabel.setText(localTimeStringConverter.toString(
LocalTime.ofSecondOfDay(tableRows.stream().mapToLong(HeimatController.Mapping::heimatSeconds).sum())));
- BooleanBinding projectsValidProperty = Bindings.createBooleanBinding(() -> items.stream().anyMatch(item -> {
- boolean shouldSync = item.shouldSyncCheckBox.get();
- boolean hasNote = !item.userNotes.get().isBlank();
- boolean hasTime = areSecondsOfDayValid(item.userTimeSeconds.get());
- return shouldSync && !(hasNote && hasTime);
- }), items2);
+ BooleanBinding saveButtonDisabledProperty = Bindings.createBooleanBinding(() -> {
+ boolean anyShouldSync = items.stream().anyMatch(item -> item.shouldSyncCheckBox.get());
+ if (!anyShouldSync) {
+ return true;
+ }
+ return items.stream().anyMatch(item -> {
+ if (!item.shouldSyncCheckBox.get()) {
+ return false;
+ }
+ boolean hasNote = !item.userNotes.get().isBlank();
+ boolean hasTime = areSecondsOfDayValid(item.userTimeSeconds.get());
+ return !(hasNote && hasTime);
+ });
+ }, itemsForBindings);
- saveButton.disableProperty().bind(projectsValidProperty);
+ saveButton.disableProperty().bind(saveButtonDisabledProperty);
+ saveButton.textProperty().bind(Bindings.createStringBinding(
+ () -> "Sync (" + items.stream().filter(item -> item.shouldSyncCheckBox.get()).count() + ")",
+ itemsForBindings));
externalSystemLink.setOnAction(ae -> BrowserHelper.openURL(heimatController.getUrlForDay(currentReportDate)));
externalSystemLinkLoadingScreen.setOnAction(
ae -> BrowserHelper.openURL(heimatController.getUrlForDay(currentReportDate)));
final List tasksForDay = heimatController.getTasks(currentReportDate);
+
final FilteredList tasksNotInList = new FilteredList<>(FXCollections.observableArrayList(tasksForDay),
(task) -> items.stream().noneMatch(tr -> task.id() == tr.mapping.heimatTaskId()));
items.addListener((ListChangeListener super TableRow>) c -> {
@@ -197,46 +219,38 @@ public void initForDate(LocalDate currentReportDate, List currentWorkItems
tasksNotInList.setPredicate(null);
tasksNotInList.setPredicate(predicate);
});
- heimatTaskComboBox.setItems(tasksNotInList);
- addHeimatTaskButton.disableProperty()
- .bind(heimatTaskComboBox.getSelectionModel().selectedItemProperty().isNull());
- addHeimatTaskButton.setOnAction(ae -> {
- final HeimatTask task = heimatTaskComboBox.getValue();
- final TableRow addedRow = new TableRow(new HeimatController.Mapping(task.id(), true, true,
- "Manually added\n\nSync to " + task.name() + "\n(" + task.taskHolderName() + ")", List.of(), List.of(),
- "", "", 0, 0), "", 0);
+
+ SearchCombobox heimatTaskSearchCombobox = new SearchCombobox<>(tasksNotInList);
+ heimatTaskSearchCombobox.setDisplayTextFunction(task -> task.taskHolderName() + " - " + task.name());
+
+ heimatTaskSearchCombobox.setOnItemSelected((selectedTask, popup) -> {
+ if (selectedTask == null)
+ return;
+ boolean alreadyExists = items.stream().anyMatch(row -> row.mapping.heimatTaskId() == selectedTask.id());
+ if (alreadyExists)
+ return;
+
+ StyledMessage syncMessage = StyledMessage.of(new StyledMessage.TextSegment("Manually added\n\nSync to "),
+ new StyledMessage.TextSegment(selectedTask.name(), true),
+ new StyledMessage.TextSegment("\n(" + selectedTask.taskHolderName() + ")"));
+
+ TableRow addedRow = new TableRow(
+ new HeimatController.Mapping(selectedTask.id(), true, true, syncMessage, selectedTask.bookingHint(), List.of(), List.of(), "",
+ "", 0, 0), "", 0);
items.add(addedRow);
- items2.add(addedRow); // add new row also to items2 - as it is not added automatically :(
- heimatTaskComboBox.getSelectionModel().clearSelection();
+ itemsForBindings.add(addedRow); // add new row also to items2 - as it is not added automatically :(
mappingTableView.scrollTo(items.size() - 1); // scroll to newly added row
});
+ heimatTaskSearchCombobox.setClearFieldAfterSelection(true);
+ heimatTaskSearchCombobox.setMaxSuggestionHeight(220);
+ heimatTaskSearchCombobox.setPromptText("Select Project...");
+ heimatTaskSearchContainer.getChildren().add(heimatTaskSearchCombobox.getComboBox());
+ HBox.setHgrow(heimatTaskSearchCombobox.getComboBox(), Priority.ALWAYS);
}
@FXML
private void initialize() {
- heimatTaskComboBox.setCellFactory(param -> new ListCell<>() {
- @Override
- protected void updateItem(HeimatTask item, boolean empty) {
- super.updateItem(item, empty);
- if (empty || item == null) {
- setText(null);
- } else {
- setText(item.taskHolderName() + " - " + item.name());
- }
- }
- });
- heimatTaskComboBox.setButtonCell(new ListCell<>() {
- @Override
- protected void updateItem(HeimatTask item, boolean empty) {
- super.updateItem(item, empty);
- if (empty || item == null) {
- setText(null);
- } else {
- setText(item.name() + " - " + item.taskHolderName());
- }
- }
- });
initializeLoadingScreen();
TableColumn shouldSyncColumn = new TableColumn<>("Sync");
@@ -244,21 +258,21 @@ protected void updateItem(HeimatTask item, boolean empty) {
// Custom Cell Factory to disable CheckBoxes
shouldSyncColumn.setCellFactory(col -> new TableCell<>() {
private final CheckBox checkBox = new CheckBox();
- private ChangeListener boolChangeListener;
+ private BooleanProperty boundProperty = null;
@Override
protected void updateItem(TableRow item, boolean empty) {
super.updateItem(item, empty);
- if (boolChangeListener != null)
- checkBox.selectedProperty().removeListener(boolChangeListener);
-
+ if(boundProperty != null){
+ checkBox.selectedProperty().unbindBidirectional(boundProperty);
+ boundProperty = null;
+ }
if (empty || item == null) {
setGraphic(null);
} else {
checkBox.setDisable(!item.mapping.canBeSynced());
- checkBox.setSelected(item.shouldSyncCheckBox.get());
- boolChangeListener = (obs, oldText, newBoolean) -> item.shouldSyncCheckBox.set(newBoolean);
- checkBox.selectedProperty().addListener(boolChangeListener);
+ checkBox.selectedProperty().bindBidirectional(item.shouldSyncCheckBox);
+ boundProperty = item.shouldSyncCheckBox;
setAlignment(Pos.TOP_CENTER);
setGraphic(checkBox);
}
@@ -278,7 +292,11 @@ protected void updateItem(List item, boolean empty) {
setText(null);
} else {
VBox vbox = new VBox(5);
- item.forEach(project -> vbox.getChildren().add(createRow(project.getColor(), project.getName())));
+
+ for (Project project : item) {
+ HBox row = createRow(project.getColor(), project.getName());
+ vbox.getChildren().add(row);
+ }
setGraphic(vbox);
}
}
@@ -286,6 +304,7 @@ protected void updateItem(List item, boolean empty) {
private HBox createRow(Color color, String text) {
Circle circle = new Circle(6, color);
Label label = new Label(text);
+ label.setTooltip(new Tooltip(text));
return new HBox(5, circle, label);
}
@@ -320,7 +339,7 @@ protected void updateItem(TableRow item, boolean empty) {
} else {
keeptimeLabel.setText("KeepTime: " + localTimeStringConverter.toString(
LocalTime.ofSecondOfDay(item.keeptimeTimeSeconds.get())));
- heimatLabel.setText("HEIMAT: " + localTimeStringConverter.toString(
+ heimatLabel.setText("Heimat: " + localTimeStringConverter.toString(
LocalTime.ofSecondOfDay(item.heimatTimeSeconds.get())));
timeSpinner.setDisable(!item.mapping.canBeSynced());
timeSpinner.getValueFactory().setValue(LocalTime.ofSecondOfDay(0));
@@ -329,6 +348,7 @@ protected void updateItem(TableRow item, boolean empty) {
localTimeChangeListener = (observable, oldValue, newValue) -> {
item.userTimeSeconds.set(newValue.toSecondOfDay());
spinnerValidConsumer.accept(timeSpinner);
+ item.shouldSyncCheckBox.set(true);
};
spinnerValidConsumer.accept(timeSpinner);
timeSpinner.valueProperty().addListener(localTimeChangeListener);
@@ -379,7 +399,7 @@ protected void updateItem(TableRow item, boolean empty) {
final Label keeptimeLabel = new Label("KeepTime:");
keeptimeLabel.setMinWidth(60);
hbox.getChildren().addAll(copyKeepTimeNotes, keeptimeLabel, keepTimeNotesLabel);
- final Label heimatLabel = new Label("HEIMAT:");
+ final Label heimatLabel = new Label("Heimat:");
heimatLabel.setMinWidth(60);
hbox2.getChildren().addAll(copyHeimatNotes, heimatLabel, heimatNotesLabel);
container.getChildren().addAll(textArea, hbox, hbox2);
@@ -411,8 +431,46 @@ protected void updateItem(TableRow item, boolean empty) {
}
});
- TableColumn syncColumn = new TableColumn<>("Sync Status");
- syncColumn.setCellValueFactory(data -> data.getValue().syncStatus);
+ TableColumn syncColumn = new TableColumn<>("Sync Status");
+ syncColumn.setCellValueFactory(data -> new SimpleObjectProperty<>(data.getValue()));
+ syncColumn.setCellFactory(column -> new TableCell<>() {
+
+ private final Tooltip tooltip = new Tooltip();
+
+ @Override
+ protected void updateItem(TableRow item, boolean empty) {
+
+ super.updateItem(item, empty);
+
+ if (empty || item == null) {
+ setTooltip(null);
+ setGraphic(null);
+ return;
+ }
+
+ TextFlow statusFlow = item.syncStatus;
+ String statusForTooltip = statusFlow.getChildren()
+ .stream()
+ .filter(Text.class::isInstance)
+ .map(n -> ((Text) n).getText())
+ .collect(Collectors.joining());
+
+ final String bookingHint = item.bookingHint.get();
+ if (!bookingHint.isEmpty()) {
+ statusFlow = new TextFlow(statusFlow);
+ tooltip.setText(statusForTooltip + "\nBookinghint: " + bookingHint);
+ Text icon = new Text(" ⓘ");
+ icon.setStyle("-fx-text-fill: #1c2070; -fx-font-size: 14px;");
+ statusFlow.getChildren().add(icon);
+ } else {
+ tooltip.setText(statusForTooltip);
+ }
+
+ setGraphic(new Group(statusFlow));
+
+ setTooltip(tooltip);
+ }
+ });
shouldSyncColumn.setPrefWidth(50);
projectColumn.setPrefWidth(100);
@@ -422,6 +480,7 @@ protected void updateItem(TableRow item, boolean empty) {
mappingTableView.getColumns().addAll(shouldSyncColumn, projectColumn, timeColumn, notesColumn, syncColumn);
mappingTableView.setSelectionModel(null);
+ mappingTableView.setFocusTraversable(false);
mappingTableView.getColumns().forEach(column -> column.setSortable(false));
saveButton.setOnAction(ae -> {
@@ -462,10 +521,14 @@ protected List call() {
loadingSuccess);
}
+ if (closingTimeline != null) {
+ closingTimeline.stop();
+ }
+
final AtomicInteger remainingSeconds = new AtomicInteger(closingSeconds);
loadingClosingMessage.setText("Closing in " + remainingSeconds + " seconds...");
loadingClosingMessage.setVisible(true);
- Timeline timeline = new Timeline(new KeyFrame(Duration.seconds(1), event -> {
+ closingTimeline = new Timeline(new KeyFrame(Duration.seconds(1), event -> {
remainingSeconds.getAndDecrement();
loadingClosingMessage.setText("Closing in " + remainingSeconds + " seconds...");
if (remainingSeconds.get() <= 0) {
@@ -474,8 +537,8 @@ protected List call() {
loadingClosingMessage.setVisible(false);
}
}));
- timeline.setCycleCount(remainingSeconds.get());
- timeline.play();
+ closingTimeline.setCycleCount(remainingSeconds.get());
+ closingTimeline.play();
});
task.setOnFailed(e -> {
@@ -579,6 +642,7 @@ private void showErrorDialog(List errorMessages) {
}
private void setUpTimeSpinner(final Spinner spinner) {
+
spinner.focusedProperty().addListener(e -> {
final LocalTimeStringConverter stringConverter = new LocalTimeStringConverter(FormatStyle.MEDIUM);
final StringProperty text = spinner.getEditor().textProperty();
@@ -601,9 +665,12 @@ public void decrement(final int steps) {
if (steps == 0)
return;
final LocalTime time = getValue();
- setValue(decrementToLastFullQuarter(time));
- }
+ if (shiftDown)
+ setValue(decrementToNextHour(time));
+ else
+ setValue(decrementToLastFullQuarter(time));
+ }
}
@Override
@@ -614,11 +681,13 @@ public void increment(final int steps) {
if (steps == 0)
return;
final LocalTime time = getValue();
- setValue(incrementToNextFullQuarter(time));
- }
+ if (shiftDown)
+ setValue(incrementToNextHour(time));
+ else
+ setValue(incrementToNextFullQuarter(time));
+ }
}
-
});
spinner.getValueFactory().setConverter(new LocalTimeStringConverter(FormatStyle.MEDIUM));
@@ -638,8 +707,62 @@ public static LocalTime incrementToNextFullQuarter(LocalTime time) {
return time.plusMinutes(increment).withSecond(0).withNano(0);
}
+ public static LocalTime incrementToNextHour(LocalTime time) {
+ return time.plusHours(1).withMinute(0).withSecond(0).withNano(0);
+ }
+
+ public static LocalTime decrementToNextHour(LocalTime time) {
+ if (time.getHour() == 0)
+ return LocalTime.MIDNIGHT;
+
+ return time.minusHours(1).withMinute(0).withSecond(0).withNano(0);
+ }
+
public void setStage(final Stage thisStage) {
this.thisStage = thisStage;
+
+
+ thisStage.setOnCloseRequest(e -> {
+ if (closingTimeline != null) {
+ closingTimeline.stop();
+ closingTimeline = null;
+ }
+ });
+
+ registerKeyEventListenersForSpinners(thisStage);
+ }
+
+ private void registerKeyEventListenersForSpinners(final Stage thisStage) {
+ thisStage.addEventFilter(KeyEvent.KEY_RELEASED, event -> {
+ if (event.getCode() == KeyCode.SHIFT) {
+ shiftDown = false;
+ }
+ });
+
+ thisStage.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
+ if (event.getCode() == KeyCode.SHIFT) {
+ shiftDown = true;
+ }
+ });
+ }
+
+ /**
+ * Converts a StyledMessage to a TextFlow for UI display.
+ *
+ * @param styledMessage
+ * The styled message to convert
+ * @return A TextFlow with properly styled text segments
+ */
+ private static TextFlow convertStyledMessageToTextFlow(StyledMessage styledMessage) {
+ TextFlow textFlow = new TextFlow();
+ for (StyledMessage.TextSegment segment : styledMessage.getSegments()) {
+ Text text = new Text(segment.text());
+ if (segment.bold()) {
+ text.setStyle("-fx-font-weight: bold;");
+ }
+ textFlow.getChildren().add(text);
+ }
+ return textFlow;
}
public static class TableRow {
@@ -654,12 +777,14 @@ public static class TableRow {
public final LongProperty userTimeSeconds;
public final LongProperty heimatTimeSeconds;
- public final StringProperty syncStatus;
+ public final TextFlow syncStatus;
+ public final StringProperty bookingHint;
public TableRow(HeimatController.Mapping mapping, String userNotes, final long userSeconds) {
this.mapping = mapping;
this.shouldSyncCheckBox = new SimpleBooleanProperty(mapping.shouldBeSynced());
- this.syncStatus = new SimpleStringProperty(mapping.syncMessage());
+ this.syncStatus = convertStyledMessageToTextFlow(mapping.syncMessage());
+ this.bookingHint = new SimpleStringProperty(mapping.bookingHint());
this.keeptimeNotes = new SimpleStringProperty(mapping.keeptimeNotes());
this.keeptimeTimeSeconds = new SimpleLongProperty(mapping.keeptimeSeconds());
diff --git a/src/main/java/de/doubleslash/keeptime/view/ReportController.java b/src/main/java/de/doubleslash/keeptime/view/ReportController.java
index 5d714518..da980d1e 100644
--- a/src/main/java/de/doubleslash/keeptime/view/ReportController.java
+++ b/src/main/java/de/doubleslash/keeptime/view/ReportController.java
@@ -18,6 +18,8 @@
import java.io.IOException;
import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
import java.util.*;
import java.util.stream.Collectors;
@@ -25,6 +27,7 @@
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.input.KeyCode;
+import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import javafx.scene.shape.SVGPath;
import org.slf4j.Logger;
@@ -101,6 +104,9 @@ public class ReportController {
@FXML
private Button heimatSyncButton;
+ @FXML
+ private Button addWorkButton;
+
private static final Logger LOG = LoggerFactory.getLogger(ReportController.class);
private final Model model;
@@ -133,6 +139,19 @@ private void initialize() {
expandCollapseButton.setOnMouseClicked(event ->toggleCollapseExpandReport());
initTableView();
initHeimatIntegration();
+ initAddManualWorkButton();
+
+ }
+
+ private void initAddManualWorkButton() {
+ addWorkButton.setOnAction(e -> onAddWork());
+ final SVGPath svgNodeWithScale = SvgNodeProvider.getSvgNodeWithScale(RESOURCE.SVG_PLUS_SOLID, 0.03, 0.03);
+ svgNodeWithScale.setStyle("-fx-fill: #00759e");
+ addWorkButton.setMaxSize(25,25);
+ addWorkButton.setMinSize(25, 25);
+ addWorkButton.setGraphic(svgNodeWithScale);
+ addWorkButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
+ addWorkButton.setTooltip(new Tooltip("Add work entry..."));
}
private void initHeimatIntegration() {
@@ -143,7 +162,7 @@ private void initHeimatIntegration() {
heimatSyncButton.setMinSize(25, 25);
heimatSyncButton.setGraphic(svgNodeWithScale);
heimatSyncButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
- heimatSyncButton.setTooltip(new Tooltip("Synchronize to HEIMAT..."));
+ heimatSyncButton.setTooltip(new Tooltip("Synchronize to Heimat..."));
heimatSyncButton.setOnAction(ae-> {
try {
showSyncStage();
@@ -169,15 +188,15 @@ private void showSyncStage(){
syncStage.setResizable(true);
syncStage.getIcons().add(new Image(Resources.getResource(RESOURCE.ICON_MAIN).toString()));
- final Scene settingsScene = new Scene(syncRoot);
- settingsScene.setOnKeyPressed(ke -> {
+ final Scene syncScene = new Scene(syncRoot);
+ syncScene.addEventFilter(KeyEvent.KEY_PRESSED, ke -> {
if (ke.getCode() == KeyCode.ESCAPE) {
LOG.info("pressed ESCAPE");
syncStage.close();
}
});
- syncStage.setScene(settingsScene);
+ syncStage.setScene(syncScene);
syncStage.showAndWait();
} catch (final Exception e) {
throw new FXMLLoaderException(e);
@@ -536,6 +555,39 @@ private Node createCopyWorkButton(final Work w) {
return copyButton;
}
+ private void onAddWork() {
+ final boolean isToday = LocalDate.now().equals(currentReportDate);
+ final LocalDateTime now = LocalDateTime.now();
+ final LocalDateTime defaultStart = isToday ? now.minusMinutes(15) : currentReportDate.atTime(LocalTime.of(9, 0));
+ final LocalDateTime defaultEnd = isToday ? now : currentReportDate.atTime(LocalTime.of(10, 0));
+
+ final Project defaultProject = model.activeWorkItem.get() != null
+ ? model.activeWorkItem.get().getProject()
+ : model.getIdleProject();
+
+ final Work newWorkDefaults = new Work(defaultStart, defaultEnd, defaultProject, "");
+ final Dialog dialog = setupAddWorkDialog(newWorkDefaults);
+
+ final Optional result = dialog.showAndWait();
+ result.ifPresent(createdWork -> {
+ controller.addWork(createdWork);
+ this.update();
+ });
+ }
+
+ private Dialog setupAddWorkDialog(final Work work) {
+ final Dialog dialog = new Dialog<>();
+ dialog.initOwner(stage);
+ dialog.setTitle("Add work");
+ dialog.setHeaderText("Add work");
+ dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
+
+ final GridPane grid = setUpEditWorkGridPane(work, dialog);
+ dialog.getDialogPane().setContent(grid);
+
+ return dialog;
+ }
+
public void update() {
heimatSyncButton.setVisible(model.getHeimatSettings().isHeimatActive());
// TODO save work so it appears directly in report. Quick fixes #170. Use #176 for this instead.
diff --git a/src/main/java/de/doubleslash/keeptime/view/SettingsController.java b/src/main/java/de/doubleslash/keeptime/view/SettingsController.java
index 4167cd28..e0cf5835 100644
--- a/src/main/java/de/doubleslash/keeptime/view/SettingsController.java
+++ b/src/main/java/de/doubleslash/keeptime/view/SettingsController.java
@@ -23,6 +23,7 @@
import java.io.InputStream;
import java.nio.file.Paths;
import java.sql.SQLException;
+import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;
@@ -213,7 +214,7 @@ public class SettingsController {
private PasswordField heimatPatTextField;
@FXML
- private Label heimatExpiresLabel;
+ private Label expirationDateLabel;
@FXML
private Button heimatValidateConnectionButton;
@@ -411,9 +412,20 @@ private void initializeHeimat() {
heimatPatTextField.textProperty().addListener((observable, oldValue, newValue)->{
try{
final JwtDecoder.JWTTokenAttributes jwt = JwtDecoder.parse(newValue);
- heimatExpiresLabel.setText(jwt.expiration().toString());
+ final boolean isExpired = JwtDecoder.isExpired(jwt, LocalDateTime.now());
+ final String expirationDate = jwt.expiration().toString();
+
+ if (isExpired) {
+ expirationDateLabel.setText(expirationDate + " (Expired)");
+ expirationDateLabel.setTextFill(Color.RED);
+ } else {
+ expirationDateLabel.setText(expirationDate);
+ expirationDateLabel.setTextFill(Color.BLACK);
+ }
+
} catch(Exception e){
- heimatExpiresLabel.setText("Does not seem to be valid");
+ expirationDateLabel.setText("Does not seem to be valid");
+ expirationDateLabel.setTextFill(Color.RED);
}
});
heimatValidateConnectionLabel.setText("Not validated.");
@@ -462,27 +474,27 @@ private void updateHeimatSettings() {
private void showMapProjectsStage() {
try{
// Settings stage
- final FXMLLoader fxmlLoader2 = createFXMLLoader(RESOURCE.FXML_EXT_PROJECT_MAPPING);
- fxmlLoader2.setControllerFactory(model.getSpringContext()::getBean);
- final Parent settingsRoot = fxmlLoader2.load();
- ExternalProjectsMapController settingsController = fxmlLoader2.getController();
- Stage settingsStage = new Stage();
- settingsController.setStage(settingsStage);
- settingsStage.initModality(Modality.APPLICATION_MODAL);
- settingsStage.setTitle("External Project Mappings");
- settingsStage.setResizable(false);
- settingsStage.getIcons().add(new Image(Resources.getResource(RESOURCE.ICON_MAIN).toString()));
-
- final Scene settingsScene = new Scene(settingsRoot);
- settingsScene.setOnKeyPressed(ke -> {
+ final FXMLLoader fxmlLoader = createFXMLLoader(RESOURCE.FXML_EXT_PROJECT_MAPPING);
+ fxmlLoader.setControllerFactory(model.getSpringContext()::getBean);
+ final Parent externalProjectRoot = fxmlLoader.load();
+ ExternalProjectsMapController externalProjectsMapController = fxmlLoader.getController();
+ Stage externalProjectMappingStage = new Stage();
+ externalProjectsMapController.setStage(externalProjectMappingStage);
+ externalProjectMappingStage.initModality(Modality.APPLICATION_MODAL);
+ externalProjectMappingStage.setTitle("External Project Mappings");
+ externalProjectMappingStage.setResizable(false);
+ externalProjectMappingStage.getIcons().add(new Image(Resources.getResource(RESOURCE.ICON_MAIN).toString()));
+
+ final Scene externalProjectMappingScene = new Scene(externalProjectRoot);
+ externalProjectMappingScene.setOnKeyPressed(ke -> {
if (ke.getCode() == KeyCode.ESCAPE) {
LOG.info("pressed ESCAPE");
- settingsStage.close();
+ externalProjectMappingStage.close();
}
});
- settingsStage.setScene(settingsScene);
- settingsStage.showAndWait();
+ externalProjectMappingStage.setScene(externalProjectMappingScene);
+ externalProjectMappingStage.showAndWait();
} catch (final Exception e) {
throw new FXMLLoaderException(e);
}
diff --git a/src/main/java/de/doubleslash/keeptime/viewpopup/SearchCombobox.java b/src/main/java/de/doubleslash/keeptime/viewpopup/SearchCombobox.java
new file mode 100644
index 00000000..ee79ebb6
--- /dev/null
+++ b/src/main/java/de/doubleslash/keeptime/viewpopup/SearchCombobox.java
@@ -0,0 +1,266 @@
+package de.doubleslash.keeptime.viewpopup;
+
+import javafx.beans.value.ChangeListener;
+import javafx.collections.FXCollections;
+import javafx.collections.ListChangeListener;
+import javafx.collections.ObservableList;
+import javafx.geometry.Bounds;
+import javafx.geometry.Pos;
+import javafx.scene.Node;
+import javafx.scene.control.*;
+import javafx.scene.input.KeyCode;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.StackPane;
+import javafx.stage.Popup;
+
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+public class SearchCombobox {
+ private final TextField searchField = new TextField();
+ private final Button showSuggestionsButton = new Button("▼");
+ private final ListView suggestionList = new ListView<>();
+ private final Popup popup = new Popup();
+ private final HBox container;
+
+ private ObservableList allItems = FXCollections.observableArrayList();
+ private ObservableList observedItemsForListener = null;
+ private final ListChangeListener listChangeListener = c -> filterList(searchField.getText());
+
+ private Function displayTextFunction = Object::toString;
+ private String promptText = "Select item…";
+ private double maxSuggestionHeight = 200;
+
+ private BiConsumer> onItemSelected = (item, popup) -> {
+ };
+ private boolean clearFieldAfterSelection = false;
+
+ public SearchCombobox(ObservableList items) {
+ container = new HBox(searchField, showSuggestionsButton);
+ container.getStyleClass().add("search-popup-container");
+ container.setAlignment(Pos.CENTER_LEFT);
+ HBox.setHgrow(searchField, Priority.ALWAYS);
+
+ setItems(items);
+
+ setupUI();
+ setupListeners();
+ }
+
+ private void setupUI() {
+ popup.setAutoHide(true);
+ popup.getContent().add(suggestionList);
+
+ searchField.setPromptText(promptText);
+ searchField.getStyleClass().add("search-popup");
+ searchField.setMaxWidth(Double.MAX_VALUE);
+
+ showSuggestionsButton.getStyleClass().add("search-popup-button");
+
+ suggestionList.setMaxHeight(maxSuggestionHeight);
+ suggestionList.getStyleClass().add("scroll-pane");
+
+ suggestionList.setCellFactory(listView -> new ListCell<>() {
+ private final Label label = new Label();
+ private final StackPane pane = new StackPane(label);
+
+ {
+ label.setWrapText(true);
+ label.setStyle("-fx-padding: 2;");
+ pane.setAlignment(Pos.CENTER_LEFT);
+ pane.setMinWidth(0);
+ pane.setPrefWidth(1);
+ }
+
+ @Override
+ protected void updateItem(T item, boolean empty) {
+ super.updateItem(item, empty);
+ if (empty) {
+ setGraphic(null);
+ } else {
+ label.setText(item == null ? "-" : displayTextFunction.apply(item));
+ setGraphic(pane);
+ }
+ }
+ });
+ }
+
+ private void setupListeners() {
+ showSuggestionsButton.setOnAction(ae -> {
+ show(searchField);
+ searchField.requestFocus();
+ });
+
+ ChangeListener hidePopupListener = (obs, was, isNow) -> {
+ if (!searchField.isFocused() && !suggestionList.isFocused())
+ popup.hide();
+ };
+
+ searchField.focusedProperty().addListener((obs, wasFocused, isNowFocused) -> {
+ if (isNowFocused && !clearFieldAfterSelection) {
+ filterList(""); // Show all items
+ show(searchField);
+ searchField.selectAll();
+ }
+ });
+
+ searchField.setOnKeyPressed(ev -> {
+ if (ev.getCode() == KeyCode.DOWN && !suggestionList.getItems().isEmpty()) {
+ show(searchField);
+ suggestionList.requestFocus();
+ suggestionList.getSelectionModel().selectFirst();
+ ev.consume();
+ }
+ });
+
+ searchField.setOnMouseClicked(ev -> {
+ if (!clearFieldAfterSelection) {
+ filterList(""); // Show all items
+ show(searchField);
+ searchField.selectAll();
+ } else {
+ filterList(searchField.getText());
+ show(searchField);
+ }
+ });
+
+ suggestionList.focusedProperty().addListener(hidePopupListener);
+
+ suggestionList.setOnKeyPressed(ev -> {
+ if (ev.getCode() == KeyCode.ENTER) {
+ T selected = suggestionList.getSelectionModel().getSelectedItem();
+ handleSelection(selected);
+ } else if (ev.getCode() == KeyCode.UP && suggestionList.getSelectionModel().getSelectedIndex() == 0) {
+ searchField.requestFocus();
+ } else if (ev.getCode() == KeyCode.ESCAPE) {
+ hide();
+ container.requestFocus();
+ }
+ });
+
+ suggestionList.setOnMouseClicked(ev -> {
+ T selected = suggestionList.getSelectionModel().getSelectedItem();
+ handleSelection(selected);
+ });
+
+ searchField.textProperty().addListener((obs, oldText, newText) -> filterList(newText));
+ }
+
+ private void filterList(String input) {
+ String filter = (input == null) ? "" : input.trim().toLowerCase();
+ ObservableList filtered = FXCollections.observableArrayList(allItems.stream()
+ .filter(item -> {
+ if(item == null) return filter.isEmpty();
+ return displayTextFunction.apply(
+ item).toLowerCase().contains(filter);})
+ .collect(Collectors.toList()));
+ suggestionList.setItems(filtered);
+ if (!filtered.isEmpty() && searchField.isFocused()) {
+ show(searchField);
+ } else {
+ popup.hide();
+ }
+ }
+
+ private void handleSelection(T selected) {
+ if (clearFieldAfterSelection) {
+ clear();
+ } else {
+ searchField.setText(selected == null ? "" : displayTextFunction.apply(selected));
+ }
+ onItemSelected.accept(selected, this);
+ popup.hide();
+ container.requestFocus();
+ }
+
+ public void setItems(ObservableList items) {
+ if (observedItemsForListener != null)
+ observedItemsForListener.removeListener(listChangeListener);
+ this.allItems = items != null ? items : FXCollections.observableArrayList();
+ allItems.addListener(listChangeListener);
+ observedItemsForListener = allItems;
+ filterList(searchField.getText());
+ }
+
+ public void setDisplayTextFunction(Function func) {
+ this.displayTextFunction = func != null ? func : Object::toString;
+ filterList(searchField.getText());
+ }
+
+ public void setPromptText(String text) {
+ this.promptText = text;
+ searchField.setPromptText(text);
+ }
+
+ public void setMaxSuggestionHeight(double height) {
+ this.maxSuggestionHeight = height;
+ suggestionList.setMaxHeight(height);
+ }
+
+ public HBox getComboBox() {return container;}
+
+ public void show(Node owner) {
+ if (owner == null || suggestionList.getItems().isEmpty())
+ return;
+ Bounds bounds = owner.localToScreen(owner.getBoundsInLocal());
+ suggestionList.setPrefWidth(container.getWidth());
+ popup.show(owner, bounds.getMinX(), bounds.getMaxY());
+ }
+
+ public void hide() {
+ popup.hide();
+ }
+
+ public void setSelectedItem(T item) {
+ if (!clearFieldAfterSelection) {
+ String text = (item == null) ? "" : displayTextFunction.apply(item);
+ searchField.setText(text);
+ }
+ }
+
+ public T getSelectedItem() {
+ String text = searchField.getText();
+ for (T item : allItems) {
+ if (displayTextFunction.apply(item).equals(text))
+ return item;
+ }
+ return null;
+ }
+
+ public TextField getSearchField() {return searchField;}
+
+ public ListView getSuggestionList() {return suggestionList;}
+
+ public Button getShowSuggestionsButton() {return showSuggestionsButton;}
+
+ public Function getDisplayTextFunction() {return displayTextFunction;}
+
+ public void setOnItemSelected(BiConsumer> handler) {
+ this.onItemSelected = handler != null ? handler : (item, popup) -> {
+ };
+ }
+
+ public void setClearFieldAfterSelection(boolean c) {this.clearFieldAfterSelection = c;}
+
+ public void clear() {
+ searchField.clear();
+ if (!promptText.isEmpty())
+ searchField.setPromptText(promptText);
+ }
+
+ public void setComboBoxTooltip(String tooltipText) {
+ if (tooltipText != null && !tooltipText.isBlank()) {
+ Tooltip tooltip = new Tooltip(tooltipText);
+
+ Tooltip.install(container, tooltip);
+ Tooltip.install(searchField, tooltip);
+ Tooltip.install(showSuggestionsButton, tooltip);
+ } else {
+ Tooltip.uninstall(container, null);
+ Tooltip.uninstall(searchField, null);
+ Tooltip.uninstall(showSuggestionsButton, null);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/css/dsStyles.css b/src/main/resources/css/dsStyles.css
index dc62f23e..97ec12ac 100644
--- a/src/main/resources/css/dsStyles.css
+++ b/src/main/resources/css/dsStyles.css
@@ -205,4 +205,55 @@
.tree-table-row-cell:selected .tree-table-cell {
-fx-border-color: #C6C6C6;
-fx-border-width: 0 0.5px 0 0;
+}
+
+.search-popup-container {
+ -fx-background-color: transparent;
+}
+
+.search-popup {
+ -fx-background-radius: 8 0 0 8;
+ -fx-border-radius: 8 0 0 8;
+ -fx-border-color: #00759E transparent #00759E #00759E;
+ -fx-border-width: 1px 0 1px 1px;
+ -fx-background-color: white;
+ -fx-pref-height: 32px;
+ -fx-min-height: 32px;
+ -fx-max-height: 32px;
+ -fx-font-size: 13px;
+ -fx-text-fill: #00759E;
+ -fx-padding: 0 0 0 8;
+}
+
+.search-popup:focused {
+ -fx-border-color: #0056b3 transparent #0056b3 #0056b3;
+}
+
+.search-popup:hover {
+ -fx-border-color: #00A5E1;
+}
+
+.search-popup-button {
+ -fx-background-radius: 0 8 8 0;
+ -fx-border-radius: 0 8 8 0;
+ -fx-border-color: #00759E #00759E #00759E transparent;
+ -fx-border-width: 1px 1px 1px 0;
+ -fx-background-color: white;
+ -fx-pref-height: 32px;
+ -fx-min-height: 32px;
+ -fx-max-height: 32px;
+ -fx-pref-width: 32px;
+ -fx-font-size: 13px;
+ -fx-cursor: hand;
+ -fx-text-fill: #00759E;
+ -fx-padding: 0;
+ -fx-content-display: center;
+ -fx-alignment: center;
+}
+
+.search-popup-button:hover {
+ -fx-text-fill: #00A5E1;
+}
+.search-popup-button:pressed {
+ -fx-text-fill: #77DDFF;
}
\ No newline at end of file
diff --git a/src/main/resources/layouts/externalProjectSync.fxml b/src/main/resources/layouts/externalProjectSync.fxml
index c88a404a..7e5adfab 100644
--- a/src/main/resources/layouts/externalProjectSync.fxml
+++ b/src/main/resources/layouts/externalProjectSync.fxml
@@ -1,23 +1,16 @@
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
+
-
-
+
+
@@ -30,13 +23,13 @@
-
+
-
+
@@ -50,21 +43,19 @@
-
+
-
-
-
-
-
-
-
+
+
+
+
+
@@ -76,7 +67,7 @@
-
+