Bläddra i källkod

adding new client gui with vaadin

Klaas, Wilfried 6 år sedan
förälder
incheckning
e61ee3c9ab
30 ändrade filer med 2677 tillägg och 48 borttagningar
  1. 0 3
      SPSEmulator-client/pom.xml
  2. 75 0
      SPSEmulator-client/src/main/java/de/mcs/tools/sps/emulator/client/SPSEmulatorClient.java
  3. 8 0
      SPSEmulator-client/src/main/java/de/mcs/tools/sps/emulator/client/package-info.java
  4. 18 44
      SPSEmulator-client/src/test/java/de/mcs/tools/sps/emulator/TestRESTEndpoints.java
  5. 33 0
      SPSEmulator-gui/.classpath
  6. 36 0
      SPSEmulator-gui/.project
  7. 36 0
      SPSEmulator-gui/README.md
  8. 136 0
      SPSEmulator-gui/pom.xml
  9. BIN
      SPSEmulator-gui/spsemulatorgui.zip
  10. 86 0
      SPSEmulator-gui/src/main/java/de/mcs/tools/sps/backend/Category.java
  11. 206 0
      SPSEmulator-gui/src/main/java/de/mcs/tools/sps/backend/CategoryService.java
  12. 184 0
      SPSEmulator-gui/src/main/java/de/mcs/tools/sps/backend/Review.java
  13. 168 0
      SPSEmulator-gui/src/main/java/de/mcs/tools/sps/backend/ReviewService.java
  14. 111 0
      SPSEmulator-gui/src/main/java/de/mcs/tools/sps/backend/StaticData.java
  15. 72 0
      SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/MainLayout.java
  16. 252 0
      SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/common/AbstractEditorDialog.java
  17. 128 0
      SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/common/ConfirmationDialog.java
  18. 29 0
      SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/encoders/LocalDateToStringEncoder.java
  19. 41 0
      SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/encoders/LongToStringEncoder.java
  20. 55 0
      SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/views/assemblerview/AssemblerView.java
  21. 8 0
      SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/views/assemblerview/package-info.java
  22. 165 0
      SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/views/categorieslist/CategoriesList.java
  23. 73 0
      SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/views/categorieslist/CategoryEditorDialog.java
  24. 141 0
      SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/views/reviewslist/ReviewEditorDialog.java
  25. 129 0
      SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/views/reviewslist/ReviewsList.java
  26. 6 0
      SPSEmulator-gui/src/main/resources/banner.txt
  27. 280 0
      SPSEmulator-gui/src/main/webapp/frontend/src/views/reviewslist/reviews-list.html
  28. 200 0
      SPSEmulator-gui/src/main/webapp/frontend/styles/shared-styles.html
  29. BIN
      SPSEmulator-gui/src/main/webapp/icons/icon.png
  30. 1 1
      SPSEmulator-service/src/main/java/de/mcs/tools/sps/emulator/model/ProgramModel.java

+ 0 - 3
SPSEmulator-client/pom.xml

@@ -130,19 +130,16 @@
 			<groupId>org.glassfish.jersey.core</groupId>
 			<artifactId>jersey-client</artifactId>
 			<version>${jersey.client.version}</version>
-			<scope>test</scope>
 		</dependency>
 		<dependency>
 			<groupId>org.glassfish.jersey.media</groupId>
 			<artifactId>jersey-media-json-jackson</artifactId>
 			<version>${jersey.client.version}</version>
-			<scope>test</scope>
 		</dependency>
 		<dependency>
 			<groupId>org.glassfish.jersey.inject</groupId>
 			<artifactId>jersey-hk2</artifactId>
 			<version>${jersey.client.version}</version>
-			<scope>test</scope>
 		</dependency>
 		<dependency>
 			<groupId>de.mcs.tools.sps</groupId>

+ 75 - 0
SPSEmulator-client/src/main/java/de/mcs/tools/sps/emulator/client/SPSEmulatorClient.java

@@ -0,0 +1,75 @@
+/**
+ * 
+ */
+package de.mcs.tools.sps.emulator.client;
+
+import java.security.KeyManagementException;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.X509Certificate;
+
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+import javax.ws.rs.client.Client;
+import javax.ws.rs.client.ClientBuilder;
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.client.Invocation.Builder;
+import javax.ws.rs.client.WebTarget;
+import javax.ws.rs.core.MediaType;
+
+import de.mcs.tools.sps.emulator.model.WebSessionModel;
+
+/**
+ * @author w.klaas
+ *
+ */
+public class SPSEmulatorClient {
+
+  private Builder invocationBuilder;
+
+  /**
+   * @throws NoSuchAlgorithmException
+   * @throws KeyManagementException
+   * 
+   */
+  public SPSEmulatorClient(String url) throws NoSuchAlgorithmException, KeyManagementException {
+    TrustManager[] noopTrustManager = new TrustManager[] { new X509TrustManager() {
+
+      @Override
+      public X509Certificate[] getAcceptedIssuers() {
+        return null;
+      }
+
+      @Override
+      public void checkClientTrusted(java.security.cert.X509Certificate[] certs, String authType) {
+      }
+
+      @Override
+      public void checkServerTrusted(java.security.cert.X509Certificate[] certs, String authType) {
+      }
+    } };
+
+    javax.net.ssl.HttpsURLConnection.setDefaultHostnameVerifier(new javax.net.ssl.HostnameVerifier() {
+
+      public boolean verify(String hostname, javax.net.ssl.SSLSession sslSession) {
+        return true;
+      }
+    });
+
+    SSLContext sc = SSLContext.getInstance("ssl");
+    sc.init(null, noopTrustManager, null);
+
+    Client client = ClientBuilder.newBuilder().sslContext(sc).build();
+    WebTarget webTarget = client.target(url);
+    invocationBuilder = webTarget.request(MediaType.APPLICATION_JSON);
+  }
+
+  public WebSessionModel getNewWebsession() {
+    return invocationBuilder.get(WebSessionModel.class);
+  }
+
+  public WebSessionModel processWebSession(WebSessionModel webSessionModel) {
+    return invocationBuilder.post(Entity.entity(webSessionModel, MediaType.APPLICATION_JSON), WebSessionModel.class);
+  }
+
+}

+ 8 - 0
SPSEmulator-client/src/main/java/de/mcs/tools/sps/emulator/client/package-info.java

@@ -0,0 +1,8 @@
+/**
+ * 
+ */
+/**
+ * @author w.klaas
+ *
+ */
+package de.mcs.tools.sps.emulator.client;

+ 18 - 44
SPSEmulator-client/src/test/java/de/mcs/tools/sps/emulator/TestRESTEndpoints.java

@@ -21,6 +21,7 @@
  */
 package de.mcs.tools.sps.emulator;
 
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -28,25 +29,15 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.security.KeyManagementException;
 import java.security.NoSuchAlgorithmException;
-import java.security.cert.X509Certificate;
 import java.util.List;
 import java.util.Set;
 
-import javax.net.ssl.SSLContext;
-import javax.net.ssl.TrustManager;
-import javax.net.ssl.X509TrustManager;
-import javax.ws.rs.client.Client;
-import javax.ws.rs.client.ClientBuilder;
-import javax.ws.rs.client.Entity;
-import javax.ws.rs.client.Invocation;
-import javax.ws.rs.client.WebTarget;
-import javax.ws.rs.core.MediaType;
-
 import org.apache.commons.io.IOUtils;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import de.mcs.tools.sps.emulator.client.SPSEmulatorClient;
 import de.mcs.tools.sps.emulator.model.CommandModel.COMMAND;
 import de.mcs.tools.sps.emulator.model.WebSessionModel;
 
