start quesitonnaire

This commit is contained in:
verboomp
2026-02-19 11:04:02 +01:00
parent 168fc986f2
commit 9b3446685a
47 changed files with 2456 additions and 97 deletions

View File

@@ -20,12 +20,10 @@ import jakarta.persistence.*;
@Entity
@Table(name = "customer")
@NamedQuery(name = Customer.FIND_ALL, query = "select c from Customer c order by c.name")
@NamedQuery(name = Customer.FIND_BY_NUMBER, query = "select c from Customer c where c.customerNumber = :cutomerNumber")
public class Customer extends AbstractDateEntity {
private static final long serialVersionUID = 1L;
public static final String SEQUENCE = "customer_seq";
public static final String FIND_ALL = "Customer.findAll";
public static final String FIND_BY_NUMBER = "Customer.findByNumber";
public static final String PARAM_NUMBER = "cutomerNumber";

View File

@@ -0,0 +1,181 @@
package marketing.heyday.hartmann.fotodocumentation.core.model;
import java.util.Date;
import org.apache.commons.lang.builder.HashCodeBuilder;
import jakarta.persistence.*;
/**
*
*
* <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: 19 Jan 2026
*/
@Entity
@Table(name = "questionnaire")
public class Questionnaire extends AbstractDateEntity {
private static final long serialVersionUID = 1L;
public static final String SEQUENCE = "questionnaire_seq";
@Id
@Column(name = "questionnaire_id", length = 22)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = SEQUENCE)
@SequenceGenerator(name = SEQUENCE, sequenceName = SEQUENCE, allocationSize = 1)
private Long questionnaireId;
// username from the person that shot the picture
@Column(name = "username")
private String username;
@Temporal(TemporalType.TIMESTAMP)
@Column(name = "questionnaire_date", nullable = false)
private Date questionnaireDate;
@Basic(fetch = FetchType.LAZY)
private String comment;
@Column(name = "evaluation")
private Integer evaluation;
@Column
private String category;
@Column(name = "questions")
@Basic(fetch = FetchType.LAZY)
private String questions;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id_fk")
private QuestionnaireCustomer customer;
public Long getQuestionnaireId() {
return questionnaireId;
}
public void setQuestionnaireId(Long questionnaireId) {
this.questionnaireId = questionnaireId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public Date getQuestionnaireDate() {
return questionnaireDate;
}
public void setQuestionnaireDate(Date questionnaireDate) {
this.questionnaireDate = questionnaireDate;
}
public String getComment() {
return comment;
}
public void setComment(String comment) {
this.comment = comment;
}
public Integer getEvaluation() {
return evaluation;
}
public void setEvaluation(Integer evaluation) {
this.evaluation = evaluation;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public String getQuestions() {
return questions;
}
public void setQuestions(String questions) {
this.questions = questions;
}
public QuestionnaireCustomer getCustomer() {
return customer;
}
public void setCustomer(QuestionnaireCustomer customer) {
this.customer = customer;
}
@Override
public int hashCode() {
return new HashCodeBuilder().append(questionnaireId).toHashCode();
}
@Override
public boolean equals(Object obj) {
if (obj == null || this.getClass() != obj.getClass() || questionnaireId == null) {
return false;
}
return this.questionnaireId.equals(((Questionnaire) obj).getQuestionnaireId());
}
public static class Builder {
private Questionnaire instance = new Questionnaire();
public Builder(){
instance.evaluation = 0;
}
public Builder username(String username) {
instance.setUsername(username);
return this;
}
public Builder questionnaireDate(Date date) {
instance.setQuestionnaireDate(date);
return this;
}
public Builder comment(String comment) {
instance.setComment(comment);
return this;
}
public Builder category(String category) {
instance.setCategory(category);
return this;
}
public Builder questions(String questions) {
instance.setQuestions(questions);
return this;
}
public Builder evaluation(Integer evaluation) {
instance.setEvaluation(evaluation);
return this;
}
public Builder customer(QuestionnaireCustomer customer) {
instance.setCustomer(customer);
return this;
}
public Questionnaire build() {
return instance;
}
}
}

View File

@@ -0,0 +1,140 @@
package marketing.heyday.hartmann.fotodocumentation.core.model;
import java.util.HashSet;
import java.util.Set;
import org.apache.commons.lang.builder.HashCodeBuilder;
import jakarta.persistence.*;
/**
*
*
* <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: 19 Jan 2026
*/
@Entity
@Table(name = "questionnaire_customer")
@NamedQuery(name = QuestionnaireCustomer.FIND_BY_NUMBER, query = "select c from QuestionnaireCustomer c where c.customerNumber = :customerNumber")
public class QuestionnaireCustomer extends AbstractDateEntity {
private static final long serialVersionUID = 1L;
public static final String SEQUENCE = "questionnaire_customer_seq";
public static final String FIND_BY_NUMBER = "QuestionnaireCustomer.findByNumber";
public static final String PARAM_NUMBER = "customerNumber";
@Id
@Column(name = "customer_id", length = 22)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = SEQUENCE)
@SequenceGenerator(name = SEQUENCE, sequenceName = SEQUENCE, allocationSize = 1)
private Long customerId;
@Column(name = "customer_number", unique = true, nullable = false)
private String customerNumber;
@Column(name = "name", nullable = false)
private String name;
@Column(name = "city")
private String city;
@Column(name = "zip")
private String zip;
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Questionnaire> questionnaires = new HashSet<>();
public Long getCustomerId() {
return customerId;
}
public void setCustomerId(Long customerId) {
this.customerId = customerId;
}
public String getCustomerNumber() {
return customerNumber;
}
public void setCustomerNumber(String customerNumber) {
this.customerNumber = customerNumber;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
public String getZip() {
return zip;
}
public void setZip(String zip) {
this.zip = zip;
}
public Set<Questionnaire> getQuestionnaires() {
return questionnaires;
}
public void setQuestionnaires(Set<Questionnaire> questionnaires) {
this.questionnaires = questionnaires;
}
@Override
public int hashCode() {
return new HashCodeBuilder().append(customerNumber).toHashCode();
}
@Override
public boolean equals(Object obj) {
if (obj == null || this.getClass() != obj.getClass()) {
return false;
}
return this.customerNumber.equals(((QuestionnaireCustomer) obj).getCustomerNumber());
}
public static class Builder {
private QuestionnaireCustomer instance = new QuestionnaireCustomer();
public Builder customerNumber(String customerNumber) {
instance.setCustomerNumber(customerNumber);
return this;
}
public Builder name(String name) {
instance.setName(name);
return this;
}
public Builder city(String city) {
instance.setCity(city);
return this;
}
public Builder zip(String zip) {
instance.setZip(zip);
return this;
}
public QuestionnaireCustomer build() {
return instance;
}
}
}

View File

@@ -0,0 +1,121 @@
package marketing.heyday.hartmann.fotodocumentation.core.service;
import java.util.*;
import org.apache.commons.lang3.StringUtils;
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.Questionnaire;
import marketing.heyday.hartmann.fotodocumentation.core.model.QuestionnaireCustomer;
import marketing.heyday.hartmann.fotodocumentation.core.utils.CalendarUtil;
import marketing.heyday.hartmann.fotodocumentation.core.utils.ExcelUtils;
import marketing.heyday.hartmann.fotodocumentation.core.utils.PdfUtils;
import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerListValue;
import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerValue;
import marketing.heyday.hartmann.fotodocumentation.rest.vo.QuestionnaireCustomerListValue;
import marketing.heyday.hartmann.fotodocumentation.rest.vo.QuestionnaireCustomerValue;
/**
*
*
* <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: 18 Feb 2026
*/
@Stateless
@LocalBean
@PermitAll
public class QuestionnaireCustomerService extends AbstractService {
@Inject
private ExcelUtils excelUtils;
@Inject
private CalendarUtil calendarUtil;
// query = search for name, number and date
public List<QuestionnaireCustomerListValue> getAll(String queryStr, String startsWith) {
CriteriaBuilder builder = entityManager.getCriteriaBuilder();
CriteriaQuery<QuestionnaireCustomer> criteriaQuery = builder.createQuery(QuestionnaireCustomer.class);
Root<QuestionnaireCustomer> customerRoot = criteriaQuery.from(QuestionnaireCustomer.class);
criteriaQuery = criteriaQuery.select(customerRoot).distinct(true);
List<Predicate> predicates = new ArrayList<>();
if (StringUtils.isNotBlank(startsWith)) {
String param = startsWith.toLowerCase() + "%";
var pred = builder.like(builder.lower(customerRoot.get("name")), param);
predicates.add(pred);
}
if (StringUtils.isNotBlank(queryStr)) {
// check if it contains a date
Date date = calendarUtil.parse(queryStr);
if (date != null) {
Date startOfDay = calendarUtil.getStartOfDay(date);
Date endOfDay = calendarUtil.getEndOfDay(date);
Fetch<QuestionnaireCustomer, Questionnaire> picturesFetch = customerRoot.fetch("pictures", JoinType.LEFT);
@SuppressWarnings("unchecked")
Join<QuestionnaireCustomer, Questionnaire> pictures = (Join<QuestionnaireCustomer, Questionnaire>) picturesFetch;
var predicateDate = builder.between(pictures.get("pictureDate"), startOfDay, endOfDay);
predicates.add(predicateDate);
} else {
String param = "%" + StringUtils.trimToEmpty(queryStr).toLowerCase() + "%";
var predicateName = builder.like(builder.lower(customerRoot.get("name")), param);
var predicateNr = builder.like(builder.lower(customerRoot.get("customerNumber")), param);
var pred = builder.or(predicateName, predicateNr);
predicates.add(pred);
}
}
if (predicates.size() == 1) {
criteriaQuery = criteriaQuery.where(predicates.getFirst());
} else if (predicates.size() > 1) {
criteriaQuery = criteriaQuery.where(builder.and(predicates.toArray(new Predicate[0])));
}
TypedQuery<QuestionnaireCustomer> typedQuery = entityManager.createQuery(criteriaQuery);
List<QuestionnaireCustomer> customers = typedQuery.getResultList();
customers.forEach(c -> c.getQuestionnaires().size());
return customers.parallelStream().map(QuestionnaireCustomerListValue::builder).toList();
}
public QuestionnaireCustomerValue get(Long id) {
QuestionnaireCustomer customer = entityManager.find(QuestionnaireCustomer.class, id);
if (customer == null) {
return null;
}
return QuestionnaireCustomerValue.builder(customer);
}
public byte[] getExport(Long id, Long questionnaireId) {
QuestionnaireCustomer customer = entityManager.find(QuestionnaireCustomer.class, id);
if (customer == null) {
return new byte[0];
}
List<Questionnaire> questionnaires = customer.getQuestionnaires().stream().sorted((x, y) -> x.getQuestionnaireDate().compareTo(y.getQuestionnaireDate())).toList();
if (questionnaireId != null) {
Optional<Questionnaire> pictureOpt = customer.getQuestionnaires().stream().filter(p -> p.getQuestionnaireId().equals(questionnaireId)).findFirst();
questionnaires = pictureOpt.map(Arrays::asList).orElse(questionnaires);
}
return excelUtils.create(customer, questionnaires);
}
}

View File

@@ -0,0 +1,26 @@
package marketing.heyday.hartmann.fotodocumentation.core.service;
import jakarta.annotation.security.PermitAll;
import jakarta.ejb.LocalBean;
import jakarta.ejb.Stateless;
import marketing.heyday.hartmann.fotodocumentation.rest.vo.QuestionnairePublishValue;
/**
*
* <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: 18 Feb 2026
*/
@Stateless
@LocalBean
@PermitAll
public class QuestionnairePublishService extends AbstractService {
public boolean publish(QuestionnairePublishValue value) {
// FIXME: implement me
return false;
}
}

View File

@@ -0,0 +1,44 @@
package marketing.heyday.hartmann.fotodocumentation.core.service;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import jakarta.annotation.security.PermitAll;
import jakarta.ejb.LocalBean;
import jakarta.ejb.Stateless;
import jakarta.persistence.EntityNotFoundException;
import marketing.heyday.hartmann.fotodocumentation.core.model.Questionnaire;
import marketing.heyday.hartmann.fotodocumentation.core.utils.StorageUtils.StorageState;
/**
*
* <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: 19 Jan 2026
*/
@Stateless
@LocalBean
@PermitAll
public class QuestionnaireService extends AbstractService {
private static final Log LOG = LogFactory.getLog(QuestionnaireService.class);
public StorageState delete(Long id) {
return super.delete(Questionnaire.class, id);
}
public StorageState updateEvaluationStatus(Long id, Integer value) {
try {
Questionnaire entity = entityManager.getReference(Questionnaire.class, id);
entity.setEvaluation(value);
entityManager.flush();
return StorageState.OK;
} catch (EntityNotFoundException e) {
LOG.warn("Failed to update evaluation value not found " + id, e);
ejbContext.setRollbackOnly();
return StorageState.NOT_FOUND;
}
}
}

View File

@@ -0,0 +1,29 @@
package marketing.heyday.hartmann.fotodocumentation.core.utils;
import java.util.List;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import marketing.heyday.hartmann.fotodocumentation.core.model.Questionnaire;
import marketing.heyday.hartmann.fotodocumentation.core.model.QuestionnaireCustomer;
/**
*
* <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
*/
@SuppressWarnings({ "java:S818", "squid:S818", "squid:S109" })
public class ExcelUtils {
private static final Log LOG = LogFactory.getLog(ExcelUtils.class);
public byte[] create(QuestionnaireCustomer customer, List<Questionnaire> questionnaires) {
// FIXME: implement excel export
return new byte[0];
}
}

View File

@@ -0,0 +1,88 @@
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;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
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.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.StreamingOutput;
import marketing.heyday.hartmann.fotodocumentation.core.service.QuestionnaireCustomerService;
import marketing.heyday.hartmann.fotodocumentation.rest.vo.QuestionnaireCustomerListValue;
import marketing.heyday.hartmann.fotodocumentation.rest.vo.QuestionnaireCustomerValue;
/**
*
* <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: 21 Jan 2026
*/
@RequestScoped
@Path("questionnairecustomer")
public class QuestionnaireCustomerResource {
private static final Log LOG = LogFactory.getLog(QuestionnaireCustomerResource.class);
@EJB
private QuestionnaireCustomerService questionnaireCustomerService;
@GZIP
@GET
@Path("")
@Produces(JSON_OUT)
@Operation(summary = "Get customer list")
@ApiResponse(responseCode = "200", description = "Successfully retrieved customer list", content = @Content(mediaType = JSON_OUT, array = @ArraySchema(schema = @Schema(implementation = QuestionnaireCustomerListValue.class))))
public Response doGetCustomerList(@QueryParam("query") String query, @QueryParam("startsWith") String startsWith) {
LOG.debug("Query customers for query " + query + " startsWith: " + startsWith);
var retVal = questionnaireCustomerService.getAll(query, startsWith);
return Response.ok().entity(retVal).build();
}
@GZIP
@GET
@Path("{id}")
@Produces(JSON_OUT)
@Operation(summary = "Get customer value")
@ApiResponse(responseCode = "200", description = "Successfully retrieved customer value", content = @Content(mediaType = JSON_OUT, array = @ArraySchema(schema = @Schema(implementation = QuestionnaireCustomerValue.class))))
public Response doGetDetailCustomer(@PathParam("id") Long id) {
LOG.debug("Get Customer details for id " + id);
var retVal = questionnaireCustomerService.get(id);
return Response.ok().entity(retVal).build();
}
@GZIP
@GET
@Path("export/{id}")
@Produces("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
@Operation(summary = "Get Export")
@ApiResponse(responseCode = "200", description = "Successfully retrieved export")
public Response doExport(@PathParam("id") Long id, @QueryParam("questionnaire") Long questionnaireId) {
LOG.debug("Create export for customer " + id + " with optional param " + questionnaireId);
byte[] pdf = questionnaireCustomerService.getExport(id, questionnaireId);
if (pdf.length == 0) {
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();
}
}

View File

@@ -0,0 +1,46 @@
package marketing.heyday.hartmann.fotodocumentation.rest;
import org.jboss.resteasy.annotations.GZIP;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.ejb.EJB;
import jakarta.enterprise.context.RequestScoped;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import marketing.heyday.hartmann.fotodocumentation.core.service.QuestionnairePublishService;
import marketing.heyday.hartmann.fotodocumentation.rest.jackson.JsonSchemaValidate;
import marketing.heyday.hartmann.fotodocumentation.rest.vo.QuestionnairePublishValue;
/**
*
* <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: 18 Feb 2026
*/
@RequestScoped
@Path("questionnaire-publish")
public class QuestionnairePublishResource {
@EJB
private QuestionnairePublishService questionnairePublishService;
@GZIP
@POST
@Path("")
@Consumes(MediaType.APPLICATION_JSON)
@Operation(summary = "Add questionnaire to database")
@ApiResponse(responseCode = "200", description = "Add successfull")
public Response doAddQuestionnaire(@JsonSchemaValidate("schema/questionnaire_publish.json") QuestionnairePublishValue value) {
boolean success = questionnairePublishService.publish(value);
return success ? Response.ok().build() : Response.status(Status.BAD_REQUEST).build();
}
}

View File

@@ -0,0 +1,70 @@
package marketing.heyday.hartmann.fotodocumentation.rest;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.ejb.EJB;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.ResponseBuilder;
import jakarta.ws.rs.core.Response.Status;
import marketing.heyday.hartmann.fotodocumentation.core.service.QuestionnaireService;
import marketing.heyday.hartmann.fotodocumentation.core.utils.EvaluationUtil;
import marketing.heyday.hartmann.fotodocumentation.core.utils.StorageUtils.StorageState;
/**
*
* <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: 21 Jan 2026
*/
@RequestScoped
@Path("questionnaire")
public class QuestionnaireResource {
private static final Log LOG = LogFactory.getLog(QuestionnaireResource.class);
@EJB
private QuestionnaireService questionnaireService;
@Inject
private EvaluationUtil evaluationUtil;
@DELETE
@Path("{id}")
@Operation(summary = "Delete questionnaire from database")
@ApiResponse(responseCode = "200", description = "Task successfully deleted")
@ApiResponse(responseCode = "404", description = "Task not found")
@ApiResponse(responseCode = "403", description = "Insufficient permissions")
public Response doDelete(@PathParam("id") Long id) {
LOG.debug("Delete questionnaire with id " + id);
var state = questionnaireService.delete(id);
return deleteResponse(state).build();
}
@PUT
@Path("evaluation/{id}")
@Operation(summary = "Update evaluation for questionnaire data to database")
@ApiResponse(responseCode = "200", description = "Task successfully updated")
public Response doUpdateEvaluation(@PathParam("id") Long id, @QueryParam("evaluation") Integer value) {
if (evaluationUtil.isInValid(value)) {
return Response.status(Status.BAD_REQUEST).build();
}
StorageState state = questionnaireService.updateEvaluationStatus(id, value);
return deleteResponse(state).build();
}
protected ResponseBuilder deleteResponse(StorageState state) {
return switch (state) {
case OK -> Response.status(Status.OK);
case NOT_FOUND -> Response.status(Status.NOT_FOUND);
default -> Response.status(Status.INTERNAL_SERVER_ERROR);
};
}
}

View File

@@ -46,6 +46,10 @@ public class ApplicationConfigApi extends Application {
retVal.add(CustomerPictureResource.class);
retVal.add(CustomerResource.class);
retVal.add(PictureResource.class);
retVal.add(QuestionnairePublishResource.class);
retVal.add(QuestionnaireCustomerResource.class);
retVal.add(QuestionnaireResource.class);
LOG.info("returning rest api classes " + retVal);
return retVal;
}

View File

@@ -0,0 +1,29 @@
package marketing.heyday.hartmann.fotodocumentation.rest.vo;
import java.util.Date;
import io.swagger.v3.oas.annotations.media.Schema;
import marketing.heyday.hartmann.fotodocumentation.core.model.Questionnaire;
import marketing.heyday.hartmann.fotodocumentation.core.model.QuestionnaireCustomer;
/**
*
* <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: 19 Jan 2026
*/
@Schema(name = "QuestionnaireCustomerList")
public record QuestionnaireCustomerListValue(Long id, String name, String customerNumber, String zip, String city, Date lastUpdateDate) {
public static QuestionnaireCustomerListValue builder(QuestionnaireCustomer customer) {
if (customer == null) {
return null;
}
Date date = customer.getQuestionnaires().stream().map(Questionnaire::getQuestionnaireDate).sorted((p1, p2) -> p2.compareTo(p1)).findFirst().orElse(null);
return new QuestionnaireCustomerListValue(customer.getCustomerId(), customer.getName(), customer.getCustomerNumber(), customer.getZip(), customer.getCity(), date);
}
}

View File

@@ -0,0 +1,27 @@
package marketing.heyday.hartmann.fotodocumentation.rest.vo;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import marketing.heyday.hartmann.fotodocumentation.core.model.QuestionnaireCustomer;
/**
*
* <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: 22 Jan 2026
*/
@Schema(name = "QuestionnaireCustomer")
public record QuestionnaireCustomerValue(Long id, String name, String customerNumber, String city, String zip, List<QuestionnaireValue> questionnaires) {
public static QuestionnaireCustomerValue builder(QuestionnaireCustomer customer) {
if (customer == null) {
return null;
}
List<QuestionnaireValue> questionnaires = customer.getQuestionnaires().parallelStream().map(QuestionnaireValue::builder).filter(p -> p != null).toList();
return new QuestionnaireCustomerValue(customer.getCustomerId(), customer.getName(), customer.getCustomerNumber(), customer.getCity(), customer.getZip(), questionnaires);
}
}

View File

@@ -0,0 +1,21 @@
package marketing.heyday.hartmann.fotodocumentation.rest.vo;
import java.util.Date;
import io.swagger.v3.oas.annotations.media.Schema;
import marketing.heyday.hartmann.fotodocumentation.rest.jackson.SchemaValidated;
/**
*
* <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: 18 Feb 2026
*/
@Schema(name = "QuestionnairePublish")
public record QuestionnairePublishValue(String username, String pharmacyName, String customerNumber, Date date, String zip, String city, String comment, String category, String base64String) implements SchemaValidated {
}

View File

@@ -0,0 +1,28 @@
package marketing.heyday.hartmann.fotodocumentation.rest.vo;
import java.util.Date;
import io.swagger.v3.oas.annotations.media.Schema;
import marketing.heyday.hartmann.fotodocumentation.core.model.Questionnaire;
/**
*
* <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: 22 Jan 2026
*/
@Schema(name = "Questionnaire")
public record QuestionnaireValue(Long id, String comment, String category, Date questionnaireDate, String username, Integer evaluation) {
public static QuestionnaireValue builder(Questionnaire questionnaire) {
if (questionnaire == null) {
return null;
}
return new QuestionnaireValue(questionnaire.getQuestionnaireId(), questionnaire.getComment(), questionnaire.getCategory(), questionnaire.getQuestionnaireDate(), questionnaire.getUsername(), questionnaire.getEvaluation());
}
}

View File

@@ -13,6 +13,9 @@
<class>marketing.heyday.hartmann.fotodocumentation.core.model.Picture</class>
<class>marketing.heyday.hartmann.fotodocumentation.core.model.JwtRefreshToken</class>
<class>marketing.heyday.hartmann.fotodocumentation.core.model.QuestionnaireCustomer</class>
<class>marketing.heyday.hartmann.fotodocumentation.core.model.Questionnaire</class>
<properties>
<property name="hibernate.format_sql" value="false" />
<property name="hibernate.show_sql" value="false" />

View File

@@ -0,0 +1,37 @@
create sequence IF NOT EXISTS questionnaire_customer_seq start 25;
CREATE TABLE IF NOT EXISTS questionnaire_customer
(
customer_id bigint PRIMARY KEY,
customer_number varchar(150) NOT NULL,
name varchar(150) NOT NULL,
zip varchar(150),
city varchar(150),
jpa_active boolean NOT NULL,
jpa_created timestamp NOT NULL,
jpa_updated timestamp NOT NULL,
jpa_version integer NOT NULL,
CONSTRAINT unq_questionnaire_customer_number UNIQUE(customer_number)
);
create sequence IF NOT EXISTS questionnaire_seq start 25;
CREATE TABLE IF NOT EXISTS questionnaire
(
questionnaire_id bigint PRIMARY KEY,
username varchar(150),
questionnaire_date timestamp NOT NULL,
comment text,
questions text,
category varchar(250),
evaluation bigint NOT NULL,
jpa_active boolean NOT NULL,
jpa_created timestamp NOT NULL,
jpa_updated timestamp NOT NULL,
jpa_version integer NOT NULL,
customer_id_fk bigint REFERENCES questionnaire_customer
);

View File

@@ -0,0 +1,48 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Add Customer Picture",
"description": "Add a Customer Picture to the system",
"type": "object",
"properties": {
"username": {
"description": "The username from the user who uploads the picture",
"type": "string"
},
"pharmacyName": {
"description": "The Name from the pharmacy customer ",
"type": "string"
},
"customerNumber": {
"description": "The unique number from the pharmacy customer ",
"type": "string"
},
"date": {
"description": "The date when the picture is taken ",
"type": "string"
},
"comment": {
"description": "A free text comment field ",
"type": "string"
},
"zip": {
"description": "The zip from the customer",
"type": "string"
},
"city": {
"description": "The city from the customer",
"type": "string"
},
"base64String": {
"description": "The Picture content as base64 ",
"type": "string"
}
},
"required": [
"username",
"pharmacyName",
"customerNumber",
"date",
"comment",
"base64String"
]
}

View File

@@ -0,0 +1,23 @@
import 'package:fotodocumentation/controller/base_controller.dart';
import 'package:fotodocumentation/dto/questionnaire_dto.dart';
abstract interface class QuestionnaireController {
Future<bool> delete(QuestionnaireDto dto);
Future<bool> updateEvaluation(QuestionnaireDto dto);
}
class QuestionnaireControllerImpl extends BaseController implements QuestionnaireController {
final String path = "questionnaire";
@override
Future<bool> delete(QuestionnaireDto dto) {
String uriStr = '${uriUtils.getBaseUrl()}$path/${dto.id}';
return runDeleteWithAuth(uriStr);
}
@override
Future<bool> updateEvaluation(QuestionnaireDto dto) {
String uriStr = '${uriUtils.getBaseUrl()}$path/evaluation/${dto.id}?evaluation=${dto.evaluation}';
return runPutWithAuth(uriStr);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:fotodocumentation/controller/base_controller.dart';
import 'package:fotodocumentation/dto/questionnaire_customer_dto.dart';
abstract interface class QuestionnaireCustomerController {
Future<List<QuestionnaireCustomerListDto>> getAll(String query, String startsWith);
Future<QuestionnaireCustomerDto?> get({required int id});
Future<List<int>> export({required int customerId, int? questionnaireId});
}
class QuestionnaireCustomerControllerImpl extends BaseController implements QuestionnaireCustomerController {
final String path = "questionnairecustomer";
@override
Future<List<QuestionnaireCustomerListDto>> getAll(String query, String startsWith) async {
String uriStr = '${uriUtils.getBaseUrl()}$path?query=$query&startsWith=$startsWith';
return runGetListWithAuth(uriStr, (p0) {
List<QuestionnaireCustomerListDto> retVal = [];
for (var elem in p0) {
var entity = QuestionnaireCustomerListDto.fromJson(elem);
retVal.add(entity);
}
return retVal;
});
}
@override
Future<QuestionnaireCustomerDto?> get({required int id}) {
String uriStr = '${uriUtils.getBaseUrl()}$path/$id';
return runGetWithAuth(uriStr, (json) => QuestionnaireCustomerDto.fromJson(json));
}
@override
Future<List<int>> export({required int customerId, int? questionnaireId}) {
String uriStr = '${uriUtils.getBaseUrl()}$path/export/$customerId';
if (questionnaireId != null) {
uriStr += '?questionnaire=$questionnaireId';
}
return runGetBytesWithAuth(uriStr);
}
}

View File

@@ -46,3 +46,5 @@ class CustomerDto {
);
}
}

View File

@@ -0,0 +1,49 @@
import 'package:fotodocumentation/dto/questionnaire_dto.dart';
import 'package:fotodocumentation/utils/date_time_utils.dart';
class QuestionnaireCustomerListDto {
final int id;
final String name;
final String customerNumber;
final String? zip;
final String? city;
final DateTime? lastUpdateDate;
QuestionnaireCustomerListDto({required this.id, required this.name, required this.customerNumber, this.zip, this.city, this.lastUpdateDate});
/// Create from JSON response
factory QuestionnaireCustomerListDto.fromJson(Map<String, dynamic> json) {
return QuestionnaireCustomerListDto(
id: json['id'] as int,
name: json['name'] as String,
customerNumber: json['customerNumber'] as String,
zip: json['zip'] as String?,
city: json['city'] as String?,
lastUpdateDate: DateTimeUtils.toDateTime(json['lastUpdateDate']),
);
}
}
class QuestionnaireCustomerDto {
final int id;
final String name;
final String customerNumber;
final String? zip;
final String? city;
final List<QuestionnaireDto> questionnaires;
QuestionnaireCustomerDto({required this.id, required this.name, required this.customerNumber, required this.questionnaires, this.zip, this.city});
/// Create from JSON response
factory QuestionnaireCustomerDto.fromJson(Map<String, dynamic> json) {
return QuestionnaireCustomerDto(
id: json['id'] as int,
name: json['name'] as String,
customerNumber: json['customerNumber'] as String,
zip: json['zip'] as String?,
city: json['city'] as String?,
questionnaires: List<QuestionnaireDto>.from(json["questionnaires"].map((x) => QuestionnaireDto.fromJson(x))),
);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:fotodocumentation/utils/date_time_utils.dart';
class QuestionnaireDto {
final int id;
final String? comment;
final String? category;
int evaluation;
final DateTime questionnaireDate;
final String? username;
QuestionnaireDto(
{required this.id, required this.comment, required this.category, required this.evaluation, required this.questionnaireDate, required this.username});
/// Create from JSON response
factory QuestionnaireDto.fromJson(Map<String, dynamic> json) {
return QuestionnaireDto(
id: json['id'] as int,
comment: json['comment'] as String?,
category: json['category'] as String?,
evaluation: json["evaluation"] as int,
questionnaireDate: DateTimeUtils.toDateTime(json['questionnaireDate']) ?? DateTime.now(),
username: json['username'] as String?,
);
}
@override
String toString() {
return 'QuestionnaireDto{id: $id}';
}
}

View File

@@ -151,5 +151,9 @@
"customerWidgetDownloadInProgress": "Download wird vorbereitet…",
"@customerWidgetDownloadInProgress": {
"description": "Download in progress message"
},
"questionnaireLoginTitle": "FRAGEBOGEN",
"@questionnaireLoginTitle": {
"description": "Questionnaire login page title"
}
}

View File

@@ -297,6 +297,12 @@ abstract class AppLocalizations {
/// In de, this message translates to:
/// **'Download wird vorbereitet…'**
String get customerWidgetDownloadInProgress;
/// Questionnaire login page title
///
/// In de, this message translates to:
/// **'FRAGEBOGEN'**
String get questionnaireLoginTitle;
}
class _AppLocalizationsDelegate

View File

@@ -117,4 +117,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get customerWidgetDownloadInProgress => 'Download wird vorbereitet…';
@override
String get questionnaireLoginTitle => 'FRAGEBOGEN';
}

View File

@@ -13,14 +13,14 @@ import 'package:fotodocumentation/utils/global_router.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
class CustomerListWidget extends StatefulWidget {
const CustomerListWidget({super.key});
class FotoCustomerListWidget extends StatefulWidget {
const FotoCustomerListWidget({super.key});
@override
State<CustomerListWidget> createState() => _CustomerListWidgetState();
State<FotoCustomerListWidget> createState() => _FotoCustomerListWidgetState();
}
class _CustomerListWidgetState extends State<CustomerListWidget> {
class _FotoCustomerListWidgetState extends State<FotoCustomerListWidget> {
CustomerController get _customerController => DiContainer.get();
GeneralStyle get _generalStyle => DiContainer.get();
@@ -242,7 +242,7 @@ class _CustomerListWidgetState extends State<CustomerListWidget> {
}
Future<void> _actionSelect(BuildContext context, CustomerListDto dto) async {
String uri = "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/${dto.id}";
String uri = "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/${dto.id}";
context.go(uri);
}

View File

@@ -6,7 +6,7 @@ import 'package:fotodocumentation/dto/customer_dto.dart';
import 'package:fotodocumentation/dto/picture_dto.dart';
import 'package:fotodocumentation/l10n/app_localizations.dart';
import 'package:fotodocumentation/pages/ui_utils/component/customer_back_button.dart';
import 'package:fotodocumentation/pages/customer/picture_delete_dialog.dart';
import 'package:fotodocumentation/pages/foto/customer/foto_picture_delete_dialog.dart';
import 'package:fotodocumentation/pages/ui_utils/component/general_error_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/component/page_header_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/component/waiting_widget.dart';
@@ -17,15 +17,15 @@ import 'package:go_router/go_router.dart';
import 'package:fotodocumentation/utils/file_download.dart';
import 'package:intl/intl.dart';
class CustomerWidget extends StatefulWidget {
class FotoCustomerWidget extends StatefulWidget {
final int customerId;
const CustomerWidget({super.key, required this.customerId});
const FotoCustomerWidget({super.key, required this.customerId});
@override
State<CustomerWidget> createState() => _CustomerWidgetState();
State<FotoCustomerWidget> createState() => _FotoCustomerWidgetState();
}
class _CustomerWidgetState extends State<CustomerWidget> {
class _FotoCustomerWidgetState extends State<FotoCustomerWidget> {
CustomerController get _customerController => DiContainer.get();
PictureController get _pictureController => DiContainer.get();
GeneralStyle get _generalStyle => DiContainer.get();
@@ -40,6 +40,14 @@ class _CustomerWidgetState extends State<CustomerWidget> {
_dto = _customerController.get(id: widget.customerId);
}
@override
void didUpdateWidget(covariant FotoCustomerWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.customerId != widget.customerId) {
_dto = _customerController.get(id: widget.customerId);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -90,7 +98,7 @@ class _CustomerWidgetState extends State<CustomerWidget> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeaderWidget(text: dto.name, subText: subText),
CustomerBackButton(path: GlobalRouter.pathHome),
CustomerBackButton(path: GlobalRouter.pathFotoHome),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
@@ -316,7 +324,7 @@ class _CustomerWidgetState extends State<CustomerWidget> {
final confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return PictureDeleteDialog();
return FotoPictureDeleteDialog();
},
);
@@ -329,7 +337,7 @@ class _CustomerWidgetState extends State<CustomerWidget> {
}
Future<void> _actionSelect(BuildContext context, CustomerDto customerDto, PictureDto pictureDto) async {
String uri = "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/${customerDto.id}${GlobalRouter.pathPicture}/${pictureDto.id}";
String uri = "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/${customerDto.id}${GlobalRouter.pathFotoPicture}/${pictureDto.id}";
context.go(uri);
setState(() {
_dto = _customerController.get(id: widget.customerId);

View File

@@ -3,10 +3,10 @@ import 'package:fotodocumentation/l10n/app_localizations.dart';
import 'package:fotodocumentation/pages/ui_utils/general_style.dart';
import 'package:fotodocumentation/utils/di_container.dart';
class PictureDeleteDialog extends StatelessWidget {
class FotoPictureDeleteDialog extends StatelessWidget {
GeneralStyle get _generalStyle => DiContainer.get();
const PictureDeleteDialog({super.key});
const FotoPictureDeleteDialog({super.key});
@override
Widget build(BuildContext context) {

View File

@@ -4,12 +4,12 @@ import 'package:fotodocumentation/dto/picture_dto.dart';
import 'package:fotodocumentation/pages/ui_utils/general_style.dart';
import 'package:fotodocumentation/utils/di_container.dart';
class PictureFullscreenDialog extends StatelessWidget {
class FotoPictureFullscreenDialog extends StatelessWidget {
GeneralStyle get _generalStyle => DiContainer.get();
final PictureDto dto;
const PictureFullscreenDialog({super.key, required this.dto});
const FotoPictureFullscreenDialog({super.key, required this.dto});
@override
Widget build(BuildContext context) {

View File

@@ -6,7 +6,7 @@ import 'package:fotodocumentation/dto/customer_dto.dart';
import 'package:fotodocumentation/dto/picture_dto.dart';
import 'package:fotodocumentation/l10n/app_localizations.dart';
import 'package:fotodocumentation/pages/ui_utils/component/customer_back_button.dart';
import 'package:fotodocumentation/pages/customer/picture_fullscreen_dialog.dart';
import 'package:fotodocumentation/pages/foto/customer/foto_picture_fullscreen_dialog.dart';
import 'package:fotodocumentation/pages/ui_utils/component/general_error_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/component/page_header_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/component/waiting_widget.dart';
@@ -15,16 +15,16 @@ import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/global_router.dart';
import 'package:intl/intl.dart';
class PictureWidget extends StatefulWidget {
class FotoPictureWidget extends StatefulWidget {
final int customerId;
final int pictureId;
const PictureWidget({super.key, required this.customerId, required this.pictureId});
const FotoPictureWidget({super.key, required this.customerId, required this.pictureId});
@override
State<PictureWidget> createState() => _PictureWidgetState();
State<FotoPictureWidget> createState() => _FotoPictureWidgetState();
}
class _PictureWidgetState extends State<PictureWidget> {
class _FotoPictureWidgetState extends State<FotoPictureWidget> {
GeneralStyle get _generalStyle => DiContainer.get();
CustomerController get _customerController => DiContainer.get();
PictureController get _pictureController => DiContainer.get();
@@ -43,6 +43,14 @@ class _PictureWidgetState extends State<PictureWidget> {
_dto = _customerController.get(id: widget.customerId);
}
@override
void didUpdateWidget(covariant FotoPictureWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.customerId != widget.customerId || oldWidget.pictureId != widget.pictureId) {
_dto = _customerController.get(id: widget.customerId);
}
}
@override
void dispose() {
_commentScrollController.dispose();
@@ -97,7 +105,7 @@ class _PictureWidgetState extends State<PictureWidget> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeaderWidget(text: _customerDto.name),
CustomerBackButton(path: GlobalRouter.pathCustomer),
CustomerBackButton(path: GlobalRouter.pathFotoCustomer),
const SizedBox(height: 24),
Expanded(
child: _mainWidget(context, selectedPicture),
@@ -415,7 +423,7 @@ class _PictureWidgetState extends State<PictureWidget> {
showDialog(
context: context,
builder: (BuildContext context) {
return PictureFullscreenDialog(dto: dto);
return FotoPictureFullscreenDialog(dto: dto);
},
);
}

View File

@@ -10,14 +10,14 @@ import 'package:fotodocumentation/pages/ui_utils/general_style.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/login_credentials.dart';
class LoginWidget extends StatefulWidget {
const LoginWidget({super.key});
class FotoLoginWidget extends StatefulWidget {
const FotoLoginWidget({super.key});
@override
State<LoginWidget> createState() => _LoginWidgetState();
State<FotoLoginWidget> createState() => _FotoLoginWidgetState();
}
class _LoginWidgetState extends State<LoginWidget> {
class _FotoLoginWidgetState extends State<FotoLoginWidget> {
LoginController get _loginController => DiContainer.get();
LoginCredentials get _loginCredentials => DiContainer.get();
GeneralStyle get _generalStyle => DiContainer.get();
@@ -201,7 +201,9 @@ class _LoginWidgetState extends State<LoginWidget> {
_loginCredentials.setLoggedIn(true);
if (context.mounted) {
context.go("/");
// Get the redirect URL from query parameters
final redirect = GoRouterState.of(context).uri.queryParameters['redirect'];
context.go(redirect ?? '/');
}
}
}

View File

@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:fotodocumentation/controller/base_controller.dart';
import 'package:fotodocumentation/controller/questionnaire_customer_controller.dart';
import 'package:fotodocumentation/dto/questionnaire_customer_dto.dart';
import 'package:fotodocumentation/l10n/app_localizations.dart';
import 'package:fotodocumentation/pages/ui_utils/component/general_error_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/component/page_header_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/component/search_bar_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/component/waiting_widget.dart';
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:intl/intl.dart';
class QuestionaireCustomerListWidget extends StatefulWidget {
const QuestionaireCustomerListWidget({super.key});
@override
State<QuestionaireCustomerListWidget> createState() => _QuestionaireCustomerListWidgetState();
}
class _QuestionaireCustomerListWidgetState extends State<QuestionaireCustomerListWidget> {
QuestionnaireCustomerController get _questionnaireCustomerController => DiContainer.get();
GeneralStyle get _generalStyle => DiContainer.get();
final _searchController = TextEditingController();
late Future<List<QuestionnaireCustomerListDto>> _dtos;
String? _selectedLetter;
late DateFormat _dateFormat;
@override
void initState() {
super.initState();
_dateFormat = DateFormat('dd MMMM yyyy');
_dtos = _questionnaireCustomerController.getAll(_searchController.text, "");
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: _body(context),
);
}
Widget _body(BuildContext context) {
return Container(
color: _generalStyle.pageBackgroundColor,
child: Padding(
padding: const EdgeInsets.only(top: 8.0, left: 50.0, right: 50.0, bottom: 8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeaderWidget(text: "FRAGENBOGEN AUSWERTUNG"),
//_abcHeaderBar(),
const SizedBox(width: 48),
SearchBarWidget(
searchController: _searchController,
onSearch: (text) async => actionSearch(text),
),
Expanded(
child: _customerListWidget(),
),
],
),
),
);
}
Widget _customerListWidget() {
return FutureBuilder<List<QuestionnaireCustomerListDto>>(
future: _dtos,
builder: (BuildContext context, AsyncSnapshot<List<QuestionnaireCustomerListDto>> snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const WaitingWidget();
}
if (snapshot.hasData) {
List<QuestionnaireCustomerListDto> dtos = snapshot.data ?? [];
return _listWidget(dtos);
} else if (snapshot.hasError) {
var error = snapshot.error;
return (error is ServerError) ? GeneralErrorWidget.fromServerError(error) : GeneralErrorWidget(error: snapshot.error.toString());
}
return const WaitingWidget();
},
);
}
Widget _listWidget(List<QuestionnaireCustomerListDto> dtos) {
if (dtos.isEmpty) {
return Text(AppLocalizations.of(context)!.customerListEmpty,
style: TextStyle(
fontFamily: _generalStyle.fontFamily,
fontWeight: FontWeight.bold,
fontSize: 20,
color: _generalStyle.secondaryWidgetBackgroundColor,
));
}
return Card(
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
_tableHeaderRow(context),
Divider(thickness: 2, height: 1, color: _generalStyle.secondaryWidgetBackgroundColor),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.all(0),
itemCount: dtos.length,
itemBuilder: (BuildContext context, int index) {
return _tableDataRow(context, dtos[index]);
},
separatorBuilder: (BuildContext context, int index) => Divider(
color: _generalStyle.secondaryWidgetBackgroundColor,
),
),
),
],
),
);
}
Widget _tableHeaderRow(BuildContext context) {
final headerStyle = TextStyle(
fontFamily: _generalStyle.fontFamily,
fontWeight: FontWeight.bold,
fontSize: 20,
color: _generalStyle.secondaryWidgetBackgroundColor,
);
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
spacing: 8.0,
children: [
const SizedBox(width: 48),
Expanded(
flex: 1,
child: Text(
AppLocalizations.of(context)!.customerListHeaderCustomerNumber,
style: headerStyle,
),
),
Expanded(
flex: 3,
child: Text(
AppLocalizations.of(context)!.customerListHeaderName,
style: headerStyle,
),
),
Expanded(
flex: 1,
child: Text(
"PLZ/Ort",
style: headerStyle,
),
),
Expanded(
flex: 2,
child: Text(
"Ort",
style: headerStyle,
),
),
Expanded(
flex: 2,
child: Wrap(
children: [
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Icon(Icons.calendar_month, color: _generalStyle.secondaryWidgetBackgroundColor),
),
Text(
AppLocalizations.of(context)!.customerListHeaderLastDate,
style: headerStyle,
),
],
),
),
],
),
);
}
Widget _tableDataRow(BuildContext context, QuestionnaireCustomerListDto dto) {
final dataStyle = TextStyle(
fontFamily: _generalStyle.fontFamily,
fontSize: 16.0,
color: _generalStyle.secondaryTextLabelColor,
);
final dateStr = dto.lastUpdateDate == null ? "" : _dateFormat.format(dto.lastUpdateDate!);
return InkWell(
onTap: () => _actionSelect(context, dto),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
spacing: 8.0,
children: [
SizedBox(
width: 48,
child: Icon(Icons.folder_open_outlined, color: _generalStyle.secondaryWidgetBackgroundColor),
),
Expanded(
flex: 1,
child: Text(dto.customerNumber, style: dataStyle),
),
Expanded(
flex: 3,
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(
flex: 2,
child: Text(dateStr, style: dataStyle),
),
],
),
),
);
}
Future<void> actionSearch(String text) async {
_reloadData();
}
Future<void> _actionSelect(BuildContext context, QuestionnaireCustomerListDto dto) async {
String uri = "${GlobalRouter.pathQuestionnaireHome}/${GlobalRouter.pathQuestionnaireCustomer}/${dto.id}";
context.go(uri);
}
void _reloadData() {
_dtos = _questionnaireCustomerController.getAll(_searchController.text, _selectedLetter ?? "");
setState(() {});
}
/*
Widget _abcHeaderBar() {
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: letters.split('').map((letter) {
final isSelected = _selectedLetter == letter;
return InkWell(
onTap: () {
setState(() {
_selectedLetter = isSelected ? null : letter;
_reloadData();
});
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? _generalStyle.secondaryWidgetBackgroundColor : Colors.white,
borderRadius: BorderRadius.circular(4),
),
child: Text(
letter,
style: TextStyle(
fontFamily: _generalStyle.fontFamily,
fontWeight: FontWeight.bold,
fontSize: 20.0,
color: isSelected ? Colors.white : _generalStyle.secondaryWidgetBackgroundColor,
),
),
),
);
}).toList(),
),
);
}*/
}

View File

@@ -0,0 +1,380 @@
import 'package:flutter/material.dart';
import 'package:fotodocumentation/controller/base_controller.dart';
import 'package:fotodocumentation/controller/questionnaire_controller.dart';
import 'package:fotodocumentation/controller/questionnaire_customer_controller.dart';
import 'package:fotodocumentation/dto/questionnaire_customer_dto.dart';
import 'package:fotodocumentation/dto/questionnaire_dto.dart';
import 'package:fotodocumentation/l10n/app_localizations.dart';
import 'package:fotodocumentation/pages/questionnaire/customer/questionnaire_delete_dialog.dart';
import 'package:fotodocumentation/pages/ui_utils/component/customer_back_button.dart';
import 'package:fotodocumentation/pages/ui_utils/component/general_error_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/component/page_header_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/component/waiting_widget.dart';
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:fotodocumentation/utils/file_download.dart';
import 'package:intl/intl.dart';
class QuestionaireCustomerWidget extends StatefulWidget {
final int customerId;
const QuestionaireCustomerWidget({super.key, required this.customerId});
@override
State<QuestionaireCustomerWidget> createState() => _QuestionaireCustomerWidgetState();
}
class _QuestionaireCustomerWidgetState extends State<QuestionaireCustomerWidget> {
QuestionnaireCustomerController get _customerController => DiContainer.get();
QuestionnaireController get _pictureController => DiContainer.get();
GeneralStyle get _generalStyle => DiContainer.get();
late Future<QuestionnaireCustomerDto?> _dto;
late DateFormat _dateFormat;
@override
void initState() {
super.initState();
_dateFormat = DateFormat('dd MMMM yyyy');
_dto = _customerController.get(id: widget.customerId);
}
@override
void didUpdateWidget(covariant QuestionaireCustomerWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.customerId != widget.customerId) {
_dto = _customerController.get(id: widget.customerId);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
color: _generalStyle.pageBackgroundColor,
child: Padding(
padding: const EdgeInsets.only(top: 8.0, left: 50.0, right: 50.0, bottom: 8.0),
child: _body(context),
),
),
);
}
Widget _body(BuildContext context) {
return FutureBuilder<QuestionnaireCustomerDto?>(
future: _dto,
builder: (BuildContext context, AsyncSnapshot<QuestionnaireCustomerDto?> snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const WaitingWidget();
}
if (snapshot.hasData) {
QuestionnaireCustomerDto? dto = snapshot.data;
if (dto == null) {
return Text(
AppLocalizations.of(context)!.customerWidgetNotFound,
style: TextStyle(
fontFamily: _generalStyle.fontFamily,
fontWeight: FontWeight.bold,
fontSize: 20,
color: _generalStyle.secondaryWidgetBackgroundColor,
),
);
}
return _mainWidget(dto);
} else if (snapshot.hasError) {
var error = snapshot.error;
return (error is ServerError) ? GeneralErrorWidget.fromServerError(error) : GeneralErrorWidget(error: snapshot.error.toString());
}
return const WaitingWidget();
},
);
}
Widget _mainWidget(QuestionnaireCustomerDto dto) {
var subText = AppLocalizations.of(context)!.customerWidgetCustomerNumberPrefix(dto.customerNumber);
return Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeaderWidget(text: dto.name, subText: subText),
CustomerBackButton(path: GlobalRouter.pathFotoHome),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_downloadButton(context, dto),
],
),
const SizedBox(height: 24),
Expanded(
child: _customerWidget(dto),
),
],
);
}
Widget _customerWidget(QuestionnaireCustomerDto customerDto) {
var questionnaireDtos = customerDto.questionnaires;
questionnaireDtos.sort((a, b) => b.questionnaireDate.compareTo(a.questionnaireDate));
return Card(
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
_tableHeaderRow(context),
Divider(thickness: 2, height: 1, color: _generalStyle.secondaryWidgetBackgroundColor),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.only(top: 8.0),
itemCount: questionnaireDtos.length,
itemBuilder: (BuildContext context, int index) {
return _tableDataRow(context, customerDto, questionnaireDtos[index]);
},
separatorBuilder: (BuildContext context, int index) => Divider(color: _generalStyle.secondaryWidgetBackgroundColor),
),
),
],
),
);
}
Widget _tableHeaderRow(BuildContext context) {
final headerStyle = TextStyle(
fontFamily: _generalStyle.fontFamily,
fontWeight: FontWeight.bold,
fontSize: 20,
color: _generalStyle.secondaryWidgetBackgroundColor,
);
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8.0,
children: [
Expanded(
flex: 1,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
AppLocalizations.of(context)!.customerWidgetHeaderFoto,
style: headerStyle,
),
),
),
Expanded(
flex: 3,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
AppLocalizations.of(context)!.customerWidgetHeaderComment,
style: headerStyle,
),
),
),
Expanded(
flex: 1,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Bewertung',
style: headerStyle,
),
),
),
Expanded(
flex: 2,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
AppLocalizations.of(context)!.customerWidgetHeaderUploadDate,
style: headerStyle,
),
),
),
const SizedBox(width: 96),
],
),
);
}
Widget _tableDataRow(BuildContext context, QuestionnaireCustomerDto customerDto, QuestionnaireDto questionnaireDto) {
final dataStyle = TextStyle(
fontFamily: _generalStyle.fontFamily,
fontSize: 16.0,
color: _generalStyle.secondaryTextLabelColor,
);
final dateStr = _dateFormat.format(questionnaireDto.questionnaireDate);
final evaluationColor = _generalStyle.evaluationColor(value: questionnaireDto.evaluation);
return InkWell(
key: Key("table_row_${customerDto.id}"),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 8.0,
children: [
Expanded(
flex: 1,
child: Align(
alignment: Alignment.centerLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 70, maxHeight: 70),
child:
Icon(Icons.abc),
/*
FIXME: remove me
Image.network(
headers: {cred.name: cred.value},
pictureDto.thumbnailSizeUrl,
fit: BoxFit.contain,
),*/
),
),
),
Expanded(
flex: 3,
child: Align(
alignment: Alignment.centerLeft,
child: Text(questionnaireDto.comment ?? "", style: dataStyle),
),
),
Expanded(
flex: 1,
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: evaluationColor,
shape: BoxShape.circle,
),
),
),
),
Expanded(
flex: 2,
child: Align(
alignment: Alignment.centerLeft,
child: Text(dateStr, style: dataStyle),
),
),
SizedBox(
width: 48,
child: IconButton(
key: Key("table_row_download_${questionnaireDto.id}"),
icon: Icon(
Icons.file_download_outlined,
color: _generalStyle.loginFormTextLabelColor,
),
onPressed: () => _actionDownload(context, customerDto, questionnaireDto),
),
),
SizedBox(
width: 48,
child: IconButton(
key: Key("table_row_delete_${customerDto.id}"),
icon: Icon(
Icons.delete_outline,
color: _generalStyle.errorColor,
),
onPressed: () => _actionDelete(context, customerDto, questionnaireDto),
),
),
],
),
),
);
}
Widget _downloadButton(BuildContext context, QuestionnaireCustomerDto customerDto) {
return ElevatedButton.icon(
key: Key("download_all_button"),
onPressed: () => _actionDownload(context, customerDto, null),
iconAlignment: IconAlignment.end,
icon: Icon(
Icons.file_download_outlined,
color: _generalStyle.primaryButtonTextColor,
size: 24,
),
label: Text(
"Alle herunterladen",
style: TextStyle(
fontFamily: _generalStyle.fontFamily,
fontWeight: FontWeight.bold,
fontSize: 16,
color: _generalStyle.primaryButtonTextColor,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: _generalStyle.secondaryWidgetBackgroundColor,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: const StadiumBorder(),
),
);
}
Future<void> _actionDelete(BuildContext context, QuestionnaireCustomerDto customerDto, QuestionnaireDto questionnaireDto) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return QuestionaireDeleteDialog();
},
);
if (confirmed == true) {
_pictureController.delete(questionnaireDto);
setState(() {
_dto = _customerController.get(id: widget.customerId);
});
}
}
Future<void> _actionDownload(BuildContext context, QuestionnaireCustomerDto customerDto, QuestionnaireDto? questionnaireDto) async {
showDialog(
context: context,
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, questionnaireId: questionnaireDto?.id);
final fileName = questionnaireDto != null ? '${customerDto.customerNumber}_${questionnaireDto.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;
}
}
}

