Explorar o código

Merge branch 'feat/view/observerBindings'

* feat/view/observerBindings:
  Make it so Products (and thus, Recipes) do not track objects by preparation when composing their ingredients list.
  Use ObservableController with an automagic value checker.
  Add separate versions of IngredientPanel for its three main use-cases: - Preparation - Cooking Steps - Summarizing Ingredients Needed
  Skip ingredients that have no prep in prep phase.
  Make PreparationPanel always match what is shown in the steps.
  Fix some bugs and update to observer:0.4.1
  Fix ProductSummaryPanel to be less bad.
  Update with new feature in observable:0.4.0
  Add harness to forward observation events up the stack.
  Add Observable project, mark Ingredients (IngredientPanel) and Products (ProductSummaryPanel).
Sam Jaffe %!s(int64=5) %!d(string=hai) anos
pai
achega
695aae6307

+ 5 - 0
pom.xml

@@ -55,6 +55,11 @@
     </plugins>
   </build>
   <dependencies>
+    <dependency>
+      <groupId>org.leumasjaffe</groupId>
+      <artifactId>observer</artifactId>
+      <version>0.5.0</version>
+    </dependency>
     <dependency>
       <groupId>org.leumasjaffe</groupId>
       <artifactId>event</artifactId>

+ 11 - 7
src/main/lombok/org/leumasjaffe/recipe/model/Card.java

@@ -5,10 +5,14 @@ import java.util.List;
 import java.util.Optional;
 import java.util.stream.Stream;
 
+import org.leumasjaffe.observer.Observable;
+
 import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NonNull;
 