@@ -56,39 +47,11 @@ import de.mcs.tools.sps.emulator.model.WebSessionModel;
  */
 class TestRESTEndpoints {
 
-  private static Invocation.Builder invocationBuilder;
+  private static SPSEmulatorClient client;
 
   @BeforeAll
   public static void init() throws KeyManagementException, NoSuchAlgorithmException {
-    TrustManager[] noopTrustManager = new TrustManager[] { new X509TrustManager() {
-
-      @Override
-      public X509Certificate[] getAcceptedIssuers() {
-        return null;
-      }
-
-      @Override
-      public void checkClientTrusted(java.security.cert.X509Certificate[] certs, String authType) {
-      }
-
-      @Override
-      public void checkServerTrusted(java.security.cert.X509Certificate[] certs, String authType) {
-      }
-    } };
-
-    javax.net.ssl.HttpsURLConnection.setDefaultHostnameVerifier(new javax.net.ssl.HostnameVerifier() {
-
-      public boolean verify(String hostname, javax.net.ssl.SSLSession sslSession) {
-        return true;
-      }
-    });
-
-    SSLContext sc = SSLContext.getInstance("ssl");
-    sc.init(null, noopTrustManager, null);
-
-    Client client = ClientBuilder.newBuilder().sslContext(sc).build();
-    WebTarget webTarget = client.target("https://127.0.0.1:8443/emulator");
-    invocationBuilder = webTarget.request(MediaType.APPLICATION_JSON);
+    client = new SPSEmulatorClient("https://127.0.0.1:8443/emulator");
   }
 
   /**
@@ -100,8 +63,10 @@ class TestRESTEndpoints {
 
   @Test
   void test() throws IOException {
-    WebSessionModel webSessionModel = invocationBuilder.get(WebSessionModel.class);
+
+    WebSessionModel webSessionModel = client.getNewWebsession();
     assertNotNull(webSessionModel);
+    assertFalse(webSessionModel.isError());
     System.out.println(webSessionModel.toString());
 
     try (InputStream inputStream = ClassLoader.getSystemResourceAsStream("test.tps")) {
@@ -114,11 +79,20 @@ class TestRESTEndpoints {
     assertNotNull(availableCommands);
     assertTrue(availableCommands.contains(COMMAND.COMPILE));
     webSessionModel.getCommand().setActualCommand(COMMAND.COMPILE);
-    webSessionModel = invocationBuilder.post(Entity.entity(webSessionModel, MediaType.APPLICATION_JSON),
-        WebSessionModel.class);
+
+    webSessionModel = client.processWebSession(webSessionModel);
     assertNotNull(webSessionModel);
+    assertFalse(webSessionModel.isError());
     assertTrue(webSessionModel.getProgram().getBin().length > 10);
     System.out.println(webSessionModel.toString());
+
+    assertTrue(availableCommands.contains(COMMAND.START));
+    webSessionModel.getCommand().setActualCommand(COMMAND.START);
+    webSessionModel = client.processWebSession(webSessionModel);
+    assertNotNull(webSessionModel);
+    assertFalse(webSessionModel.isError());
+    assertFalse(webSessionModel.getProgram().isModified());
+    System.out.println(webSessionModel.toString());
   }
 
 }

+ 33 - 0
SPSEmulator-gui/.classpath

@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<classpath>
+	<classpathentry kind="src" output="target/classes" path="src/main/java">
+		<attributes>
+			<attribute name="optional" value="true"/>
+			<attribute name="maven.pomderived" value="true"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry excluding="**" kind="src" output="target/classes" path="src/main/resources">
+		<attributes>
+			<attribute name="maven.pomderived" value="true"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry kind="src" output="target/test-classes" path="src/test/java">
+		<attributes>
+			<attribute name="optional" value="true"/>
+			<attribute name="maven.pomderived" value="true"/>
+			<attribute name="test" value="true"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8">
+		<attributes>
+			<attribute name="maven.pomderived" value="true"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
+		<attributes>
+			<attribute name="maven.pomderived" value="true"/>
+			<attribute name="org.eclipse.jst.component.dependency" value="/WEB-INF/lib"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry kind="output" path="target/classes"/>
+</classpath>

+ 36 - 0
SPSEmulator-gui/.project

@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<projectDescription>
+	<name>SPSEmulator-gui</name>
+	<comment></comment>
+	<projects>
+	</projects>
+	<buildSpec>
+		<buildCommand>
+			<name>org.eclipse.jdt.core.javabuilder</name>
+			<arguments>
+			</arguments>
+		</buildCommand>
+		<buildCommand>
+			<name>org.eclipse.wst.common.project.facet.core.builder</name>
+			<arguments>
+			</arguments>
+		</buildCommand>
+		<buildCommand>
+			<name>org.eclipse.m2e.core.maven2Builder</name>
+			<arguments>
+			</arguments>
+		</buildCommand>
+		<buildCommand>
+			<name>org.eclipse.wst.validation.validationbuilder</name>
+			<arguments>
+			</arguments>
+		</buildCommand>
+	</buildSpec>
+	<natures>
+		<nature>org.eclipse.jem.workbench.JavaEMFNature</nature>
+		<nature>org.eclipse.wst.common.modulecore.ModuleCoreNature</nature>
+		<nature>org.eclipse.jdt.core.javanature</nature>
+		<nature>org.eclipse.m2e.core.maven2Nature</nature>
+		<nature>org.eclipse.wst.common.project.facet.core.nature</nature>
+	</natures>
+</projectDescription>

+ 36 - 0
SPSEmulator-gui/README.md

@@ -0,0 +1,36 @@
+[![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/vaadin-flow/Lobby#?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
+
+# Beverage Buddy App Starter for Vaadin Flow
+:coffee::tea::sake::baby_bottle::beer::cocktail::tropical_drink::wine_glass:
+
+This is a Vaadin platform Java example application, used to demonstrate features of the Vaadin Flow framework.
+
+The easiest way of using it is via [https://vaadin.com/start](https://vaadin.com/start/v10-simple-ui) - you can choose the package naming you want.
+
+The Starter demonstrates the core Vaadin Flow concepts:
+* Building UIs in Java with Components based on [Vaadin components](https://vaadin.com/components/browse), such as `TextField`, `Button`, `ComboBox`, `DatePicker`, `VerticalLayout` and `Grid` (see `CategoriesList`)
+* [Creating forms with `Binder`](https://github.com/vaadin/free-starter-flow/blob/master/documentation/using-binder-in-review-editor-dialog.asciidoc) (`ReviewEditorDialog`)
+* Making reusable Components on server side with `Composite` (`AbstractEditorDialog`)
+* [Creating a Component based on a HTML Template](https://github.com/vaadin/free-starter-flow/blob/master/documentation/polymer-template-based-view.asciidoc) (`ReviewsList`) 
+  * This template can be opened and edited with [the Vaadin Designer](https://vaadin.com/designer)
+* [Creating Navigation with the Router API](https://github.com/vaadin/free-starter-flow/blob/master/documentation/using-annotation-based-router-api.asciidoc) (`MainLayout`, `ReviewsList`, `CategoriesList`)
+
+## Prerequisites
+
+The project can be imported into the IDE of your choice, with Java 8 installed, as a Maven project.
+
+## Running the Project
+
+1. Run using `mvn jetty:run`
+2. Wait for the application to start
+3. Open http://localhost:8080/ to view the application
+
+## Documentation
+
+Brief introduction to the application parts can be found from the `documentation` folder. For Vaadin documentation for Java users, see [Vaadin.com/docs](https://vaadin.com/docs/v10/flow/Overview.html).
+
+### Branching information
+* `master` the latest version of the starter, using the latest platform snapshot
+* `V10` the version for Vaadin 10
+* `V11` the version for Vaadin 11
+* `V12` the version for Vaadin 12

+ 136 - 0
SPSEmulator-gui/pom.xml

@@ -0,0 +1,136 @@
+<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>de.mcs.tools.sps</groupId>
+    <artifactId>spsemulator-gui</artifactId>
+    <name>SPSEmulator-GUI</name>
+    <version>1.0-SNAPSHOT</version>
+    <packaging>war</packaging>
+
+    <properties>
+        <maven.compiler.source>1.8</maven.compiler.source>
+        <maven.compiler.target>1.8</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+        <failOnMissingWebXml>false</failOnMissingWebXml>
+
+        <vaadin.version>12.0.5</vaadin.version>
+        <jetty.version>9.4.11.v20180605</jetty.version>
+    </properties>
+
+    <repositories>
+        <!-- Repository used by many Vaadin add-ons -->
+        <repository>
+            <id>Vaadin Directory</id>
+            <url>http://maven.vaadin.com/vaadin-addons</url>
+        </repository>
+        <!-- Repository needed for the prerelease versions of Vaadin -->
+        <repository>
+            <id>vaadin-prereleases</id>
+            <url>https://maven.vaadin.com/vaadin-prereleases</url>
+        </repository>
+    </repositories>
+
+    <pluginRepositories>
+        <!-- Repository needed for the prerelease versions of Vaadin -->
+        <pluginRepository>
+            <id>vaadin-prereleases</id>
+            <url>https://maven.vaadin.com/vaadin-prereleases</url>
+        </pluginRepository>
+    </pluginRepositories>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>com.vaadin</groupId>
+                <artifactId>vaadin-bom</artifactId>
+                <type>pom</type>
+                <scope>import</scope>
+                <version>${vaadin.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.vaadin</groupId>
+            <artifactId>vaadin-core</artifactId>
+        </dependency>
+
+        <!-- Added to provide logging output as Flow uses -->
+        <!-- the unbound SLF4J no-operation (NOP) logger implementation -->
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-simple</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>javax.servlet</groupId>
+            <artifactId>javax.servlet-api</artifactId>
+            <version>3.1.0</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>commons-beanutils</groupId>
+            <artifactId>commons-beanutils</artifactId>
+            <version>1.9.2</version>
+            <type>jar</type>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.eclipse.jetty</groupId>
+                <artifactId>jetty-maven-plugin</artifactId>
+                <version>${jetty.version}</version>
+            </plugin>
+        </plugins>
+    </build>
+
+    <profiles>
+        <profile>
+            <!-- Production mode can be activated with either property or profile -->
+            <id>production-mode</id>
+            <activation>
+                <property>
+                    <name>vaadin.productionMode</name>
+                </property>
+            </activation>
+
+            <properties>
+                <vaadin.productionMode>true</vaadin.productionMode>
+            </properties>
+
+            <dependencies>
+                <dependency>
+                    <groupId>com.vaadin</groupId>
+                    <artifactId>flow-server-production-mode</artifactId>
+                </dependency>
+            </dependencies>
+
+            <build>
+                <plugins>
+                    <plugin>
+                        <groupId>com.vaadin</groupId>
+                        <artifactId>vaadin-maven-plugin</artifactId>
+                        <version>${vaadin.version}</version>
+                        <executions>
+                            <execution>
+                                <goals>
+                                    <goal>copy-production-files</goal>
+                                    <goal>package-for-production</goal>
+                                </goals>
+                            </execution>
+                        </executions>
+                    </plugin>
+
+                    <plugin>
+                        <groupId>org.apache.maven.plugins</groupId>
+                        <artifactId>maven-war-plugin</artifactId>
+                        <version>3.1.0</version>
+                    </plugin>
+                </plugins>
+            </build>
+        </profile>
+    </profiles>
+</project>

BIN
SPSEmulator-gui/spsemulatorgui.zip


+ 86 - 0
SPSEmulator-gui/src/main/java/de/mcs/tools/sps/backend/Category.java

@@ -0,0 +1,86 @@
+package de.mcs.tools.sps.backend;
+
+import java.io.Serializable;
+import java.util.Objects;
+
+/**
+ * Represents a beverage category.
+ */
+public class Category implements Serializable {
+
+    private Long id = null;
+
+    private String name = "";
+
+    public Category() {
+    }
+
+    public Category(String name) {
+        this.name = name;
+    }
+
+    public Category(Category other) {
+        Objects.requireNonNull(other);
+        this.name = other.getName();
+        this.id = other.getId();
+    }
+
+    public Long getId() {
+        return id;
+    }
+
+    public void setId(Long id) {
+        this.id = id;
+    }
+
+    /**
+     * Gets the value of name
+     *
+     * @return the value of name
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * Sets the value name
+     *
+     * @param name
+     *            new value of name
+     */
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    @Override
+    public String toString() {
+        // Must use getters instead of direct member access,
+        // to make it work with proxy objects generated by the view model
+        return "Category{" + getId() + ":" + getName() + '}';
+    }
+
+    @Override
+    public int hashCode() {
+        if (getId() == null) {
+            return super.hashCode();
+        }
+        return getId().hashCode();
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!(obj instanceof Category)) {
+            return false;
+        }
+        Category other = (Category) obj;
+        if (getId() == null) {
+            if (other.getId() != null)
+                return false;
+        } else if (!getId().equals(other.getId()))
+            return false;
+        return true;
+    }
+}

+ 206 - 0
SPSEmulator-gui/src/main/java/de/mcs/tools/sps/backend/CategoryService.java

