Compare commits
8 Commits
cb5d0de5b2
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
168fc986f2 | ||
|
|
a6216f6e81 | ||
|
|
2955a7eb1c | ||
|
|
7819b963f2 | ||
|
|
5e941753e2 | ||
|
|
9c439e21ee | ||
|
|
b6158d933f | ||
|
|
bb4d7fbf68 |
@@ -44,6 +44,13 @@
|
|||||||
<version>3.0.5</version>
|
<version>3.0.5</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- EXIF metadata extraction for image orientation -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.drewnoakes</groupId>
|
||||||
|
<artifactId>metadata-extractor</artifactId>
|
||||||
|
<version>2.19.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Elytron secrity used for username/password login -->
|
<!-- Elytron secrity used for username/password login -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.wildfly.security</groupId>
|
<groupId>org.wildfly.security</groupId>
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package marketing.heyday.hartmann.fotodocumentation.core.utils;
|
||||||
|
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.RenderingHints;
|
||||||
|
import java.awt.geom.AffineTransform;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
|
||||||
|
import org.apache.commons.logging.Log;
|
||||||
|
import org.apache.commons.logging.LogFactory;
|
||||||
|
|
||||||
|
import com.drew.imaging.ImageMetadataReader;
|
||||||
|
import com.drew.imaging.ImageProcessingException;
|
||||||
|
import com.drew.metadata.Metadata;
|
||||||
|
import com.drew.metadata.MetadataException;
|
||||||
|
import com.drew.metadata.exif.ExifIFD0Directory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* <p>Copyright: Copyright (c) 2024</p>
|
||||||
|
* <p>Company: heyday Marketing GmbH</p>
|
||||||
|
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
|
||||||
|
* @version 1.0
|
||||||
|
*
|
||||||
|
* created: 6 Feb 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
public interface ImageHandler {
|
||||||
|
static final Log LOG = LogFactory.getLog(ImageHandler.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads image bytes and returns a BufferedImage with correct EXIF orientation applied.
|
||||||
|
*/
|
||||||
|
default BufferedImage readImageWithCorrectOrientation(byte[] imageBytes) throws IOException {
|
||||||
|
int orientation = getExifOrientation(imageBytes);
|
||||||
|
BufferedImage image = ImageIO.read(new ByteArrayInputStream(imageBytes));
|
||||||
|
if (image == null) {
|
||||||
|
throw new IOException("Failed to read image from byte array");
|
||||||
|
}
|
||||||
|
return applyOrientation(image, orientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
default int getExifOrientation(byte[] imageBytes) {
|
||||||
|
try {
|
||||||
|
Metadata metadata = ImageMetadataReader.readMetadata(new ByteArrayInputStream(imageBytes));
|
||||||
|
ExifIFD0Directory directory = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class);
|
||||||
|
if (directory != null && directory.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) {
|
||||||
|
return directory.getInt(ExifIFD0Directory.TAG_ORIENTATION);
|
||||||
|
}
|
||||||
|
} catch (ImageProcessingException | IOException | MetadataException e) {
|
||||||
|
LOG.debug("Could not read EXIF orientation: " + e.getMessage());
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
default BufferedImage applyOrientation(BufferedImage image, int orientation) {
|
||||||
|
int width = image.getWidth();
|
||||||
|
int height = image.getHeight();
|
||||||
|
|
||||||
|
AffineTransform transform = new AffineTransform();
|
||||||
|
int newWidth = width;
|
||||||
|
int newHeight = height;
|
||||||
|
|
||||||
|
switch (orientation) {
|
||||||
|
case 1 -> {
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
case 2 -> {
|
||||||
|
transform.scale(-1.0, 1.0);
|
||||||
|
transform.translate(-width, 0);
|
||||||
|
}
|
||||||
|
case 3 -> {
|
||||||
|
transform.translate(width, height);
|
||||||
|
transform.rotate(Math.PI);
|
||||||
|
}
|
||||||
|
case 4 -> {
|
||||||
|
transform.scale(1.0, -1.0);
|
||||||
|
transform.translate(0, -height);
|
||||||
|
}
|
||||||
|
case 5 -> {
|
||||||
|
newWidth = height;
|
||||||
|
newHeight = width;
|
||||||
|
transform.rotate(-Math.PI / 2);
|
||||||
|
transform.scale(-1.0, 1.0);
|
||||||
|
}
|
||||||
|
case 6 -> {
|
||||||
|
newWidth = height;
|
||||||
|
newHeight = width;
|
||||||
|
transform.translate(height, 0);
|
||||||
|
transform.rotate(Math.PI / 2);
|
||||||
|
}
|
||||||
|
case 7 -> {
|
||||||
|
newWidth = height;
|
||||||
|
newHeight = width;
|
||||||
|
transform.scale(-1.0, 1.0);
|
||||||
|
transform.translate(-height, 0);
|
||||||
|
transform.translate(0, width);
|
||||||
|
transform.rotate(3 * Math.PI / 2);
|
||||||
|
}
|
||||||
|
case 8 -> {
|
||||||
|
newWidth = height;
|
||||||
|
newHeight = width;
|
||||||
|
transform.translate(0, width);
|
||||||
|
transform.rotate(-Math.PI / 2);
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BufferedImage rotated = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB);
|
||||||
|
Graphics2D g2d = rotated.createGraphics();
|
||||||
|
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||||
|
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||||
|
g2d.drawImage(image, transform, null);
|
||||||
|
g2d.dispose();
|
||||||
|
|
||||||
|
return rotated;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ import org.apache.commons.logging.LogFactory;
|
|||||||
* created: 2 Feb 2026
|
* created: 2 Feb 2026
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public class ImageUtil {
|
public class ImageUtil implements ImageHandler {
|
||||||
private static final Log LOG = LogFactory.getLog(ImageUtil.class);
|
private static final Log LOG = LogFactory.getLog(ImageUtil.class);
|
||||||
|
|
||||||
private static final int NORMAL_MAX_WIDTH = 1200;
|
private static final int NORMAL_MAX_WIDTH = 1200;
|
||||||
@@ -44,10 +44,10 @@ public class ImageUtil {
|
|||||||
public byte[] getImage(String base64, int size) {
|
public byte[] getImage(String base64, int size) {
|
||||||
byte[] original = Base64.getDecoder().decode(base64);
|
byte[] original = Base64.getDecoder().decode(base64);
|
||||||
return switch (size) {
|
return switch (size) {
|
||||||
case 1 -> original;
|
case 1 -> applyExifOrientation(original);
|
||||||
case 2 -> normal(original);
|
case 2 -> normal(original);
|
||||||
case 3 -> thumbnail(original);
|
case 3 -> thumbnail(original);
|
||||||
default -> original;
|
default -> applyExifOrientation(original);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,16 +61,27 @@ public class ImageUtil {
|
|||||||
|
|
||||||
private byte[] resize(byte[] original, int maxWidth, float quality) {
|
private byte[] resize(byte[] original, int maxWidth, float quality) {
|
||||||
try {
|
try {
|
||||||
|
int orientation = getExifOrientation(original);
|
||||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(original));
|
BufferedImage image = ImageIO.read(new ByteArrayInputStream(original));
|
||||||
if (image == null) {
|
if (image == null) {
|
||||||
LOG.error("Failed to read image from byte array");
|
LOG.error("Failed to read image from byte array");
|
||||||
return original;
|
return original;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For rotated images (orientation 5, 6, 7, 8), width and height are swapped
|
||||||
|
int effectiveWidth = (orientation >= 5 && orientation <= 8) ? image.getHeight() : image.getWidth();
|
||||||
|
|
||||||
|
// If no resize needed and no orientation fix needed, return original
|
||||||
|
if (effectiveWidth <= maxWidth && orientation == 1) {
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
|
image = applyOrientation(image, orientation);
|
||||||
|
|
||||||
int originalWidth = image.getWidth();
|
int originalWidth = image.getWidth();
|
||||||
|
|
||||||
if (originalWidth <= maxWidth) {
|
if (originalWidth <= maxWidth) {
|
||||||
return original;
|
return writeJpeg(image, quality);
|
||||||
}
|
}
|
||||||
|
|
||||||
double scale = (double) maxWidth / originalWidth;
|
double scale = (double) maxWidth / originalWidth;
|
||||||
@@ -92,6 +103,26 @@ public class ImageUtil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private byte[] applyExifOrientation(byte[] original) {
|
||||||
|
try {
|
||||||
|
int orientation = getExifOrientation(original);
|
||||||
|
if (orientation == 1) {
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
|
BufferedImage image = ImageIO.read(new ByteArrayInputStream(original));
|
||||||
|
if (image == null) {
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
|
BufferedImage rotated = applyOrientation(image, orientation);
|
||||||
|
return writeJpeg(rotated, 1.0F);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.error("Failed to apply EXIF orientation", e);
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private byte[] writeJpeg(BufferedImage image, float quality) throws IOException {
|
private byte[] writeJpeg(BufferedImage image, float quality) throws IOException {
|
||||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
ImageWriter writer = ImageIO.getImageWritersByFormatName("jpg").next();
|
ImageWriter writer = ImageIO.getImageWritersByFormatName("jpg").next();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package marketing.heyday.hartmann.fotodocumentation.core.utils;
|
package marketing.heyday.hartmann.fotodocumentation.core.utils;
|
||||||
|
|
||||||
import java.awt.Color;
|
import java.awt.Color;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
@@ -19,6 +20,7 @@ import org.apache.pdfbox.pdmodel.font.PDFont;
|
|||||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||||
|
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||||
|
|
||||||
import marketing.heyday.hartmann.fotodocumentation.core.model.Customer;
|
import marketing.heyday.hartmann.fotodocumentation.core.model.Customer;
|
||||||
@@ -34,7 +36,7 @@ import marketing.heyday.hartmann.fotodocumentation.core.model.Picture;
|
|||||||
* created: 2 Feb 2026
|
* created: 2 Feb 2026
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings({ "java:S818", "squid:S818", "squid:S109" })
|
@SuppressWarnings({ "java:S818", "squid:S818", "squid:S109" })
|
||||||
public class PdfUtils {
|
public class PdfUtils implements ImageHandler {
|
||||||
private static final Log LOG = LogFactory.getLog(PdfUtils.class);
|
private static final Log LOG = LogFactory.getLog(PdfUtils.class);
|
||||||
|
|
||||||
private static final String FONT_PANTON_REGULAR = "fonts/Panton-Regular.ttf";
|
private static final String FONT_PANTON_REGULAR = "fonts/Panton-Regular.ttf";
|
||||||
@@ -70,7 +72,8 @@ public class PdfUtils {
|
|||||||
float pageWidth = page.getMediaBox().getWidth();
|
float pageWidth = page.getMediaBox().getWidth();
|
||||||
float pageHeight = page.getMediaBox().getHeight();
|
float pageHeight = page.getMediaBox().getHeight();
|
||||||
float contentWidth = pageWidth - 2 * PAGE_MARGIN;
|
float contentWidth = pageWidth - 2 * PAGE_MARGIN;
|
||||||
float halfWidth = contentWidth / 2F;
|
float imageWidth = contentWidth * 0.75F;
|
||||||
|
float metadataWidth = contentWidth * 0.25F;
|
||||||
|
|
||||||
try (PDPageContentStream cs = new PDPageContentStream(document, page)) {
|
try (PDPageContentStream cs = new PDPageContentStream(document, page)) {
|
||||||
float yPosition = pageHeight - 50F;
|
float yPosition = pageHeight - 50F;
|
||||||
@@ -87,16 +90,17 @@ public class PdfUtils {
|
|||||||
firstPage = false;
|
firstPage = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Left side: image (50% of content width)
|
// Left side: image (75% of content width)
|
||||||
float imageX = PAGE_MARGIN;
|
float imageX = PAGE_MARGIN;
|
||||||
float imageY = yPosition;
|
float imageY = yPosition;
|
||||||
float imageMaxWidth = halfWidth - 10F;
|
float imageMaxWidth = imageWidth - 10F;
|
||||||
float imageMaxHeight = pageHeight - 2 * PAGE_MARGIN - 40F;
|
float imageMaxHeight = pageHeight - 2 * PAGE_MARGIN - 40F;
|
||||||
|
|
||||||
if (picture.getImage() != null) {
|
if (picture.getImage() != null) {
|
||||||
try {
|
try {
|
||||||
byte[] imageBytes = Base64.getDecoder().decode(picture.getImage());
|
byte[] imageBytes = Base64.getDecoder().decode(picture.getImage());
|
||||||
PDImageXObject pdImage = PDImageXObject.createFromByteArray(document, imageBytes, "picture");
|
BufferedImage correctedImage = readImageWithCorrectOrientation(imageBytes);
|
||||||
|
PDImageXObject pdImage = LosslessFactory.createFromImage(document, correctedImage);
|
||||||
|
|
||||||
float scale = Math.min(imageMaxWidth / pdImage.getWidth(), imageMaxHeight / pdImage.getHeight());
|
float scale = Math.min(imageMaxWidth / pdImage.getWidth(), imageMaxHeight / pdImage.getHeight());
|
||||||
float drawWidth = pdImage.getWidth() * scale;
|
float drawWidth = pdImage.getWidth() * scale;
|
||||||
@@ -108,30 +112,25 @@ public class PdfUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right side: metadata (top-aligned with image)
|
// Right side: metadata (25% of content width, top-aligned with image)
|
||||||
float rightX = PAGE_MARGIN + halfWidth + 10F;
|
float rightX = PAGE_MARGIN + imageWidth + 10F;
|
||||||
float rightY = imageY - 32F;
|
float rightY = imageY - 10F;
|
||||||
|
|
||||||
// Date (no label, bold, size 44)
|
// Date (bold, size 10 - matching labels)
|
||||||
String dateStr = picture.getPictureDate() != null ? (DATE_FORMAT.format(picture.getPictureDate()) + " UHR") : "";
|
String dateStr = picture.getPictureDate() != null ? (DATE_FORMAT.format(picture.getPictureDate()) + " UHR") : "";
|
||||||
cs.setFont(fontBold, 32);
|
cs.setFont(fontBold, 10);
|
||||||
cs.setNonStrokingColor(COLOR_DATE);
|
cs.setNonStrokingColor(COLOR_DATE);
|
||||||
cs.beginText();
|
cs.beginText();
|
||||||
cs.newLineAtOffset(rightX, rightY);
|
cs.newLineAtOffset(rightX, rightY);
|
||||||
cs.showText(dateStr);
|
cs.showText(dateStr);
|
||||||
cs.endText();
|
cs.endText();
|
||||||
rightY -= 54F;
|
rightY -= 24F;
|
||||||
|
|
||||||
// Customer number
|
// Customer number
|
||||||
float kundenNummerY = rightY;
|
|
||||||
rightY = drawLabel(cs, fontBold, "KUNDENNUMMER", rightX, rightY);
|
rightY = drawLabel(cs, fontBold, "KUNDENNUMMER", rightX, rightY);
|
||||||
rightY = drawValue(cs, fontRegular, nullSafe(customer.getCustomerNumber()), rightX, rightY);
|
rightY = drawValue(cs, fontRegular, nullSafe(customer.getCustomerNumber()), rightX, rightY);
|
||||||
rightY -= 10F;
|
rightY -= 10F;
|
||||||
|
|
||||||
// Evaluation card with circles
|
|
||||||
float circlesX = rightX + 140F;
|
|
||||||
drawEvaluationCard(cs, fontBold, circlesX, kundenNummerY, picture.getEvaluation());
|
|
||||||
|
|
||||||
// ZIP
|
// ZIP
|
||||||
rightY = drawLabel(cs, fontBold, "PLZ", rightX, rightY);
|
rightY = drawLabel(cs, fontBold, "PLZ", rightX, rightY);
|
||||||
rightY = drawValue(cs, fontRegular, nullSafe(customer.getZip()), rightX, rightY);
|
rightY = drawValue(cs, fontRegular, nullSafe(customer.getZip()), rightX, rightY);
|
||||||
@@ -142,9 +141,27 @@ public class PdfUtils {
|
|||||||
rightY = drawValue(cs, fontRegular, nullSafe(customer.getCity()), rightX, rightY);
|
rightY = drawValue(cs, fontRegular, nullSafe(customer.getCity()), rightX, rightY);
|
||||||
rightY -= 10F;
|
rightY -= 10F;
|
||||||
|
|
||||||
|
// Evaluation card with circles
|
||||||
|
float evaluationY = rightY;
|
||||||
|
drawEvaluationCard(cs, fontBold, rightX, evaluationY, picture.getEvaluation());
|
||||||
|
rightY -= 80F;
|
||||||
|
|
||||||
// Comment
|
// Comment
|
||||||
rightY = drawLabel(cs, fontBold, "KOMMENTAR", rightX, rightY);
|
rightY = drawLabel(cs, fontBold, "KOMMENTAR", rightX, rightY);
|
||||||
drawWrappedText(cs, fontRegular, nullSafe(picture.getComment()), rightX, rightY, halfWidth - 20f);
|
String remainingComment = drawWrappedText(cs, fontRegular, nullSafe(picture.getComment()), rightX, rightY, metadataWidth - 20f, 50F);
|
||||||
|
|
||||||
|
// Continue comment on additional pages if needed
|
||||||
|
while (remainingComment != null && !remainingComment.isEmpty()) {
|
||||||
|
PDPage continuationPage = new PDPage(new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth()));
|
||||||
|
document.addPage(continuationPage);
|
||||||
|
|
||||||
|
try (PDPageContentStream continuationCs = new PDPageContentStream(document, continuationPage)) {
|
||||||
|
float continuationY = continuationPage.getMediaBox().getHeight() - PAGE_MARGIN;
|
||||||
|
float continuationWidth = continuationPage.getMediaBox().getWidth() - 2 * PAGE_MARGIN;
|
||||||
|
continuationY = drawLabel(continuationCs, fontBold, "KOMMENTAR (FORTSETZUNG)", PAGE_MARGIN, continuationY);
|
||||||
|
remainingComment = drawWrappedText(continuationCs, fontRegular, remainingComment, PAGE_MARGIN, continuationY, continuationWidth, 50F);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,9 +206,9 @@ public class PdfUtils {
|
|||||||
return y - 14F;
|
return y - 14F;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void drawWrappedText(PDPageContentStream cs, PDFont font, String text, float x, float y, float maxWidth) throws IOException {
|
private String drawWrappedText(PDPageContentStream cs, PDFont font, String text, float x, float y, float maxWidth, float minY) throws IOException {
|
||||||
if (text == null || text.isEmpty()) {
|
if (text == null || text.isEmpty()) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
cs.setFont(font, 10);
|
cs.setFont(font, 10);
|
||||||
cs.setNonStrokingColor(COLOR_TEXT_GRAY);
|
cs.setNonStrokingColor(COLOR_TEXT_GRAY);
|
||||||
@@ -199,11 +216,23 @@ public class PdfUtils {
|
|||||||
String[] words = text.split("\\s+");
|
String[] words = text.split("\\s+");
|
||||||
StringBuilder line = new StringBuilder();
|
StringBuilder line = new StringBuilder();
|
||||||
float currentY = y;
|
float currentY = y;
|
||||||
|
int wordIndex = 0;
|
||||||
|
|
||||||
for (String word : words) {
|
for (wordIndex = 0; wordIndex < words.length; wordIndex++) {
|
||||||
|
String word = words[wordIndex];
|
||||||
String testLine = line.isEmpty() ? word : (line + " " + word);
|
String testLine = line.isEmpty() ? word : (line + " " + word);
|
||||||
float textWidth = font.getStringWidth(testLine) / 1000F * 10F;
|
float textWidth = font.getStringWidth(testLine) / 1000F * 10F;
|
||||||
if (textWidth > maxWidth && !line.isEmpty()) {
|
if (textWidth > maxWidth && !line.isEmpty()) {
|
||||||
|
// Check if we have room for this line
|
||||||
|
if (currentY < minY) {
|
||||||
|
// Return remaining text starting from current word
|
||||||
|
StringBuilder remaining = new StringBuilder(line);
|
||||||
|
for (int i = wordIndex; i < words.length; i++) {
|
||||||
|
if (!remaining.isEmpty()) remaining.append(" ");
|
||||||
|
remaining.append(words[i]);
|
||||||
|
}
|
||||||
|
return remaining.toString();
|
||||||
|
}
|
||||||
cs.beginText();
|
cs.beginText();
|
||||||
cs.newLineAtOffset(x, currentY);
|
cs.newLineAtOffset(x, currentY);
|
||||||
cs.showText(line.toString());
|
cs.showText(line.toString());
|
||||||
@@ -215,45 +244,36 @@ public class PdfUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!line.isEmpty()) {
|
if (!line.isEmpty()) {
|
||||||
|
if (currentY < minY) {
|
||||||
|
return line.toString();
|
||||||
|
}
|
||||||
cs.beginText();
|
cs.beginText();
|
||||||
cs.newLineAtOffset(x, currentY);
|
cs.newLineAtOffset(x, currentY);
|
||||||
cs.showText(line.toString());
|
cs.showText(line.toString());
|
||||||
cs.endText();
|
cs.endText();
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void drawEvaluationCard(PDPageContentStream cs, PDFont fontBold, float x, float y, Integer evaluation) throws IOException {
|
private void drawEvaluationCard(PDPageContentStream cs, PDFont fontBold, float x, float y, Integer evaluation) throws IOException {
|
||||||
int eval = evaluation != null ? evaluation : 0;
|
int eval = evaluation != null ? evaluation : 0;
|
||||||
Color[] colors = { COLOR_GREEN, COLOR_YELLOW, COLOR_RED };
|
Color[] colors = { COLOR_GREEN, COLOR_YELLOW, COLOR_RED };
|
||||||
|
|
||||||
float cardPadding = 10F;
|
|
||||||
float cardWidth = 2 * CIRCLE_SPACING + 2 * HIGHLIGHT_RADIUS + 2 * cardPadding;
|
|
||||||
float labelHeight = 14F;
|
float labelHeight = 14F;
|
||||||
float cardHeight = labelHeight + 2 * HIGHLIGHT_RADIUS + 2 * cardPadding + 4F;
|
|
||||||
float cardX = x - HIGHLIGHT_RADIUS - cardPadding;
|
|
||||||
float cardY = y - cardHeight + cardPadding;
|
|
||||||
|
|
||||||
// Draw card background (rounded rectangle)
|
|
||||||
cs.setStrokingColor(new Color(0xDD, 0xDD, 0xDD));
|
|
||||||
cs.setNonStrokingColor(new Color(0xF8, 0xF8, 0xF8));
|
|
||||||
cs.setLineWidth(1f);
|
|
||||||
drawRoundedRect(cs, cardX, cardY, cardWidth, cardHeight, 6F);
|
|
||||||
cs.fillAndStroke();
|
|
||||||
|
|
||||||
// Draw "BEWERTUNG" label above circles
|
// Draw "BEWERTUNG" label above circles
|
||||||
float labelX = x;
|
|
||||||
float labelY = y - labelHeight;
|
float labelY = y - labelHeight;
|
||||||
cs.setFont(fontBold, 9);
|
cs.setFont(fontBold, 10);
|
||||||
cs.setNonStrokingColor(COLOR_CUSTOMER_NAME);
|
cs.setNonStrokingColor(COLOR_CUSTOMER_NAME);
|
||||||
cs.beginText();
|
cs.beginText();
|
||||||
cs.newLineAtOffset(labelX, labelY);
|
cs.newLineAtOffset(x, labelY);
|
||||||
cs.showText("BEWERTUNG");
|
cs.showText("BEWERTUNG");
|
||||||
cs.endText();
|
cs.endText();
|
||||||
|
|
||||||
// Draw circles below the label
|
// Draw circles below the label
|
||||||
float circleY = labelY - cardPadding - HIGHLIGHT_RADIUS - 2F;
|
float circleY = labelY - HIGHLIGHT_RADIUS - 10F;
|
||||||
for (int i = 0; i < 3; i++) {
|
for (int i = 0; i < 3; i++) {
|
||||||
float cx = x + i * CIRCLE_SPACING;
|
float cx = x + CIRCLE_RADIUS + i * CIRCLE_SPACING;
|
||||||
|
|
||||||
// Highlight circle if this matches the evaluation (1=green, 2=yellow, 3=red)
|
// Highlight circle if this matches the evaluation (1=green, 2=yellow, 3=red)
|
||||||
if (eval == i + 1) {
|
if (eval == i + 1) {
|
||||||
@@ -270,19 +290,6 @@ public class PdfUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void drawRoundedRect(PDPageContentStream cs, float x, float y, float w, float h, float r) throws IOException {
|
|
||||||
cs.moveTo(x + r, y);
|
|
||||||
cs.lineTo(x + w - r, y);
|
|
||||||
cs.curveTo(x + w, y, x + w, y, x + w, y + r);
|
|
||||||
cs.lineTo(x + w, y + h - r);
|
|
||||||
cs.curveTo(x + w, y + h, x + w, y + h, x + w - r, y + h);
|
|
||||||
cs.lineTo(x + r, y + h);
|
|
||||||
cs.curveTo(x, y + h, x, y + h, x, y + h - r);
|
|
||||||
cs.lineTo(x, y + r);
|
|
||||||
cs.curveTo(x, y, x, y, x + r, y);
|
|
||||||
cs.closePath();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void drawCircle(PDPageContentStream cs, float cx, float cy, float r) throws IOException {
|
private void drawCircle(PDPageContentStream cs, float cx, float cy, float r) throws IOException {
|
||||||
float k = 0.5523f; // Bezier approximation for circle
|
float k = 0.5523f; // Bezier approximation for circle
|
||||||
cs.moveTo(cx - r, cy);
|
cs.moveTo(cx - r, cy);
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ import marketing.heyday.hartmann.fotodocumentation.core.model.Picture;
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
@Schema(name = "CustomerList")
|
@Schema(name = "CustomerList")
|
||||||
public record CustomerListValue(Long id, String name, String customerNumber, Date lastUpdateDate) {
|
public record CustomerListValue(Long id, String name, String customerNumber, String zip, String city, Date lastUpdateDate) {
|
||||||
|
|
||||||
public static CustomerListValue builder(Customer customer) {
|
public static CustomerListValue builder(Customer customer) {
|
||||||
if (customer == null) {
|
if (customer == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
Date date = customer.getPictures().stream().map(Picture::getPictureDate).sorted((p1, p2) -> p2.compareTo(p1)).findFirst().orElse(null);
|
Date date = customer.getPictures().stream().map(Picture::getPictureDate).sorted((p1, p2) -> p2.compareTo(p1)).findFirst().orElse(null);
|
||||||
return new CustomerListValue(customer.getCustomerId(), customer.getName(), customer.getCustomerNumber(), date);
|
return new CustomerListValue(customer.getCustomerId(), customer.getName(), customer.getCustomerNumber(), customer.getZip(), customer.getCity(), date);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class PdfUtilsTest {
|
|||||||
byte[] pdfBytes = pdfUtils.createPdf(customer, pictures);
|
byte[] pdfBytes = pdfUtils.createPdf(customer, pictures);
|
||||||
|
|
||||||
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
|
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
|
||||||
assertEquals(3, document.getNumberOfPages());
|
assertEquals(4, document.getNumberOfPages());
|
||||||
}
|
}
|
||||||
writeToFile(pdfBytes, "createPdf_multiplePictures_createsOnPagePerPicture.pdf");
|
writeToFile(pdfBytes, "createPdf_multiplePictures_createsOnPagePerPicture.pdf");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,4 +178,16 @@ public class PictureResourceTest extends AbstractRestTest {
|
|||||||
int code = httpResponse.getStatusLine().getStatusCode();
|
int code = httpResponse.getStatusLine().getStatusCode();
|
||||||
assertEquals(404, code);
|
assertEquals(404, code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws IOException {
|
||||||
|
|
||||||
|
var test = new PictureResourceTest();
|
||||||
|
|
||||||
|
test.deploymentURL = "http://localhost:8080/";
|
||||||
|
test.deploymentURL = "https://hartmann-cue.heydevelop.de/";
|
||||||
|
test.username = "adm";
|
||||||
|
test.password = "x1t0e7Pb49";
|
||||||
|
|
||||||
|
test.doGetPicture();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ class CustomerListDto {
|
|||||||
final int id;
|
final int id;
|
||||||
final String name;
|
final String name;
|
||||||
final String customerNumber;
|
final String customerNumber;
|
||||||
|
final String? zip;
|
||||||
|
final String? city;
|
||||||
final DateTime? lastUpdateDate;
|
final DateTime? lastUpdateDate;
|
||||||
|
|
||||||
CustomerListDto({required this.id, required this.name, required this.customerNumber, this.lastUpdateDate});
|
CustomerListDto({required this.id, required this.name, required this.customerNumber, this.zip, this.city, this.lastUpdateDate});
|
||||||
|
|
||||||
/// Create from JSON response
|
/// Create from JSON response
|
||||||
factory CustomerListDto.fromJson(Map<String, dynamic> json) {
|
factory CustomerListDto.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -15,6 +17,8 @@ class CustomerListDto {
|
|||||||
id: json['id'] as int,
|
id: json['id'] as int,
|
||||||
name: json['name'] as String,
|
name: json['name'] as String,
|
||||||
customerNumber: json['customerNumber'] as String,
|
customerNumber: json['customerNumber'] as String,
|
||||||
|
zip: json['zip'] as String?,
|
||||||
|
city: json['city'] as String?,
|
||||||
lastUpdateDate: DateTimeUtils.toDateTime(json['lastUpdateDate']),
|
lastUpdateDate: DateTimeUtils.toDateTime(json['lastUpdateDate']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"errorWidget": "Fehler: {name}",
|
"errorWidget": "Fehler: {name}",
|
||||||
"@errorWidget": {
|
"@errorWidget": {
|
||||||
"description": "Error widget text",
|
"description": "Error widget text",
|
||||||
@@ -80,7 +79,7 @@
|
|||||||
"@customerListHeaderName": {
|
"@customerListHeaderName": {
|
||||||
"description": "Customer list table header for name"
|
"description": "Customer list table header for name"
|
||||||
},
|
},
|
||||||
"customerListHeaderLastDate": "Datum Bilder",
|
"customerListHeaderLastDate": "Datum",
|
||||||
"@customerListHeaderLastDate": {
|
"@customerListHeaderLastDate": {
|
||||||
"description": "Customer list table header for last date"
|
"description": "Customer list table header for last date"
|
||||||
},
|
},
|
||||||
@@ -144,5 +143,13 @@
|
|||||||
"pictureWidgetLabelEvaluation": "BEWERTUNG",
|
"pictureWidgetLabelEvaluation": "BEWERTUNG",
|
||||||
"@pictureWidgetLabelEvaluation": {
|
"@pictureWidgetLabelEvaluation": {
|
||||||
"description": "Picture widget label for evaluation"
|
"description": "Picture widget label for evaluation"
|
||||||
|
},
|
||||||
|
"pictureWidgetNotFound": "Das Bild konnte nicht gefunden werden.",
|
||||||
|
"@pictureWidgetNotFound": {
|
||||||
|
"description": "Picture not found error message"
|
||||||
|
},
|
||||||
|
"customerWidgetDownloadInProgress": "Download wird vorbereitet…",
|
||||||
|
"@customerWidgetDownloadInProgress": {
|
||||||
|
"description": "Download in progress message"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,7 +199,7 @@ abstract class AppLocalizations {
|
|||||||
/// Customer list table header for last date
|
/// Customer list table header for last date
|
||||||
///
|
///
|
||||||
/// In de, this message translates to:
|
/// In de, this message translates to:
|
||||||
/// **'Datum Bilder'**
|
/// **'Datum'**
|
||||||
String get customerListHeaderLastDate;
|
String get customerListHeaderLastDate;
|
||||||
|
|
||||||
/// Customer list table header for ladt date
|
/// Customer list table header for ladt date
|
||||||
@@ -285,6 +285,18 @@ abstract class AppLocalizations {
|
|||||||
/// In de, this message translates to:
|
/// In de, this message translates to:
|
||||||
/// **'BEWERTUNG'**
|
/// **'BEWERTUNG'**
|
||||||
String get pictureWidgetLabelEvaluation;
|
String get pictureWidgetLabelEvaluation;
|
||||||
|
|
||||||
|
/// Picture not found error message
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'Das Bild konnte nicht gefunden werden.'**
|
||||||
|
String get pictureWidgetNotFound;
|
||||||
|
|
||||||
|
/// Download in progress message
|
||||||
|
///
|
||||||
|
/// In de, this message translates to:
|
||||||
|
/// **'Download wird vorbereitet…'**
|
||||||
|
String get customerWidgetDownloadInProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get customerListHeaderName => 'Apothekenname';
|
String get customerListHeaderName => 'Apothekenname';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get customerListHeaderLastDate => 'Datum Bilder';
|
String get customerListHeaderLastDate => 'Datum';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get customerListHeaderLastDateSuffix => ' (zuletzt aktualisiert)';
|
String get customerListHeaderLastDateSuffix => ' (zuletzt aktualisiert)';
|
||||||
@@ -111,4 +111,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pictureWidgetLabelEvaluation => 'BEWERTUNG';
|
String get pictureWidgetLabelEvaluation => 'BEWERTUNG';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pictureWidgetNotFound => 'Das Bild konnte nicht gefunden werden.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get customerWidgetDownloadInProgress => 'Download wird vorbereitet…';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,15 +138,10 @@ class _CustomerListWidgetState extends State<CustomerListWidget> {
|
|||||||
color: _generalStyle.secondaryWidgetBackgroundColor,
|
color: _generalStyle.secondaryWidgetBackgroundColor,
|
||||||
);
|
);
|
||||||
|
|
||||||
final headerStyleSuffix = TextStyle(
|
|
||||||
fontFamily: _generalStyle.fontFamily,
|
|
||||||
fontWeight: FontWeight.normal,
|
|
||||||
fontSize: 20,
|
|
||||||
color: _generalStyle.secondaryWidgetBackgroundColor,
|
|
||||||
);
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
spacing: 8.0,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(width: 48),
|
const SizedBox(width: 48),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -163,18 +158,32 @@ class _CustomerListWidgetState extends State<CustomerListWidget> {
|
|||||||
style: headerStyle,
|
style: headerStyle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: Text(
|
||||||
|
"PLZ/Ort",
|
||||||
|
style: headerStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Text(
|
||||||
|
"Ort",
|
||||||
|
style: headerStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
children: [
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8.0),
|
||||||
|
child: Icon(Icons.calendar_month, color: _generalStyle.secondaryWidgetBackgroundColor),
|
||||||
|
),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context)!.customerListHeaderLastDate,
|
AppLocalizations.of(context)!.customerListHeaderLastDate,
|
||||||
style: headerStyle,
|
style: headerStyle,
|
||||||
),
|
),
|
||||||
Text(
|
|
||||||
AppLocalizations.of(context)!.customerListHeaderLastDateSuffix,
|
|
||||||
style: headerStyleSuffix,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -196,6 +205,7 @@ class _CustomerListWidgetState extends State<CustomerListWidget> {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
spacing: 8.0,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 48,
|
width: 48,
|
||||||
@@ -209,6 +219,14 @@ class _CustomerListWidgetState extends State<CustomerListWidget> {
|
|||||||
flex: 3,
|
flex: 3,
|
||||||
child: Text(dto.name, style: dataStyle),
|
child: Text(dto.name, style: dataStyle),
|
||||||
),
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: Text(dto.zip ?? "", style: dataStyle),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Text(dto.city ?? "", style: dataStyle),
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: Text(dateStr, style: dataStyle),
|
child: Text(dateStr, style: dataStyle),
|
||||||
|
|||||||
@@ -337,8 +337,43 @@ class _CustomerWidgetState extends State<CustomerWidget> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _actionDownload(BuildContext context, CustomerDto customerDto, PictureDto? pictureDto) async {
|
Future<void> _actionDownload(BuildContext context, CustomerDto customerDto, PictureDto? pictureDto) async {
|
||||||
final bytes = await _customerController.export(customerId: customerDto.id, pictureId: pictureDto?.id);
|
showDialog(
|
||||||
final fileName = pictureDto != null ? '${customerDto.customerNumber}_${pictureDto.id}.pdf' : '${customerDto.customerNumber}.pdf';
|
context: context,
|
||||||
await downloadFile(bytes, fileName);
|
barrierDismissible: false,
|
||||||
|
builder: (context) => Dialog(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Text(
|
||||||
|
AppLocalizations.of(context)!.customerWidgetDownloadInProgress,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: _generalStyle.fontFamily,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final bytes = await _customerController.export(customerId: customerDto.id, pictureId: pictureDto?.id);
|
||||||
|
final fileName = pictureDto != null ? '${customerDto.customerNumber}_${pictureDto.id}.pdf' : '${customerDto.customerNumber}.pdf';
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
await downloadFile(bytes, fileName);
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,13 +72,13 @@ class _PictureWidgetState extends State<PictureWidget> {
|
|||||||
if (snapshot.hasData) {
|
if (snapshot.hasData) {
|
||||||
CustomerDto? dto = snapshot.data;
|
CustomerDto? dto = snapshot.data;
|
||||||
if (dto == null) {
|
if (dto == null) {
|
||||||
return GeneralErrorWidget(error: "FIXME"); // FIXME: set error text data not found
|
return GeneralErrorWidget(error: AppLocalizations.of(context)!.customerWidgetNotFound);
|
||||||
}
|
}
|
||||||
_customerDto = dto;
|
_customerDto = dto;
|
||||||
_selectedPicture ??= dto.pictures.firstWhere((p) => p.id == widget.pictureId);
|
_selectedPicture ??= dto.pictures.firstWhere((p) => p.id == widget.pictureId);
|
||||||
_selectedPicture ??= _customerDto.pictures.firstOrNull;
|
_selectedPicture ??= _customerDto.pictures.firstOrNull;
|
||||||
if (_selectedPicture == null) {
|
if (_selectedPicture == null) {
|
||||||
return GeneralErrorWidget(error: "FIXME"); // FIXME: set error text data not found
|
return GeneralErrorWidget(error: AppLocalizations.of(context)!.pictureWidgetNotFound);
|
||||||
}
|
}
|
||||||
return _body(context, _selectedPicture!);
|
return _body(context, _selectedPicture!);
|
||||||
} else if (snapshot.hasError) {
|
} else if (snapshot.hasError) {
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ void main() {
|
|||||||
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
|
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
|
||||||
provideMockedNetworkImages(() async {
|
provideMockedNetworkImages(() async {
|
||||||
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
|
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
|
||||||
await tester.pump(Duration(seconds: 10));
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// The comment field should be empty but the label should exist
|
// The comment field should be empty but the label should exist
|
||||||
|
|||||||
Reference in New Issue
Block a user