-@Data
-public class Card implements CompoundRecipeComponent {
+@Data @EqualsAndHashCode(callSuper=false)
+public class Card extends Observable.Instance implements CompoundRecipeComponent {	
 	int id = 0; // TODO Fix this
 	int[] dependsOn = {}; // decltype(id)[]
 	String vessel = "";
@@ -21,10 +25,10 @@ public class Card implements CompoundRecipeComponent {
 	}
 	
 	public Stream<? extends RecipeComponent> getComponents() {
-		if (preparation.isPresent()) {
-			return Stream.of(preparation.get());
-		} else {
-			return cooking.stream();
-		}
+		return cooking.stream();
+	}
+	
+	public void setPreparation(final @NonNull Preparation p) {
+		preparation = Optional.of(new Preparation(p.duration, this::getIngredients));
 	}
 }

+ 3 - 9
src/main/lombok/org/leumasjaffe/recipe/model/CompoundRecipeComponent.java

@@ -1,10 +1,10 @@
 package org.leumasjaffe.recipe.model;
 
 import java.util.Collection;
-import java.util.HashMap;
-import java.util.Map;
 import java.util.stream.Stream;
 
+import org.leumasjaffe.recipe.util.Collator;
+
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 
 @JsonIgnoreProperties({"duration", "ingredients", "components", "ingredientsAsStream"})
@@ -20,12 +20,6 @@ interface CompoundRecipeComponent extends RecipeComponent {
 	
 	@Override
 	default Collection<Ingredient> getIngredients() {
-		final Map<String, Ingredient> map = new HashMap<>();
-        getIngredientsAsStream().forEach(i -> {
-        	final String key = i.key();
-            map.computeIfPresent(key, (k, v) -> v.plus(i));
-            map.computeIfAbsent(key, k -> i);
-        });
-        return map.values();
+		return Collator.collateBy(getIngredientsAsStream(), Ingredient::key, Ingredient::plus);
 	}
 }

+ 5 - 2
src/main/lombok/org/leumasjaffe/recipe/model/Ingredient.java

@@ -1,11 +1,14 @@
 package org.leumasjaffe.recipe.model;
 
+import org.leumasjaffe.observer.Observable;
+
 import lombok.AllArgsConstructor;
 import lombok.Data;
+import lombok.EqualsAndHashCode;
 import lombok.NoArgsConstructor;
 
-@Data @AllArgsConstructor @NoArgsConstructor
-public class Ingredient {
+@Data @AllArgsConstructor @NoArgsConstructor @EqualsAndHashCode(callSuper=false)
+public class Ingredient extends Observable.Instance {
 	String name;
 	String preparation = "";
 	Amount amount;

+ 16 - 3
src/main/lombok/org/leumasjaffe/recipe/model/Preparation.java

@@ -1,12 +1,25 @@
 package org.leumasjaffe.recipe.model;
 
-import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
 
+import com.fasterxml.jackson.annotation.JsonIgnore;
+
+import lombok.AllArgsConstructor;
 import lombok.Data;
+import lombok.NoArgsConstructor;
 
-@Data
+@Data @AllArgsConstructor @NoArgsConstructor
 public class Preparation implements RecipeComponent {
-	List<Ingredient> ingredients = new ArrayList<>();
 	Duration duration;
+	@JsonIgnore Supplier<Collection<Ingredient>> producer = Collections::emptyList;
+	
+	@JsonIgnore
+	public List<Ingredient> getIngredients() {
+		return producer.get().stream().filter(i -> !i.getPreparation().isEmpty())
+				.collect(Collectors.toList());
+	}
 }

+ 12 - 3
src/main/lombok/org/leumasjaffe/recipe/model/Product.java

@@ -3,20 +3,29 @@ package org.leumasjaffe.recipe.model;
 import java.util.List;
 import java.util.stream.Stream;
 
+import org.leumasjaffe.observer.Observable;
+import org.leumasjaffe.recipe.util.Collator;
+
 import lombok.Data;
+import lombok.EqualsAndHashCode;
 
-@Data
-public class Product implements CompoundRecipeComponent {
+@Data @EqualsAndHashCode(callSuper=false)
+public class Product extends Observable.Instance implements CompoundRecipeComponent {
 	String name;
 	List<Card> cards;
 
 	@Override
 	public Stream<Ingredient> getIngredientsAsStream() {
-		return cards.stream().flatMap(Card::getIngredientsAsStream);
+		return Collator.collateBy(cards.stream().flatMap(Card::getIngredientsAsStream),
+				Product::key, Ingredient::plus).stream();
 	}
 
 	@Override
 	public Stream<? extends RecipeComponent> getComponents() {
 		return cards.stream();
 	}
+	
+	private static String key(Ingredient ingredient) {
+		return ingredient.getName() + ingredient.getAmount().getUnit();
+	}
 }

+ 5 - 2
src/main/lombok/org/leumasjaffe/recipe/model/Step.java

@@ -3,10 +3,13 @@ package org.leumasjaffe.recipe.model;
 import java.util.ArrayList;
 import java.util.List;
 
+import org.leumasjaffe.observer.Observable;
+
 import lombok.Data;
+import lombok.EqualsAndHashCode;
 
-@Data
-public class Step implements RecipeComponent {
+@Data @EqualsAndHashCode(callSuper=false)
+public class Step extends Observable.Instance implements RecipeComponent {
 	List<Ingredient> ingredients = new ArrayList<>();
 	Duration duration;
 	String instruction;

+ 24 - 0
src/main/lombok/org/leumasjaffe/recipe/util/Collator.java

@@ -0,0 +1,24 @@
+package org.leumasjaffe.recipe.util;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+import lombok.experimental.UtilityClass;
+
+@UtilityClass
+public class Collator {
+	public <T> Collection<T> collateBy(final Stream<T> stream, final Function<T, String> getKey,
+			final BiFunction<T, T, T> folder) {
+		final Map<String, T> map = new HashMap<>();
+        stream.forEach(value -> {
+        	final String key = getKey.apply(value);
+            map.computeIfPresent(key, (k, v) -> folder.apply(v, value));
+            map.computeIfAbsent(key, k -> value);
+        });
+        return map.values();
+	}
+}

+ 11 - 1
src/main/lombok/org/leumasjaffe/recipe/view/CardPanel.java

@@ -2,6 +2,8 @@ package org.leumasjaffe.recipe.view;
 
 import javax.swing.JPanel;
 
+import org.leumasjaffe.observer.ForwardingObservableListener;
+import org.leumasjaffe.observer.ObserverDispatch;
 import org.leumasjaffe.recipe.model.Card;
 import org.leumasjaffe.recipe.model.Preparation;
 import org.leumasjaffe.recipe.model.Rest;
@@ -11,13 +13,15 @@ import org.jdesktop.swingx.VerticalLayout;
 @SuppressWarnings("serial")
 public class CardPanel extends JPanel {
 	private int steps = 0;
-	
+	private final ForwardingObservableListener<Card> listener = new ForwardingObservableListener<>();
+
 	public CardPanel(final Card card) {		
 		setLayout(new VerticalLayout(5));
 		
 		card.getPreparation().ifPresent(this::addPrep);
 		card.getCooking().forEach(this::addStep);
 		card.getRest().ifPresent(this::addRest);
+		listener.setObserved(card, card.getCooking());
 	}
 	
 	void addPrep(final Preparation step) {
@@ -31,5 +35,11 @@ public class CardPanel extends JPanel {
 	void addRest(final Rest rest) {
 		add(new RestPanel(rest));
 	}
+	
+	@Override
+	public void removeNotify() {
+		super.removeNotify();
+		ObserverDispatch.unsubscribeAll(listener);
+	}
 
 }

+ 19 - 5
src/main/lombok/org/leumasjaffe/recipe/view/IngredientPanel.java

@@ -11,6 +11,8 @@ import java.util.Locale;
 import javax.swing.event.DocumentListener;
 import javax.swing.text.NumberFormatter;
 
+import org.leumasjaffe.observer.ObservableController;
+import org.leumasjaffe.observer.ObserverDispatch;
 import org.leumasjaffe.recipe.model.Ingredient;
 
 import javax.swing.JFormattedTextField;
@@ -19,10 +21,9 @@ import javax.swing.JLabel;
 
 @SuppressWarnings("serial")
 public class IngredientPanel extends JPanel implements AutoGrowPanel.DocumentListenable {
+	ObservableController<JTextField, Ingredient> controller;
 	private JTextField txtName;
-	private JTextField txtUnit;
-	private JTextField txtPreparation;
-	
+		
 	public IngredientPanel(final Ingredient ingredient) {
 		GridBagLayout gridBagLayout = new GridBagLayout();
 		gridBagLayout.columnWidths = new int[]{0, 0, 0, 0, 0, 0};
@@ -63,7 +64,7 @@ public class IngredientPanel extends JPanel implements AutoGrowPanel.DocumentLis
 		add(txtAmount, gbc_txtAmount);
 		txtAmount.setColumns(4);
 		
-		txtUnit = new JTextField(ingredient.getAmount().unitName());
+		JTextField txtUnit = new JTextField(ingredient.getAmount().unitName());
 		txtUnit.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
 		GridBagConstraints gbc_txtUnit = new GridBagConstraints();
 		gbc_txtUnit.insets = new Insets(0, 0, 0, 5);
@@ -74,7 +75,7 @@ public class IngredientPanel extends JPanel implements AutoGrowPanel.DocumentLis
 		add(txtUnit, gbc_txtUnit);
 		txtUnit.setColumns(6);
 		
-		txtPreparation = new JTextField(ingredient.getPreparation());
+		JTextField txtPreparation = new JTextField(ingredient.getPreparation());
 		txtPreparation.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
 		GridBagConstraints gbc_txtPreparation = new GridBagConstraints();
 		gbc_txtPreparation.anchor = GridBagConstraints.ABOVE_BASELINE;
@@ -83,6 +84,13 @@ public class IngredientPanel extends JPanel implements AutoGrowPanel.DocumentLis
 		gbc_txtPreparation.gridy = 0;
 		add(txtPreparation, gbc_txtPreparation);
 		txtPreparation.setColumns(10);
+		
+		// I technically don't need to listen here as of this change,
+		// but if I ever restore support for it, it will be convenient.
+		controller = new ObservableController<>(txtName,
+				Ingredient::getName, Ingredient::setName,
+				JTextField::setText);
+		controller.setObserved(ingredient);
 	}
 
 	@Override
@@ -94,5 +102,11 @@ public class IngredientPanel extends JPanel implements AutoGrowPanel.DocumentLis
 	public void removeDocumentListener(DocumentListener dl) {
 		this.txtName.getDocument().removeDocumentListener(dl);
 	}
+	
+	@Override
+	public void removeNotify() {
+		super.removeNotify();
+		ObserverDispatch.unsubscribeAll(controller);
+	}
 
 }

+ 104 - 0
src/main/lombok/org/leumasjaffe/recipe/view/IngredientPreparationPanel.java

@@ -0,0 +1,104 @@
+package org.leumasjaffe.recipe.view;
+
+import javax.swing.JPanel;
+import java.awt.GridBagLayout;
+import javax.swing.JTextField;
+import java.awt.GridBagConstraints;
+import java.awt.Insets;
+import java.text.NumberFormat;
+import java.util.Locale;
+
+import javax.swing.text.NumberFormatter;
+
+import org.leumasjaffe.observer.ObservableListener;
+import org.leumasjaffe.observer.ObserverDispatch;
+import org.leumasjaffe.recipe.model.Ingredient;
+
+import javax.swing.JFormattedTextField;
+import java.awt.Font;
+import javax.swing.JLabel;
+
+@SuppressWarnings("serial")
+public class IngredientPreparationPanel extends JPanel {
+	
+	private ObservableListener<IngredientPreparationPanel, Ingredient> listener;
+	
+	public IngredientPreparationPanel(final Ingredient ingredient) {
+		GridBagLayout gridBagLayout = new GridBagLayout();
+		gridBagLayout.columnWidths = new int[]{0, 0, 0, 0, 0, 0};
+		gridBagLayout.rowHeights = new int[]{0, 0};
+		gridBagLayout.columnWeights = new double[]{0.0, 1.0, 0.0, 0.0, 0.0, Double.MIN_VALUE};
+		gridBagLayout.rowWeights = new double[]{0.0, Double.MIN_VALUE};
+		setLayout(gridBagLayout);
+		
+		JLabel label = new JLabel("\u2022");
+		GridBagConstraints gbc_label = new GridBagConstraints();
+		gbc_label.insets = new Insets(0, 0, 0, 5);
+		gbc_label.anchor = GridBagConstraints.EAST;
+		gbc_label.gridx = 0;
+		gbc_label.gridy = 0;
+		add(label, gbc_label);
+		
+		JTextField txtName = new JTextField(ingredient.getName());
+		txtName.setEditable(false);
+		txtName.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
+		GridBagConstraints gbc_txtName = new GridBagConstraints();
+		gbc_txtName.fill = GridBagConstraints.HORIZONTAL;
+		gbc_txtName.insets = new Insets(0, 0, 0, 5);
+		gbc_txtName.gridx = 1;
+		gbc_txtName.gridy = 0;
+		add(txtName, gbc_txtName);
+		txtName.setColumns(10);
+		
+		NumberFormatter fmtDone = new NumberFormatter(NumberFormat.getNumberInstance(Locale.getDefault()));
+		fmtDone.setMinimum(0.0);
+		fmtDone.setCommitsOnValidEdit(true);
+		JFormattedTextField txtAmount = new JFormattedTextField(fmtDone);
+		txtAmount.setEditable(false);
+		txtAmount.setValue(ingredient.getAmount().getValue());
+		txtAmount.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
+		GridBagConstraints gbc_txtAmount = new GridBagConstraints();
+		gbc_txtAmount.fill = GridBagConstraints.HORIZONTAL;
+		gbc_txtAmount.insets = new Insets(0, 0, 0, 5);
+		gbc_txtAmount.gridx = 2;
+		gbc_txtAmount.gridy = 0;
+		add(txtAmount, gbc_txtAmount);
+		txtAmount.setColumns(4);
+		
+		JTextField txtUnit = new JTextField(ingredient.getAmount().unitName());
+		txtUnit.setEditable(false);
+		txtUnit.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
+		GridBagConstraints gbc_txtUnit = new GridBagConstraints();
+		gbc_txtUnit.insets = new Insets(0, 0, 0, 5);
+		gbc_txtUnit.anchor = GridBagConstraints.ABOVE_BASELINE;
+		gbc_txtUnit.fill = GridBagConstraints.HORIZONTAL;
+		gbc_txtUnit.gridx = 3;
+		gbc_txtUnit.gridy = 0;
+		add(txtUnit, gbc_txtUnit);
+		txtUnit.setColumns(6);
+		
+		JTextField txtPreparation = new JTextField(ingredient.getPreparation());
+		txtPreparation.setEditable(false);
+		txtPreparation.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
+		GridBagConstraints gbc_txtPreparation = new GridBagConstraints();
+		gbc_txtPreparation.anchor = GridBagConstraints.ABOVE_BASELINE;
+		gbc_txtPreparation.fill = GridBagConstraints.HORIZONTAL;
+		gbc_txtPreparation.gridx = 4;
+		gbc_txtPreparation.gridy = 0;
+		add(txtPreparation, gbc_txtPreparation);
+		txtPreparation.setColumns(10);
+		
+		listener = new ObservableListener<>(this, (c, t) -> {
+			if (txtName.getText().equals(t.getName())) return;
+			txtName.setText(t.getName());
+		});
+		listener.setObserved(ingredient);
+	}
+	
+	@Override
+	public void removeNotify() {
+		super.removeNotify();
+		ObserverDispatch.unsubscribeAll(listener);
+	}
+
+}

+ 5 - 14
src/main/lombok/org/leumasjaffe/recipe/view/PreparationPanel.java

@@ -1,8 +1,8 @@
 package org.leumasjaffe.recipe.view;
 
 import javax.swing.JPanel;
-import javax.swing.event.DocumentListener;
 
+import org.jdesktop.swingx.VerticalLayout;
 import org.leumasjaffe.recipe.model.Preparation;
 
 import java.awt.GridBagLayout;
@@ -11,14 +11,12 @@ import java.awt.GridBagConstraints;
 import java.awt.Insets;
 
 import javax.swing.JLabel;
-import javax.swing.JTextPane;
 import java.awt.Component;
 import javax.swing.Box;
 
 @SuppressWarnings("serial")
-public class PreparationPanel extends JPanel implements AutoGrowPanel.DocumentListenable {
+public class PreparationPanel extends JPanel {
 	private JLabel lblIndex;
-	private JTextPane txtpnInstructions;
 		
 	public PreparationPanel(Preparation step) {
 		GridBagLayout gridBagLayout = new GridBagLayout();
@@ -64,7 +62,8 @@ public class PreparationPanel extends JPanel implements AutoGrowPanel.DocumentLi
 		gbc_lblDuration.gridy = 0;
 		panelLeft.add(lblDuration, gbc_lblDuration);
 		
-		AutoGrowPanel panelIngredients = new AutoGrowPanel(IngredientPanel::new, step.getIngredients());
+		JPanel panelIngredients = new JPanel();
+		panelIngredients.setLayout(new VerticalLayout(5));
 		GridBagConstraints gbc_panelIngredients = new GridBagConstraints();
 		gbc_panelIngredients.gridwidth = 3;
 		gbc_panelIngredients.insets = new Insets(0, 0, 0, 5);
@@ -72,15 +71,7 @@ public class PreparationPanel extends JPanel implements AutoGrowPanel.DocumentLi
 		gbc_panelIngredients.gridx = 0;
 		gbc_panelIngredients.gridy = 1;
 		panelLeft.add(panelIngredients, gbc_panelIngredients);
+		step.getIngredients().stream().map(IngredientPreparationPanel::new).forEach(panelIngredients::add);
 	}
 
-	@Override
-	public void addDocumentListener(DocumentListener dl) {
-		this.txtpnInstructions.getDocument().addDocumentListener(dl);
-	}
-
-	@Override
-	public void removeDocumentListener(DocumentListener dl) {
-		this.txtpnInstructions.getDocument().removeDocumentListener(dl);		
-	}
 }

+ 6 - 1
src/main/lombok/org/leumasjaffe/recipe/view/ProductPanel.java

@@ -3,6 +3,7 @@ package org.leumasjaffe.recipe.view;
 import javax.swing.JPanel;
 import javax.swing.JScrollPane;
 
+import org.leumasjaffe.observer.ForwardingObservableListener;
 import org.leumasjaffe.recipe.model.Card;
 import org.leumasjaffe.recipe.model.Product;
 import org.jdesktop.swingx.VerticalLayout;
@@ -13,6 +14,8 @@ import javax.swing.JSeparator;
 @SuppressWarnings("serial")
 public class ProductPanel extends JScrollPane {
 	private JPanel panelViewPort;
+	private final ForwardingObservableListener<Product> listener = new ForwardingObservableListener<>();
+
 	public ProductPanel(Product product) {
 		JPanel panelColumnHeader = new JPanel();
 		setColumnHeaderView(panelColumnHeader);
@@ -23,10 +26,12 @@ public class ProductPanel extends JScrollPane {
 		panelViewPort = new JPanel();
 		setViewportView(panelViewPort);
 		panelViewPort.setLayout(new VerticalLayout(5));
-		
+				
 		for (final Card card : product.getCards()) {
 			panelViewPort.add(new CardPanel(card));
 			panelViewPort.add(new JSeparator());
 		}
+		
+		listener.setObserved(product, product.getCards());
 	}
 }

+ 31 - 13
src/main/lombok/org/leumasjaffe/recipe/view/ProductSummaryPanel.java

@@ -2,38 +2,56 @@ package org.leumasjaffe.recipe.view;
 
 import javax.swing.JPanel;
 
+import org.jdesktop.swingx.VerticalLayout;
+import org.leumasjaffe.observer.ObservableListener;
+import org.leumasjaffe.observer.ObserverDispatch;
 import org.leumasjaffe.recipe.model.Product;
+
 import java.awt.GridBagLayout;
 import java.awt.Insets;
-import java.util.ArrayList;
 
 import javax.swing.JLabel;
 import java.awt.GridBagConstraints;
 
 @SuppressWarnings("serial")
 public class ProductSummaryPanel extends JPanel {
+	ObservableListener<JPanel, Product> listener;
+	
 	public ProductSummaryPanel(final Product product) {
 		GridBagLayout gridBagLayout = new GridBagLayout();
 		gridBagLayout.columnWidths = new int[]{0, 0, 0};
-		gridBagLayout.rowHeights = new int[]{0, 0};
-		gridBagLayout.columnWeights = new double[]{0.0, 0.0, Double.MIN_VALUE};
-		gridBagLayout.rowWeights = new double[]{0.0, Double.MIN_VALUE};
+		gridBagLayout.rowHeights = new int[]{0, 0, 0};
+		gridBagLayout.columnWeights = new double[]{1.0, 0.0, Double.MIN_VALUE};
+		gridBagLayout.rowWeights = new double[]{0.0, 1.0, Double.MIN_VALUE};
 		setLayout(gridBagLayout);
 		
 		JLabel lblProductName = new JLabel(product.getName());
 		GridBagConstraints gbc_lblProductName = new GridBagConstraints();
+		gbc_lblProductName.insets = new Insets(0, 0, 5, 5);
 		gbc_lblProductName.gridx = 0;
 		gbc_lblProductName.gridy = 0;
 		add(lblProductName, gbc_lblProductName);
+				
+		JPanel panel = new JPanel();
+		panel.setLayout(new VerticalLayout());
+		GridBagConstraints gbc_panel = new GridBagConstraints();
+		gbc_panel.gridwidth = 2;
+		gbc_panel.insets = new Insets(0, 0, 0, 5);
+		gbc_panel.fill = GridBagConstraints.BOTH;
+		gbc_panel.gridx = 0;
+		gbc_panel.gridy = 1;
+		add(panel, gbc_panel);
 		
-		AutoGrowPanel panelIngredients = new AutoGrowPanel(IngredientPanel::new,
-				new ArrayList<>(product.getIngredients()));
-		GridBagConstraints gbc_panelIngredients = new GridBagConstraints();
-		gbc_panelIngredients.gridwidth = 2;
-		gbc_panelIngredients.insets = new Insets(0, 0, 0, 5);
-		gbc_panelIngredients.fill = GridBagConstraints.BOTH;
-		gbc_panelIngredients.gridx = 0;
-		gbc_panelIngredients.gridy = 1;
-		add(panelIngredients, gbc_panelIngredients);
+		listener = new ObservableListener<>(panel, (c, t) -> {
+			c.removeAll();
+			product.getIngredientsAsStream().map(SummaryIngredientPanel::new).forEach(c::add);
+		});
+		listener.setObserved(product);
+	}
+	
+	@Override
+	public void removeNotify() {
+		super.removeNotify();
+		ObserverDispatch.unsubscribeAll(listener);
 	}
 }

+ 10 - 0
src/main/lombok/org/leumasjaffe/recipe/view/StepPanel.java

@@ -3,6 +3,8 @@ package org.leumasjaffe.recipe.view;
 import javax.swing.JPanel;
 import javax.swing.event.DocumentListener;
 
+import org.leumasjaffe.observer.ForwardingObservableListener;
+import org.leumasjaffe.observer.ObserverDispatch;
 import org.leumasjaffe.recipe.model.Step;
 
 import java.awt.GridBagLayout;
@@ -20,6 +22,7 @@ import java.awt.Dimension;
 public class StepPanel extends JPanel implements AutoGrowPanel.DocumentListenable {
 	private JLabel lblIndex;
 	private JTextPane txtpnInstructions;
+	private final ForwardingObservableListener<Step> listener = new ForwardingObservableListener<>();
 		
 	public StepPanel(int zeroIndex, Step step) {
 		GridBagLayout gridBagLayout = new GridBagLayout();
@@ -84,6 +87,7 @@ public class StepPanel extends JPanel implements AutoGrowPanel.DocumentListenabl
 		add(txtpnInstructions, gbc_txtpnInstructions);
 		
 		setListPosition(zeroIndex);
+		listener.setObserved(step, step.getIngredients());
 	}
 
 	@Override
@@ -101,4 +105,10 @@ public class StepPanel extends JPanel implements AutoGrowPanel.DocumentListenabl
 		this.lblIndex.setText("Step " + Integer.toString(zeroIndex + 1));
 		repaint();
 	}
+	
+	@Override
+	public void removeNotify() {
+		super.removeNotify();
+		ObserverDispatch.unsubscribeAll(listener);
+	}
 }

+ 91 - 0
src/main/lombok/org/leumasjaffe/recipe/view/SummaryIngredientPanel.java

@@ -0,0 +1,91 @@
+package org.leumasjaffe.recipe.view;
+
+import javax.swing.JPanel;
+import java.awt.GridBagLayout;
+import javax.swing.JTextField;
+import java.awt.GridBagConstraints;
+import java.awt.Insets;
+import java.text.NumberFormat;
+import java.util.Locale;
+
+import javax.swing.text.NumberFormatter;
+
+import org.leumasjaffe.observer.ObservableListener;
+import org.leumasjaffe.observer.ObserverDispatch;
+import org.leumasjaffe.recipe.model.Ingredient;
+
+import javax.swing.JFormattedTextField;
+import java.awt.Font;
+import javax.swing.JLabel;
+
+@SuppressWarnings("serial")
+public class SummaryIngredientPanel extends JPanel {
+	private ObservableListener<SummaryIngredientPanel, Ingredient> listener;
+	
+	public SummaryIngredientPanel(final Ingredient ingredient) {
+		GridBagLayout gridBagLayout = new GridBagLayout();
+		gridBagLayout.columnWidths = new int[]{0, 0, 0, 0, 0};
+		gridBagLayout.rowHeights = new int[]{0, 0};
+		gridBagLayout.columnWeights = new double[]{0.0, 1.0, 0.0, 0.0, Double.MIN_VALUE};
+		gridBagLayout.rowWeights = new double[]{0.0, Double.MIN_VALUE};
+		setLayout(gridBagLayout);
+		
+		JLabel label = new JLabel("\u2022");
+		GridBagConstraints gbc_label = new GridBagConstraints();
+		gbc_label.insets = new Insets(0, 0, 0, 5);
+		gbc_label.anchor = GridBagConstraints.EAST;
+		gbc_label.gridx = 0;
+		gbc_label.gridy = 0;
+		add(label, gbc_label);
+		
+		JTextField txtName = new JTextField(ingredient.getName());
+		txtName.setEditable(false);
+		txtName.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
+		GridBagConstraints gbc_txtName = new GridBagConstraints();
+		gbc_txtName.fill = GridBagConstraints.HORIZONTAL;
+		gbc_txtName.insets = new Insets(0, 0, 0, 5);
+		gbc_txtName.gridx = 1;
+		gbc_txtName.gridy = 0;
+		add(txtName, gbc_txtName);
+		txtName.setColumns(10);
+		
+		NumberFormatter fmtDone = new NumberFormatter(NumberFormat.getNumberInstance(Locale.getDefault()));
+		fmtDone.setMinimum(0.0);
+		fmtDone.setCommitsOnValidEdit(true);
+		JFormattedTextField txtAmount = new JFormattedTextField(fmtDone);
+		txtAmount.setEditable(false);
+		txtAmount.setValue(ingredient.getAmount().getValue());
+		txtAmount.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
+		GridBagConstraints gbc_txtAmount = new GridBagConstraints();
+		gbc_txtAmount.fill = GridBagConstraints.HORIZONTAL;
+		gbc_txtAmount.insets = new Insets(0, 0, 0, 5);
+		gbc_txtAmount.gridx = 2;
+		gbc_txtAmount.gridy = 0;
+		add(txtAmount, gbc_txtAmount);
+		txtAmount.setColumns(4);
+		
+		JTextField txtUnit = new JTextField(ingredient.getAmount().unitName());
+		txtUnit.setEditable(false);
+		txtUnit.setFont(new Font("Source Code Pro", Font.PLAIN, 10));
+		GridBagConstraints gbc_txtUnit = new GridBagConstraints();
+		gbc_txtUnit.anchor = GridBagConstraints.ABOVE_BASELINE;
+		gbc_txtUnit.fill = GridBagConstraints.HORIZONTAL;
+		gbc_txtUnit.gridx = 3;
+		gbc_txtUnit.gridy = 0;
+		add(txtUnit, gbc_txtUnit);
+		txtUnit.setColumns(6);
+		
+		listener = new ObservableListener<>(this, (c, t) -> {
+			if (txtName.getText().equals(t.getName())) return;
+			txtName.setText(t.getName());
+		});
+		listener.setObserved(ingredient);
+	}
+	
+	@Override
+	public void removeNotify() {
+		super.removeNotify();
+		ObserverDispatch.unsubscribeAll(listener);
+	}
+
+}

+ 0 - 19
src/test/resources/example.json

@@ -10,25 +10,6 @@
           "dependsOn": [],
           "vessel": "",
           "preparation": {
-            "ingredients": [
-              {
-                "name": "onion",
-                "preparation": "chopped",
-                "amount": "100 g"
-              },
-              {
-                "name": "onion",
-                "amount": "1"
-              },
-              {
-              	"name": "oil",
-              	"amount": "1 Tbsp"
-            	},
-              {
-              	"name": "oil",
-              	"amount": "1 Tbsp"
-            	}
-            ],
             "duration": {
               "displayAs": "MINUTES",
               "approximate": true,