@@ -0,0 +1,206 @@
+package de.mcs.tools.sps.backend;
+
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.stream.Collectors;
+
+/**
+ * Simple backend service to store and retrieve {@link Category} instances.
+ */
+public class CategoryService {
+
+    /**
+     * Helper class to initialize the singleton Service in a thread-safe way and
+     * to keep the initialization ordering clear between the two services. See
+     * also: https://en.wikipedia.org/wiki/Initialization-on-demand_holder_idiom
+     */
+    private static class SingletonHolder {
+        static final CategoryService INSTANCE = createDemoCategoryService();
+
+        /** This class is not meant to be instantiated. */
+        private SingletonHolder() {
+        }
+
+        private static CategoryService createDemoCategoryService() {
+            CategoryService categoryService = new CategoryService();
+            Set<String> categoryNames = new LinkedHashSet<>(
+                    StaticData.BEVERAGES.values());
+
+            categoryNames.forEach(name -> {
+                Category category = categoryService
+                        .doSaveCategory(new Category(name));
+                if (StaticData.UNDEFINED.equals(name)) {
+                    categoryService.undefinedCategoryId.set(category.getId());
+                }
+            });
+
+            return categoryService;
+        }
+    }
+
+    private Map<Long, Category> categories = new HashMap<>();
+    private AtomicLong nextId = new AtomicLong(0);
+    private AtomicLong undefinedCategoryId = new AtomicLong(-1);
+
+    /**
+     * Declared private to ensure uniqueness of this Singleton.
+     */
+    private CategoryService() {
+    }
+
+    /**
+     * Gets the unique instance of this Singleton.
+     *
+     * @return the unique instance of this Singleton
+     */
+    public static CategoryService getInstance() {
+        return SingletonHolder.INSTANCE;
+    }
+
+    /**
+     * Returns a dedicated undefined category.
+     *
+     * @return the undefined category
+     */
+    public Category getUndefinedCategory() {
+        return categories.get(undefinedCategoryId.get());
+    }
+
+    /**
+     * Fetches the categories whose name matches the given filter text.
+     *
+     * The matching is case insensitive. When passed an empty filter text, the
+     * method returns all categories. The returned list is ordered by name.
+     *
+     * @param filter
+     *            the filter text
+     * @return the list of matching categories
+     */
+    public List<Category> findCategories(String filter) {
+        String normalizedFilter = filter.toLowerCase();
+
+        // Make a copy of each matching item to keep entities and DTOs separated
+        return categories.values().stream()
+                .filter(c -> c
+                        .getName().toLowerCase().contains(normalizedFilter))
+                .map(Category::new)
+                .sorted((c1, c2) -> c1.getName()
+                        .compareToIgnoreCase(c2.getName()))
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * Searches for the exact category whose name matches the given filter text.
+     *
+     * The matching is case insensitive.
+     *
+     * @param name
+     *            the filter text
+     * @return an {@link Optional} containing the category if found, or
+     *         {@link Optional#empty()}
+     * @throws IllegalStateException
+     *             if the result is ambiguous
+     */
+    public Optional<Category> findCategoryByName(String name) {
+        List<Category> categoriesMatching = findCategories(name);
+
+        if (categoriesMatching.isEmpty()) {
+            return Optional.empty();
+        }
+        if (categoriesMatching.size() > 1) {
+            throw new IllegalStateException(
+                    "Category " + name + " is ambiguous");
+        }
+        return Optional.of(categoriesMatching.get(0));
+    }
+
+    /**
+     * Fetches the exact category whose name matches the given filter text.
+     *
+     * Behaves like {@link #findCategoryByName(String)}, except that returns a
+     * {@link Category} instead of an {@link Optional}. If the category can't be
+     * identified, an exception is thrown.
+     *
+     * @param name
+     *            the filter text
+     * @return the category, if found
+     * @throws IllegalStateException
+     *             if not exactly one category matches the given name
+     */
+    public Category findCategoryOrThrow(String name) {
+        return findCategoryByName(name)
+                .orElseThrow(() -> new IllegalStateException(
+                        "Category " + name + " does not exist"));
+    }
+
+    /**
+     * Searches for the exact category with the given id.
+     *
+     * @param id
+     *            the category id
+     * @return an {@link Optional} containing the category if found, or
+     *         {@link Optional#empty()}
+     */
+    public Optional<Category> findCategoryById(Long id) {
+        Category category = categories.get(id);
+        return Optional.ofNullable(category);
+    }
+
+    /**
+     * Deletes the given category from the category store.
+     *
+     * @param category
+     *            the category to delete
+     * @return true if the operation was successful, otherwise false
+     */
+    public boolean deleteCategory(Category category) {
+        if (category.getId() != null
+                && undefinedCategoryId.get() == category.getId().longValue()) {
+            throw new IllegalArgumentException(
+                    "Undefined category may not be removed");
+        }
+        return categories.remove(category.getId()) != null;
+    }
+
+    /**
+     * Persists the given category into the category store.
+     *
+     * If the category is already persistent, the saved category will get
+     * updated with the name of the given category object. If the category is
+     * new (i.e. its id is null), it will get a new unique id before being
+     * saved.
+     *
+     * @param dto
+     *            the category to save
+     */
+    public void saveCategory(Category dto) {
+        doSaveCategory(dto);
+    }
+
+    private Category doSaveCategory(Category dto) {
+        Category entity = categories.get(dto.getId());
+
+        if (entity == null) {
+            // Make a copy to keep entities and DTOs separated
+            entity = new Category(dto);
+            if (dto.getId() == null) {
+                entity.setId(nextId.incrementAndGet());
+            }
+            categories.put(entity.getId(), entity);
+        } else if (undefinedCategoryId.get() == dto.getId().longValue()
+                && !Objects.equals(entity.getName(), dto.getName())) {
+            throw new IllegalArgumentException(
+                    "Undefined category may not be renamed");
+        } else {
+            entity.setName(dto.getName());
+        }
+        return entity;
+    }
+
+}

+ 184 - 0
SPSEmulator-gui/src/main/java/de/mcs/tools/sps/backend/Review.java

@@ -0,0 +1,184 @@
+package de.mcs.tools.sps.backend;
+
+import java.io.Serializable;
+import java.time.LocalDate;
+
+/**
+ * Represents a beverage review.
+ */
+public class Review implements Serializable {
+
+    private Long id = null;
+    private int score;
+    private String name;
+    private LocalDate date;
+    private Category category;
+    private int count;
+
+    /**
+     * Default constructor.
+     */
+    public Review() {
+        reset();
+    }
+
+    /**
+     * Constructs a new instance with the given data.
+     *
+     * @param score
+     *            Review score
+     * @param name
+     *            Name of beverage reviewed
+     * @param date
+     *            Last review date
+     * @param category
+     *            Category of beverage
+     * @param count
+     *            Times tasted
+     */
+    public Review(int score, String name, LocalDate date, Category category,
+            int count) {
+        this.score = score;
+        this.name = name;
+        this.date = date;
+        this.category = new Category(category);
+        this.count = count;
+    }
+
+    /**
+     * Copy constructor.
+     *
+     * @param other
+     *            The instance to copy
+     */
+    public Review(Review other) {
+        this(other.getScore(), other.getName(), other.getDate(),
+                other.getCategory(), other.getCount());
+        this.id = other.getId();
+    }
+
+    /**
+     * Resets all fields to their default values.
+     */
+    public void reset() {
+        this.id = null;
+        this.score = 1;
+        this.name = "";
+        this.date = LocalDate.now();
+        this.category = null;
+        this.count = 1;
+    }
+
+    public Long getId() {
+        return id;
+    }
+
+    public void setId(Long id) {
+        this.id = id;
+    }
+
+    /**
+     * Gets the value of score
+     *
+     * @return the value of score
+     */
+    public int getScore() {
+        return score;
+    }
+
+    /**
+     * Sets the value of score
+     *
+     * @param score
+     *            new value of Score
+     */
+    public void setScore(int score) {
+        this.score = score;
+    }
+
+    /**
+     * Gets the value of name
+     *
+     * @return the value of name
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * Sets the value of name
+     *
+     * @param name
+     *            new value of name
+     */
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    /**
+     * Gets the value of category
+     *
+     * @return the value of category
+     */
+    public Category getCategory() {
+        return category;
+    }
+
+    /**
+     * Sets the value of category
+     *
+     * @param category
+     *            new value of category
+     */
+    public void setCategory(Category category) {
+        this.category = category;
+    }
+
+    /**
+     * Gets the value of date
+     *
+     * @return the value of date
+     */
+    public LocalDate getDate() {
+        return date;
+    }
+
+    /**
+     * Sets the value of date
+     *
+     * @param date
+     *            new value of date
+     */
+    public void setDate(LocalDate date) {
+        this.date = date;
+    }
+
+    /**
+     * Gets the value of count
+     *
+     * @return the value of count
+     */
+    public int getCount() {
+        return count;
+    }
+
+    /**
+     * Sets the value of count
+     *
+     * @param count
+     *            new value of count
+     */
+    public void setCount(int count) {
+        this.count = count;
+    }
+
+    @Override
+    public String toString() {
+        // Must use getters instead of direct member access,
+        // to make it work with proxy objects generated by the view model
+        return "Review{" + "id=" + getId() + ", score=" + getScore() + ", name="
+                + getName() + ", category=" + getCategory() + ", date="
+                + getDate() + ", count=" + getCount() + '}';
+    }
+
+}

+ 168 - 0
SPSEmulator-gui/src/main/java/de/mcs/tools/sps/backend/ReviewService.java

@@ -0,0 +1,168 @@
+package de.mcs.tools.sps.backend;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import de.mcs.tools.sps.ui.encoders.LocalDateToStringEncoder;
+
+/**
+ * Simple backend service to store and retrieve {@link Review} instances.
+ */
+public class ReviewService {
+
+    /**
+     * Helper class to initialize the singleton Service in a thread-safe way and
+     * to keep the initialization ordering clear between the two services. See
+     * also: https://en.wikipedia.org/wiki/Initialization-on-demand_holder_idiom
+     */
+    private static class SingletonHolder {
+        static final ReviewService INSTANCE = createDemoReviewService();
+
+        /** This class is not meant to be instantiated. */
+        private SingletonHolder() {
+        }
+
+        private static ReviewService createDemoReviewService() {
+            final ReviewService reviewService = new ReviewService();
+            Random r = new Random();
+            int reviewCount = 20 + r.nextInt(30);
+            List<Map.Entry<String, String>> beverages = new ArrayList<>(
+                    StaticData.BEVERAGES.entrySet());
+
+            for (int i = 0; i < reviewCount; i++) {
+                Review review = new Review();
+                Map.Entry<String, String> beverage = beverages
+                        .get(r.nextInt(StaticData.BEVERAGES.size()));
+                Category category = CategoryService.getInstance()
+                        .findCategoryOrThrow(beverage.getValue());
+
+                review.setName(beverage.getKey());
+                LocalDate testDay = getRandomDate();
+                review.setDate(testDay);
+                review.setScore(1 + r.nextInt(5));
+                review.setCategory(category);
+                review.setCount(1 + r.nextInt(15));
+                reviewService.saveReview(review);
+            }
+
+            return reviewService;
+        }
+
+        private static LocalDate getRandomDate() {
+            long minDay = LocalDate.of(1930, 1, 1).toEpochDay();
+            long maxDay = LocalDate.now().toEpochDay();
+            long randomDay = ThreadLocalRandom.current().nextLong(minDay,
+                    maxDay);
+            return LocalDate.ofEpochDay(randomDay);
+        }
+    }
+
+    private Map<Long, Review> reviews = new HashMap<>();
+    private AtomicLong nextId = new AtomicLong(0);
+
+    /**
+     * Declared private to ensure uniqueness of this Singleton.
+     */
+    private ReviewService() {
+    }
+
+    /**
+     * Gets the unique instance of this Singleton.
+     *
+     * @return the unique instance of this Singleton
+     */
+    public static ReviewService getInstance() {
+        return SingletonHolder.INSTANCE;
+    }
+
+    /**
+     * Fetches the reviews matching the given filter text.
+     *
+     * The matching is case insensitive. When passed an empty filter text, the
+     * method returns all categories. The returned list is ordered by name.
+     *
+     * @param filter
+     *            the filter text
+     * @return the list of matching reviews
+     */
+    public List<Review> findReviews(String filter) {
+        String normalizedFilter = filter.toLowerCase();
+
+        return reviews.values().stream().filter(
+                review -> filterTextOf(review).contains(normalizedFilter))
+                .sorted((r1, r2) -> r2.getId().compareTo(r1.getId()))
+                .collect(Collectors.toList());
+    }
+
+    private String filterTextOf(Review review) {
+        LocalDateToStringEncoder dateConverter = new LocalDateToStringEncoder();
+        // Use a delimiter which can't be entered in the search box,
+        // to avoid false positives
+        String filterableText = Stream
+                .of(review.getName(),
+                        review.getCategory() == null ? StaticData.UNDEFINED
+                                : review.getCategory().getName(),
+                        String.valueOf(review.getScore()),
+                        String.valueOf(review.getCount()),
+                        dateConverter.encode(review.getDate()))
+                .collect(Collectors.joining("\t"));
+        return filterableText.toLowerCase();
+    }
+
+    /**
+     * Deletes the given review from the review store.
+     *
+     * @param review
+     *            the review to delete
+     * @return true if the operation was successful, otherwise false
+     */
+    public boolean deleteReview(Review review) {
+        return reviews.remove(review.getId()) != null;
+    }
+
+    /**
+     * Persists the given review into the review store.
+     *
+     * If the review is already persistent, the saved review will get updated
+     * with the field values of the given review object. If the review is new
+     * (i.e. its id is null), it will get a new unique id before being saved.
+     *
+     * @param dto
+     *            the review to save
+     */
+    public void saveReview(Review dto) {
+        Review entity = reviews.get(dto.getId());
+        Category category = dto.getCategory();
+
+        if (category != null) {
+            // The case when the category is new (not persisted yet, thus
+            // has null id) is not handled here, because it can't currently
+            // occur via the UI.
+            // Note that Category.UNDEFINED also gets mapped to null.
+            category = CategoryService.getInstance()
+                    .findCategoryById(category.getId()).orElse(null);
+        }
+        if (entity == null) {
+            // Make a copy to keep entities and DTOs separated
+            entity = new Review(dto);
+            if (dto.getId() == null) {
+                entity.setId(nextId.incrementAndGet());
+            }
+            reviews.put(entity.getId(), entity);
+        } else {
+            entity.setScore(dto.getScore());
+            entity.setName(dto.getName());
+            entity.setDate(dto.getDate());
+            entity.setCount(dto.getCount());
+        }
+        entity.setCategory(category);
+    }
+}

+ 111 - 0
SPSEmulator-gui/src/main/java/de/mcs/tools/sps/backend/StaticData.java

@@ -0,0 +1,111 @@
+package de.mcs.tools.sps.backend;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.stream.Stream;
+
+class StaticData {
+
+    private static final String MINERAL_WATER = "Mineral Water";
+    private static final String SOFT_DRINK = "Soft Drink";
+    private static final String COFFEE = "Coffee";
+    private static final String TEA = "Tea";
+    private static final String DAIRY = "Dairy";
+    private static final String CIDER = "Cider";
+    private static final String BEER = "Beer";
+    private static final String WINE = "Wine";
+    private static final String OTHER = "Other";
+
+    public static final String UNDEFINED = "Undefined";
+    
+    static final Map<String, String> BEVERAGES = new LinkedHashMap<>();
+
+    static {
+        Stream.of("Evian",
+                "Voss",
+                "Veen",
+                "San Pellegrino",
+                "Perrier")
+                .forEach(name -> BEVERAGES.put(name, MINERAL_WATER));
+
+        Stream.of("Coca-Cola",
+                "Fanta",
+                "Sprite")
+                .forEach(name -> BEVERAGES.put(name, SOFT_DRINK));
+
+        Stream.of("Maxwell Ready-to-Drink Coffee",
+                "Nescafé Gold",
+                "Starbucks East Timor Tatamailau")
+                .forEach(name -> BEVERAGES.put(name, COFFEE));
+
+        Stream.of("Prince Of Peace Organic White Tea",
+                "Pai Mu Tan White Peony Tea",
+                "Tazo Zen Green Tea",
+                "Dilmah Sencha Green Tea",
+                "Twinings Earl Grey",
+                "Twinings Lady Grey",
+                "Classic Indian Chai")
+                .forEach(name -> BEVERAGES.put(name, TEA));
+
+        Stream.of("Cow's Milk",
+                "Goat's Milk",
+                "Unicorn's Milk",
+                "Salt Lassi",
+                "Mango Lassi",
+                "Airag")
+                .forEach(name -> BEVERAGES.put(name, DAIRY));
+
+        Stream.of("Crowmoor Extra Dry Apple",
+                "Golden Cap Perry",
+                "Somersby Blueberry",
+                "Kopparbergs Naked Apple Cider",
+                "Kopparbergs Raspberry",
+                "Kingstone Press Wild Berry Flavoured Cider",
+                "Crumpton Oaks Apple",
+                "Frosty Jack's",
+                "Ciderboys Mad Bark",
+                "Angry Orchard Stone Dry",
+                "Walden Hollow",
+                "Fox Barrel Wit Pear")
+                .forEach(name -> BEVERAGES.put(name, CIDER));
+
+        Stream.of("Budweiser",
+                "Miller",
+                "Heineken",
+                "Holsten Pilsener",
+                "Krombacher",
+                "Weihenstephaner Hefeweissbier",
+                "Ayinger Kellerbier",
+                "Guinness Draught",
+                "Kilkenny Irish Cream Ale",
+                "Hoegaarden White",
+                "Barbar",
+                "Corsendonk Agnus Dei",
+                "Leffe Blonde",
+                "Chimay Tripel",
+                "Duvel",
+                "Pilsner Urquell",
+                "Kozel",
+                "Staropramen",
+                "Lapin Kulta IVA",
+                "Kukko Pils III",
+                "Finlandia Sahti")
+                .forEach(name -> BEVERAGES.put(name, BEER));
+
+        Stream.of("Jacob's Creek Classic Shiraz",
+                "Chateau d’Yquem Sauternes",
+                "Oremus Tokaji Aszú 5 Puttonyos")
+                .forEach(name -> BEVERAGES.put(name, WINE));
+
+        Stream.of("Pan Galactic Gargle Blaster",
+                "Mead",
+                "Soma")
+                .forEach(name -> BEVERAGES.put(name, OTHER));
+
+        BEVERAGES.put("", UNDEFINED);
+    }
+
+    /** This class is not meant to be instantiated. */
+    private StaticData() {
+    }
+}

+ 72 - 0
SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/MainLayout.java

@@ -0,0 +1,72 @@
+/*
+ * Copyright 2000-2017 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package de.mcs.tools.sps.ui;
+
+import com.vaadin.flow.component.Text;
+import com.vaadin.flow.component.dependency.HtmlImport;
+import com.vaadin.flow.component.html.Div;
+import com.vaadin.flow.component.html.H2;
+import com.vaadin.flow.component.icon.Icon;
+import com.vaadin.flow.component.icon.VaadinIcon;
+import com.vaadin.flow.component.page.Viewport;
+import com.vaadin.flow.router.HighlightConditions;
+import com.vaadin.flow.router.RouterLayout;
+import com.vaadin.flow.router.RouterLink;
+import com.vaadin.flow.server.InitialPageSettings;
+import com.vaadin.flow.server.PWA;
+import com.vaadin.flow.server.PageConfigurator;
+
+import de.mcs.tools.sps.ui.views.assemblerview.AssemblerView;
+import de.mcs.tools.sps.ui.views.categorieslist.CategoriesList;
+
+/**
+ * The main layout contains the header with the navigation buttons, and the child views below that.
+ */
+@HtmlImport("frontend://styles/shared-styles.html")
+@PWA(name = "ArduinoSPS/TinySPS", shortName = "ArduinoSPS")
+@Viewport("width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes")
+public class MainLayout extends Div implements RouterLayout, PageConfigurator {
+
+  public MainLayout() {
+    H2 title = new H2("ArduinoSPS/TinySPS");
+    title.addClassName("main-layout__title");
+
+    RouterLink reviews = new RouterLink(null, AssemblerView.class);
+    reviews.add(new Icon(VaadinIcon.LINES), new Text("Assembler"));
+    reviews.addClassName("main-layout__nav-item");
+    // Only show as active for the exact URL, but not for sub paths
+    reviews.setHighlightCondition(HighlightConditions.sameLocation());
+
+    RouterLink categories = new RouterLink(null, CategoriesList.class);
+    categories.add(new Icon(VaadinIcon.COG), new Text("Emulator"));
+    categories.addClassName("main-layout__nav-item");
+
+    Div navigation = new Div(reviews, categories);
+    navigation.addClassName("main-layout__nav");
+
+    Div header = new Div(title, navigation);
+    header.addClassName("main-layout__header");
+    add(header);
+
+    addClassName("main-layout");
+  }
+
+  @Override
+  public void configurePage(InitialPageSettings settings) {
+    settings.addMetaTag("apple-mobile-web-app-capable", "yes");
+    settings.addMetaTag("apple-mobile-web-app-status-bar-style", "black");
+  }
+}

+ 252 - 0
SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/common/AbstractEditorDialog.java

@@ -0,0 +1,252 @@
+/*
+ * Copyright 2000-2017 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package de.mcs.tools.sps.ui.common;
+
+import java.io.Serializable;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.button.ButtonVariant;
+import com.vaadin.flow.component.dialog.Dialog;
+import com.vaadin.flow.component.formlayout.FormLayout;
+import com.vaadin.flow.component.html.Div;
+import com.vaadin.flow.component.html.H3;
+import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
+import com.vaadin.flow.data.binder.Binder;
+import com.vaadin.flow.data.binder.BinderValidationStatus;
+import com.vaadin.flow.shared.Registration;
+
+/**
+ * Abstract base class for dialogs adding, editing or deleting items.
+ *
+ * Subclasses are expected to
+ * <ul>
+ * <li>add, during construction, the needed UI components to
+ * {@link #getFormLayout()} and bind them using {@link #getBinder()}, as well
+ * as</li>
+ * <li>override {@link #confirmDelete()} to open the confirmation dialog with
+ * the desired message (by calling
+ * {@link #openConfirmationDialog(String, String, String)}.</li>
+ * </ul>
+ *
+ * @param <T>
+ *            the type of the item to be added, edited or deleted
+ */
+public abstract class AbstractEditorDialog<T extends Serializable>
+        extends Dialog {
+
+    /**
+     * The operations supported by this dialog. Delete is enabled when editing
+     * an already existing item.
+     */
+    public enum Operation {
+        ADD("New", "add", false), EDIT("Edit", "edit", true);
+
+        private final String nameInTitle;
+        private final String nameInText;
+        private final boolean deleteEnabled;
+
+        Operation(String nameInTitle, String nameInText,
+                boolean deleteEnabled) {
+            this.nameInTitle = nameInTitle;
+            this.nameInText = nameInText;
+            this.deleteEnabled = deleteEnabled;
+        }
+
+        public String getNameInTitle() {
+            return nameInTitle;
+        }
+
+        public String getNameInText() {
+            return nameInText;
+        }
+
+        public boolean isDeleteEnabled() {
+            return deleteEnabled;
+        }
+    }
+
+    private final H3 titleField = new H3();
+    private final Button saveButton = new Button("Save");
+    private final Button cancelButton = new Button("Cancel");
+    private final Button deleteButton = new Button("Delete");
+    private Registration registrationForSave;
+
+    private final FormLayout formLayout = new FormLayout();
+    private final HorizontalLayout buttonBar = new HorizontalLayout(saveButton,
+            cancelButton, deleteButton);
+
+    private Binder<T> binder = new Binder<>();
+    private T currentItem;
+
+    private final ConfirmationDialog<T> confirmationDialog = new ConfirmationDialog<>();
+
+    private final String itemType;
+    private final BiConsumer<T, Operation> itemSaver;
+    private final Consumer<T> itemDeleter;
+
+    /**
+     * Constructs a new instance.
+     *
+     * @param itemType
+     *            The readable name of the item type
+     * @param itemSaver
+     *            Callback to save the edited item
+     * @param itemDeleter
+     *            Callback to delete the edited item
+     */
+    protected AbstractEditorDialog(String itemType,
+            BiConsumer<T, Operation> itemSaver, Consumer<T> itemDeleter) {
+        this.itemType = itemType;
+        this.itemSaver = itemSaver;
+        this.itemDeleter = itemDeleter;
+
+        initTitle();
+        initFormLayout();
+        initButtonBar();
+        setCloseOnEsc(true);
+        setCloseOnOutsideClick(false);
+    }
+
+    private void initTitle() {
+        add(titleField);
+    }
+
+    private void initFormLayout() {
+        formLayout.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1),
+                new FormLayout.ResponsiveStep("25em", 2));
+        Div div = new Div(formLayout);
+        div.addClassName("has-padding");
+        add(div);
+    }
+
+    private void initButtonBar() {
+        saveButton.setAutofocus(true);
+        saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
+        cancelButton.addClickListener(e -> close());
+        deleteButton.addClickListener(e -> deleteClicked());
+        deleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR);
+        buttonBar.setClassName("buttons");
+        buttonBar.setSpacing(true);
+        add(buttonBar);
+    }
+
+    /**
+     * Gets the form layout, where additional components can be added for
+     * displaying or editing the item's properties.
+     *
+     * @return the form layout
+     */
+    protected final FormLayout getFormLayout() {
+        return formLayout;
+    }
+
+    /**
+     * Gets the binder.
+     *
+     * @return the binder
+     */
+    protected final Binder<T> getBinder() {
+        return binder;
+    }
+
+    /**
+     * Gets the item currently being edited.
+     *
+     * @return the item currently being edited
+     */
+    protected final T getCurrentItem() {
+        return currentItem;
+    }
+
+    /**
+     * Opens the given item for editing in the dialog.
+     *
+     * @param item
+     *            The item to edit; it may be an existing or a newly created
+     *            instance
+     * @param operation
+     *            The operation being performed on the item
+     */
+    public final void open(T item, Operation operation) {
+        currentItem = item;
+        titleField.setText(operation.getNameInTitle() + " " + itemType);
+        if (registrationForSave != null) {
+            registrationForSave.remove();
+        }
+        registrationForSave = saveButton
+                .addClickListener(e -> saveClicked(operation));
+        binder.readBean(currentItem);
+
+        deleteButton.setEnabled(operation.isDeleteEnabled());
+        open();
+    }
+
+    private void saveClicked(Operation operation) {
+        boolean isValid = binder.writeBeanIfValid(currentItem);
+
+        if (isValid) {
+            itemSaver.accept(currentItem, operation);
+            close();
+        } else {
+            BinderValidationStatus<T> status = binder.validate();
+        }
+    }
+
+    private void deleteClicked() {
+        if (confirmationDialog.getElement().getParent() == null) {
+            getUI().ifPresent(ui -> ui.add(confirmationDialog));
+        }
+        confirmDelete();
+    }
+
+    protected abstract void confirmDelete();
+
+    /**
+     * Opens the confirmation dialog before deleting the current item.
+     *
+     * The dialog will display the given title and message(s), then call
+     * {@link #deleteConfirmed(Serializable)} if the Delete button is clicked.
+     *
+     * @param title
+     *            The title text
+     * @param message
+     *            Detail message (optional, may be empty)
+     * @param additionalMessage
+     *            Additional message (optional, may be empty)
+     */
+    protected final void openConfirmationDialog(String title, String message,
+            String additionalMessage) {
+        confirmationDialog.open(title, message, additionalMessage, "Delete",
+                true, getCurrentItem(), this::deleteConfirmed, null);
+    }
+
+    /**
+     * Removes the {@code item} from the backend and close the dialog.
+     *
+     * @param item
+     *            the item to delete
+     */
+    protected void doDelete(T item) {
+        itemDeleter.accept(item);
+        close();
+    }
+
+    private void deleteConfirmed(T item) {
+        doDelete(item);
+    }
+}

