Added download
This commit is contained in:
@@ -163,6 +163,11 @@ public class Picture extends AbstractDateEntity {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder evaluation(Integer evaluation) {
|
||||
instance.setEvaluation(evaluation);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder customer(Customer customer) {
|
||||
instance.setCustomer(customer);
|
||||
return this;
|
||||
|
||||
@@ -11,11 +11,13 @@ import org.apache.commons.logging.LogFactory;
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import jakarta.ejb.LocalBean;
|
||||
import jakarta.ejb.Stateless;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.persistence.TypedQuery;
|
||||
import jakarta.persistence.criteria.*;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.model.Customer;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.model.Picture;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.query.Param;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.utils.PdfUtils;
|
||||
import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerListValue;
|
||||
import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerPictureValue;
|
||||
import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerValue;
|
||||
@@ -35,6 +37,9 @@ import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerValue;
|
||||
public class CustomerPictureService extends AbstractService {
|
||||
private static final Log LOG = LogFactory.getLog(CustomerPictureService.class);
|
||||
|
||||
@Inject
|
||||
private PdfUtils pdfUtils;
|
||||
|
||||
public boolean addCustomerPicture(CustomerPictureValue customerPictureValue) {
|
||||
Optional<Customer> customerOpt = queryService.callNamedQuerySingleResult(Customer.FIND_BY_NUMBER, new Param(Customer.PARAM_NUMBER, customerPictureValue.customerNumber()));
|
||||
Customer customer = customerOpt.orElseGet(() -> new Customer.Builder().customerNumber(customerPictureValue.customerNumber()).name(customerPictureValue.pharmacyName())
|
||||
@@ -134,4 +139,20 @@ public class CustomerPictureService extends AbstractService {
|
||||
|
||||
return CustomerValue.builder(customer, baseUrl);
|
||||
}
|
||||
|
||||
public byte[] getExport(Long id, Long pictureId) {
|
||||
Customer customer = entityManager.find(Customer.class, id);
|
||||
if (customer == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Picture> pictures = customer.getPictures().stream().sorted((x, y) -> x.getPictureDate().compareTo(y.getPictureDate())).toList();
|
||||
|
||||
if (pictureId != null) {
|
||||
Optional<Picture> pictureOpt = customer.getPictures().stream().filter(p -> p.getPictureId().equals(pictureId)).findFirst();
|
||||
pictures = pictureOpt.map(p -> Arrays.asList(p)).orElse(pictures);
|
||||
}
|
||||
|
||||
return pdfUtils.createPdf(customer, pictures);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
package marketing.heyday.hartmann.fotodocumentation.core.utils;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.model.Customer;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.model.Picture;
|
||||
|
||||
/**
|
||||
*
|
||||
* <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: 2 Feb 2026
|
||||
*/
|
||||
|
||||
public class PdfUtils {
|
||||
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_BOLD = "fonts/Panton-Bold.ttf";
|
||||
|
||||
private static final Color COLOR_CUSTOMER_NAME = new Color(0x00, 0x45, 0xFF);
|
||||
private static final Color COLOR_DATE = new Color(0x00, 0x16, 0x89);
|
||||
|
||||
private static final Color COLOR_TEXT_GRAY = new Color(0x2F, 0x2F, 0x2F);
|
||||
|
||||
|
||||
private static final Color COLOR_GREEN = new Color(76, 175, 80);
|
||||
private static final Color COLOR_YELLOW = new Color(255, 193, 7);
|
||||
private static final Color COLOR_RED = new Color(244, 67, 54);
|
||||
private static final Color COLOR_HIGHLIGHT = new Color(41, 98, 175);
|
||||
|
||||
private static final float PAGE_MARGIN = 40f;
|
||||
private static final float CIRCLE_RADIUS = 8f;
|
||||
private static final float HIGHLIGHT_RADIUS = 12f;
|
||||
private static final float CIRCLE_SPACING = 30f;
|
||||
|
||||
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMAN);
|
||||
|
||||
public byte[] createPdf(Customer customer, List<Picture> pictures) {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
PDFont fontBold = loadFont(document, FONT_PANTON_BOLD, Standard14Fonts.FontName.HELVETICA_BOLD);
|
||||
PDFont fontRegular = loadFont(document, FONT_PANTON_REGULAR, Standard14Fonts.FontName.HELVETICA);
|
||||
boolean firstPage = true;
|
||||
|
||||
for (Picture picture : pictures) {
|
||||
PDPage page = new PDPage(new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth()));
|
||||
document.addPage(page);
|
||||
|
||||
float pageWidth = page.getMediaBox().getWidth();
|
||||
float pageHeight = page.getMediaBox().getHeight();
|
||||
float contentWidth = pageWidth - 2 * PAGE_MARGIN;
|
||||
float halfWidth = contentWidth / 2f;
|
||||
|
||||
try (PDPageContentStream cs = new PDPageContentStream(document, page)) {
|
||||
float yPosition = pageHeight - 50f;
|
||||
|
||||
// Customer name on the first page
|
||||
if (firstPage) {
|
||||
cs.setFont(fontBold, 50);
|
||||
cs.setNonStrokingColor(COLOR_CUSTOMER_NAME);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(PAGE_MARGIN, yPosition);
|
||||
cs.showText(nullSafe(customer.getName()));
|
||||
cs.endText();
|
||||
yPosition -= 60f;
|
||||
firstPage = false;
|
||||
}
|
||||
|
||||
// Left side: image (50% of content width)
|
||||
float imageX = PAGE_MARGIN;
|
||||
float imageY = yPosition;
|
||||
float imageMaxWidth = halfWidth - 10f;
|
||||
float imageMaxHeight = pageHeight - 2 * PAGE_MARGIN - 40f;
|
||||
|
||||
if (picture.getImage() != null) {
|
||||
try {
|
||||
byte[] imageBytes = Base64.getDecoder().decode(picture.getImage());
|
||||
PDImageXObject pdImage = PDImageXObject.createFromByteArray(document, imageBytes, "picture");
|
||||
|
||||
float scale = Math.min(imageMaxWidth / pdImage.getWidth(), imageMaxHeight / pdImage.getHeight());
|
||||
float drawWidth = pdImage.getWidth() * scale;
|
||||
float drawHeight = pdImage.getHeight() * scale;
|
||||
|
||||
cs.drawImage(pdImage, imageX, imageY - drawHeight, drawWidth, drawHeight);
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to embed image in PDF", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Right side: metadata (top-aligned with image)
|
||||
float rightX = PAGE_MARGIN + halfWidth + 10f;
|
||||
float rightY = imageY - 32f;
|
||||
|
||||
// Date (no label, bold, size 44)
|
||||
String dateStr = picture.getPictureDate() != null ? DATE_FORMAT.format(picture.getPictureDate()) + " UHR": "";
|
||||
cs.setFont(fontBold, 32);
|
||||
cs.setNonStrokingColor(COLOR_DATE);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(rightX, rightY);
|
||||
cs.showText(dateStr);
|
||||
cs.endText();
|
||||
rightY -= 54f;
|
||||
|
||||
// Customer number
|
||||
float kundenNummerY = rightY;
|
||||
rightY = drawLabel(cs, fontBold, "KUNDENNUMMER", rightX, rightY);
|
||||
rightY = drawValue(cs, fontRegular, nullSafe(customer.getCustomerNumber()), rightX, rightY);
|
||||
rightY -= 10f;
|
||||
|
||||
// Evaluation card with circles
|
||||
float circlesX = rightX + 140f;
|
||||
drawEvaluationCard(cs, fontBold, circlesX, kundenNummerY, picture.getEvaluation());
|
||||
|
||||
// ZIP
|
||||
rightY = drawLabel(cs, fontBold, "PLZ", rightX, rightY);
|
||||
rightY = drawValue(cs, fontRegular, nullSafe(customer.getZip()), rightX, rightY);
|
||||
rightY -= 10f;
|
||||
|
||||
// City
|
||||
rightY = drawLabel(cs, fontBold, "ORT", rightX, rightY);
|
||||
rightY = drawValue(cs, fontRegular, nullSafe(customer.getCity()), rightX, rightY);
|
||||
rightY -= 10f;
|
||||
|
||||
// Comment
|
||||
rightY = drawLabel(cs, fontBold, "KOMMENTAR", rightX, rightY);
|
||||
drawWrappedText(cs, fontRegular, nullSafe(picture.getComment()), rightX, rightY, halfWidth - 20f);
|
||||
}
|
||||
}
|
||||
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
document.save(output);
|
||||
return output.toByteArray();
|
||||
} catch (IOException e) {
|
||||
LOG.error("Failed to create PDF", e);
|
||||
return new byte[0];
|
||||
}
|
||||
}
|
||||
|
||||
private PDFont loadFont(PDDocument document, String resourcePath, Standard14Fonts.FontName fallback) {
|
||||
try (InputStream fontStream = Thread.currentThread().getContextClassLoader().getResourceAsStream(resourcePath)) {
|
||||
if (fontStream != null) {
|
||||
return PDType0Font.load(document, fontStream);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warn("Failed to load font " + resourcePath + ", using fallback", e);
|
||||
}
|
||||
LOG.info("Font " + resourcePath + " not found, using fallback " + fallback.getName());
|
||||
return new PDType1Font(fallback);
|
||||
}
|
||||
|
||||
private float drawLabel(PDPageContentStream cs, PDFont font, String label, float x, float y) throws IOException {
|
||||
cs.setFont(font, 10);
|
||||
cs.setNonStrokingColor(COLOR_CUSTOMER_NAME);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(x, y);
|
||||
cs.showText(label);
|
||||
cs.endText();
|
||||
return y - 14f;
|
||||
}
|
||||
|
||||
private float drawValue(PDPageContentStream cs, PDFont font, String value, float x, float y) throws IOException {
|
||||
cs.setFont(font, 10);
|
||||
cs.setNonStrokingColor(COLOR_TEXT_GRAY);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(x, y);
|
||||
cs.showText(value);
|
||||
cs.endText();
|
||||
return y - 14f;
|
||||
}
|
||||
|
||||
private void drawWrappedText(PDPageContentStream cs, PDFont font, String text, float x, float y, float maxWidth) throws IOException {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
cs.setFont(font, 10);
|
||||
cs.setNonStrokingColor(COLOR_TEXT_GRAY);
|
||||
|
||||
String[] words = text.split("\\s+");
|
||||
StringBuilder line = new StringBuilder();
|
||||
float currentY = y;
|
||||
|
||||
for (String word : words) {
|
||||
String testLine = line.isEmpty() ? word : line + " " + word;
|
||||
float textWidth = font.getStringWidth(testLine) / 1000f * 10f;
|
||||
if (textWidth > maxWidth && !line.isEmpty()) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(x, currentY);
|
||||
cs.showText(line.toString());
|
||||
cs.endText();
|
||||
currentY -= 14f;
|
||||
line = new StringBuilder(word);
|
||||
} else {
|
||||
line = new StringBuilder(testLine);
|
||||
}
|
||||
}
|
||||
if (!line.isEmpty()) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(x, currentY);
|
||||
cs.showText(line.toString());
|
||||
cs.endText();
|
||||
}
|
||||
}
|
||||
|
||||
private void drawEvaluationCard(PDPageContentStream cs, PDFont fontBold, float x, float y, Integer evaluation) throws IOException {
|
||||
int eval = evaluation != null ? evaluation : 0;
|
||||
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 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
|
||||
float labelX = x;
|
||||
float labelY = y - labelHeight;
|
||||
cs.setFont(fontBold, 9);
|
||||
cs.setNonStrokingColor(COLOR_CUSTOMER_NAME);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(labelX, labelY);
|
||||
cs.showText("BEWERTUNG");
|
||||
cs.endText();
|
||||
|
||||
// Draw circles below the label
|
||||
float circleY = labelY - cardPadding - HIGHLIGHT_RADIUS - 2f;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
float cx = x + i * CIRCLE_SPACING;
|
||||
|
||||
// Highlight circle if this matches the evaluation (1=green, 2=yellow, 3=red)
|
||||
if (eval == i + 1) {
|
||||
cs.setStrokingColor(COLOR_HIGHLIGHT);
|
||||
cs.setLineWidth(2f);
|
||||
drawCircle(cs, cx, circleY, HIGHLIGHT_RADIUS);
|
||||
cs.stroke();
|
||||
}
|
||||
|
||||
// Filled color circle
|
||||
cs.setNonStrokingColor(colors[i]);
|
||||
drawCircle(cs, cx, circleY, CIRCLE_RADIUS);
|
||||
cs.fill();
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
float k = 0.5523f; // Bezier approximation for circle
|
||||
cs.moveTo(cx - r, cy);
|
||||
cs.curveTo(cx - r, cy + r * k, cx - r * k, cy + r, cx, cy + r);
|
||||
cs.curveTo(cx + r * k, cy + r, cx + r, cy + r * k, cx + r, cy);
|
||||
cs.curveTo(cx + r, cy - r * k, cx + r * k, cy - r, cx, cy - r);
|
||||
cs.curveTo(cx - r * k, cy - r, cx - r, cy - r * k, cx - r, cy);
|
||||
cs.closePath();
|
||||
}
|
||||
|
||||
private String nullSafe(String value) {
|
||||
return value != null ? value : "";
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package marketing.heyday.hartmann.fotodocumentation.rest;
|
||||
|
||||
import static marketing.heyday.hartmann.fotodocumentation.rest.jackson.ApplicationConfigApi.JSON_OUT;
|
||||
|
||||
import java.io.OutputStream;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import org.jboss.resteasy.annotations.GZIP;
|
||||
@@ -16,7 +18,9 @@ import jakarta.enterprise.context.RequestScoped;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.StreamingOutput;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import jakarta.ws.rs.core.Response.Status;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.service.CustomerPictureService;
|
||||
import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerListValue;
|
||||
import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerValue;
|
||||
@@ -66,4 +70,25 @@ public class CustomerResource {
|
||||
var retVal = customerPictureService.get(id, baseUrl);
|
||||
return Response.ok().entity(retVal).build();
|
||||
}
|
||||
|
||||
@GZIP
|
||||
@GET
|
||||
@Path("export/{id}")
|
||||
@Produces("application/pdf")
|
||||
@Operation(summary = "Get Export")
|
||||
@ApiResponse(responseCode = "200", description = "Successfully retrieved export")
|
||||
public Response doExport(@PathParam("id") Long id, @QueryParam("picture") Long pictureId) {
|
||||
LOG.debug("Create export for customer " + id + " with optional param " + pictureId);
|
||||
byte[] pdf = customerPictureService.getExport(id, pictureId);
|
||||
|
||||
if (pdf == null) {
|
||||
return Response.status(Status.NOT_FOUND).build();
|
||||
}
|
||||
|
||||
StreamingOutput streamingOutput = (OutputStream output) -> {
|
||||
LOG.debug("Start writing content to OutputStream available bytes");
|
||||
output.write(pdf);
|
||||
};
|
||||
return Response.status(Status.OK).entity(streamingOutput).build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package marketing.heyday.hartmann.fotodocumentation.rest;
|
||||
|
||||
import static marketing.heyday.hartmann.fotodocumentation.rest.jackson.ApplicationConfigApi.JSON_OUT;
|
||||
|
||||
import java.io.OutputStream;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
@@ -13,7 +11,6 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import jakarta.ejb.EJB;
|
||||
import jakarta.enterprise.context.RequestScoped;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.CacheControl;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.Response.ResponseBuilder;
|
||||
import jakarta.ws.rs.core.Response.Status;
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
package marketing.heyday.hartmann.fotodocumentation.core.utils;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.model.Customer;
|
||||
import marketing.heyday.hartmann.fotodocumentation.core.model.Picture;
|
||||
|
||||
/**
|
||||
*
|
||||
* <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: 2 Feb 2026
|
||||
*/
|
||||
class PdfUtilsTest {
|
||||
private static final Log LOG = LogFactory.getLog(PdfUtilsTest.class);
|
||||
|
||||
private PdfUtils pdfUtils;
|
||||
private Customer customer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
pdfUtils = new PdfUtils();
|
||||
customer = new Customer.Builder()
|
||||
.name("Apotheke Musterstadt")
|
||||
.customerNumber("KD-12345")
|
||||
.zip("50667")
|
||||
.city("Köln")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPdf_singlePicture_returnsValidPdf() throws IOException {
|
||||
Picture picture = createPicture(new Date(), "Schaufenster Dekoration", 1);
|
||||
List<Picture> pictures = List.of(picture);
|
||||
|
||||
byte[] pdfBytes = pdfUtils.createPdf(customer, pictures);
|
||||
|
||||
assertNotNull(pdfBytes);
|
||||
assertTrue(pdfBytes.length > 0);
|
||||
|
||||
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
|
||||
assertEquals(1, document.getNumberOfPages());
|
||||
}
|
||||
writeToFile(pdfBytes, "createPdf_singlePicture_returnsValidPdf.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPdf_multiplePictures_createsOnPagePerPicture() throws IOException {
|
||||
List<Picture> pictures = List.of(
|
||||
createPicture(new Date(), "Bild 1", 1),
|
||||
createPicture(new Date(), longComment, 2),
|
||||
createPicture(new Date(), "Bild 3", 3));
|
||||
|
||||
byte[] pdfBytes = pdfUtils.createPdf(customer, pictures);
|
||||
|
||||
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
|
||||
assertEquals(3, document.getNumberOfPages());
|
||||
}
|
||||
writeToFile(pdfBytes, "createPdf_multiplePictures_createsOnPagePerPicture.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPdf_containsCustomerName() throws IOException {
|
||||
Picture picture = createPicture(new Date(), "Test", 1);
|
||||
|
||||
byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture));
|
||||
|
||||
String text = extractText(pdfBytes);
|
||||
assertTrue(text.contains("Apotheke Musterstadt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPdf_containsCustomerNumber() throws IOException {
|
||||
Picture picture = createPicture(new Date(), "Test", 1);
|
||||
|
||||
byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture));
|
||||
|
||||
String text = extractText(pdfBytes);
|
||||
assertTrue(text.contains("KD-12345"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPdf_containsLabels() throws IOException {
|
||||
Picture picture = createPicture(new Date(), "Test Kommentar", 2);
|
||||
|
||||
byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture));
|
||||
|
||||
String text = extractText(pdfBytes);
|
||||
assertTrue(text.contains("KUNDENNUMMER"));
|
||||
assertTrue(text.contains("PLZ"));
|
||||
assertTrue(text.contains("ORT"));
|
||||
assertTrue(text.contains("KOMMENTAR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPdf_containsZipAndCity() throws IOException {
|
||||
Picture picture = createPicture(new Date(), "Test", 1);
|
||||
|
||||
byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture));
|
||||
|
||||
String text = extractText(pdfBytes);
|
||||
assertTrue(text.contains("50667"));
|
||||
assertTrue(text.contains("Köln"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPdf_containsComment() throws IOException {
|
||||
Picture picture = createPicture(new Date(), "Wichtiger Kommentar zum Bild", 1);
|
||||
|
||||
byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture));
|
||||
|
||||
String text = extractText(pdfBytes);
|
||||
assertTrue(text.contains("Wichtiger Kommentar zum Bild"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPdf_emptyPictureList_returnsValidEmptyPdf() throws IOException {
|
||||
byte[] pdfBytes = pdfUtils.createPdf(customer, Collections.emptyList());
|
||||
|
||||
assertNotNull(pdfBytes);
|
||||
assertTrue(pdfBytes.length > 0);
|
||||
|
||||
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
|
||||
assertEquals(0, document.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPdf_nullImage_doesNotThrow() throws IOException {
|
||||
Picture picture = new Picture.Builder()
|
||||
.pictureDate(new Date())
|
||||
.comment("Ohne Bild")
|
||||
.customer(customer)
|
||||
.build();
|
||||
|
||||
byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture));
|
||||
|
||||
assertNotNull(pdfBytes);
|
||||
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
|
||||
assertEquals(1, document.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPdf_nullComment_doesNotThrow() throws IOException {
|
||||
Picture picture = new Picture.Builder()
|
||||
.pictureDate(new Date())
|
||||
.image(createTestImageBase64())
|
||||
.customer(customer)
|
||||
.build();
|
||||
|
||||
byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture));
|
||||
|
||||
assertNotNull(pdfBytes);
|
||||
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
|
||||
assertEquals(1, document.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPdf_nullDate_doesNotThrow() throws IOException {
|
||||
Picture picture = new Picture.Builder()
|
||||
.comment("Kein Datum")
|
||||
.image(createTestImageBase64())
|
||||
.customer(customer)
|
||||
.build();
|
||||
|
||||
byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture));
|
||||
|
||||
assertNotNull(pdfBytes);
|
||||
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
|
||||
assertEquals(1, document.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPdf_nullEvaluation_doesNotThrow() throws IOException {
|
||||
Picture picture = new Picture.Builder()
|
||||
.pictureDate(new Date())
|
||||
.comment("Test")
|
||||
.image(createTestImageBase64())
|
||||
.customer(customer)
|
||||
.build();
|
||||
// evaluation defaults to 0 in Builder
|
||||
|
||||
byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture));
|
||||
|
||||
assertNotNull(pdfBytes);
|
||||
assertTrue(pdfBytes.length > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPdf_allEvaluationValues_produceValidPdf() throws IOException {
|
||||
for (int eval = 1; eval <= 3; eval++) {
|
||||
Picture picture = createPicture(new Date(), "Eval " + eval, eval);
|
||||
|
||||
byte[] pdfBytes = pdfUtils.createPdf(customer, List.of(picture));
|
||||
|
||||
assertNotNull(pdfBytes);
|
||||
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
|
||||
assertEquals(1, document.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPdf_nullFieldsOnCustomer_doesNotThrow() throws IOException {
|
||||
Customer emptyCustomer = new Customer.Builder()
|
||||
.name("Test")
|
||||
.customerNumber("000")
|
||||
.build();
|
||||
|
||||
Picture picture = createPicture(new Date(), "Test", 1);
|
||||
|
||||
byte[] pdfBytes = pdfUtils.createPdf(emptyCustomer, List.of(picture));
|
||||
|
||||
assertNotNull(pdfBytes);
|
||||
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
|
||||
assertEquals(1, document.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
private Picture createPicture(Date date, String comment, int evaluation) {
|
||||
return new Picture.Builder()
|
||||
.pictureDate(date)
|
||||
.comment(comment)
|
||||
.evaluation(evaluation)
|
||||
.image(createTestImageBase64())
|
||||
.customer(customer)
|
||||
.build();
|
||||
}
|
||||
|
||||
private String createTestImageBase64() {
|
||||
try {
|
||||
BufferedImage image = new BufferedImage(100, 80, BufferedImage.TYPE_INT_RGB);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "png", baos);
|
||||
return Base64.getEncoder().encodeToString(baos.toByteArray());
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private String extractText(byte[] pdfBytes) throws IOException {
|
||||
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
|
||||
PDFTextStripper stripper = new PDFTextStripper();
|
||||
return stripper.getText(document);
|
||||
}
|
||||
}
|
||||
|
||||
public void writeToFile(final byte[] content, final String fileName) {
|
||||
File file = new File("target/test/output/");
|
||||
file.mkdirs();
|
||||
try (FileOutputStream out = new FileOutputStream(new File(file, fileName))) {
|
||||
|
||||
IOUtils.write(content, out);
|
||||
} catch (Exception e) {
|
||||
LOG.error("Error saveing pdf file", e);
|
||||
}
|
||||
}
|
||||
|
||||
String longComment = "This is a sample text used for unit testing purposes. It contains multiple sentences with different structures, punctuation marks, and line breaks. The goal is to simulate realistic content that a program might process during normal execution. Developers often need such text to verify that parsing, searching, filtering, or transformation logic behaves as expected.\n"
|
||||
+ "\n"
|
||||
+ "The text includes numbers like 12345, special characters such as @, #, and %, and mixed casing to ensure case-insensitive comparisons can be tested properly. It also contains repeated keywords like skillmatrix and SkillMatrix to validate string matching and normalization features.\n"
|
||||
+ "\n"
|
||||
+ "Additionally, this paragraph spans several lines to test newline handling and formatting behavior. Unit tests may check whether the system correctly reads files, counts words, trims whitespace, or handles empty lines without errors.\n"
|
||||
+ "\n"
|
||||
+ "Overall, this content is intentionally generic but sufficiently detailed to serve as stable input data for automated tests.";
|
||||
}
|
||||
@@ -197,4 +197,26 @@ public class CustomerResourceTest extends AbstractRestTest {
|
||||
String expected = fileToString(BASE_DOWNLOAD + "doGetCustomer.json");
|
||||
jsonAssert(expected, text);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
public void doDownload() throws IOException {
|
||||
LOG.info("doDownload");
|
||||
|
||||
String authorization = getAuthorization();
|
||||
LOG.info("authorization: " + authorization);
|
||||
String path = deploymentURL + PATH + "/export/1";
|
||||
Request request = Request.Get(path).addHeader("Accept", "application/pdf")
|
||||
.addHeader("Authorization", authorization);
|
||||
|
||||
HttpResponse httpResponse = executeRequest(request);
|
||||
int code = httpResponse.getStatusLine().getStatusCode();
|
||||
assertEquals(200, code);
|
||||
|
||||
|
||||
byte[] text = getResponse(httpResponse);
|
||||
writeFile(text, "doDownload.pdf");
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,18 +11,10 @@ import 'package:fotodocumentation/utils/url_utils.dart';
|
||||
|
||||
abstract class BaseController {
|
||||
UrlUtils get uriUtils => DiContainer.get();
|
||||
JwtTokenStorage get _jwtTokenStorage => DiContainer.get();
|
||||
HttpClientUtils get httpClientUtils => DiContainer.get();
|
||||
|
||||
Future<Header> getAuthHeader() async {
|
||||
final accessToken = await _jwtTokenStorage.getAccessToken();
|
||||
|
||||
if (accessToken != null && accessToken.isNotEmpty) {
|
||||
// Use JWT Bearer token
|
||||
return Header('Authorization', 'Bearer $accessToken');
|
||||
} else {
|
||||
return const Header("Accept-Language", "en-US");
|
||||
}
|
||||
Header getAuthHeader() {
|
||||
return HeaderUtils().getAuthHeader();
|
||||
}
|
||||
|
||||
Exception getServerError(Response response) {
|
||||
@@ -32,7 +24,7 @@ abstract class BaseController {
|
||||
Future<List<T>> runGetListWithAuth<T>(String uriStr, List<T> Function(dynamic) convert) async {
|
||||
http.Client client = httpClientUtils.client;
|
||||
try {
|
||||
Header cred = await getAuthHeader();
|
||||
Header cred = getAuthHeader();
|
||||
Uri uri = Uri.parse(uriStr);
|
||||
var response = await client.get(uri, headers: {cred.name: cred.value});
|
||||
if (response.statusCode == 200) {
|
||||
@@ -51,7 +43,7 @@ abstract class BaseController {
|
||||
Future<T?> runGetWithAuth<T>(String uriStr, T Function(dynamic) convert) async {
|
||||
http.Client client = httpClientUtils.client;
|
||||
try {
|
||||
Header cred = await getAuthHeader();
|
||||
Header cred = getAuthHeader();
|
||||
Uri uri = Uri.parse(uriStr);
|
||||
var response = await client.get(uri, headers: {cred.name: cred.value});
|
||||
if (response.statusCode == 200) {
|
||||
@@ -67,9 +59,26 @@ abstract class BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<int>> runGetBytesWithAuth(String uriStr) async {
|
||||
http.Client client = httpClientUtils.client;
|
||||
try {
|
||||
Header cred = getAuthHeader();
|
||||
Uri uri = Uri.parse(uriStr);
|
||||
var response = await client.get(uri, headers: {cred.name: cred.value});
|
||||
if (response.statusCode == 200) {
|
||||
return response.bodyBytes;
|
||||
} else {
|
||||
throw ServerError(response.statusCode);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.e("exception $e");
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> runDeleteWithAuth(String uriStr) async {
|
||||
http.Client client = httpClientUtils.client;
|
||||
Header cred = await getAuthHeader();
|
||||
Header cred = getAuthHeader();
|
||||
Uri uri = Uri.parse(uriStr);
|
||||
var response = await client.delete(uri, headers: {cred.name: cred.value});
|
||||
return response.statusCode == 200;
|
||||
@@ -77,13 +86,28 @@ abstract class BaseController {
|
||||
|
||||
Future<bool> runPutWithAuth(String uriStr) async {
|
||||
http.Client client = httpClientUtils.client;
|
||||
Header cred = await getAuthHeader();
|
||||
Header cred = getAuthHeader();
|
||||
Uri uri = Uri.parse(uriStr);
|
||||
var response = await client.put(uri, headers: {cred.name: cred.value});
|
||||
return response.statusCode == 200;
|
||||
}
|
||||
}
|
||||
|
||||
class HeaderUtils{
|
||||
JwtTokenStorage get _jwtTokenStorage => DiContainer.get();
|
||||
|
||||
Header getAuthHeader() {
|
||||
final accessToken = _jwtTokenStorage.getAccessToken();
|
||||
|
||||
if (accessToken != null && accessToken.isNotEmpty) {
|
||||
// Use JWT Bearer token
|
||||
return Header('Authorization', 'Bearer $accessToken');
|
||||
} else {
|
||||
return const Header("Accept-Language", "en-US");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Header {
|
||||
final String name;
|
||||
final String value;
|
||||
|
||||
@@ -5,6 +5,8 @@ abstract interface class CustomerController {
|
||||
Future<List<CustomerListDto>> getAll(String query, String startsWith);
|
||||
|
||||
Future<CustomerDto?> get({required int id});
|
||||
|
||||
Future<List<int>> export({required int customerId, int? pictureId});
|
||||
}
|
||||
|
||||
class CustomerControllerImpl extends BaseController implements CustomerController {
|
||||
@@ -28,4 +30,13 @@ class CustomerControllerImpl extends BaseController implements CustomerControlle
|
||||
String uriStr = '${uriUtils.getBaseUrl()}$path/$id';
|
||||
return runGetWithAuth(uriStr, (json) => CustomerDto.fromJson(json));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<int>> export({required int customerId, int? pictureId}) {
|
||||
String uriStr = '${uriUtils.getBaseUrl()}$path/export/$customerId';
|
||||
if (pictureId != null) {
|
||||
uriStr += '?picture=$pictureId';
|
||||
}
|
||||
return runGetBytesWithAuth(uriStr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ typedef AuthenticateReply = ({JwtTokenPairDto? jwtTokenPairDto});
|
||||
abstract interface class LoginController {
|
||||
Future<AuthenticateReply> authenticate(String username, String password);
|
||||
Future<bool> refreshAccessToken();
|
||||
Future<bool> isUsingJwtAuth();
|
||||
bool isUsingJwtAuth();
|
||||
}
|
||||
|
||||
class LoginControllerImpl extends BaseController implements LoginController {
|
||||
@@ -36,7 +36,7 @@ class LoginControllerImpl extends BaseController implements LoginController {
|
||||
final tokenPair = JwtTokenPairDto.fromJson(data);
|
||||
|
||||
// Store tokens securely
|
||||
await _jwtTokenStorage.saveTokens(tokenPair.accessToken, tokenPair.refreshToken);
|
||||
_jwtTokenStorage.saveTokens(tokenPair.accessToken, tokenPair.refreshToken);
|
||||
|
||||
// Load user data using the new token
|
||||
return (jwtTokenPairDto: tokenPair);
|
||||
@@ -53,7 +53,7 @@ class LoginControllerImpl extends BaseController implements LoginController {
|
||||
@override
|
||||
Future<bool> refreshAccessToken() async {
|
||||
try {
|
||||
final refreshToken = await _jwtTokenStorage.getRefreshToken();
|
||||
final refreshToken = _jwtTokenStorage.getRefreshToken();
|
||||
if (refreshToken == null) {
|
||||
logger.i('No refresh token available');
|
||||
return false;
|
||||
@@ -74,7 +74,7 @@ class LoginControllerImpl extends BaseController implements LoginController {
|
||||
final newAccessToken = data['accessToken'] as String;
|
||||
|
||||
// Update only the access token (keep same refresh token)
|
||||
await _jwtTokenStorage.updateAccessToken(newAccessToken);
|
||||
_jwtTokenStorage.updateAccessToken(newAccessToken);
|
||||
|
||||
logger.d('Access token refreshed successfully');
|
||||
return true;
|
||||
@@ -89,8 +89,8 @@ class LoginControllerImpl extends BaseController implements LoginController {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isUsingJwtAuth() async {
|
||||
return await _jwtTokenStorage.hasTokens();
|
||||
bool isUsingJwtAuth() {
|
||||
return _jwtTokenStorage.hasTokens();
|
||||
}
|
||||
|
||||
Header _getLoginHeader(String username, String password) {
|
||||
|
||||
@@ -22,7 +22,7 @@ void main() async {
|
||||
|
||||
await initializeDateFormatting('de_DE', null);
|
||||
LoginController loginController = DiContainer.get();
|
||||
await loginController.isUsingJwtAuth();
|
||||
loginController.isUsingJwtAuth();
|
||||
runApp(FotoDocumentationApp(theme: theme));
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:fotodocumentation/pages/ui_utils/general_style.dart';
|
||||
import 'package:fotodocumentation/utils/di_container.dart';
|
||||
import 'package:fotodocumentation/utils/global_router.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:fotodocumentation/utils/file_download.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class CustomerWidget extends StatefulWidget {
|
||||
@@ -202,6 +203,7 @@ class _CustomerWidgetState extends State<CustomerWidget> {
|
||||
|
||||
final dateStr = _dateFormat.format(pictureDto.pictureDate);
|
||||
final evaluationColor = _generalStyle.evaluationColor(value: pictureDto.evaluation);
|
||||
Header cred = HeaderUtils().getAuthHeader();
|
||||
return InkWell(
|
||||
key: Key("table_row_${customerDto.id}"),
|
||||
onTap: () => _actionSelect(context, customerDto, pictureDto),
|
||||
@@ -219,6 +221,7 @@ class _CustomerWidgetState extends State<CustomerWidget> {
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 70, maxHeight: 70),
|
||||
child: Image.network(
|
||||
headers: {cred.name: cred.value},
|
||||
pictureDto.thumbnailSizeUrl,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
@@ -334,6 +337,10 @@ class _CustomerWidgetState extends State<CustomerWidget> {
|
||||
}
|
||||
|
||||
Future<void> _actionDownload(BuildContext context, CustomerDto customerDto, PictureDto? pictureDto) async {
|
||||
// FIXME: implement a download from the export
|
||||
final bytes = await _customerController.export(customerId: customerDto.id, pictureId: pictureDto?.id);
|
||||
final fileName = pictureDto != null
|
||||
? '${customerDto.customerNumber}_${pictureDto.id}.pdf'
|
||||
: '${customerDto.customerNumber}.pdf';
|
||||
await downloadFile(bytes, fileName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fotodocumentation/controller/base_controller.dart';
|
||||
import 'package:fotodocumentation/dto/picture_dto.dart';
|
||||
import 'package:fotodocumentation/pages/ui_utils/general_style.dart';
|
||||
import 'package:fotodocumentation/utils/di_container.dart';
|
||||
@@ -15,6 +16,7 @@ class PictureFullscreenDialog extends StatelessWidget {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final dialogWidth = screenSize.width * 0.9;
|
||||
final dialogHeight = screenSize.height * 0.9 - 50;
|
||||
Header cred = HeaderUtils().getAuthHeader();
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: Colors.black,
|
||||
@@ -50,6 +52,7 @@ class PictureFullscreenDialog extends StatelessWidget {
|
||||
minScale: 0.5,
|
||||
maxScale: 5.0,
|
||||
child: Image.network(
|
||||
headers: {cred.name: cred.value},
|
||||
dto.imageUrl,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -144,6 +144,8 @@ class _PictureWidgetState extends State<PictureWidget> {
|
||||
}
|
||||
|
||||
Widget _imageWidget(PictureDto dto) {
|
||||
Header cred = HeaderUtils().getAuthHeader();
|
||||
|
||||
return GestureDetector(
|
||||
key: const Key("image"),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
@@ -153,6 +155,7 @@ class _PictureWidgetState extends State<PictureWidget> {
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 100, minHeight: 100),
|
||||
child: Image.network(
|
||||
headers: {cred.name: cred.value},
|
||||
dto.normalSizeUrl,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
@@ -176,16 +179,17 @@ class _PictureWidgetState extends State<PictureWidget> {
|
||||
color: _generalStyle.secondaryTextLabelColor,
|
||||
);
|
||||
|
||||
String dateText = _dateFormat.format(dto.pictureDate);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_dateFormat.format(dto.pictureDate),
|
||||
"$dateText UHR",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 44,
|
||||
fontSize: 32,
|
||||
fontFamily: _generalStyle.fontFamily,
|
||||
color: _generalStyle.primaryTextLabelColor,
|
||||
color: _generalStyle.secondaryWidgetBackgroundColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export 'file_download_stub.dart' if (dart.library.js) 'file_download_web.dart' if (dart.library.io) 'file_download_app.dart';
|
||||
@@ -0,0 +1,3 @@
|
||||
Future<void> downloadFile(List<int> bytes, String fileName) {
|
||||
throw UnsupportedError('File download not supported on this platform');
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
Future<void> downloadFile(List<int> bytes, String fileName) {
|
||||
throw UnsupportedError('Cannot download file on this platform');
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
Future<void> downloadFile(List<int> bytes, String fileName) async {
|
||||
await FilePicker.platform.saveFile(
|
||||
dialogTitle: fileName,
|
||||
fileName: fileName,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf'],
|
||||
bytes: Uint8List.fromList(bytes),
|
||||
);
|
||||
}
|
||||
@@ -3,30 +3,30 @@ abstract class JwtTokenStorage {
|
||||
///
|
||||
/// @param accessToken The short-lived access token
|
||||
/// @param refreshToken The long-lived refresh token
|
||||
Future<void> saveTokens(String accessToken, String refreshToken);
|
||||
void saveTokens(String accessToken, String refreshToken);
|
||||
|
||||
/// Get the stored access token
|
||||
///
|
||||
/// @return Access token or null if not found
|
||||
Future<String?> getAccessToken();
|
||||
String? getAccessToken();
|
||||
|
||||
/// Get the stored refresh token
|
||||
///
|
||||
/// @return Refresh token or null if not found
|
||||
Future<String?> getRefreshToken();
|
||||
String? getRefreshToken();
|
||||
|
||||
/// Clear all stored tokens (on logout)
|
||||
Future<void> clearTokens();
|
||||
void clearTokens();
|
||||
|
||||
/// Check if tokens are stored
|
||||
///
|
||||
/// @return true if access token exists
|
||||
Future<bool> hasTokens();
|
||||
bool hasTokens();
|
||||
|
||||
/// Update only the access token (used after refresh)
|
||||
///
|
||||
/// @param accessToken New access token
|
||||
Future<void> updateAccessToken(String accessToken);
|
||||
void updateAccessToken(String accessToken);
|
||||
}
|
||||
|
||||
class JwtTokenStorageImpl extends JwtTokenStorage {
|
||||
@@ -36,34 +36,34 @@ class JwtTokenStorageImpl extends JwtTokenStorage {
|
||||
String? _keyRefreshToken;
|
||||
|
||||
@override
|
||||
Future<void> saveTokens(String accessToken, String refreshToken) async {
|
||||
void saveTokens(String accessToken, String refreshToken) async {
|
||||
_keyAccessToken = accessToken;
|
||||
_keyRefreshToken = refreshToken;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getAccessToken() async {
|
||||
String? getAccessToken() {
|
||||
return _keyAccessToken;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getRefreshToken() async {
|
||||
String? getRefreshToken() {
|
||||
return _keyRefreshToken;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearTokens() async {
|
||||
void clearTokens() {
|
||||
_keyAccessToken = null;
|
||||
_keyRefreshToken = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> hasTokens() async {
|
||||
bool hasTokens() {
|
||||
return _keyAccessToken != null && _keyAccessToken!.isNotEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateAccessToken(String accessToken) async {
|
||||
void updateAccessToken(String accessToken) {
|
||||
_keyAccessToken == accessToken;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import '../testing/test_utils.mocks.dart';
|
||||
void main() {
|
||||
DiContainer.instance.initState();
|
||||
var jwtTokenStorage = MockJwtTokenStorage();
|
||||
when(jwtTokenStorage.getAccessToken()).thenAnswer((_) async => null);
|
||||
when(jwtTokenStorage.getAccessToken()).thenAnswer((_) => null);
|
||||
DiContainer.instance.put(JwtTokenStorage, jwtTokenStorage);
|
||||
|
||||
CustomerController controller = CustomerControllerImpl();
|
||||
|
||||
@@ -13,7 +13,7 @@ void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
DiContainer.instance.initState();
|
||||
var jwtTokenStorage = MockJwtTokenStorage();
|
||||
when(jwtTokenStorage.getAccessToken()).thenAnswer((_) async => null);
|
||||
when(jwtTokenStorage.getAccessToken()).thenAnswer((_) => null);
|
||||
DiContainer.instance.put(JwtTokenStorage, jwtTokenStorage);
|
||||
|
||||
LoginController controller = LoginControllerImpl();
|
||||
|
||||
@@ -13,7 +13,7 @@ import '../testing/test_utils.mocks.dart';
|
||||
void main() {
|
||||
DiContainer.instance.initState();
|
||||
var jwtTokenStorage = MockJwtTokenStorage();
|
||||
when(jwtTokenStorage.getAccessToken()).thenAnswer((_) async => null);
|
||||
when(jwtTokenStorage.getAccessToken()).thenAnswer((_) => null);
|
||||
DiContainer.instance.put(JwtTokenStorage, jwtTokenStorage);
|
||||
|
||||
PictureController controller = PictureControllerImpl();
|
||||
|
||||
@@ -175,13 +175,13 @@ class MockLoginController extends _i1.Mock implements _i6.LoginController {
|
||||
) as _i7.Future<bool>);
|
||||
|
||||
@override
|
||||
_i7.Future<bool> isUsingJwtAuth() => (super.noSuchMethod(
|
||||
bool isUsingJwtAuth() => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#isUsingJwtAuth,
|
||||
[],
|
||||
),
|
||||
returnValue: _i7.Future<bool>.value(false),
|
||||
) as _i7.Future<bool>);
|
||||
returnValue: false,
|
||||
) as bool);
|
||||
}
|
||||
|
||||
/// A class which mocks [CustomerController].
|
||||
@@ -219,6 +219,23 @@ class MockCustomerController extends _i1.Mock
|
||||
),
|
||||
returnValue: _i7.Future<_i10.CustomerDto?>.value(),
|
||||
) as _i7.Future<_i10.CustomerDto?>);
|
||||
|
||||
@override
|
||||
_i7.Future<List<int>> export({
|
||||
required int? customerId,
|
||||
int? pictureId,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#export,
|
||||
[],
|
||||
{
|
||||
#customerId: customerId,
|
||||
#pictureId: pictureId,
|
||||
},
|
||||
),
|
||||
returnValue: _i7.Future<List<int>>.value(<int>[]),
|
||||
) as _i7.Future<List<int>>);
|
||||
}
|
||||
|
||||
/// A class which mocks [PictureController].
|
||||
@@ -258,11 +275,11 @@ class MockJwtTokenStorage extends _i1.Mock implements _i13.JwtTokenStorage {
|
||||
}
|
||||
|
||||
@override
|
||||
_i7.Future<void> saveTokens(
|
||||
void saveTokens(
|
||||
String? accessToken,
|
||||
String? refreshToken,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#saveTokens,
|
||||
[
|
||||
@@ -270,57 +287,35 @@ class MockJwtTokenStorage extends _i1.Mock implements _i13.JwtTokenStorage {
|
||||
refreshToken,
|
||||
],
|
||||
),
|
||||
returnValue: _i7.Future<void>.value(),
|
||||
returnValueForMissingStub: _i7.Future<void>.value(),
|
||||
) as _i7.Future<void>);
|
||||
returnValueForMissingStub: null,
|
||||
);
|
||||
|
||||
@override
|
||||
_i7.Future<String?> getAccessToken() => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#getAccessToken,
|
||||
[],
|
||||
),
|
||||
returnValue: _i7.Future<String?>.value(),
|
||||
) as _i7.Future<String?>);
|
||||
|
||||
@override
|
||||
_i7.Future<String?> getRefreshToken() => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#getRefreshToken,
|
||||
[],
|
||||
),
|
||||
returnValue: _i7.Future<String?>.value(),
|
||||
) as _i7.Future<String?>);
|
||||
|
||||
@override
|
||||
_i7.Future<void> clearTokens() => (super.noSuchMethod(
|
||||
void clearTokens() => super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#clearTokens,
|
||||
[],
|
||||
),
|
||||
returnValue: _i7.Future<void>.value(),
|
||||
returnValueForMissingStub: _i7.Future<void>.value(),
|
||||
) as _i7.Future<void>);
|
||||
returnValueForMissingStub: null,
|
||||
);
|
||||
|
||||
@override
|
||||
_i7.Future<bool> hasTokens() => (super.noSuchMethod(
|
||||
bool hasTokens() => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#hasTokens,
|
||||
[],
|
||||
),
|
||||
returnValue: _i7.Future<bool>.value(false),
|
||||
) as _i7.Future<bool>);
|
||||
returnValue: false,
|
||||
) as bool);
|
||||
|
||||
@override
|
||||
_i7.Future<void> updateAccessToken(String? accessToken) =>
|
||||
(super.noSuchMethod(
|
||||
void updateAccessToken(String? accessToken) => super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#updateAccessToken,
|
||||
[accessToken],
|
||||
),
|
||||
returnValue: _i7.Future<void>.value(),
|
||||
returnValueForMissingStub: _i7.Future<void>.value(),
|
||||
) as _i7.Future<void>);
|
||||
returnValueForMissingStub: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// A class which mocks [Client].
|
||||
|
||||
@@ -11,29 +11,29 @@ void main() {
|
||||
|
||||
test('initially has no tokens', () async {
|
||||
// Verify initial state is empty
|
||||
expect(await storage.getAccessToken(), isNull);
|
||||
expect(await storage.getRefreshToken(), isNull);
|
||||
expect(await storage.hasTokens(), isFalse);
|
||||
expect(storage.getAccessToken(), isNull);
|
||||
expect(storage.getRefreshToken(), isNull);
|
||||
expect(storage.hasTokens(), isFalse);
|
||||
});
|
||||
|
||||
test('saveTokens stores both access and refresh tokens', () async {
|
||||
const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access';
|
||||
const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh';
|
||||
|
||||
await storage.saveTokens(accessToken, refreshToken);
|
||||
storage.saveTokens(accessToken, refreshToken);
|
||||
|
||||
expect(await storage.getAccessToken(), equals(accessToken));
|
||||
expect(await storage.getRefreshToken(), equals(refreshToken));
|
||||
expect(await storage.hasTokens(), isTrue);
|
||||
expect(storage.getAccessToken(), equals(accessToken));
|
||||
expect(storage.getRefreshToken(), equals(refreshToken));
|
||||
expect(storage.hasTokens(), isTrue);
|
||||
});
|
||||
|
||||
test('getAccessToken returns correct token after save', () async {
|
||||
const accessToken = 'test_access_token_123';
|
||||
const refreshToken = 'test_refresh_token_456';
|
||||
|
||||
await storage.saveTokens(accessToken, refreshToken);
|
||||
storage.saveTokens(accessToken, refreshToken);
|
||||
|
||||
final retrievedAccessToken = await storage.getAccessToken();
|
||||
final retrievedAccessToken = storage.getAccessToken();
|
||||
expect(retrievedAccessToken, equals(accessToken));
|
||||
});
|
||||
|
||||
@@ -41,9 +41,9 @@ void main() {
|
||||
const accessToken = 'test_access_token_123';
|
||||
const refreshToken = 'test_refresh_token_456';
|
||||
|
||||
await storage.saveTokens(accessToken, refreshToken);
|
||||
storage.saveTokens(accessToken, refreshToken);
|
||||
|
||||
final retrievedRefreshToken = await storage.getRefreshToken();
|
||||
final retrievedRefreshToken = storage.getRefreshToken();
|
||||
expect(retrievedRefreshToken, equals(refreshToken));
|
||||
});
|
||||
|
||||
@@ -52,35 +52,35 @@ void main() {
|
||||
const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh';
|
||||
|
||||
// First save tokens
|
||||
await storage.saveTokens(accessToken, refreshToken);
|
||||
expect(await storage.hasTokens(), isTrue);
|
||||
storage.saveTokens(accessToken, refreshToken);
|
||||
expect(storage.hasTokens(), isTrue);
|
||||
|
||||
// Then clear them
|
||||
await storage.clearTokens();
|
||||
storage.clearTokens();
|
||||
|
||||
expect(await storage.getAccessToken(), isNull);
|
||||
expect(await storage.getRefreshToken(), isNull);
|
||||
expect(await storage.hasTokens(), isFalse);
|
||||
expect(storage.getAccessToken(), isNull);
|
||||
expect(storage.getRefreshToken(), isNull);
|
||||
expect(storage.hasTokens(), isFalse);
|
||||
});
|
||||
|
||||
test('hasTokens returns true when access token exists', () async {
|
||||
const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access';
|
||||
const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh';
|
||||
|
||||
expect(await storage.hasTokens(), isFalse);
|
||||
expect(storage.hasTokens(), isFalse);
|
||||
|
||||
await storage.saveTokens(accessToken, refreshToken);
|
||||
storage.saveTokens(accessToken, refreshToken);
|
||||
|
||||
expect(await storage.hasTokens(), isTrue);
|
||||
expect(storage.hasTokens(), isTrue);
|
||||
});
|
||||
|
||||
test('hasTokens returns false when access token is empty string', () async {
|
||||
const accessToken = '';
|
||||
const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh';
|
||||
|
||||
await storage.saveTokens(accessToken, refreshToken);
|
||||
storage.saveTokens(accessToken, refreshToken);
|
||||
|
||||
expect(await storage.hasTokens(), isFalse);
|
||||
expect(storage.hasTokens(), isFalse);
|
||||
});
|
||||
|
||||
test('updateAccessToken updates only the access token', () async {
|
||||
@@ -89,20 +89,20 @@ void main() {
|
||||
const newAccessToken = 'new_access_token';
|
||||
|
||||
// Save initial tokens
|
||||
await storage.saveTokens(initialAccessToken, initialRefreshToken);
|
||||
storage.saveTokens(initialAccessToken, initialRefreshToken);
|
||||
|
||||
// Update access token
|
||||
await storage.updateAccessToken(newAccessToken);
|
||||
storage.updateAccessToken(newAccessToken);
|
||||
|
||||
// Note: Due to bug in implementation (line 67 uses == instead of =),
|
||||
// this test will fail. The access token won't actually be updated.
|
||||
// Uncomment below when bug is fixed:
|
||||
// expect(await storage.getAccessToken(), equals(newAccessToken));
|
||||
// expect(await storage.getRefreshToken(), equals(initialRefreshToken));
|
||||
// expect(storage.getAccessToken(), equals(newAccessToken));
|
||||
// expect(storage.getRefreshToken(), equals(initialRefreshToken));
|
||||
|
||||
// Current behavior (with bug):
|
||||
expect(await storage.getAccessToken(), equals(initialAccessToken));
|
||||
expect(await storage.getRefreshToken(), equals(initialRefreshToken));
|
||||
expect(storage.getAccessToken(), equals(initialAccessToken));
|
||||
expect(storage.getRefreshToken(), equals(initialRefreshToken));
|
||||
});
|
||||
|
||||
test('saveTokens can overwrite existing tokens', () async {
|
||||
@@ -112,27 +112,27 @@ void main() {
|
||||
const secondRefreshToken = 'second_refresh_token';
|
||||
|
||||
// Save first set of tokens
|
||||
await storage.saveTokens(firstAccessToken, firstRefreshToken);
|
||||
expect(await storage.getAccessToken(), equals(firstAccessToken));
|
||||
expect(await storage.getRefreshToken(), equals(firstRefreshToken));
|
||||
storage.saveTokens(firstAccessToken, firstRefreshToken);
|
||||
expect(storage.getAccessToken(), equals(firstAccessToken));
|
||||
expect(storage.getRefreshToken(), equals(firstRefreshToken));
|
||||
|
||||
// Overwrite with second set
|
||||
await storage.saveTokens(secondAccessToken, secondRefreshToken);
|
||||
expect(await storage.getAccessToken(), equals(secondAccessToken));
|
||||
expect(await storage.getRefreshToken(), equals(secondRefreshToken));
|
||||
storage.saveTokens(secondAccessToken, secondRefreshToken);
|
||||
expect(storage.getAccessToken(), equals(secondAccessToken));
|
||||
expect(storage.getRefreshToken(), equals(secondRefreshToken));
|
||||
});
|
||||
|
||||
test('clearTokens can be called multiple times safely', () async {
|
||||
const accessToken = 'test_access_token';
|
||||
const refreshToken = 'test_refresh_token';
|
||||
|
||||
await storage.saveTokens(accessToken, refreshToken);
|
||||
await storage.clearTokens();
|
||||
await storage.clearTokens(); // Call again
|
||||
storage.saveTokens(accessToken, refreshToken);
|
||||
storage.clearTokens();
|
||||
storage.clearTokens(); // Call again
|
||||
|
||||
expect(await storage.getAccessToken(), isNull);
|
||||
expect(await storage.getRefreshToken(), isNull);
|
||||
expect(await storage.hasTokens(), isFalse);
|
||||
expect(storage.getAccessToken(), isNull);
|
||||
expect(storage.getRefreshToken(), isNull);
|
||||
expect(storage.hasTokens(), isFalse);
|
||||
});
|
||||
|
||||
test('handles long JWT tokens correctly', () async {
|
||||
@@ -143,40 +143,40 @@ void main() {
|
||||
'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyNDI2MjJ9.'
|
||||
'Ks_BdfH4CWilyzLNk8S2gShdsGuhkle-VsNBJJxulJc';
|
||||
|
||||
await storage.saveTokens(longAccessToken, longRefreshToken);
|
||||
storage.saveTokens(longAccessToken, longRefreshToken);
|
||||
|
||||
expect(await storage.getAccessToken(), equals(longAccessToken));
|
||||
expect(await storage.getRefreshToken(), equals(longRefreshToken));
|
||||
expect(await storage.hasTokens(), isTrue);
|
||||
expect(storage.getAccessToken(), equals(longAccessToken));
|
||||
expect(storage.getRefreshToken(), equals(longRefreshToken));
|
||||
expect(storage.hasTokens(), isTrue);
|
||||
});
|
||||
|
||||
test('typical authentication flow', () async {
|
||||
// 1. Initial state - no tokens
|
||||
expect(await storage.hasTokens(), isFalse);
|
||||
expect(storage.hasTokens(), isFalse);
|
||||
|
||||
// 2. User logs in - tokens are saved
|
||||
const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access';
|
||||
const refreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh';
|
||||
await storage.saveTokens(accessToken, refreshToken);
|
||||
storage.saveTokens(accessToken, refreshToken);
|
||||
|
||||
expect(await storage.hasTokens(), isTrue);
|
||||
expect(await storage.getAccessToken(), equals(accessToken));
|
||||
expect(await storage.getRefreshToken(), equals(refreshToken));
|
||||
expect(storage.hasTokens(), isTrue);
|
||||
expect(storage.getAccessToken(), equals(accessToken));
|
||||
expect(storage.getRefreshToken(), equals(refreshToken));
|
||||
|
||||
// 3. Access token expires, refresh with new access token
|
||||
const newAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new_access';
|
||||
await storage.updateAccessToken(newAccessToken);
|
||||
storage.updateAccessToken(newAccessToken);
|
||||
|
||||
// Note: Due to bug, this won't work as expected
|
||||
// expect(await storage.getAccessToken(), equals(newAccessToken));
|
||||
// expect(await storage.getRefreshToken(), equals(refreshToken));
|
||||
// expect(storage.getAccessToken(), equals(newAccessToken));
|
||||
// expect(storage.getRefreshToken(), equals(refreshToken));
|
||||
|
||||
// 4. User logs out - tokens are cleared
|
||||
await storage.clearTokens();
|
||||
storage.clearTokens();
|
||||
|
||||
expect(await storage.hasTokens(), isFalse);
|
||||
expect(await storage.getAccessToken(), isNull);
|
||||
expect(await storage.getRefreshToken(), isNull);
|
||||
expect(storage.hasTokens(), isFalse);
|
||||
expect(storage.getAccessToken(), isNull);
|
||||
expect(storage.getRefreshToken(), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,9 @@
|
||||
<web-resource-collection>
|
||||
<web-resource-name>Secure</web-resource-name>
|
||||
<url-pattern>/api/customer</url-pattern>
|
||||
<url-pattern>/api/customer/*</url-pattern>
|
||||
<url-pattern>/api/picture</url-pattern>
|
||||
<url-pattern>/api/picture/*</url-pattern>
|
||||
|
||||
<http-method>GET</http-method>
|
||||
<http-method>POST</http-method>
|
||||
|
||||
Reference in New Issue
Block a user