View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:fotodocumentation/l10n/app_localizations.dart';
import 'package:fotodocumentation/pages/ui_utils/general_style.dart';
import 'package:fotodocumentation/utils/di_container.dart';
class QuestionaireDeleteDialog extends StatelessWidget {
GeneralStyle get _generalStyle => DiContainer.get();
const QuestionaireDeleteDialog({super.key});
@override
Widget build(BuildContext context) {
return AlertDialog(
actionsAlignment: MainAxisAlignment.center,
actionsPadding: EdgeInsets.only(bottom: 50),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(top: 50.0),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _generalStyle.errorColor,
shape: BoxShape.circle,
),
child: const Icon(
Icons.delete_outline,
size: 32,
color: Colors.white,
),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.only(left:50, right: 50, bottom: 50),
child: Text(
AppLocalizations.of(context)!.deleteDialogText,
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: _generalStyle.fontFamily,
color: _generalStyle.secondaryTextLabelColor,
fontSize: 28.71,
fontWeight: FontWeight.normal,
),
),
),
],
),
actions: [
ElevatedButton(
key: Key("questionnaire_delete_no"),
onPressed: () => Navigator.of(context).pop(false),
style: ElevatedButton.styleFrom(
backgroundColor: _generalStyle.deleteCancelButtonBackgroundColor,
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
),
child: Text(
AppLocalizations.of(context)!.deleteDialogButtonCancel,
style: TextStyle(
fontFamily: _generalStyle.fontFamily,
color: _generalStyle.deleteCancelTextColor,
fontWeight: FontWeight.bold,
fontSize: 18.37,
),
),
),
ElevatedButton(
key: Key("questionnaire_delete_yes"),
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: _generalStyle.primaryButtonBackgroundColor,
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
),
child: Text(
AppLocalizations.of(context)!.deleteDialogButtonApprove,
style: TextStyle(
fontFamily: _generalStyle.fontFamily,
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 18.37,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,209 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:fotodocumentation/controller/login_controller.dart';
import 'package:fotodocumentation/dto/jwt_token_pair_dto.dart';
import 'package:fotodocumentation/l10n/app_localizations.dart';
import 'package:fotodocumentation/pages/ui_utils/general_style.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/login_credentials.dart';
class QuestionaireLoginWidget extends StatefulWidget {
const QuestionaireLoginWidget({super.key});
@override
State<QuestionaireLoginWidget> createState() => _QuestionaireLoginWidgetState();
}
class _QuestionaireLoginWidgetState extends State<QuestionaireLoginWidget> {
LoginController get _loginController => DiContainer.get();
LoginCredentials get _loginCredentials => DiContainer.get();
GeneralStyle get _generalStyle => DiContainer.get();
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
String? _error;
final FocusNode _focusNode = FocusNode();
@override
void dispose() {
_focusNode.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: _body(context),
);
}
Widget _body(BuildContext context) {
return Container(
color: _generalStyle.pageBackgroundColor,
child: _content(context),
);
}
Widget _content(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20.0),
child: Form(
key: _formKey,
child: KeyboardListener(
focusNode: _focusNode,
onKeyEvent: (event) {
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter) {
_actionSubmit(context);
}
},
child: ListView(
children: [
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 40.0),
child: Image.asset(
'assets/images/logo.png',
height: 120,
),
),
),
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: Card(
elevation: 0,
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(70.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Text(
key: Key("login_title"),
AppLocalizations.of(context)!.questionnaireLoginTitle,
style: TextStyle(
fontWeight: FontWeight.bold,
color: _generalStyle.primaryTextLabelColor,
fontFamily: _generalStyle.fontFamily,
fontSize: 50,
),
),
),
const SizedBox(height: 40),
if (_error != null) ...[
_errorWidget(),
const SizedBox(height: 40),
],
TextFormField(
key: Key("username"),
controller: _usernameController,
decoration: _formFieldInputDecoration(AppLocalizations.of(context)!.loginUsernameTFLabel),
),
const SizedBox(height: 25),
TextFormField(
key: Key("password"),
controller: _passwordController,
obscureText: true,
decoration: _formFieldInputDecoration(AppLocalizations.of(context)!.loginPasswordTFLabel),
),
const SizedBox(height: 25),
Center(
child: ElevatedButton(
key: Key("SubmitWidgetButton"),
style: ElevatedButton.styleFrom(
backgroundColor: _generalStyle.primaryButtonBackgroundColor,
foregroundColor: _generalStyle.primaryButtonTextColor,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
),
onPressed: () async => await _actionSubmit(context),
child: Text(
AppLocalizations.of(context)!.loginLoginButtonLabel,
style: TextStyle(fontWeight: FontWeight.bold, fontFamily: _generalStyle.fontFamily),
),
),
),
const SizedBox(height: 30),
],
),
),
),
),
),
],
),
),
),
);
}
Widget _errorWidget() {
return Center(
child: Text(
_error!,
style: TextStyle(
color: _generalStyle.errorColor,
fontWeight: FontWeight.bold,
fontSize: 16,
fontFamily: _generalStyle.fontFamily,
),
),
);
}
InputDecoration _formFieldInputDecoration(String text) {
var formLabelTextStyle = TextStyle(
color: _generalStyle.loginFormTextLabelColor,
fontFamily: _generalStyle.fontFamily,
fontSize: 14,
fontStyle: FontStyle.normal,
fontWeight: FontWeight.normal,
);
return InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: _error != null ? BorderSide(color: _generalStyle.errorColor) : BorderSide(),
),
enabledBorder: _error != null
? OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _generalStyle.errorColor),
)
: null,
labelText: text.toUpperCase(),
labelStyle: formLabelTextStyle,
floatingLabelBehavior: FloatingLabelBehavior.always,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
);
}
Future<void> _actionSubmit(BuildContext context) async {
String username = _usernameController.text;
String password = _passwordController.text;
AuthenticateReply authenticateReply = await _loginController.authenticate(username, password);
JwtTokenPairDto? jwtTokenPairDto = authenticateReply.jwtTokenPairDto;
if (jwtTokenPairDto == null) {
setState(() => _error = AppLocalizations.of(context)!.loginErrorMessage);
return;
}
_loginCredentials.setLoggedIn(true);
if (context.mounted) {
// Get the redirect URL from query parameters
final redirect = GoRouterState.of(context).uri.queryParameters['redirect'];
context.go(redirect ?? '/');
}
}
}