+ 128 - 0
SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/common/ConfirmationDialog.java

@@ -0,0 +1,128 @@
+/*
+ * Copyright 2000-2017 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package de.mcs.tools.sps.ui.common;
+
+import java.io.Serializable;
+import java.util.function.Consumer;
+
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.button.ButtonVariant;
+import com.vaadin.flow.component.dialog.Dialog;
+import com.vaadin.flow.component.html.Div;
+import com.vaadin.flow.component.html.H3;
+import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
+import com.vaadin.flow.shared.Registration;
+
+/**
+ * A generic dialog for confirming or cancelling an action.
+ *
+ * @param <T>
+ *            The type of the action's subject
+ */
+class ConfirmationDialog<T extends Serializable> extends Dialog {
+
+    private final H3 titleField = new H3();
+    private final Div messageLabel = new Div();
+    private final Div extraMessageLabel = new Div();
+    private final Button confirmButton = new Button();
+    private final Button cancelButton = new Button("Cancel");
+    private Registration registrationForConfirm;
+    private Registration registrationForCancel;
+
+    private static final Runnable NO_OP = () -> {
+    };
+
+    /**
+     * Constructor.
+     */
+    public ConfirmationDialog() {
+        setCloseOnEsc(true);
+        setCloseOnOutsideClick(false);
+
+        confirmButton.addClickListener(e -> close());
+        confirmButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
+        confirmButton.setAutofocus(true);
+        cancelButton.addClickListener(e -> close());
+        cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
+
+        HorizontalLayout buttonBar = new HorizontalLayout(confirmButton,
+                cancelButton);
+        buttonBar.setClassName("buttons confirm-buttons");
+
+        Div labels = new Div(messageLabel, extraMessageLabel);
+        labels.setClassName("confirm-text");
+
+        titleField.setClassName("confirm-title");
+
+        add(titleField, labels, buttonBar);
+    }
+
+    /**
+     * Opens the confirmation dialog.
+     *
+     * The dialog will display the given title and message(s), then call
+     * <code>confirmHandler</code> if the Confirm button is clicked, or
+     * <code>cancelHandler</code> if the Cancel button is clicked.
+     *
+     * @param title
+     *            The title text
+     * @param message
+     *            Detail message (optional, may be empty)
+     * @param additionalMessage
+     *            Additional message (optional, may be empty)
+     * @param actionName
+     *            The action name to be shown on the Confirm button
+     * @param isDisruptive
+     *            True if the action is disruptive, such as deleting an item
+     * @param item
+     *            The subject of the action
+     * @param confirmHandler
+     *            The confirmation handler function
+     * @param cancelHandler
+     *            The cancellation handler function
+     */
+    public void open(String title, String message, String additionalMessage,
+            String actionName, boolean isDisruptive, T item,
+            Consumer<T> confirmHandler, Runnable cancelHandler) {
+        titleField.setText(title);
+        messageLabel.setText(message);
+        extraMessageLabel.setText(additionalMessage);
+        confirmButton.setText(actionName);
+
+        Runnable cancelAction = cancelHandler == null ? NO_OP : cancelHandler;
+
+        if (registrationForConfirm != null) {
+            registrationForConfirm.remove();
+        }
+        registrationForConfirm = confirmButton
+                .addClickListener(e -> confirmHandler.accept(item));
+        if (registrationForCancel != null) {
+            registrationForCancel.remove();
+        }
+        registrationForCancel = cancelButton
+                .addClickListener(e -> cancelAction.run());
+        this.addOpenedChangeListener(e -> {
+            if (!e.isOpened()) {
+                cancelAction.run();
+            }
+        });
+        confirmButton.removeThemeVariants(ButtonVariant.LUMO_ERROR);
+        if (isDisruptive) {
+            confirmButton.addThemeVariants(ButtonVariant.LUMO_ERROR);
+        }
+        open();
+    }
+}

+ 29 - 0
SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/encoders/LocalDateToStringEncoder.java

@@ -0,0 +1,29 @@
+package de.mcs.tools.sps.ui.encoders;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+
+import com.vaadin.flow.templatemodel.ModelEncoder;
+
+/**
+ * Converts between DateTime-objects and their String-representations
+ *
+ */
+
+public class LocalDateToStringEncoder
+        implements ModelEncoder<LocalDate, String> {
+
+    public static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter
+            .ofPattern("MM/dd/yyyy");
+
+    @Override
+    public LocalDate decode(String presentationValue) {
+        return LocalDate.parse(presentationValue, DATE_FORMAT);
+    }
+
+    @Override
+    public String encode(LocalDate modelValue) {
+        return modelValue == null ? null : modelValue.format(DATE_FORMAT);
+    }
+
+}

+ 41 - 0
SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/encoders/LongToStringEncoder.java

@@ -0,0 +1,41 @@
+/*
+ * Copyright 2000-2017 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package de.mcs.tools.sps.ui.encoders;
+
+import com.vaadin.flow.templatemodel.ModelEncoder;
+
+/**
+ * @author Vaadin Ltd
+ *
+ */
+
+public class LongToStringEncoder implements ModelEncoder<Long, String> {
+
+    @Override
+    public String encode(Long modelValue) {
+        if (modelValue == null)
+            return null;
+        return modelValue.toString();
+    }
+
+    @Override
+    public Long decode(String presentationValue) {
+        if (presentationValue == null)
+            return null;
+        return Long.parseLong(presentationValue);
+    }
+
+}

+ 55 - 0
SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/views/assemblerview/AssemblerView.java

@@ -0,0 +1,55 @@
+/**
+ * 
+ */
+package de.mcs.tools.sps.ui.views.assemblerview;
+
+import com.vaadin.flow.component.combobox.ComboBox;
+import com.vaadin.flow.component.html.Div;
+import com.vaadin.flow.component.html.H2;
+import com.vaadin.flow.component.orderedlayout.VerticalLayout;
+import com.vaadin.flow.component.textfield.TextArea;
+import com.vaadin.flow.router.PageTitle;
+import com.vaadin.flow.router.Route;
+
+import de.mcs.tools.sps.ui.MainLayout;
+
+/**
+ * @author w.klaas
+ *
+ */
+@Route(value = "", layout = MainLayout.class)
+@PageTitle("Assembler")
+public class AssemblerView extends VerticalLayout {
+
+  private final H2 header = new H2("SPSAssembler");
+  private final TextArea source = new TextArea("source");
+  private final ComboBox<String> hardware = new ComboBox<>("Hardware:");
+  private final ComboBox<String> outputFormat = new ComboBox<>("Output Format:");
+
+  /**
+   * 
+   */
+  public AssemblerView() {
+    initView();
+
+    Div viewToolbar = new Div();
+    viewToolbar.addClassName("view-toolbar");
+    viewToolbar.add(header);
+    viewToolbar.add(hardware);
+    viewToolbar.add(outputFormat);
+    add(viewToolbar);
+
+    VerticalLayout container = new VerticalLayout();
+    container.setClassName("view-container");
+    container.setAlignItems(Alignment.STRETCH);
+    source.setSizeFull();
+    container.add(source);
+    add(container);
+  }
+
+  private void initView() {
+    addClassName("assembler-view");
+    setDefaultHorizontalComponentAlignment(Alignment.STRETCH);
+  }
+
+}