View File

@@ -94,7 +94,7 @@ class PageHeaderWidget extends StatelessWidget {
return ElevatedButton.icon(
onPressed: () {
loginCredentials.logout();
context.go(GlobalRouter.pathLogin);
context.go(GlobalRouter.pathFotoLogin);
},
icon: Icon(
Icons.logout_rounded,

View File

@@ -1,6 +1,8 @@
import 'package:fotodocumentation/controller/customer_controller.dart';
import 'package:fotodocumentation/controller/login_controller.dart';
import 'package:fotodocumentation/controller/picture_controller.dart';
import 'package:fotodocumentation/controller/questionnaire_controller.dart';
import 'package:fotodocumentation/controller/questionnaire_customer_controller.dart';
import 'package:fotodocumentation/pages/ui_utils/general_style.dart';
import 'package:fotodocumentation/utils/http_client_utils.dart';
import 'package:fotodocumentation/utils/jwt_token_storage.dart';
@@ -26,6 +28,9 @@ class DiContainer {
DiContainer.instance.put(LoginController, LoginControllerImpl());
DiContainer.instance.put(CustomerController, CustomerControllerImpl());
DiContainer.instance.put(PictureController, PictureControllerImpl());
DiContainer.instance.put(QuestionnaireCustomerController, QuestionnaireCustomerControllerImpl());
DiContainer.instance.put(QuestionnaireController, QuestionnaireControllerImpl());
}
void put<T>(Type key, T object) {

View File

@@ -1,26 +1,35 @@
// needed for web horizontal scroll behavior
import 'package:flutter/material.dart';
import 'package:fotodocumentation/main.dart';
import 'package:fotodocumentation/pages/customer/customer_list_widget.dart';
import 'package:fotodocumentation/pages/customer/customer_widget.dart';
import 'package:fotodocumentation/pages/customer/picture_widget.dart';
import 'package:fotodocumentation/pages/login/login_widget.dart';
import 'package:fotodocumentation/pages/foto/customer/foto_customer_list_widget.dart';
import 'package:fotodocumentation/pages/foto/customer/foto_customer_widget.dart';
import 'package:fotodocumentation/pages/foto/customer/foto_picture_widget.dart';
import 'package:fotodocumentation/pages/foto/login/foto_login_widget.dart';
import 'package:fotodocumentation/pages/questionnaire/customer/questionnaire_customer_list_widget.dart';
import 'package:fotodocumentation/pages/questionnaire/customer/questionnaire_customer_widget.dart';
import 'package:fotodocumentation/pages/questionnaire/login/questionnaire_login_widget.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/login_credentials.dart';
import 'package:go_router/go_router.dart';
class GlobalRouter {
static final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'root');
static final GlobalKey<NavigatorState> bottomBarNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'bottombar');
static final GlobalKey<NavigatorState> adminNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'admin');
static final GlobalKey<NavigatorState> skillEditorNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'skillEditor');
static final String pathHome = "/home";
static final String pathCustomer = "/customer";
static final String pathPicture = "/picture";
static final String pathLogin = "/login";
static final String pathRoot = "/";
static final GoRouter router = createRouter(pathHome);
static final String pathFoto = "/foto";
static final String pathQuestionnaire = "/fragenbogen";
static final String pathFotoHome = "$pathFoto/home";
static final String pathFotoCustomer = "$pathFoto/customer";
static final String pathFotoPicture = "$pathFoto/picture";
static final String pathFotoLogin = "$pathFoto/login";
static final String pathQuestionnaireHome = "$pathQuestionnaire/home";
static final String pathQuestionnaireCustomer = "customer";
static final String pathQuestionnaireLogin = "$pathQuestionnaire/login";
static final GoRouter router = createRouter(pathRoot);
static GoRouter createRouter(String initialLocation) {
return GoRouter(
@@ -29,39 +38,79 @@ class GlobalRouter {
routes: <RouteBase>[
GoRoute(
path: "/",
redirect: (_, __) => pathHome,
redirect: (_, __) {//nvlev4YnTi
logger.t("uri / redirect to $pathFotoHome");
return pathFotoHome;
}),
GoRoute(
path: pathFoto,
redirect: (_, __) {
logger.t("uri $pathFoto redirect to $pathFotoHome");
return pathFotoHome;
}),
GoRoute(
path: pathFotoLogin,
builder: (_, __) {
logger.t("uri $pathFotoLogin show foto login");
return const FotoLoginWidget();
},
),
GoRoute(
path: pathLogin,
builder: (BuildContext context, GoRouterState state) => const LoginWidget(),
),
GoRoute(
path: pathHome,
builder: (context, state) => CustomerListWidget(),
path: pathFotoHome,
builder: (context, state) => FotoCustomerListWidget(),
routes: [
GoRoute(
path: "$pathCustomer/:customerId",
path: "$pathFotoCustomer/:customerId",
builder: (context, state) {
var idStr = state.pathParameters['customerId'];
var id = idStr == null ? null : int.tryParse(idStr);
return CustomerWidget(customerId: id ?? -1);
return FotoCustomerWidget(customerId: id ?? -1);
},
routes: [
GoRoute(
path: "$pathPicture/:pictureId",
path: "$pathFotoPicture/:pictureId",
builder: (context, state) {
var customerIdStr = state.pathParameters['customerId'];
var customerId = customerIdStr == null ? null : int.tryParse(customerIdStr);
var pictureIdStr = state.pathParameters['pictureId'];
var pictureId = pictureIdStr == null ? null : int.tryParse(pictureIdStr);
return PictureWidget(customerId: customerId ?? -1, pictureId: pictureId ?? -1);
return FotoPictureWidget(customerId: customerId ?? -1, pictureId: pictureId ?? -1);
},
),
],
),
],
),
GoRoute(
path: pathQuestionnaire,
redirect: (_, __) {
logger.t("uri $pathQuestionnaire redirect to $pathQuestionnaireHome");
return pathQuestionnaireHome;
}),
GoRoute(
path: pathQuestionnaireLogin,
builder: (_, __) {
logger.t("uri $pathQuestionnaireLogin show questionnaire login");
return const QuestionaireLoginWidget();
},
),
GoRoute(
path: pathQuestionnaireHome,
builder: (context, state) => QuestionaireCustomerListWidget(),
routes: [
GoRoute(
path: "$pathQuestionnaireCustomer/:customerId",
builder: (context, state) {
var uriStr = state.uri.toString();
logger.t("uri $uriStr show questionnaire");
var idStr = state.pathParameters['customerId'];
var id = idStr == null ? null : int.tryParse(idStr);
return QuestionaireCustomerWidget(customerId: id ?? -1);
},
),
],
),
],
redirect: (context, state) {
var uriStr = state.uri.toString();
@@ -69,7 +118,18 @@ class GlobalRouter {
LoginCredentials loginCredentials = DiContainer.get();
if (!loginCredentials.isLoggedIn) {
return pathLogin;
if (uriStr != '/') {
if (uriStr.startsWith(pathFoto) && !uriStr.startsWith(pathFotoLogin)) {
var url = '$pathFotoLogin?redirect=${Uri.encodeComponent(uriStr)}';
logger.t("foto redirect to $url");
return url;
}
if (uriStr.startsWith(pathQuestionnaire) && !uriStr.startsWith(pathQuestionnaireLogin)) {
var url = '$pathQuestionnaireLogin?redirect=${Uri.encodeComponent(uriStr)}';
logger.t("questionnaire redirect to $url");
return url;
}
}
}
return null;
},

View File

@@ -31,7 +31,7 @@ class HttpClientInterceptor extends http.BaseClient {
// Navigate to login page using GoRouter
final context = GlobalRouter.rootNavigatorKey.currentContext;
if (context != null) {
context.go(GlobalRouter.pathLogin);
context.go(GlobalRouter.pathFotoLogin);
}
}
}

View File

@@ -10,8 +10,8 @@ import 'package:fotodocumentation/controller/customer_controller.dart';
import 'package:fotodocumentation/dto/customer_dto.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import '../testing/test_utils.dart';
import '../testing/test_utils.mocks.dart';
import '../../../testing/test_utils.dart';
import '../../../testing/test_utils.mocks.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -36,7 +36,7 @@ Future<void> _searchtest(WidgetTester tester) async {
when(controller.getAll("", "")).thenAnswer((_) async => _list);
when(controller.getAll(searchText, "")).thenAnswer((_) async => [_list.first]);
await pumpAppConfig(tester, GlobalRouter.pathHome);
await pumpAppConfig(tester, GlobalRouter.pathFotoHome);
verify(controller.getAll(argThat(equals("")), argThat(equals("")))).called(1);
await tester.enterText(find.byKey(Key("Search_text_field")), searchText);

View File

@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fotodocumentation/dto/picture_dto.dart';
import 'package:fotodocumentation/pages/customer/picture_delete_dialog.dart';
import 'package:fotodocumentation/pages/customer/picture_widget.dart';
import 'package:fotodocumentation/pages/foto/customer/foto_picture_delete_dialog.dart';
import 'package:fotodocumentation/pages/foto/customer/foto_picture_widget.dart';
import 'package:fotodocumentation/utils/global_router.dart';
import 'package:fotodocumentation/utils/login_credentials.dart';
import 'package:mockito/mockito.dart';
@@ -14,8 +14,8 @@ import 'package:fotodocumentation/dto/customer_dto.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import 'package:flutter_image_test_utils/flutter_image_test_utils.dart';
import '../testing/test_utils.dart';
import '../testing/test_utils.mocks.dart';
import '../../../testing/test_utils.dart';
import '../../../testing/test_utils.mocks.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -33,7 +33,7 @@ void main() {
when(controller.get(id: 1)).thenAnswer((_) async => _dto);
when(controller.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1");
verify(controller.get(id: 1)).called(1);
// Click on the first row (InkWell) to open the picture popup
@@ -41,7 +41,7 @@ void main() {
await tester.pumpAndSettle();
// Verify that the popup is shown by checking for the PictureWidget
expect(find.byType(PictureWidget), findsOneWidget);
expect(find.byType(FotoPictureWidget), findsOneWidget);
});
});
@@ -58,7 +58,7 @@ void main() {
when(pictureController.delete(argThat(isA<PictureDto>()))).thenAnswer((_) async => true);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1");
verify(controller.get(id: 1)).called(1);
// Click on the first row (InkWell) to open the picture popup
@@ -66,7 +66,7 @@ void main() {
await tester.pumpAndSettle();
// Verify that the popup is shown by checking for the PictureWidget
expect(find.byType(PictureDeleteDialog), findsOneWidget);
expect(find.byType(FotoPictureDeleteDialog), findsOneWidget);
// Click the yes button to confirm delete
await tester.tap(find.byKey(Key("picture_delete_yes")));
@@ -89,7 +89,7 @@ void main() {
when(controller.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1");
verify(controller.get(id: 1)).called(1);
// Click on the first row (InkWell) to open the picture popup
@@ -97,7 +97,7 @@ void main() {
await tester.pumpAndSettle();
// Verify that the popup is shown by checking for the PictureWidget
expect(find.byType(PictureDeleteDialog), findsOneWidget);
expect(find.byType(FotoPictureDeleteDialog), findsOneWidget);
// Click the yes button to confirm delete
await tester.tap(find.byKey(Key("picture_delete_no")));

View File

@@ -4,14 +4,14 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:fotodocumentation/controller/customer_controller.dart';
import 'package:fotodocumentation/dto/customer_dto.dart' show CustomerDto, CustomerListDto;
import 'package:fotodocumentation/dto/picture_dto.dart';
import 'package:fotodocumentation/pages/customer/picture_fullscreen_dialog.dart';
import 'package:fotodocumentation/pages/foto/customer/foto_picture_fullscreen_dialog.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/global_router.dart';
import 'package:fotodocumentation/utils/login_credentials.dart';
import 'package:mockito/mockito.dart';
import '../testing/test_utils.dart';
import '../testing/test_utils.mocks.dart';
import '../../../testing/test_utils.dart';
import '../../../testing/test_utils.mocks.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -80,7 +80,7 @@ void main() {
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/1");
await tester.pumpAndSettle();
// Verify customer name is displayed
@@ -102,7 +102,7 @@ void main() {
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/1");
await tester.pumpAndSettle();
// Verify the comment is displayed
@@ -116,7 +116,7 @@ void main() {
when(mockCustomerController.get(id: 1)).thenAnswer((_) async => customerDto);
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/1");
await tester.pumpAndSettle();
// The comment field should be empty but the label should exist
@@ -131,7 +131,7 @@ void main() {
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/1");
await tester.pumpAndSettle();
// Both navigation buttons should always be visible at the bottom
@@ -146,7 +146,7 @@ void main() {
when(mockCustomerController.get(id: 1)).thenAnswer((_) async => customerDto);
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/1");
await tester.pumpAndSettle();
// Both navigation buttons should be visible
@@ -161,7 +161,7 @@ void main() {
when(mockCustomerController.get(id: 1)).thenAnswer((_) async => customerDto);
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/1");
await tester.pumpAndSettle();
// Both navigation buttons should be visible (right one is disabled)
@@ -176,7 +176,7 @@ void main() {
when(mockCustomerController.get(id: 1)).thenAnswer((_) async => customerDto);
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/1");
await tester.pumpAndSettle();
// Verify first picture comment is shown
@@ -199,7 +199,7 @@ void main() {
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/2");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/2");
await tester.pumpAndSettle();
// Verify second picture comment is shown
@@ -229,7 +229,7 @@ void main() {
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/1");
await tester.pumpAndSettle();
// Both navigation buttons should be shown but disabled
@@ -244,7 +244,7 @@ void main() {
when(mockCustomerController.get(id: 1)).thenAnswer((_) async => customerDto);
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/1");
await tester.pumpAndSettle();
// Find the GestureDetector with the image key
@@ -261,7 +261,7 @@ void main() {
await tester.pumpAndSettle();
// Verify fullscreen dialog is shown
expect(find.byType(PictureFullscreenDialog), findsOneWidget);
expect(find.byType(FotoPictureFullscreenDialog), findsOneWidget);
});
});
});

View File

@@ -2,14 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fotodocumentation/controller/login_controller.dart';
import 'package:fotodocumentation/dto/jwt_token_pair_dto.dart';
import 'package:fotodocumentation/pages/login/login_widget.dart';
import 'package:fotodocumentation/pages/foto/login/foto_login_widget.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/global_router.dart';
import 'package:fotodocumentation/utils/login_credentials.dart';
import 'package:mockito/mockito.dart';
import '../testing/test_utils.dart';
import '../testing/test_utils.mocks.dart';
import '../../../testing/test_utils.dart';
import '../../../testing/test_utils.mocks.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -32,7 +32,7 @@ void main() {
testWidgets('displays login title', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const LoginWidget());
await pumpApp(tester, const FotoLoginWidget());
await tester.pumpAndSettle();
// Verify the login title is displayed (German localization)
@@ -42,7 +42,7 @@ void main() {
testWidgets('displays username and password fields', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const LoginWidget());
await pumpApp(tester, const FotoLoginWidget());
await tester.pumpAndSettle();
// Verify username field exists
@@ -55,7 +55,7 @@ void main() {
testWidgets('displays login button', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const LoginWidget());
await pumpApp(tester, const FotoLoginWidget());
await tester.pumpAndSettle();
// Verify login button exists
@@ -65,7 +65,7 @@ void main() {
testWidgets('can enter username and password', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const LoginWidget());
await pumpApp(tester, const FotoLoginWidget());
await tester.pumpAndSettle();
// Enter username
@@ -91,7 +91,7 @@ void main() {
when(mockLoginController.authenticate('testuser', 'testpassword'))
.thenAnswer((_) async => (jwtTokenPairDto: jwtTokenPairDto));
await pumpAppConfig(tester, GlobalRouter.pathLogin);
await pumpAppConfig(tester, GlobalRouter.pathFotoLogin);
await tester.pumpAndSettle();
// Enter credentials
@@ -118,7 +118,7 @@ void main() {
when(mockLoginController.authenticate('testuser', 'testpassword'))
.thenAnswer((_) async => (jwtTokenPairDto: jwtTokenPairDto));
await pumpAppConfig(tester, GlobalRouter.pathLogin);
await pumpAppConfig(tester, GlobalRouter.pathFotoLogin);
await tester.pumpAndSettle();
// Enter credentials
@@ -140,7 +140,7 @@ void main() {
when(mockLoginController.authenticate('testuser', 'wrongpassword'))
.thenAnswer((_) async => (jwtTokenPairDto: null));
await pumpAppConfig(tester, GlobalRouter.pathLogin);
await pumpAppConfig(tester, GlobalRouter.pathFotoLogin);
await tester.pumpAndSettle();
// Enter credentials
@@ -162,7 +162,7 @@ void main() {
when(mockLoginController.authenticate('testuser', 'wrongpassword'))
.thenAnswer((_) async => (jwtTokenPairDto: null));
await pumpApp(tester, const LoginWidget());
await pumpApp(tester, const FotoLoginWidget());
await tester.pumpAndSettle();
// Enter credentials
@@ -181,7 +181,7 @@ void main() {
testWidgets('password field obscures text', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const LoginWidget());
await pumpApp(tester, const FotoLoginWidget());
await tester.pumpAndSettle();
// Find the EditableText descendant of the password field

View File

@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fotodocumentation/controller/login_controller.dart';
import 'package:fotodocumentation/dto/jwt_token_pair_dto.dart';
import 'package:fotodocumentation/pages/questionnaire/login/questionnaire_login_widget.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/global_router.dart';
import 'package:fotodocumentation/utils/login_credentials.dart';
import 'package:mockito/mockito.dart';
import '../../../testing/test_utils.dart';
import '../../../testing/test_utils.mocks.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
DiContainer.instance.initState();
late MockLoginController mockLoginController;
late MockLoginCredentials mockLoginCredentials;
setUp(() {
mockLoginController = MockLoginController();
mockLoginCredentials = MockLoginCredentials();
when(mockLoginCredentials.isLoggedIn).thenReturn(false);
DiContainer.instance.put(LoginController, mockLoginController);
DiContainer.instance.put(LoginCredentials, mockLoginCredentials);
});
group('LoginWidget', () {
testWidgets('displays login title', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const QuestionaireLoginWidget());
await tester.pumpAndSettle();
// Verify the login title is displayed (German localization)
expect(find.byKey(const Key('login_title')), findsOneWidget);
});
testWidgets('displays username and password fields', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const QuestionaireLoginWidget());
await tester.pumpAndSettle();
// Verify username field exists
expect(find.byKey(const Key("username")), findsOneWidget);
// Verify password field exists
expect(find.byKey(const Key("password")), findsOneWidget);
});
testWidgets('displays login button', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const QuestionaireLoginWidget());
await tester.pumpAndSettle();
// Verify login button exists
expect(find.byKey(const Key("SubmitWidgetButton")), findsOneWidget);
});
testWidgets('can enter username and password', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const QuestionaireLoginWidget());
await tester.pumpAndSettle();
// Enter username
await tester.enterText(find.byKey(const Key("username")), 'testuser');
await tester.pumpAndSettle();
// Enter password
await tester.enterText(find.byKey(const Key("password")), 'testpassword');
await tester.pumpAndSettle();
// Verify text was entered
expect(find.text('testuser'), findsOneWidget);
});
testWidgets('calls authenticate on login button tap', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
final jwtTokenPairDto = JwtTokenPairDto(
accessToken: 'test_access_token',
refreshToken: 'test_refresh_token',
);
when(mockLoginController.authenticate('testuser', 'testpassword'))
.thenAnswer((_) async => (jwtTokenPairDto: jwtTokenPairDto));
await pumpAppConfig(tester, GlobalRouter.pathFotoLogin);
await tester.pumpAndSettle();
// Enter credentials
await tester.enterText(find.byKey(const Key("username")), 'testuser');
await tester.enterText(find.byKey(const Key("password")), 'testpassword');
await tester.pumpAndSettle();
// Tap login button
await tester.tap(find.byKey(const Key("SubmitWidgetButton")));
await tester.pumpAndSettle();
// Verify authenticate was called with correct credentials
verify(mockLoginController.authenticate('testuser', 'testpassword')).called(1);
});
testWidgets('sets logged in on successful authentication', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
final jwtTokenPairDto = JwtTokenPairDto(
accessToken: 'test_access_token',
refreshToken: 'test_refresh_token',
);
when(mockLoginController.authenticate('testuser', 'testpassword'))
.thenAnswer((_) async => (jwtTokenPairDto: jwtTokenPairDto));
await pumpAppConfig(tester, GlobalRouter.pathFotoLogin);
await tester.pumpAndSettle();
// Enter credentials
await tester.enterText(find.byKey(const Key("username")), 'testuser');
await tester.enterText(find.byKey(const Key("password")), 'testpassword');
await tester.pumpAndSettle();
// Tap login button
await tester.tap(find.byKey(const Key("SubmitWidgetButton")));
await tester.pumpAndSettle();
// Verify setLoggedIn was called
verify(mockLoginCredentials.setLoggedIn(true)).called(1);
});
testWidgets('displays error message on failed authentication', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
when(mockLoginController.authenticate('testuser', 'wrongpassword'))
.thenAnswer((_) async => (jwtTokenPairDto: null));
await pumpAppConfig(tester, GlobalRouter.pathFotoLogin);
await tester.pumpAndSettle();
// Enter credentials
await tester.enterText(find.byKey(const Key("username")), 'testuser');
await tester.enterText(find.byKey(const Key("password")), 'wrongpassword');
await tester.pumpAndSettle();
// Tap login button
await tester.tap(find.byKey(const Key("SubmitWidgetButton")));
await tester.pumpAndSettle();
// Verify error message is displayed (German localization)
expect(find.text('Falscher Benutzername oder Passwort'), findsOneWidget);
});
testWidgets('does not call setLoggedIn on failed authentication', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
when(mockLoginController.authenticate('testuser', 'wrongpassword'))
.thenAnswer((_) async => (jwtTokenPairDto: null));
await pumpApp(tester, const QuestionaireLoginWidget());
await tester.pumpAndSettle();
// Enter credentials
await tester.enterText(find.byKey(const Key("username")), 'testuser');
await tester.enterText(find.byKey(const Key("password")), 'wrongpassword');
await tester.pumpAndSettle();
// Tap login button
await tester.tap(find.byKey(const Key("SubmitWidgetButton")));
await tester.pumpAndSettle();
// Verify setLoggedIn was NOT called
verifyNever(mockLoginCredentials.setLoggedIn(any));
});
testWidgets('password field obscures text', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const QuestionaireLoginWidget());
await tester.pumpAndSettle();
// Find the EditableText descendant of the password field
// TextFormField wraps TextField which contains EditableText
final passwordFieldFinder = find.byKey(const Key("password"));
final editableTextFinder = find.descendant(
of: passwordFieldFinder,
matching: find.byType(EditableText),
);
final editableText = tester.widget<EditableText>(editableTextFinder);
// Verify obscureText is true
expect(editableText.obscureText, isTrue);
});
});
}

View File

@@ -9,6 +9,12 @@
<url-pattern>/api/picture</url-pattern>
<url-pattern>/api/picture/*</url-pattern>
<url-pattern>/api/questionnairecustomer</url-pattern>
<url-pattern>/api/questionnairecustomer/*</url-pattern>
<url-pattern>/api/questionnaire</url-pattern>
<url-pattern>/api/questionnaire/*</url-pattern>
<http-method>GET</http-method>
<http-method>POST</http-method>
<http-method>PUT</http-method>