+ 8 - 0
SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/views/assemblerview/package-info.java

@@ -0,0 +1,8 @@
+/**
+ * 
+ */
+/**
+ * @author w.klaas
+ *
+ */
+package de.mcs.tools.sps.ui.views.assemblerview;

+ 165 - 0
SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/views/categorieslist/CategoriesList.java

@@ -0,0 +1,165 @@
+/*
+ * Copyright 2000-2017 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package de.mcs.tools.sps.ui.views.categorieslist;
+
+import java.util.List;
+
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.button.ButtonVariant;
+import com.vaadin.flow.component.grid.Grid;
+import com.vaadin.flow.component.grid.Grid.SelectionMode;
+import com.vaadin.flow.component.html.Div;
+import com.vaadin.flow.component.html.H2;
+import com.vaadin.flow.component.icon.Icon;
+import com.vaadin.flow.component.notification.Notification;
+import com.vaadin.flow.component.notification.Notification.Position;
+import com.vaadin.flow.component.orderedlayout.VerticalLayout;
+import com.vaadin.flow.component.textfield.TextField;
+import com.vaadin.flow.data.renderer.ComponentRenderer;
+import com.vaadin.flow.data.value.ValueChangeMode;
+import com.vaadin.flow.router.PageTitle;
+import com.vaadin.flow.router.Route;
+import de.mcs.tools.sps.backend.Category;
+import de.mcs.tools.sps.backend.CategoryService;
+import de.mcs.tools.sps.backend.Review;
+import de.mcs.tools.sps.backend.ReviewService;
+import de.mcs.tools.sps.ui.MainLayout;
+import de.mcs.tools.sps.ui.common.AbstractEditorDialog;
+
+/**
+ * Displays the list of available categories, with a search filter as well as
+ * buttons to add a new category or edit existing ones.
+ */
+@Route(value = "categories", layout = MainLayout.class)
+@PageTitle("Categories List")
+public class CategoriesList extends VerticalLayout {
+
+    private final TextField searchField = new TextField("",
+            "Search categories");
+    private final H2 header = new H2("Categories");
+    private final Grid<Category> grid = new Grid<>();
+
+    private final CategoryEditorDialog form = new CategoryEditorDialog(
+            this::saveCategory, this::deleteCategory);
+
+    public CategoriesList() {
+        initView();
+
+        addSearchBar();
+        addContent();
+
+        updateView();
+    }
+
+    private void initView() {
+        addClassName("categories-list");
+        setDefaultHorizontalComponentAlignment(Alignment.STRETCH);
+    }
+
+    private void addSearchBar() {
+        Div viewToolbar = new Div();
+        viewToolbar.addClassName("view-toolbar");
+
+        searchField.setPrefixComponent(new Icon("lumo", "search"));
+        searchField.addClassName("view-toolbar__search-field");
+        searchField.addValueChangeListener(e -> updateView());
+        searchField.setValueChangeMode(ValueChangeMode.EAGER);
+
+        Button newButton = new Button("New category", new Icon("lumo", "plus"));
+        newButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
+        newButton.addClassName("view-toolbar__button");
+        newButton.addClickListener(e -> form.open(new Category(),
+                AbstractEditorDialog.Operation.ADD));
+
+        viewToolbar.add(searchField, newButton);
+        add(viewToolbar);
+    }
+
+    private void addContent() {
+        VerticalLayout container = new VerticalLayout();
+        container.setClassName("view-container");
+        container.setAlignItems(Alignment.STRETCH);
+
+        grid.addColumn(Category::getName).setHeader("Name").setWidth("8em")
+                .setResizable(true);
+        grid.addColumn(this::getReviewCount).setHeader("Beverages")
+                .setWidth("6em");
+        grid.addColumn(new ComponentRenderer<>(this::createEditButton))
+                .setFlexGrow(0);
+        grid.setSelectionMode(SelectionMode.NONE);
+
+        container.add(header, grid);
+        add(container);
+    }
+
+    private Button createEditButton(Category category) {
+        Button edit = new Button("Edit", event -> form.open(category,
+                AbstractEditorDialog.Operation.EDIT));
+        edit.setIcon(new Icon("lumo", "edit"));
+        edit.addClassName("review__edit");
+        edit.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
+        if (CategoryService.getInstance().getUndefinedCategory().getId()
+                .equals(category.getId())) {
+            edit.setEnabled(false);
+        }
+        return edit;
+    }
+
+    private String getReviewCount(Category category) {
+        List<Review> reviewsInCategory = ReviewService.getInstance()
+                .findReviews(category.getName());
+        int sum = reviewsInCategory.stream().mapToInt(Review::getCount).sum();
+        return Integer.toString(sum);
+    }
+
+    private void updateView() {
+        List<Category> categories = CategoryService.getInstance()
+                .findCategories(searchField.getValue());
+        grid.setItems(categories);
+
+        if (searchField.getValue().length() > 0) {
+            header.setText("Search for “" + searchField.getValue() + "”");
+        } else {
+            header.setText("Categories");
+        }
+    }
+
+    private void saveCategory(Category category,
+            AbstractEditorDialog.Operation operation) {
+        CategoryService.getInstance().saveCategory(category);
+
+        Notification.show(
+                "Category successfully " + operation.getNameInText() + "ed.",
+                3000, Position.BOTTOM_START);
+        updateView();
+    }
+
+    private void deleteCategory(Category category) {
+        List<Review> reviewsInCategory = ReviewService.getInstance()
+                .findReviews(category.getName());
+
+        reviewsInCategory.forEach(review -> {
+            review.setCategory(
+                    CategoryService.getInstance().getUndefinedCategory());
+            ReviewService.getInstance().saveReview(review);
+        });
+        CategoryService.getInstance().deleteCategory(category);
+
+        Notification.show("Category successfully deleted.", 3000,
+                Position.BOTTOM_START);
+        updateView();
+    }
+}

+ 73 - 0
SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/views/categorieslist/CategoryEditorDialog.java

@@ -0,0 +1,73 @@
+/*
+ * Copyright 2000-2017 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package de.mcs.tools.sps.ui.views.categorieslist;
+
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+import com.vaadin.flow.component.textfield.TextField;
+import com.vaadin.flow.data.validator.StringLengthValidator;
+import de.mcs.tools.sps.backend.Category;
+import de.mcs.tools.sps.backend.CategoryService;
+import de.mcs.tools.sps.backend.ReviewService;
+import de.mcs.tools.sps.ui.common.AbstractEditorDialog;
+
+/**
+ * A dialog for editing {@link Category} objects.
+ */
+public class CategoryEditorDialog extends AbstractEditorDialog<Category> {
+
+    private final TextField categoryNameField = new TextField("Name");
+
+    public CategoryEditorDialog(BiConsumer<Category, Operation> itemSaver,
+            Consumer<Category> itemDeleter) {
+        super("category", itemSaver, itemDeleter);
+
+        addNameField();
+    }
+
+    private void addNameField() {
+        getFormLayout().add(categoryNameField);
+
+        getBinder().forField(categoryNameField)
+                .withConverter(String::trim, String::trim)
+                .withValidator(new StringLengthValidator(
+                        "Category name must contain at least 3 printable characters",
+                        3, null))
+                .withValidator(
+                        name -> CategoryService.getInstance()
+                                .findCategories(name).size() == 0,
+                        "Category name must be unique")
+                .bind(Category::getName, Category::setName);
+    }
+
+    @Override
+    protected void confirmDelete() {
+        int reviewCount = ReviewService.getInstance()
+                .findReviews(getCurrentItem().getName()).size();
+        if (reviewCount > 0) {
+            openConfirmationDialog("Delete category",
+                    "Are you sure you want to delete the “"
+                            + getCurrentItem().getName()
+                            + "” category? There are " + reviewCount
+                            + " reviews associated with this category.",
+                    "Deleting the category will mark the associated reviews as “undefined”. "
+                            + "You can edit individual reviews to select another category.");
+        } else {
+            doDelete(getCurrentItem());
+        }
+    }
+}

+ 141 - 0
SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/views/reviewslist/ReviewEditorDialog.java

@@ -0,0 +1,141 @@
+/*
+ * Copyright 2000-2017 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package de.mcs.tools.sps.ui.views.reviewslist;
+
+import java.time.LocalDate;
+import java.util.Objects;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+import com.vaadin.flow.component.combobox.ComboBox;
+import com.vaadin.flow.component.datepicker.DatePicker;
+import com.vaadin.flow.component.textfield.TextField;
+import com.vaadin.flow.data.converter.StringToIntegerConverter;
+import com.vaadin.flow.data.validator.DateRangeValidator;
+import com.vaadin.flow.data.validator.IntegerRangeValidator;
+import com.vaadin.flow.data.validator.StringLengthValidator;
+import de.mcs.tools.sps.backend.Category;
+import de.mcs.tools.sps.backend.CategoryService;
+import de.mcs.tools.sps.backend.Review;
+import de.mcs.tools.sps.ui.common.AbstractEditorDialog;
+
+/**
+ * A dialog for editing {@link Review} objects.
+ */
+public class ReviewEditorDialog extends AbstractEditorDialog<Review> {
+
+    private transient CategoryService categoryService = CategoryService
+            .getInstance();
+
+    private ComboBox<Category> categoryBox = new ComboBox<>();
+    private ComboBox<String> scoreBox = new ComboBox<>();
+    private DatePicker lastTasted = new DatePicker();
+    private TextField beverageName = new TextField();
+    private TextField timesTasted = new TextField();
+
+    public ReviewEditorDialog(BiConsumer<Review, Operation> saveHandler,
+            Consumer<Review> deleteHandler) {
+        super("review", saveHandler, deleteHandler);
+
+        createNameField();
+        createCategoryBox();
+        createDatePicker();
+        createTimesField();
+        createScoreBox();
+    }
+
+    private void createScoreBox() {
+        scoreBox.setLabel("Rating");
+        scoreBox.setRequired(true);
+        scoreBox.setAllowCustomValue(false);
+        scoreBox.setItems("1", "2", "3", "4", "5");
+        getFormLayout().add(scoreBox);
+
+        getBinder().forField(scoreBox)
+                .withConverter(new StringToIntegerConverter(0,
+                        "The score should be a number."))
+                .withValidator(new IntegerRangeValidator(
+                        "The tasting count must be between 1 and 5.", 1, 5))
+                .bind(Review::getScore, Review::setScore);
+    }
+
+    private void createDatePicker() {
+        lastTasted.setLabel("Last tasted");
+        lastTasted.setRequired(true);
+        lastTasted.setMax(LocalDate.now());
+        lastTasted.setMin(LocalDate.of(1, 1, 1));
+        lastTasted.setValue(LocalDate.now());
+        getFormLayout().add(lastTasted);
+
+        getBinder().forField(lastTasted)
+                .withValidator(Objects::nonNull,
+                        "The date should be in MM/dd/yyyy format.")
+                .withValidator(new DateRangeValidator(
+                        "The date should be neither Before Christ nor in the future.",
+                        LocalDate.of(1, 1, 1), LocalDate.now()))
+                .bind(Review::getDate, Review::setDate);
+
+    }
+
+    private void createCategoryBox() {
+        categoryBox.setLabel("Category");
+        categoryBox.setRequired(true);
+        categoryBox.setItemLabelGenerator(Category::getName);
+        categoryBox.setAllowCustomValue(false);
+        categoryBox.setItems(categoryService.findCategories(""));
+        getFormLayout().add(categoryBox);
+
+        getBinder().forField(categoryBox)
+                .withValidator(Objects::nonNull,
+                        "The category should be defined.")
+                .bind(Review::getCategory, Review::setCategory);
+    }
+
+    private void createTimesField() {
+        timesTasted.setLabel("Times tasted");
+        timesTasted.setRequired(true);
+        timesTasted.setPattern("[0-9]*");
+        timesTasted.setPreventInvalidInput(true);
+        getFormLayout().add(timesTasted);
+
+        getBinder().forField(timesTasted)
+                .withConverter(
+                        new StringToIntegerConverter(0, "Must enter a number."))
+                .withValidator(new IntegerRangeValidator(
+                        "The tasting count must be between 1 and 99.", 1, 99))
+                .bind(Review::getCount, Review::setCount);
+    }
+
+    private void createNameField() {
+        beverageName.setLabel("Beverage");
+        beverageName.setRequired(true);
+        getFormLayout().add(beverageName);
+
+        getBinder().forField(beverageName)
+                .withConverter(String::trim, String::trim)
+                .withValidator(new StringLengthValidator(
+                        "Beverage name must contain at least 3 printable characters",
+                        3, null))
+                .bind(Review::getName, Review::setName);
+    }
+
+    @Override
+    protected void confirmDelete() {
+        openConfirmationDialog("Delete review",
+                "Are you sure you want to delete the review for “" + getCurrentItem().getName() + "”?", "");
+    }
+
+}

+ 129 - 0
SPSEmulator-gui/src/main/java/de/mcs/tools/sps/ui/views/reviewslist/ReviewsList.java

@@ -0,0 +1,129 @@
+/*
+ * Copyright 2000-2017 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package de.mcs.tools.sps.ui.views.reviewslist;
+
+import java.util.List;
+
+import com.vaadin.flow.component.Tag;
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.dependency.HtmlImport;
+import com.vaadin.flow.component.html.H2;
+import com.vaadin.flow.component.html.Span;
+import com.vaadin.flow.component.notification.Notification;
+import com.vaadin.flow.component.notification.Notification.Position;
+import com.vaadin.flow.component.polymertemplate.EventHandler;
+import com.vaadin.flow.component.polymertemplate.Id;
+import com.vaadin.flow.component.polymertemplate.ModelItem;
+import com.vaadin.flow.component.polymertemplate.PolymerTemplate;
+import com.vaadin.flow.component.textfield.TextField;
+import com.vaadin.flow.data.value.ValueChangeMode;
+import com.vaadin.flow.router.PageTitle;
+import com.vaadin.flow.router.Route;
+import com.vaadin.flow.templatemodel.Encode;
+import com.vaadin.flow.templatemodel.TemplateModel;
+
+import de.mcs.tools.sps.backend.Review;
+import de.mcs.tools.sps.backend.ReviewService;
+import de.mcs.tools.sps.ui.MainLayout;
+import de.mcs.tools.sps.ui.common.AbstractEditorDialog;
+import de.mcs.tools.sps.ui.encoders.LocalDateToStringEncoder;
+import de.mcs.tools.sps.ui.encoders.LongToStringEncoder;
+import de.mcs.tools.sps.ui.views.reviewslist.ReviewsList.ReviewsModel;
+
+/**
+ * Displays the list of available categories, with a search filter as well as buttons to add a new category or edit
+ * existing ones.
+ *
+ * Implemented using a simple template.
+ */
+@Route(value = "reviews", layout = MainLayout.class)
+@PageTitle("Review List")
+@Tag("reviews-list")
+@HtmlImport("frontend://src/views/reviewslist/reviews-list.html")
+public class ReviewsList extends PolymerTemplate<ReviewsModel> {
+
+  public interface ReviewsModel extends TemplateModel {
+    @Encode(value = LongToStringEncoder.class, path = "id")
+    @Encode(value = LocalDateToStringEncoder.class, path = "date")
+    @Encode(value = LongToStringEncoder.class, path = "category.id")
+    void setReviews(List<Review> reviews);
+  }
+
+  @Id("search")
+  private TextField search;
+  @Id("newReview")
+  private Button addReview;
+  @Id("header")
+  private H2 header;
+
+  private ReviewEditorDialog reviewForm = new ReviewEditorDialog(this::saveUpdate, this::deleteUpdate);
+
+  public ReviewsList() {
+    search.setPlaceholder("Search reviews");
+    search.addValueChangeListener(e -> updateList());
+    search.setValueChangeMode(ValueChangeMode.EAGER);
+
+    addReview.addClickListener(e -> openForm(new Review(), AbstractEditorDialog.Operation.ADD));
+
+    // Set review button and edit button text from Java
+    getElement().setProperty("reviewButtonText", "New review");
+    getElement().setProperty("editButtonText", "Edit");
+
+    updateList();
+
+  }
+
+  public void saveUpdate(Review review, AbstractEditorDialog.Operation operation) {
+    ReviewService.getInstance().saveReview(review);
+    updateList();
+    Notification.show("Beverage successfully " + operation.getNameInText() + "ed.", 3000, Position.BOTTOM_START);
+  }
+
+  public void deleteUpdate(Review review) {
+    ReviewService.getInstance().deleteReview(review);
+    updateList();
+    Notification.show("Beverage successfully deleted.", 3000, Position.BOTTOM_START);
+  }
+
+  private void updateList() {
+    List<Review> reviews = ReviewService.getInstance().findReviews(search.getValue());
+    if (search.isEmpty()) {
+      header.setText("Reviews");
+      header.add(new Span(reviews.size() + " in total"));
+    } else {
+      header.setText("Search for “" + search.getValue() + "”");
+      if (!reviews.isEmpty()) {
+        header.add(new Span(reviews.size() + " results"));
+      }
+    }
+    getModel().setReviews(reviews);
+  }
+
+  @EventHandler
+  private void edit(@ModelItem Review review) {
+    openForm(review, AbstractEditorDialog.Operation.EDIT);
+  }
+
+  private void openForm(Review review, AbstractEditorDialog.Operation operation) {
+    // Add the form lazily as the UI is not yet initialized when
+    // this view is constructed
+    if (reviewForm.getElement().getParent() == null) {
+      getUI().ifPresent(ui -> ui.add(reviewForm));
+    }
+    reviewForm.open(review, operation);
+  }
+
+}

+ 6 - 0
SPSEmulator-gui/src/main/resources/banner.txt

@@ -0,0 +1,6 @@
+ ____   ____   ____   _____                    _         _                  ____  _   _  ___ 
+/ ___| |  _ \ / ___| | ____| _ __ ___   _   _ | |  __ _ | |_   ___   _ __  / ___|| | | ||_ _|
+\___ \ | |_) |\___ \ |  _|  | '_ ` _ \ | | | || | / _` || __| / _ \ | '__|| |  _ | | | | | | 
+ ___) ||  __/  ___) || |___ | | | | | || |_| || || (_| || |_ | (_) || |   | |_| || |_| | | | 
+|____/ |_|    |____/ |_____||_| |_| |_| \__,_||_| \__,_| \__| \___/ |_|    \____| \___/ |___|
+                                                                                             

+ 280 - 0
SPSEmulator-gui/src/main/webapp/frontend/src/views/reviewslist/reviews-list.html

@@ -0,0 +1,280 @@
+<!--
+  ~ Copyright 2000-2017 Vaadin Ltd.
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License"); you may not
+  ~ use this file except in compliance with the License. You may obtain a copy of
+  ~ the License at
+  ~
+  ~ http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+  ~ WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+  ~ License for the specific language governing permissions and limitations under
+  ~ the License.
+  -->
+
+<!-- Dependency resources -->
+<link rel="import" href="../../../bower_components/polymer/polymer-element.html">
+<!-- Added Web Component dependencies to make Vaadin Designer preview work -->
+<link rel="import" href="../../../bower_components/vaadin-text-field/src/vaadin-text-field.html">
+<link rel="import" href="../../../bower_components/vaadin-button/src/vaadin-button.html">
+<link rel="import" href="../../../bower_components/iron-icon/iron-icon.html">
+<link rel="import" href="../../../bower_components/vaadin-lumo-styles/badge.html">
+<!-- TODO Needed to show icons in Vaadin Designer preview mode for now, but can be removed soon -->
+<link rel="import" href="../../../bower_components/vaadin-lumo-styles/icons.html">
+
+
+<!-- Defines the reviews-list element -->
+<dom-module id="reviews-list">
+    <template>
+        <style include="lumo-color lumo-typography lumo-badge view-styles">
+            :host {
+                display: block;
+            }
+
+            #header {
+                display: flex;
+                justify-content: space-between;
+                flex-wrap: wrap;
+                align-items: baseline;
+            }
+
+            /* Subtitle for the header */
+            #header span {
+                display: block;
+                font-size: var(--lumo-font-size-s);
+                font-weight: 400;
+                color: var(--lumo-secondary-text-color);
+                letter-spacing: 0;
+                margin-top: 0.3em;
+                margin-left: auto;
+                margin-right: 20px;
+            }
+
+            .review {
+                display: flex;
+                align-items: center;
+                width: 100%;
+                padding: var(--lumo-space-wide-xl);
+                padding-right: var(--lumo-space-m);
+                box-sizing: border-box;
+                margin-bottom: 8px;
+                background-color: var(--lumo-base-color);
+                box-shadow: 0 0 0 1px var(--lumo-shade-5pct), 0 2px 5px 0 var(--lumo-shade-10pct);
+                border-radius: var(--lumo-border-radius);
+            }
+
+            .review__rating {
+                flex: none;
+                align-self: flex-start;
+                margin: 0 1em 0 0;
+                position: relative;
+                cursor: default;
+            }
+
+            .review__score {
+                display: inline-flex;
+                align-items: center;
+                justify-content: center;
+                border-radius: var(--lumo-border-radius);
+                font-weight: 600;
+                width: 2.5em;
+                height: 2.5em;
+                margin: 0;
+            }
+
+            [data-score="1"] {
+                box-shadow: inset 0 0 0 1px var(--lumo-contrast-10pct);
+            }
+
+            [data-score="2"] {
+                background-color: var(--lumo-contrast-20pct);
+            }
+
+            [data-score="3"] {
+                background-color: var(--lumo-contrast-40pct);
+            }
+
+            [data-score="4"] {
+                background-color: var(--lumo-contrast-60pct);
+                color: var(--lumo-base-color);
+            }
+
+            [data-score="5"] {
+                background-color: var(--lumo-contrast-80pct);
+                color: var(--lumo-base-color);
+            }
+
+            .review__count {
+                position: absolute;
+                display: inline-flex;
+                align-items: center;
+                justify-content: center;
+                height: 20px;
+                min-width: 8px;
+                padding: 0 6px;
+                background: var(--lumo-base-color);
+                color: var(--lumo-secondary-text-color);
+                top: -10px;
+                left: -10px;
+                border-radius: var(--lumo-border-radius);
+                margin: 0;
+                font-size: 12px;
+                font-weight: 500;
+                box-shadow: 0 0 0 1px var(--lumo-contrast-20pct);
+            }
+
+            .review__count span {
+                width: 0;
+                overflow: hidden;
+                white-space: nowrap;
+            }
+
+            .review__rating:hover .review__count span {
+                width: auto;
+                margin-left: 0.4em;
+            }
+
+            .review__details {
+                width: 100%;
+                max-width: calc(100% - 8.5em);
+                flex: auto;
+                line-height: var(--lumo-line-height-xs);
+                overflow: hidden;
+            }
+
+            .review__name {
+                margin: 0 0.5em 0 0;
+                white-space: nowrap;
+                overflow: hidden;
+                text-overflow: ellipsis;
+            }
+
+            .review__category {
+                margin: 0;
+                flex: none;
+            }
+
+            /* We only expect to have 10 categories at most, after which the colors start to rotate */
+            .review__category {
+                color: hsla(calc(340 + 360 / 10 * var(--category)), 40%, 20%, 1);
+                background-color: hsla(calc(340 + 360 / 10 * var(--category)), 60%, 50%, 0.1);
+            }
+
+            .review__date {
+                white-space: nowrap;
+                line-height: var(--lumo-line-height-xs);
+                margin: 0 1em;
+                width: 30%;
+            }
+
+            .review__date h5 {
+                font-size: var(--lumo-font-size-s);
+                font-weight: 400;
+                color: var(--lumo-secondary-text-color);
+                margin: 0;
+            }
+
+            .review__date p {
+                font-size: var(--lumo-font-size-s);
+                margin: 0;
+            }
+
+            .review__edit {
+                align-self: flex-start;
+                flex: none;
+                margin: 0 0 0 auto;
+                width: 5em;
+            }
+
+            .reviews__no-matches {
+                display: flex;
+                align-items: center;
+                justify-content: center;
+                height: 4em;
+                font-size: 22px;
+                color: var(--lumo-tertiary-text-color);
+            }
+
+            /* Small viewport styles */
+
+            @media (max-width: 500px) {
+                .review {
+                    padding: var(--lumo-space-m);
+                    padding-right: var(--lumo-space-s);
+                    flex-wrap: wrap;
+                }
+
+                .review__date {
+                    order: 1;
+                    margin-left: 3.5em;
+                    margin-top: 0.5em;
+                }
+            }
+
+        </style>
+
+        <div class="view-toolbar">
+            <vaadin-text-field id="search" class="view-toolbar__search-field" autocapitalize=off>
+                <iron-icon icon="lumo:search" slot="prefix"></iron-icon>
+            </vaadin-text-field>
+            <vaadin-button id="newReview" class="view-toolbar__button" theme="primary">
+                <iron-icon icon="lumo:plus" slot="prefix"></iron-icon>
+                <span>[[reviewButtonText]]</span>
+            </vaadin-button>
+        </div>
+
+        <div class="view-container reviews">
+            <h2 id="header"></h2>
+            <template is="dom-if" if="{{!_isEmpty(reviews)}}">
+                <template is="dom-repeat" items="[[reviews]]">
+                    <div class="review">
+                        <div class="review__rating">
+                            <p class="review__score" data-score$="[[item.score]]">[[item.score]]</p>
+                            <p class="review__count">
+                                [[item.count]]
+                                <span>times tasted</span>
+                            </p>
+                        </div>
+                        <div class="review__details">
+                            <h4 class="review__name">[[item.name]]</h4>
+                            <template is="dom-if" if="[[item.category]]">
+                                <p class="review__category" theme="badge small" style$="--category: [[item.category.id]];">[[item.category.name]]</p>
+                            </template>
+                            <template is="dom-if" if="[[!item.category]]">
+                                <p class="review__category" style="--category: -1;">Undefined</p>
+                            </template>
+                        </div>
+                        <div class="review__date">
+                            <h5>Last tasted</h5>
+                            <p>[[item.date]]</p>
+                        </div>
+                        <vaadin-button on-click="edit" class="review__edit" theme="tertiary">
+                            <iron-icon icon="lumo:edit"></iron-icon><span>[[editButtonText]]</span>
+                        </vaadin-button>
+                    </div>
+                </template>
+            </template>
+
+            <template is="dom-if" if="{{_isEmpty(reviews)}}">
+                <div class="reviews__no-matches">No matches</div>
+            </template>
+        </div>
+    </template>
+
+    <!-- Polymer boilerplate to register the reviews-list element -->
+    <script>
+        class ReviewListElement extends Polymer.Element {
+            static get is() {
+                return 'reviews-list'
+            }
+
+            _isEmpty(array) {
+                return array.length == 0;
+            }
+        }
+        customElements.define(ReviewListElement.is, ReviewListElement);
+    </script>
+
+</dom-module>

+ 200 - 0
SPSEmulator-gui/src/main/webapp/frontend/styles/shared-styles.html

@@ -0,0 +1,200 @@
+<!--
+  ~ Copyright 2000-2017 Vaadin Ltd.
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License"); you may not
+  ~ use this file except in compliance with the License. You may obtain a copy of
+  ~ the License at
+  ~
+  ~ http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+  ~ WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+  ~ License for the specific language governing permissions and limitations under
+  ~ the License.
+  -->
+
+<link rel="import" href="../bower_components/vaadin-lumo-styles/color.html">
+<link rel="import" href="../bower_components/vaadin-lumo-styles/typography.html">
+
+<dom-module id="view-styles">
+    <template>
+        <style>
+            /* Stretch to fill the entire browser viewport while keeping the content constrained to
+            parent element max-width */
+
+            .view-toolbar {
+                display: flex;
+                background-color: var(--lumo-base-color);
+                box-shadow: 0 1px 0 0 var(--lumo-contrast-10pct);
+                margin: 0 calc(-50vw + 50%);
+                padding: 8px calc(50vw - 50% + 16px);
+                position: relative;
+                z-index: 1;
+                flex: none;
+            }
+
+            .view-toolbar__search-field {
+                flex: auto;
+                min-width: 0;
+                margin-right: 16px;
+            }
+            .view-container {
+                flex: auto;
+            }
+        </style>
+    </template>
+</dom-module>
+
+<custom-style>
+    <style include="view-styles">
+        html {
+            height: auto;
+            --main-layout-header-height: 64px;
+            background-color: transparent !important;
+        }
+
+        body {
+            /* Avoid horizontal scrollbars, mainly on IE11 */
+            overflow-x: hidden;
+            background-color: var(--lumo-contrast-5pct);
+        }
+
+        .main-layout {
+            display: flex;
+            flex-direction: column;
+            width: 100%;
+            height: 100%;
+            min-height: 100vh;
+            max-width: 960px;
+            margin: 0 auto;
+        }
+
+        .main-layout__title {
+            font-size: 1em;
+            margin: 0;
+            /* Allow the nav-items to take all the space so they are centered */
+            width: 0;
+            line-height: 1;
+            letter-spacing: -0.02em;
+            font-weight: 500;
+        }
+
+        .main-layout > * {
+            flex: auto;
+        }
+
+        .main-layout__header {
+            display: flex;
+            flex: none;
+            align-items: center;
+            height: var(--main-layout-header-height);
+
+            /* Stretch to fill the entire browser viewport, while keeping the content constrained to
+               parent element max-width */
+            margin: 0 calc(-50vw + 50%);
+            padding: 0 calc(50vw - 50% + 16px);
+
+            background-color: var(--lumo-base-color);
+            box-shadow: 0 1px 0 0 var(--lumo-contrast-5pct);
+        }
+
+        .main-layout__nav {
+            display: flex;
+            flex: 1;
+            justify-content: center;
+        }
+
+        .main-layout__nav-item {
+            display: inline-flex;
+            flex-direction: column;
+            align-items: center;
+            padding: 4px 8px;
+            cursor: pointer;
+            transition: 0.3s color, 0.3s transform;
+            will-change: transform;
+            -webkit-user-select: none;
+            -moz-user-select: none;
+            -ms-user-select: none;
+            user-select: none;
+            font-size: var(--lumo-font-size-s);
+            color: var(--lumo-secondary-text-color);
+            font-weight: 500;
+            line-height: 1.3;
+        }
+
+        .main-layout__nav-item:hover {
+            text-decoration: none;
+        }
+
+        .main-layout__nav-item:not([highlight]):hover {
+            color: inherit;
+        }
+
+        .main-layout__nav-item[highlight] {
+            color: var(--lumo-primary-text-color);
+            cursor: default;
+        }
+
+        .main-layout__nav-item iron-icon {
+            /* Vaadin icons are using a 16x16 grid */
+            padding: 4px;
+            box-sizing: border-box;
+            pointer-events: none;
+        }
+    </style>
+
+    <dom-module id="my-dialog-styles" theme-for="vaadin-dialog-overlay">
+        <template>
+            <style include="lumo-color lumo-typography">
+                h3 {
+                    margin-top: 0;
+                }
+
+                vaadin-form-layout {
+                    max-width: 30em;
+                }
+
+                .buttons {
+                    padding: var(--lumo-space-s) var(--lumo-space-l);
+                    margin: calc(var(--lumo-space-l) * -1);
+                    margin-top: var(--lumo-space-l);
+                    border-top: 1px solid var(--lumo-contrast-10pct);
+                }
+
+                .buttons > :last-child {
+                    margin-left: auto;
+                }
+
+                .buttons > :nth-last-child(2) {
+                    margin-right: var(--lumo-space-m);
+                }
+
+                .confirm-buttons {
+                    justify-content: space-between;
+                    padding: var(--lumo-space-xs) var(--lumo-space-m);
+                    margin-top: var(--lumo-space-m);
+                }
+
+                .has-padding {
+                    padding: 0 var(--lumo-space-l);
+                    margin: 0 calc(var(--lumo-space-l) * -1);
+                }
+
+                .confirm-text {
+                    max-width: 25em;
+                    line-height: var(--lumo-line-height-s);
+                }
+
+                .confirm-text > * {
+                    margin-bottom: 0.6em;
+                }
+
+                .confirm-text div:not(:first-child) {
+                    color: var(--lumo-secondary-text-color);
+                    font-size: var(--lumo-font-size-s);
+                }
+            </style>
+        </template>
+    </dom-module>
+</custom-style>

BIN
SPSEmulator-gui/src/main/webapp/icons/icon.png


+ 1 - 1
SPSEmulator-service/src/main/java/de/mcs/tools/sps/emulator/model/ProgramModel.java

@@ -131,7 +131,7 @@ public class ProgramModel {
           this.hash = newHash;
           this.setModified(false);
         } else {
-          this.setModified(true);
+          this.setModified(!newHash.equals(hash));
         }
       } catch (UnsupportedEncodingException e) {
         e.printStackTrace();