diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bd8107b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build Commands + +### Backend (Maven) +```bash +# Full build and deploy +mvn deploy -U -f pom.xml + +# Run integration tests with Docker +mvn deploy -P docker -f hartmann-foto-documentation-docker/pom.xml +``` + +### Frontend (Flutter) +```bash +cd hartmann-foto-documentation-frontend + +# Install dependencies +flutter pub get + +# Build for web +flutter build web --no-tree-shake-icons + +# Run tests with coverage +flutter test --coverage --reporter=json + +# Run a single test file +flutter test test/path/to/test_file.dart + +# Static analysis +flutter analyze + +# Generate localization files +flutter gen-l10n +``` + +## Architecture + +### Multi-Module Maven Project +- **hartmann-foto-documentation-app** - Backend REST API (jar) +- **hartmann-foto-documentation-web** - WAR deployment package +- **hartmann-foto-documentation-docker** - Docker/integration tests +- **hartmann-foto-documentation-frontend** - Flutter mobile/web app + +### Technology Stack +- **Backend:** Java 21, WildFly 26.1.3, Jakarta EE 10, Hibernate 6.2, PostgreSQL 11, JWT auth +- **Frontend:** Flutter 3.3.0+, Provider for state management, go_router for navigation +- **API Docs:** Swagger/OpenAPI v3 + +### Backend Layered Architecture +``` +REST Resources → Services (@Stateless EJBs) → QueryService → JPA Entities +``` + +Key packages in `hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/`: +- `core/model/` - JPA entities (User, Right, Customer, Picture, JwtRefreshToken) +- `core/service/` - Business logic with AbstractService base class +- `core/query/` - QueryService for database operations +- `rest/` - JAX-RS resources and value objects + +### Frontend Structure +``` +lib/ +├── main.dart # Entry point (MaterialApp.router) +├── controller/ # Business logic controllers +├── pages/ # Feature-based UI (login/, customer/) +├── dto/ # Data Transfer Objects +├── utils/ # DI container, theme, routing +└── l10n/ # Localization (German supported) +``` + +### Authentication +- JWT token-based authentication with refresh tokens +- WildFly Elytron security realm +- Bearer token auth for REST endpoints +- User passwords stored with salt-based hashing + +### Database Entities +- User ↔ Right (many-to-many via user_to_right) +- Customer → Picture (one-to-many) +- JwtRefreshToken (device/IP tracking) + +Named queries pattern: `@NamedQuery` on entities (e.g., `User.BY_USERNAME`) + +## Local Development + +Docker Compose provides PostgreSQL and WildFly: +```bash +cd hartmann-foto-documentation-docker +docker-compose up +``` + +Port mappings: WildFly HTTP (8180), Management (9990), SMTP (8280) + +## CI/CD + +Jenkins pipeline with SonarQube integration. Build runs on macOS agent with JDK 21. diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/model/Picture.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/model/Picture.java index 6750a73..29e0b20 100644 --- a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/model/Picture.java +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/model/Picture.java @@ -39,6 +39,9 @@ public class Picture extends AbstractDateEntity { @Basic(fetch = FetchType.LAZY) private String comment; + + + private String category; @Column(name = "image") @Basic(fetch = FetchType.LAZY) @@ -79,6 +82,16 @@ public class Picture extends AbstractDateEntity { public void setComment(String comment) { this.comment = comment; } + + + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } public String getImage() { return image; @@ -121,12 +134,17 @@ public class Picture extends AbstractDateEntity { instance.setPictureDate(pictureDate); return this; } - + public Builder comment(String comment) { instance.setComment(comment); return this; } + public Builder category(String category) { + instance.setCategory(category); + return this; + } + public Builder image(String image) { instance.setImage(image); return this; diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/service/CustomerPictureService.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/service/CustomerPictureService.java index e1140bc..40b247f 100644 --- a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/service/CustomerPictureService.java +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/service/CustomerPictureService.java @@ -3,14 +3,21 @@ package marketing.heyday.hartmann.fotodocumentation.core.service; import java.util.List; import java.util.Optional; +import org.apache.commons.lang3.StringUtils; + import jakarta.annotation.security.PermitAll; import jakarta.ejb.LocalBean; import jakarta.ejb.Stateless; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Root; import marketing.heyday.hartmann.fotodocumentation.core.model.Customer; import marketing.heyday.hartmann.fotodocumentation.core.model.Picture; import marketing.heyday.hartmann.fotodocumentation.core.query.Param; import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerListValue; import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerPictureValue; +import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerValue; /** * @@ -30,8 +37,12 @@ public class CustomerPictureService extends AbstractService { Optional customerOpt = queryService.callNamedQuerySingleResult(Customer.FIND_BY_NUMBER, new Param(Customer.PARAM_NUMBER, customerPictureValue.customerNumber())); Customer customer = customerOpt.orElseGet(() -> new Customer.Builder().customerNumber(customerPictureValue.customerNumber()).name(customerPictureValue.pharmacyName()).build()); customer = entityManager.merge(customer); - - Picture picture = new Picture.Builder().customer(customer).username(customerPictureValue.username()).comment(customerPictureValue.comment()).image(customerPictureValue.base64String()).pictureDate(customerPictureValue.date()).build(); + + Picture picture = new Picture.Builder().customer(customer).username(customerPictureValue.username()) + .category(customerPictureValue.category()) + .comment(customerPictureValue.comment()) + .image(customerPictureValue.base64String()) + .pictureDate(customerPictureValue.date()).build(); customer.getPictures().add(picture); entityManager.persist(picture); @@ -39,10 +50,42 @@ public class CustomerPictureService extends AbstractService { return true; } - public List getAll(String query) { + // query = search for name, number and date + public List getAll(String queryStr, String startsWith) { // FIXME: do query - List customers = queryService.callNamedQueryList(Customer.FIND_ALL); + + CriteriaBuilder builder = entityManager.getCriteriaBuilder(); + CriteriaQuery criteriaQuery = builder.createQuery(Customer.class); + Root customerRoot = criteriaQuery.from(Customer.class); + + criteriaQuery = criteriaQuery.select(customerRoot); + + if (StringUtils.isNotBlank(startsWith)) { + String param = startsWith.toLowerCase() + "%"; + criteriaQuery = criteriaQuery.where(builder.like(builder.lower(customerRoot.get("name")), param)); + } + + if (StringUtils.isNotBlank(queryStr)) { + 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 predicate = builder.or(predicateName, predicateNr); + criteriaQuery = criteriaQuery.where(predicate); + } + + TypedQuery typedQuery = entityManager.createQuery(criteriaQuery); + List customers = typedQuery.getResultList(); customers.forEach(c -> c.getPictures().size()); return customers.parallelStream().map(CustomerListValue::builder).toList(); } + + public CustomerValue get(Long id) { + Customer customer = entityManager.find(Customer.class, id); + if (customer == null) { + return null; + } + + return CustomerValue.builder(customer); + } } diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/service/PictureService.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/service/PictureService.java new file mode 100644 index 0000000..62213cd --- /dev/null +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/core/service/PictureService.java @@ -0,0 +1,31 @@ +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.core.model.Picture; +import marketing.heyday.hartmann.fotodocumentation.rest.vo.PictureValue; + +/** + * + *

Copyright: Copyright (c) 2024

+ *

Company: heyday Marketing GmbH

+ * @author Patrick Verboom + * @version 1.0 + * + * created: 19 Jan 2026 + */ +@Stateless +@LocalBean +@PermitAll +public class PictureService extends AbstractService { + + public PictureValue get(Long id) { + Picture picture = entityManager.find(Picture.class, id); + if (picture == null) { + return null; + } + + return PictureValue.builder(picture); + } +} diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/CustomerResource.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/CustomerResource.java index e41fc9f..d88e8f5 100644 --- a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/CustomerResource.java +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/CustomerResource.java @@ -13,14 +13,11 @@ 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.Consumes; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.*; import jakarta.ws.rs.core.Response; import marketing.heyday.hartmann.fotodocumentation.core.service.CustomerPictureService; import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerListValue; +import marketing.heyday.hartmann.fotodocumentation.rest.vo.CustomerValue; /** * @@ -42,12 +39,24 @@ public class CustomerResource { @GZIP @GET @Path("") - @Consumes(MediaType.APPLICATION_JSON) + @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 = CustomerListValue.class)))) - public Response doAddCustomerPicture(@QueryParam("query") String query) { - LOG.debug("Query customers for query " + query); - var retVal = customerPictureService.getAll(query); + public Response doGetCustomerList(@QueryParam("query") String query, @QueryParam("startsWith") String startsWith) { + LOG.debug("Query customers for query " + query + " startsWith: " + startsWith); + var retVal = customerPictureService.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 = CustomerValue.class)))) + public Response doGetDetailCustomer(@PathParam("id") Long id) { + LOG.debug("Get Customer details for id " + id); + var retVal = customerPictureService.get(id); return Response.ok().entity(retVal).build(); } } diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/PictureResource.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/PictureResource.java new file mode 100644 index 0000000..f9d2cea --- /dev/null +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/PictureResource.java @@ -0,0 +1,52 @@ +package marketing.heyday.hartmann.fotodocumentation.rest; + +import static marketing.heyday.hartmann.fotodocumentation.rest.jackson.ApplicationConfigApi.JSON_OUT; + +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.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Response; +import marketing.heyday.hartmann.fotodocumentation.core.service.PictureService; +import marketing.heyday.hartmann.fotodocumentation.rest.vo.PictureValue; + +/** + * + *

Copyright: Copyright (c) 2024

+ *

Company: heyday Marketing GmbH

+ * @author Patrick Verboom + * @version 1.0 + * + * created: 21 Jan 2026 + */ +@RequestScoped +@Path("picture") +public class PictureResource { + private static final Log LOG = LogFactory.getLog(PictureResource.class); + + @EJB + private PictureService pictureService; + + @GZIP + @GET + @Path("{id}") + @Produces(JSON_OUT) + @Operation(summary = "Get picture value") + @ApiResponse(responseCode = "200", description = "Successfully retrieved picture value", content = @Content(mediaType = JSON_OUT, array = @ArraySchema(schema = @Schema(implementation = PictureValue.class)))) + public Response doGetDetailCustomer(@PathParam("id") Long id) { + LOG.debug("Get Picture details for id " + id); + var retVal = pictureService.get(id); + return Response.ok().entity(retVal).build(); + } +} diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/jackson/ApplicationConfigApi.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/jackson/ApplicationConfigApi.java index 174aaaf..50d38e2 100644 --- a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/jackson/ApplicationConfigApi.java +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/jackson/ApplicationConfigApi.java @@ -14,10 +14,7 @@ import io.swagger.v3.oas.annotations.servers.Server; import jakarta.ws.rs.ApplicationPath; import jakarta.ws.rs.core.Application; import jakarta.ws.rs.core.MediaType; -import marketing.heyday.hartmann.fotodocumentation.rest.CustomerPictureResource; -import marketing.heyday.hartmann.fotodocumentation.rest.CustomerResource; -import marketing.heyday.hartmann.fotodocumentation.rest.LoginResource; -import marketing.heyday.hartmann.fotodocumentation.rest.MonitoringResource; +import marketing.heyday.hartmann.fotodocumentation.rest.*; /** * @@ -48,6 +45,7 @@ public class ApplicationConfigApi extends Application { retVal.add(MonitoringResource.class); retVal.add(CustomerPictureResource.class); retVal.add(CustomerResource.class); + retVal.add(PictureResource.class); LOG.info("returning rest api classes " + retVal); return retVal; } diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/vo/CustomerListValue.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/vo/CustomerListValue.java index 6ebabf4..5a40024 100644 --- a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/vo/CustomerListValue.java +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/vo/CustomerListValue.java @@ -1,6 +1,10 @@ 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.Customer; +import marketing.heyday.hartmann.fotodocumentation.core.model.Picture; /** * @@ -12,12 +16,14 @@ import marketing.heyday.hartmann.fotodocumentation.core.model.Customer; * created: 19 Jan 2026 */ -public record CustomerListValue(String name, String customerNumber, int amountOfPicture) { +@Schema(name = "CustomerList") +public record CustomerListValue(Long id, String name, String customerNumber, Date lastUpdateDate) { public static CustomerListValue builder(Customer customer) { if (customer == null) { return null; } - return new CustomerListValue(customer.getName(), customer.getCustomerNumber(), customer.getPictures().size()); + Date date = customer.getPictures().stream().map(Picture::getPictureDate).sorted((p1, p2) -> p2.compareTo(p1)).findFirst().orElse(null); + return new CustomerListValue(customer.getCustomerId(), customer.getName(), customer.getCustomerNumber(), date); } } diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/vo/CustomerPictureValue.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/vo/CustomerPictureValue.java index 0aa212e..86da208 100644 --- a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/vo/CustomerPictureValue.java +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/vo/CustomerPictureValue.java @@ -2,6 +2,8 @@ package marketing.heyday.hartmann.fotodocumentation.rest.vo; import java.util.Date; +import io.swagger.v3.oas.annotations.media.Schema; + /** * *

Copyright: Copyright (c) 2024

@@ -11,7 +13,7 @@ import java.util.Date; * * created: 19 Jan 2026 */ - -public record CustomerPictureValue(String username, String pharmacyName, String customerNumber, Date date, String comment, String base64String) { +@Schema(name = "CustomerPictureUpload") +public record CustomerPictureValue(String username, String pharmacyName, String customerNumber, Date date, String comment, String category, String base64String) { } diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/vo/CustomerValue.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/vo/CustomerValue.java new file mode 100644 index 0000000..e70ba7b --- /dev/null +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/vo/CustomerValue.java @@ -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.Customer; + +/** + * + *

Copyright: Copyright (c) 2024

+ *

Company: heyday Marketing GmbH

+ * @author Patrick Verboom + * @version 1.0 + * + * created: 22 Jan 2026 + */ +@Schema(name = "Customer") +public record CustomerValue(Long id, String name, String customerNumber, List pictures) { + + public static CustomerValue builder(Customer customer) { + if (customer == null) { + return null; + } + List pictures = customer.getPictures().parallelStream().map(PictureValue::builder).filter(p -> p != null).toList(); + return new CustomerValue(customer.getCustomerId(), customer.getName(), customer.getCustomerNumber(), pictures); + } +} \ No newline at end of file diff --git a/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/vo/PictureValue.java b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/vo/PictureValue.java new file mode 100644 index 0000000..31cdeea --- /dev/null +++ b/hartmann-foto-documentation-app/src/main/java/marketing/heyday/hartmann/fotodocumentation/rest/vo/PictureValue.java @@ -0,0 +1,27 @@ +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.Picture; + +/** + * + *

Copyright: Copyright (c) 2024

+ *

Company: heyday Marketing GmbH

+ * @author Patrick Verboom + * @version 1.0 + * + * created: 22 Jan 2026 + */ + +@Schema(name = "Picture") +public record PictureValue(Long id, String comment, String category, String image, Date pictureDate, String username) { + + public static PictureValue builder(Picture picture) { + if (picture == null) { + return null; + } + return new PictureValue(picture.getPictureId(), picture.getComment(), picture.getCategory(), picture.getImage(), picture.getPictureDate(), picture.getUsername()); + } +} \ No newline at end of file diff --git a/hartmann-foto-documentation-app/src/main/resources/marketing/heyday/hartmann/fotodocumentation/core/db/migration/V2__init2.sql b/hartmann-foto-documentation-app/src/main/resources/marketing/heyday/hartmann/fotodocumentation/core/db/migration/V2__init2.sql new file mode 100644 index 0000000..e81bded --- /dev/null +++ b/hartmann-foto-documentation-app/src/main/resources/marketing/heyday/hartmann/fotodocumentation/core/db/migration/V2__init2.sql @@ -0,0 +1,4 @@ + +-- picture + +alter table picture add column category varchar(250); diff --git a/hartmann-foto-documentation-docker/src/main/docker/hartmann-foto-documentation-web-1.0.0-SNAPSHOT.war b/hartmann-foto-documentation-docker/src/main/docker/hartmann-foto-documentation-web-1.0.0-SNAPSHOT.war index 9f17fc9..133c95d 100644 Binary files a/hartmann-foto-documentation-docker/src/main/docker/hartmann-foto-documentation-web-1.0.0-SNAPSHOT.war and b/hartmann-foto-documentation-docker/src/main/docker/hartmann-foto-documentation-web-1.0.0-SNAPSHOT.war differ diff --git a/hartmann-foto-documentation-frontend/assets/images/logo.png b/hartmann-foto-documentation-frontend/assets/images/logo.png new file mode 100644 index 0000000..8026da0 Binary files /dev/null and b/hartmann-foto-documentation-frontend/assets/images/logo.png differ diff --git a/hartmann-foto-documentation-frontend/lib/controller/customer_controller.dart b/hartmann-foto-documentation-frontend/lib/controller/customer_controller.dart new file mode 100644 index 0000000..541de6e --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/controller/customer_controller.dart @@ -0,0 +1,31 @@ +import 'package:fotodocumentation/controller/base_controller.dart'; +import 'package:fotodocumentation/dto/customer_dto.dart'; + +abstract interface class CustomerController { + Future> getAll(String query, String startsWith); + + Future get({required int id}); +} + +class CustomerControllerImpl extends BaseController implements CustomerController { + final String path = "customer"; + + @override + Future> getAll(String query, String startsWith) async { + String uriStr = '${uriUtils.getBaseUrl()}$path?query=$query&startsWith=$startsWith'; + return runGetListWithAuth(uriStr, (p0) { + List retVal = []; + for (var elem in p0) { + var entity = CustomerListDto.fromJson(elem); + retVal.add(entity); + } + return retVal; + }); + } + + @override + Future get({required int id}) { + String uriStr = '${uriUtils.getBaseUrl()}$path/$id'; + return runGetWithAuth(uriStr, (json) => CustomerDto.fromJson(json)); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/controller/picture_controller.dart b/hartmann-foto-documentation-frontend/lib/controller/picture_controller.dart new file mode 100644 index 0000000..af168ce --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/controller/picture_controller.dart @@ -0,0 +1,23 @@ +import 'package:fotodocumentation/controller/base_controller.dart'; +import 'package:fotodocumentation/dto/customer_dto.dart'; + +abstract interface class PictureController { + Future get({required int id}); + Future delete(PictureDto dto); +} + +class PictureControllerImpl extends BaseController implements PictureController { + final String path = "picture"; + + @override + Future get({required int id}) { + String uriStr = '${uriUtils.getBaseUrl()}$path/$id'; + return runGetWithAuth(uriStr, (json) => PictureDto.fromJson(json)); + } + + @override + Future delete(PictureDto dto) { + // TODO: implement delete + throw UnimplementedError(); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/dto/customer_dto.dart b/hartmann-foto-documentation-frontend/lib/dto/customer_dto.dart new file mode 100644 index 0000000..8f78a18 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/dto/customer_dto.dart @@ -0,0 +1,62 @@ +import 'package:fotodocumentation/utils/date_time_utils.dart'; + +class CustomerListDto { + final int id; + final String name; + final String customerNumber; + final DateTime? lastUpdateDate; + + CustomerListDto({required this.id, required this.name, required this.customerNumber, required this.lastUpdateDate}); + + /// Create from JSON response + factory CustomerListDto.fromJson(Map json) { + return CustomerListDto( + id: json['id'] as int, + name: json['name'] as String, + customerNumber: json['customerNumber'] as String, + lastUpdateDate: DateTimeUtils.toDateTime(json['lastUpdateDate']), + ); + } +} + +class CustomerDto { + final int id; + final String name; + final String customerNumber; + final List pictures; + + CustomerDto({required this.id, required this.name, required this.customerNumber, required this.pictures}); + + /// Create from JSON response + factory CustomerDto.fromJson(Map json) { + return CustomerDto( + id: json['id'] as int, + name: json['name'] as String, + customerNumber: json['customerNumber'] as String, + pictures: List.from(json["pictures"].map((x) => PictureDto.fromJson(x))), + ); + } +} + +class PictureDto { + final int id; + final String? comment; + final String? category; + final String image; + final DateTime pictureDate; + final String? username; + +PictureDto({required this.id, required this.comment, required this.category, required this.image, required this.pictureDate, required this.username}); + + /// Create from JSON response + factory PictureDto.fromJson(Map json) { + return PictureDto( + id: json['id'] as int, + comment: json['comment'] as String?, + category: json['category'] as String?, + image: json['image'] as String, + pictureDate: DateTimeUtils.toDateTime(json['pictureDate']) ?? DateTime.now(), + username: json['username'] as String?, + ); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/l10n/app_de.arb b/hartmann-foto-documentation-frontend/lib/l10n/app_de.arb index 18f6c9f..b27c4c0 100644 --- a/hartmann-foto-documentation-frontend/lib/l10n/app_de.arb +++ b/hartmann-foto-documentation-frontend/lib/l10n/app_de.arb @@ -1,13 +1,9 @@ { "@@locale": "de", - "searchTFHint": "Suchtext", + "searchTFHint": "Suche nach Apothekennamen, Kundennummer, Datum", "@searchTFHint": { "description": "Search hint TextField" }, - "searchButtonLabel": "Suchen", - "@searchButtonLabel": { - "description": "Search button label" - }, "loginUsernameTFLabel": "Benutzername", "@loginUsernameTFLabel": { "description": "Usernamt TextField Label" @@ -16,10 +12,18 @@ "@loginPasswordTFLabel": { "description": "Password TextField Label" }, - "loginLoginButtonLabel": "Anmelden", + "loginLoginButtonLabel": "Einloggen", "@loginLoginButtonLabel": { "description": "Login Button Label" }, + "loginTitle": "BILDERUPLOAD", + "@loginTitle": { + "description": "Login page title" + }, + "loginErrorMessage": "Falscher Benutzername oder Passwort", + "@loginErrorMessage": { + "description": "Login error message for invalid credentials" + }, "errorWidgetStatusCode": "Statuscode {statusCode}", "@errorWidgetStatusCode": { "description": "Error message showing server status code", @@ -48,10 +52,6 @@ "@submitWidget": { "description": "Save Button text" }, - "textInputWidgetValidatorText": "Bitte geben Sie einen Text ein", - "@textInputWidgetValidatorText": { - "description": "Awaiting result info text" - }, "waitingWidget": "Warten auf Ergebnis …", "@waitingWidget": { "description": "Awaiting result info text" @@ -71,5 +71,58 @@ "deleteDialogButtonApprove": "Ja", "@deleteDialogButtonApprove": { "description": "Approve Button text" + }, + "customerListHeaderCustomerNumber": "Kunden-Nr.", + "@customerListHeaderCustomerNumber": { + "description": "Customer list table header for customer number" + }, + "customerListHeaderName": "Apothekenname", + "@customerListHeaderName": { + "description": "Customer list table header for name" + }, + "customerListHeaderLastDate": "Datum Bilder", + "@customerListHeaderLastDate": { + "description": "Customer list table header for last date" + }, + "customerListHeaderLastDateSuffix": " (zuletzt aktualisiert)", + "@customerListHeaderLastDateSuffix": { + "description": "Customer list table header for ladt date" + }, + "customerListHeadline": "BILDERUPLOAD", + "@customerListHeadline": { + "description": "Customer list page headline" + }, + "customerListEmpty": "Keine Ergebnisse gefunden", + "@customerListEmpty": { + "description": "Empty customer list message" + }, + "customerWidgetNotFound": "Die Apotheke konnte nicht gefunden werden.", + "@customerWidgetNotFound": { + "description": "Customer not found error message" + }, + "customerWidgetCustomerNumberPrefix": "KundenNr: {customerNumber}", + "@customerWidgetCustomerNumberPrefix": { + "description": "Customer number prefix with placeholder", + "placeholders": { + "customerNumber": { + "type": "String" + } + } + }, + "customerWidgetHeaderFoto": "Foto", + "@customerWidgetHeaderFoto": { + "description": "Customer widget table header for photo" + }, + "customerWidgetHeaderComment": "Kommentar", + "@customerWidgetHeaderComment": { + "description": "Customer widget table header for comment" + }, + "customerWidgetHeaderUploadDate": "Upload-Datum", + "@customerWidgetHeaderUploadDate": { + "description": "Customer widget table header for upload date" + }, + "backButtonLabel": "zurück", + "@backButtonLabel": { + "description": "Back button label" } } \ No newline at end of file diff --git a/hartmann-foto-documentation-frontend/lib/l10n/app_localizations.dart b/hartmann-foto-documentation-frontend/lib/l10n/app_localizations.dart index af6b044..8a31bd0 100644 --- a/hartmann-foto-documentation-frontend/lib/l10n/app_localizations.dart +++ b/hartmann-foto-documentation-frontend/lib/l10n/app_localizations.dart @@ -97,15 +97,9 @@ abstract class AppLocalizations { /// Search hint TextField /// /// In de, this message translates to: - /// **'Suchtext'** + /// **'Suche nach Apothekennamen, Kundennummer, Datum'** String get searchTFHint; - /// Search button label - /// - /// In de, this message translates to: - /// **'Suchen'** - String get searchButtonLabel; - /// Usernamt TextField Label /// /// In de, this message translates to: @@ -121,9 +115,21 @@ abstract class AppLocalizations { /// Login Button Label /// /// In de, this message translates to: - /// **'Anmelden'** + /// **'Einloggen'** String get loginLoginButtonLabel; + /// Login page title + /// + /// In de, this message translates to: + /// **'BILDERUPLOAD'** + String get loginTitle; + + /// Login error message for invalid credentials + /// + /// In de, this message translates to: + /// **'Falscher Benutzername oder Passwort'** + String get loginErrorMessage; + /// Error message showing server status code /// /// In de, this message translates to: @@ -148,12 +154,6 @@ abstract class AppLocalizations { /// **'Speichern'** String get submitWidget; - /// Awaiting result info text - /// - /// In de, this message translates to: - /// **'Bitte geben Sie einen Text ein'** - String get textInputWidgetValidatorText; - /// Awaiting result info text /// /// In de, this message translates to: @@ -183,6 +183,78 @@ abstract class AppLocalizations { /// In de, this message translates to: /// **'Ja'** String get deleteDialogButtonApprove; + + /// Customer list table header for customer number + /// + /// In de, this message translates to: + /// **'Kunden-Nr.'** + String get customerListHeaderCustomerNumber; + + /// Customer list table header for name + /// + /// In de, this message translates to: + /// **'Apothekenname'** + String get customerListHeaderName; + + /// Customer list table header for last date + /// + /// In de, this message translates to: + /// **'Datum Bilder'** + String get customerListHeaderLastDate; + + /// Customer list table header for ladt date + /// + /// In de, this message translates to: + /// **' (zuletzt aktualisiert)'** + String get customerListHeaderLastDateSuffix; + + /// Customer list page headline + /// + /// In de, this message translates to: + /// **'BILDERUPLOAD'** + String get customerListHeadline; + + /// Empty customer list message + /// + /// In de, this message translates to: + /// **'Keine Ergebnisse gefunden'** + String get customerListEmpty; + + /// Customer not found error message + /// + /// In de, this message translates to: + /// **'Die Apotheke konnte nicht gefunden werden.'** + String get customerWidgetNotFound; + + /// Customer number prefix with placeholder + /// + /// In de, this message translates to: + /// **'KundenNr: {customerNumber}'** + String customerWidgetCustomerNumberPrefix(String customerNumber); + + /// Customer widget table header for photo + /// + /// In de, this message translates to: + /// **'Foto'** + String get customerWidgetHeaderFoto; + + /// Customer widget table header for comment + /// + /// In de, this message translates to: + /// **'Kommentar'** + String get customerWidgetHeaderComment; + + /// Customer widget table header for upload date + /// + /// In de, this message translates to: + /// **'Upload-Datum'** + String get customerWidgetHeaderUploadDate; + + /// Back button label + /// + /// In de, this message translates to: + /// **'zurück'** + String get backButtonLabel; } class _AppLocalizationsDelegate diff --git a/hartmann-foto-documentation-frontend/lib/l10n/app_localizations_de.dart b/hartmann-foto-documentation-frontend/lib/l10n/app_localizations_de.dart index 3851fc8..ddcdc7e 100644 --- a/hartmann-foto-documentation-frontend/lib/l10n/app_localizations_de.dart +++ b/hartmann-foto-documentation-frontend/lib/l10n/app_localizations_de.dart @@ -9,10 +9,7 @@ class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); @override - String get searchTFHint => 'Suchtext'; - - @override - String get searchButtonLabel => 'Suchen'; + String get searchTFHint => 'Suche nach Apothekennamen, Kundennummer, Datum'; @override String get loginUsernameTFLabel => 'Benutzername'; @@ -21,7 +18,13 @@ class AppLocalizationsDe extends AppLocalizations { String get loginPasswordTFLabel => 'Passwort'; @override - String get loginLoginButtonLabel => 'Anmelden'; + String get loginLoginButtonLabel => 'Einloggen'; + + @override + String get loginTitle => 'BILDERUPLOAD'; + + @override + String get loginErrorMessage => 'Falscher Benutzername oder Passwort'; @override String errorWidgetStatusCode(int statusCode) { @@ -39,9 +42,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get submitWidget => 'Speichern'; - @override - String get textInputWidgetValidatorText => 'Bitte geben Sie einen Text ein'; - @override String get waitingWidget => 'Warten auf Ergebnis …'; @@ -57,4 +57,43 @@ class AppLocalizationsDe extends AppLocalizations { @override String get deleteDialogButtonApprove => 'Ja'; + + @override + String get customerListHeaderCustomerNumber => 'Kunden-Nr.'; + + @override + String get customerListHeaderName => 'Apothekenname'; + + @override + String get customerListHeaderLastDate => 'Datum Bilder'; + + @override + String get customerListHeaderLastDateSuffix => ' (zuletzt aktualisiert)'; + + @override + String get customerListHeadline => 'BILDERUPLOAD'; + + @override + String get customerListEmpty => 'Keine Ergebnisse gefunden'; + + @override + String get customerWidgetNotFound => + 'Die Apotheke konnte nicht gefunden werden.'; + + @override + String customerWidgetCustomerNumberPrefix(String customerNumber) { + return 'KundenNr: $customerNumber'; + } + + @override + String get customerWidgetHeaderFoto => 'Foto'; + + @override + String get customerWidgetHeaderComment => 'Kommentar'; + + @override + String get customerWidgetHeaderUploadDate => 'Upload-Datum'; + + @override + String get backButtonLabel => 'zurück'; } diff --git a/hartmann-foto-documentation-frontend/lib/main.dart b/hartmann-foto-documentation-frontend/lib/main.dart index 42f8d02..cfea410 100644 --- a/hartmann-foto-documentation-frontend/lib/main.dart +++ b/hartmann-foto-documentation-frontend/lib/main.dart @@ -8,6 +8,8 @@ import 'package:fotodocumentation/utils/di_container.dart'; import 'package:fotodocumentation/utils/main_utils.dart'; import 'package:fotodocumentation/utils/global_router.dart'; +import 'package:intl/date_symbol_data_local.dart'; + var logger = Logger( printer: PrettyPrinter(methodCount: 2, errorMethodCount: 8, colors: true, printEmojis: true, dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart), ); @@ -18,6 +20,7 @@ void main() async { final theme = await ThemeLoader.loadTheme(); + await initializeDateFormatting('de_DE', null); LoginController loginController = DiContainer.get(); //await loginController.isLoggedIn(); runApp(FotoDocumentationApp(theme: theme)); diff --git a/hartmann-foto-documentation-frontend/lib/pages/customer/customer_list_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/customer/customer_list_widget.dart new file mode 100644 index 0000000..593941c --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/customer/customer_list_widget.dart @@ -0,0 +1,275 @@ +import 'package:flutter/material.dart'; +import 'package:fotodocumentation/controller/base_controller.dart'; +import 'package:fotodocumentation/controller/customer_controller.dart'; +import 'package:fotodocumentation/dto/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 CustomerListWidget extends StatefulWidget { + const CustomerListWidget({super.key}); + + @override + State createState() => _CustomerListWidgetState(); +} + +class _CustomerListWidgetState extends State { + CustomerController get _customerController => DiContainer.get(); + GeneralStyle get _generalStyle => DiContainer.get(); + + final _searchController = TextEditingController(); + late Future> _dtos; + String? _selectedLetter; + late DateFormat _dateFormat; + + @override + void initState() { + super.initState(); + _dateFormat = DateFormat('dd MMMM yyyy'); + _dtos = _customerController.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: AppLocalizations.of(context)!.customerListHeadline), + _abcHeaderBar(), + FractionallySizedBox( + widthFactor: 0.5, + alignment: Alignment.centerLeft, + child: SearchBarWidget( + searchController: _searchController, + onSearch: (text) async => actionSearch(text), + ), + ), + Expanded( + child: _customerListWidget(), + ), + ], + ), + ), + ); + } + + Widget _customerListWidget() { + return FutureBuilder>( + future: _dtos, + builder: (BuildContext context, AsyncSnapshot> snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const WaitingWidget(); + } + if (snapshot.hasData) { + List 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 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, + ); + + final headerStyleSuffix = TextStyle( + fontFamily: _generalStyle.fontFamily, + fontWeight: FontWeight.normal, + fontSize: 20, + color: _generalStyle.secondaryWidgetBackgroundColor, + ); + return Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + 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: 2, + child: Wrap( + children: [ + Text( + AppLocalizations.of(context)!.customerListHeaderLastDate, + style: headerStyle, + ), + Text( + AppLocalizations.of(context)!.customerListHeaderLastDateSuffix, + style: headerStyleSuffix, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _tableDataRow(BuildContext context, CustomerListDto 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( + 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: 2, + child: Text(dateStr, style: dataStyle), + ), + ], + ), + ), + ); + } + + Future actionSearch(String text) async { + _reloadData(); + } + + Future _actionSelect(BuildContext context, CustomerListDto dto) async { + context.go("${GlobalRouter.pathCustomer}/${dto.id}"); + } + + void _reloadData() { + _dtos = _customerController.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(), + ), + ); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/pages/customer/customer_row_item.dart b/hartmann-foto-documentation-frontend/lib/pages/customer/customer_row_item.dart new file mode 100644 index 0000000..8848a42 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/customer/customer_row_item.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:fotodocumentation/dto/customer_dto.dart'; +import 'package:fotodocumentation/pages/ui_utils/dialog/delete_dialog.dart'; + +class CustomerRowItem extends StatelessWidget { + final CustomerListDto dto; + final Future Function(CustomerListDto) doDelete; + final Future Function(CustomerListDto)? doSelect; + + const CustomerRowItem({super.key, required this.dto, required this.doDelete, this.doSelect}); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: CircleAvatar( + backgroundColor: Colors.grey[300], + child: Text( + dto.name.isNotEmpty ? dto.name[0].toUpperCase() : '?', + style: TextStyle( + color: Colors.grey[700], + fontWeight: FontWeight.bold, + ), + ), + ), + title: Text(dto.name), + subtitle: Text('CustomerNumber: ${dto.customerNumber}'), + + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + onTap: () async => await _doSelect(context), + ); + } + + Future _doSelect(BuildContext context) async { + if (doSelect != null) { + await doSelect!(dto); + } + } +} diff --git a/hartmann-foto-documentation-frontend/lib/pages/customer/customer_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/customer/customer_widget.dart index 72f592d..de766e3 100644 --- a/hartmann-foto-documentation-frontend/lib/pages/customer/customer_widget.dart +++ b/hartmann-foto-documentation-frontend/lib/pages/customer/customer_widget.dart @@ -1,15 +1,260 @@ +import 'dart:convert' show base64Decode; + import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:intl/intl.dart'; + +import 'package:fotodocumentation/controller/base_controller.dart'; +import 'package:fotodocumentation/l10n/app_localizations.dart'; +import 'package:fotodocumentation/controller/customer_controller.dart'; +import 'package:fotodocumentation/dto/customer_dto.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'; + class CustomerWidget extends StatefulWidget { - const CustomerWidget({super.key}); + final int customerId; + const CustomerWidget({super.key, required this.customerId}); @override State createState() => _CustomerWidgetState(); } class _CustomerWidgetState extends State { + CustomerController get _customerController => DiContainer.get(); + GeneralStyle get _generalStyle => DiContainer.get(); + + late Future _dto; + late DateFormat _dateFormat; + + @override + void initState() { + super.initState(); + _dateFormat = DateFormat('dd MMMM yyyy'); + _dto = _customerController.get(id: widget.customerId); + } + @override Widget build(BuildContext context) { - return const Placeholder(); + 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), + ), + ), + ); } -} \ No newline at end of file + + Widget _body(BuildContext context) { + return FutureBuilder( + future: _dto, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const WaitingWidget(); + } + if (snapshot.hasData) { + CustomerDto? dto = snapshot.data; + + 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(CustomerDto? dto) { + if (dto == null) { + return Text( + AppLocalizations.of(context)!.customerWidgetNotFound, + style: TextStyle( + fontFamily: _generalStyle.fontFamily, + fontWeight: FontWeight.bold, + fontSize: 20, + color: _generalStyle.secondaryWidgetBackgroundColor, + ), + ); + } + + var subText = AppLocalizations.of(context)!.customerWidgetCustomerNumberPrefix(dto.customerNumber); + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PageHeaderWidget(text: dto.name, subText: subText), + _backButton(context), + const SizedBox(height: 24), + Expanded( + child: _customerWidget(dto), + ), + ], + ), + ); + } + + Widget _customerWidget(CustomerDto dto) { + var dtos = dto.pictures; + + 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: 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( + crossAxisAlignment: CrossAxisAlignment.start, + 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: 2, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + AppLocalizations.of(context)!.customerWidgetHeaderUploadDate, + style: headerStyle, + ), + ), + ), + ], + ), + ); + } + + Widget _tableDataRow(BuildContext context, PictureDto dto) { + final dataStyle = TextStyle( + fontFamily: _generalStyle.fontFamily, + fontSize: 16.0, + color: _generalStyle.secondaryTextLabelColor, + ); + + final dateStr = _dateFormat.format(dto.pictureDate); + return InkWell( + onTap: () => _actionSelect(context, dto), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 1, + child: Align( + alignment: Alignment.centerLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 70, maxHeight: 70), + child: Image.memory( + base64Decode(dto.image), + fit: BoxFit.contain, + ), + ), + ), + ), + Expanded( + flex: 3, + child: Align( + alignment: Alignment.centerLeft, + child: Text(dto.comment ?? "", style: dataStyle), + ), + ), + Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerLeft, + child: Text(dateStr, style: dataStyle), + ), + ), + ], + ), + ), + ); + } + + Future _actionSelect(BuildContext context, PictureDto dto) async { + context.go("${GlobalRouter.pathPicture}/${dto.id}"); + } + + Widget _backButton(BuildContext context) { + return ElevatedButton.icon( + onPressed: () => context.go(GlobalRouter.pathHome), + icon: Icon( + Icons.chevron_left, + color: _generalStyle.secondaryTextLabelColor, + size: 24, + ), + label: Text( + AppLocalizations.of(context)!.backButtonLabel, + style: TextStyle( + fontFamily: _generalStyle.fontFamily, + fontWeight: FontWeight.normal, + fontSize: 16, + color: _generalStyle.secondaryTextLabelColor, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/pages/customer/picture_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/customer/picture_widget.dart new file mode 100644 index 0000000..30877b7 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/customer/picture_widget.dart @@ -0,0 +1,234 @@ +import 'dart:convert' show base64Decode; + +import 'package:flutter/material.dart'; +import 'package:fotodocumentation/controller/picture_controller.dart'; + +import 'package:intl/intl.dart'; + +import 'package:fotodocumentation/controller/base_controller.dart'; +import 'package:fotodocumentation/dto/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/waiting_widget.dart'; +import 'package:fotodocumentation/pages/ui_utils/general_style.dart'; +import 'package:fotodocumentation/utils/di_container.dart'; + +class PictureWidget extends StatefulWidget { + final int id; + const PictureWidget({super.key, required this.id}); + + @override + State createState() => _PictureWidgetState(); +} + +class _PictureWidgetState extends State { + PictureController get _pictureController => DiContainer.get(); + GeneralStyle get _generalStyle => DiContainer.get(); + + late Future _dto; + late DateFormat _dateFormat; + final ScrollController _commentScrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _dateFormat = DateFormat('dd MMMM yyyy'); + _dto = _pictureController.get(id: widget.id); + } + + @override + void dispose() { + _commentScrollController.dispose(); + super.dispose(); + } + + @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( + future: _dto, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const WaitingWidget(); + } + if (snapshot.hasData) { + PictureDto? dto = snapshot.data; + + 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(PictureDto? dto) { + if (dto == null) { + return Text( + AppLocalizations.of(context)!.customerWidgetNotFound, + 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: Padding( + padding: const EdgeInsets.all(24.0), + child: LayoutBuilder( + builder: (context, constraints) { + final isNarrow = constraints.maxWidth < 800; + return SingleChildScrollView( + child: isNarrow + ? Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _imageWidget(dto), + const SizedBox(height: 32), + _contentWidget(dto), + ], + ) + : Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _imageWidget(dto), + const SizedBox(width: 32), + _contentWidget(dto), + ], + ), + ); + }, + ), + ), + ); + } + + Widget _imageWidget(PictureDto dto) { + return Image.memory( + base64Decode(dto.image), + fit: BoxFit.contain, + ); + } + + Widget _contentWidget(PictureDto dto) { + final labelStyle = TextStyle( + fontWeight: FontWeight.normal, + fontSize: 16, + fontFamily: _generalStyle.fontFamily, + color: _generalStyle.primaryTextLabelColor, + ); + + final contentStyle = TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + fontFamily: _generalStyle.fontFamily, + color: _generalStyle.secondaryTextLabelColor, + ); + + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + "INFORMATIONEN", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 44, + fontFamily: _generalStyle.fontFamily, + color: _generalStyle.primaryTextLabelColor, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20.0), + child: Text( + "APOTHEKE", + style: labelStyle, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + "Name of apotheke", + style: contentStyle, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20.0), + child: Text( + "KUNDENNUMMER", + style: labelStyle, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + "123445587474873", + style: contentStyle, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20.0), + child: Text( + "DATUM", + style: labelStyle, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + _dateFormat.format(dto.pictureDate), + style: contentStyle, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20.0), + child: Text( + "KOMMENTAR", + style: labelStyle, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Container( + width: 300, + height: 150, + decoration: BoxDecoration( + border: Border.all(color: _generalStyle.secondaryTextLabelColor.withValues(alpha: 0.3)), + borderRadius: BorderRadius.circular(8), + ), + child: Scrollbar( + controller: _commentScrollController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: _commentScrollController, + padding: const EdgeInsets.all(8.0), + child: Text( + dto.comment ?? "", + style: contentStyle, + ), + ), + ), + ), + ), + ]); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/pages/login/login_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/login/login_widget.dart index 3bd35a3..b3d9d33 100644 --- a/hartmann-foto-documentation-frontend/lib/pages/login/login_widget.dart +++ b/hartmann-foto-documentation-frontend/lib/pages/login/login_widget.dart @@ -6,9 +6,7 @@ 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/component/general_submit_widget.dart'; -import 'package:fotodocumentation/pages/ui_utils/header_utils.dart'; -import 'package:fotodocumentation/pages/ui_utils/modern_app_bar.dart'; +import 'package:fotodocumentation/pages/ui_utils/general_style.dart'; import 'package:fotodocumentation/utils/di_container.dart'; import 'package:fotodocumentation/utils/login_credentials.dart'; @@ -20,9 +18,9 @@ class LoginWidget extends StatefulWidget { } class _LoginWidgetState extends State { - HeaderUtils get _headerUtils => DiContainer.get(); LoginController get _loginController => DiContainer.get(); LoginCredentials get _loginCredentials => DiContainer.get(); + GeneralStyle get _generalStyle => DiContainer.get(); final GlobalKey _formKey = GlobalKey(); @@ -43,26 +41,13 @@ class _LoginWidgetState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: ModernAppBar( - title: _headerUtils.titleWidget("Login title"), - actions: [], - ), body: _body(context), ); } Widget _body(BuildContext context) { return Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.grey[50]!, - Colors.white, - ], - ), - ), + color: _generalStyle.pageBackgroundColor, child: _content(context), ); } @@ -81,49 +66,116 @@ class _LoginWidgetState extends State { }, child: ListView( children: [ - Card( - elevation: 4, - margin: EdgeInsets.zero, - clipBehavior: Clip.antiAlias, + Center( child: Padding( - padding: const EdgeInsets.all(30.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextFormField( - key: Key("username"), - controller: _usernameController, - decoration: InputDecoration( - border: UnderlineInputBorder(), - labelText: AppLocalizations.of(context)!.loginUsernameTFLabel, - ), + 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( + AppLocalizations.of(context)!.loginTitle, + style: TextStyle( + fontWeight: FontWeight.bold, + color: _generalStyle.primaryTextLabelColor, + fontFamily: _generalStyle.fontFamily, + fontSize: 50, + ), + ), + ), + const SizedBox(height: 40), + if (_error != null) ...[ + Center( + child: Text( + _error!, + style: TextStyle( + color: _generalStyle.errorColor, + fontWeight: FontWeight.bold, + fontSize: 16, + fontFamily: _generalStyle.fontFamily, + ), + ), + ), + const SizedBox(height: 40), + ], + TextFormField( + key: Key("username"), + controller: _usernameController, + decoration: 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: AppLocalizations.of(context)!.loginUsernameTFLabel.toUpperCase(), + labelStyle: TextStyle(color: _generalStyle.primaryTextLabelColor, fontFamily: _generalStyle.fontFamily), + floatingLabelBehavior: FloatingLabelBehavior.always, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ), + const SizedBox(height: 25), + TextFormField( + key: Key("password"), + controller: _passwordController, + obscureText: true, + decoration: 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: AppLocalizations.of(context)!.loginPasswordTFLabel.toUpperCase(), + labelStyle: TextStyle(color: _generalStyle.primaryTextLabelColor, fontFamily: _generalStyle.fontFamily), + floatingLabelBehavior: FloatingLabelBehavior.always, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ), + 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), + ], ), - const SizedBox(height: 10), - TextFormField( - key: Key("password"), - controller: _passwordController, - obscureText: true, - decoration: InputDecoration( - border: UnderlineInputBorder(), - labelText: AppLocalizations.of(context)!.loginPasswordTFLabel, - ), - ), - const SizedBox(height: 10), - if (_error != null) ...[ - Text( - _error!, - style: const TextStyle(color: Colors.red), - ), - const SizedBox(height: 10), - ], - GeneralSubmitWidget( - key: const Key("submit"), - onSelect: () async => await _actionSubmit(context), - title: AppLocalizations.of(context)!.loginLoginButtonLabel, - ), - const SizedBox(height: 30), - ], + ), ), ), ), @@ -142,7 +194,7 @@ class _LoginWidgetState extends State { JwtTokenPairDto? jwtTokenPairDto = authenticateReply.jwtTokenPairDto; if (jwtTokenPairDto == null) { - setState(() => _error = "Error message"); + setState(() => _error = AppLocalizations.of(context)!.loginErrorMessage); return; } diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/page_header_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/page_header_widget.dart index 2203e39..6204f16 100644 --- a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/page_header_widget.dart +++ b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/page_header_widget.dart @@ -1,65 +1,54 @@ import 'package:flutter/material.dart'; +import 'package:fotodocumentation/pages/ui_utils/general_style.dart'; +import 'package:fotodocumentation/utils/di_container.dart'; class PageHeaderWidget extends StatelessWidget { - final IconData iconData; final String text; final String subText; - final Color? iconColor; - const PageHeaderWidget({super.key, this.iconData = Icons.business, required this.text, this.subText = "", this.iconColor}); + const PageHeaderWidget({super.key, required this.text, this.subText = ""}); + + GeneralStyle get _generalStyle => DiContainer.get(); @override Widget build(BuildContext context) { - final color = iconColor ?? Theme.of(context).colorScheme.primary; - return Card( - elevation: 2, - margin: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide( - color: Colors.grey[300]!, - width: 1, - ), - ), - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: color.withAlpha(51), - shape: BoxShape.circle, - ), - child: Icon( - iconData, - size: 32, - color: color, - ), - ), - const SizedBox(width: 16), - Text( - text, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - if (subText.isNotEmpty) ...[ - const SizedBox(height: 16), + return Padding( + padding: const EdgeInsets.only(top:24.0, bottom: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ Text( - subText, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey[600], - ), + key: Key("PageHeaderTextHeadline"), + text, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 50, + fontFamily: _generalStyle.fontFamily, + color: _generalStyle.primaryTextLabelColor, + ), + ), + const Spacer(), + Image.asset( + 'assets/images/logo.png', + height: 48, ), ], + ), + if (subText.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + key: Key("PageHeaderTextSubHeadline"), + subText, + style: TextStyle( + fontSize: 16, + fontFamily: _generalStyle.fontFamily, + color: _generalStyle.secondaryTextLabelColor, + ), + ), ], - ), + ], ), ); } diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/search_bar_card_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/search_bar_card_widget.dart deleted file mode 100644 index 2f4c052..0000000 --- a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/search_bar_card_widget.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:fotodocumentation/l10n/app_localizations.dart'; - -class SearchBarCardWidget extends StatefulWidget { - final TextEditingController searchController; - final Function(String) onSearch; - const SearchBarCardWidget({super.key, required this.searchController, required this.onSearch}); - - @override - State createState() => _SearchBarCardWidgetState(); -} - -class _SearchBarCardWidgetState extends State { - @override - Widget build(BuildContext context) { - return Card( - elevation: 2, - margin: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide( - color: Colors.grey[300]!, - width: 1, - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: TextField( - key: Key("Search_text_field"), - controller: widget.searchController, - textAlignVertical: TextAlignVertical.center, - decoration: InputDecoration( - hintText: AppLocalizations.of(context)!.searchTFHint, - border: InputBorder.none, - prefixIcon: const Icon(Icons.search, size: 28), - contentPadding: EdgeInsets.zero, - isDense: true, - suffixIcon: InkWell( - key: Key("Search_text_clear_button"), - onTap: () => _actionClear(), - child: const Icon( - Icons.close, - color: Colors.black, - ), - ) - ), - onSubmitted: (_) => _actionSubmit(), - ), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - key: Key("Search_text_button"), - onPressed: _actionSubmit, - icon: const Icon(Icons.search, size: 18), - label: Text(AppLocalizations.of(context)!.searchButtonLabel), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ], - ), - ), - ); - } - - void _actionSubmit() { - widget.onSearch(widget.searchController.text); - } - - void _actionClear() { - widget.searchController.text = ""; - widget.onSearch(widget.searchController.text); - } -} diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/search_bar_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/search_bar_widget.dart new file mode 100644 index 0000000..0ee5f71 --- /dev/null +++ b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/search_bar_widget.dart @@ -0,0 +1,59 @@ +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 SearchBarWidget extends StatefulWidget { + final TextEditingController searchController; + final Function(String) onSearch; + const SearchBarWidget({super.key, required this.searchController, required this.onSearch}); + + @override + State createState() => _SearchBarWidgetState(); +} + +class _SearchBarWidgetState extends State { + GeneralStyle get _generalStyle => DiContainer.get(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 0.0, vertical: 16.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: TextField( + key: Key("Search_text_field"), + controller: widget.searchController, + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + hint: Text( + AppLocalizations.of(context)!.searchTFHint, + style: TextStyle( + fontSize: 16, + fontFamily: _generalStyle.fontFamily, + color: _generalStyle.secondaryTextLabelColor.withValues(alpha: 0.5), + ), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(30), + borderSide: BorderSide(color: _generalStyle.secondaryTextLabelColor), + ), + prefixIcon: Icon(Icons.search, size: 30, color: _generalStyle.secondaryWidgetBackgroundColor,), + contentPadding: EdgeInsets.zero, + isDense: true, + ), + onSubmitted: (_) => _actionSubmit(), + ), + ), + const SizedBox(width: 8), + ], + ), + ); + } + + void _actionSubmit() { + widget.onSearch(widget.searchController.text); + } +} diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/text_input_widget.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/text_input_widget.dart deleted file mode 100644 index e09551f..0000000 --- a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/component/text_input_widget.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:fotodocumentation/l10n/app_localizations.dart'; - -import 'package:provider/provider.dart'; - -class TextInputWidget extends StatelessWidget { - final String labelText; - final bool required; - final bool obscureText; - final bool readOnly; - final Function? onTap; - const TextInputWidget({super.key, required this.labelText, this.required = false, this.obscureText = false, this.readOnly = false, this.onTap}); - - @override - Widget build(BuildContext context) { - return Consumer(builder: (context, controller, child) { - return TextFormField( - readOnly: readOnly, - obscureText: obscureText, - controller: controller, - decoration: InputDecoration( - border: const UnderlineInputBorder(), - labelText: labelText, - ), - validator: (String? value) => required && (value == null || value.isEmpty) ? AppLocalizations.of(context)!.textInputWidgetValidatorText : null, - onTap: () => onTap?.call(), - ); - }); - } -} - -class TextMultiInputWidget extends StatelessWidget { - final String labelText; - final bool required; - final bool obscureText; - final bool readOnly; - final Function? onTap; - final int maxLines; - const TextMultiInputWidget({super.key, required this.labelText, this.required = false, this.obscureText = false, this.readOnly = false, this.maxLines = 6, this.onTap}); - - @override - Widget build(BuildContext context) { - return Consumer(builder: (context, controller, child) { - return TextFormField( - readOnly: readOnly, - minLines: 3, // Set this - maxLines: maxLines, // and this - keyboardType: TextInputType.multiline, - obscureText: obscureText, - controller: controller, - decoration: InputDecoration( - border: const UnderlineInputBorder(), - labelText: labelText, - ), - validator: (String? value) => required && (value == null || value.isEmpty) ? AppLocalizations.of(context)!.textInputWidgetValidatorText : null, - onTap: () => onTap?.call(), - ); - }); - } -} diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/dialog/dialog_result.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/dialog/dialog_result.dart deleted file mode 100644 index 682a170..0000000 --- a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/dialog/dialog_result.dart +++ /dev/null @@ -1,11 +0,0 @@ -class DialogResult { - final DialogResultType type; - final T? dto; - - const DialogResult({required this.type, this.dto}); -} - -enum DialogResultType { - create, - add; -} \ No newline at end of file diff --git a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/general_style.dart b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/general_style.dart index 07bb923..99da76d 100644 --- a/hartmann-foto-documentation-frontend/lib/pages/ui_utils/general_style.dart +++ b/hartmann-foto-documentation-frontend/lib/pages/ui_utils/general_style.dart @@ -1,36 +1,55 @@ import 'package:flutter/material.dart'; -import 'package:pinput/pinput.dart'; - abstract interface class GeneralStyle { - PinTheme get pinTheme; - ButtonStyle get elevatedButtonStyle; ButtonStyle get roundedButtonStyle; + + Color get primaryTextLabelColor; + Color get secondaryTextLabelColor; + + Color get primaryButtonBackgroundColor; + Color get primaryButtonTextColor; + + Color get secondaryWidgetBackgroundColor; + + Color get pageBackgroundColor; + + Color get errorColor; + + String get fontFamily; } class GeneralStyleImpl implements GeneralStyle { static final ButtonStyle _elevatedButtonStyle = ElevatedButton.styleFrom(textStyle: const TextStyle(fontSize: 20)); static final ButtonStyle _roundedButtonStyle = ElevatedButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(8)); - @override - PinTheme get pinTheme => _getPinTheme(); - @override ButtonStyle get elevatedButtonStyle => _elevatedButtonStyle; @override ButtonStyle get roundedButtonStyle => _roundedButtonStyle; - PinTheme _getPinTheme() { - return PinTheme( - width: 56, - height: 56, - textStyle: TextStyle(fontSize: 20, fontWeight: FontWeight.w600), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - borderRadius: BorderRadius.circular(8), - ), - ); - } + @override + Color get primaryTextLabelColor => const Color(0xFF0045FF); + + @override + Color get secondaryTextLabelColor => const Color(0xFF2F2F2F); + + @override + Color get primaryButtonBackgroundColor => const Color(0xFF0045FF); + + @override + Color get secondaryWidgetBackgroundColor => const Color(0xFF001689); + + @override + Color get primaryButtonTextColor => Colors.white; + + @override + Color get pageBackgroundColor => const Color(0xFFF5F5F5); + + @override + Color get errorColor => const Color(0xFFFF0000); + + @override + String get fontFamily => 'Panton'; } diff --git a/hartmann-foto-documentation-frontend/lib/utils/di_container.dart b/hartmann-foto-documentation-frontend/lib/utils/di_container.dart index 8a43e06..da1e34d 100644 --- a/hartmann-foto-documentation-frontend/lib/utils/di_container.dart +++ b/hartmann-foto-documentation-frontend/lib/utils/di_container.dart @@ -1,4 +1,6 @@ +import 'package:fotodocumentation/controller/customer_controller.dart'; import 'package:fotodocumentation/controller/login_controller.dart'; +import 'package:fotodocumentation/controller/picture_controller.dart'; import 'package:fotodocumentation/pages/ui_utils/dialog/snackbar_utils.dart'; import 'package:fotodocumentation/pages/ui_utils/general_style.dart'; import 'package:fotodocumentation/pages/ui_utils/header_utils.dart'; @@ -26,6 +28,8 @@ class DiContainer { DiContainer.instance.put(UrlUtils, UrlUtilsImpl()); DiContainer.instance.put(SnackbarUtils, SnackbarUtilsImpl()); DiContainer.instance.put(LoginController, LoginControllerImpl()); + DiContainer.instance.put(CustomerController, CustomerControllerImpl()); + DiContainer.instance.put(PictureController, PictureControllerImpl()); } void put(Type key, T object) { diff --git a/hartmann-foto-documentation-frontend/lib/utils/extensions.dart b/hartmann-foto-documentation-frontend/lib/utils/extensions.dart index 9a99099..978a955 100644 --- a/hartmann-foto-documentation-frontend/lib/utils/extensions.dart +++ b/hartmann-foto-documentation-frontend/lib/utils/extensions.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart' show Colors, Color; +import 'package:flutter/material.dart' show Color; extension HexColor on Color { /// String is in the format "aabbcc" or "ffaabbcc" with an optional leading "#". @@ -9,21 +9,3 @@ extension HexColor on Color { return Color(int.parse(buffer.toString(), radix: 16)); } } - -extension RiskColor on Color { - static const Color noRisk = Colors.transparent; - static final Color lowRisk = HexColor.fromHex("#FFFF00"); - static final Color mediumRisk = HexColor.fromHex("#FF9000"); - static final Color highRisk = HexColor.fromHex("#FF4000"); - - static Color colorForRisk(int value) { - if (value == 1) { - return lowRisk; - } else if (value == 2) { - return mediumRisk; - } else if (value == 3) { - return highRisk; - } - return noRisk; - } -} diff --git a/hartmann-foto-documentation-frontend/lib/utils/global_router.dart b/hartmann-foto-documentation-frontend/lib/utils/global_router.dart index f92dc22..37d99d3 100644 --- a/hartmann-foto-documentation-frontend/lib/utils/global_router.dart +++ b/hartmann-foto-documentation-frontend/lib/utils/global_router.dart @@ -1,7 +1,9 @@ // 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/utils/di_container.dart'; import 'package:fotodocumentation/utils/login_credentials.dart'; @@ -14,6 +16,8 @@ class GlobalRouter { static final GlobalKey skillEditorNavigatorKey = GlobalKey(debugLabel: 'skillEditor'); static final String pathHome = "/home"; + static final String pathCustomer = "/customer"; + static final String pathPicture = "/picture"; static final String pathLogin = "/login"; static final GoRouter router = createRouter(pathHome); @@ -33,7 +37,23 @@ class GlobalRouter { ), GoRoute( path: pathHome, - builder: (context, state) => CustomerWidget(), + builder: (context, state) => CustomerListWidget(), + ), + GoRoute( + path: "$pathCustomer/:id", + builder: (context, state) { + var idStr = state.pathParameters['id']; + var id = idStr == null ? null : int.tryParse(idStr); + return CustomerWidget(customerId: id ?? -1); + }, + ), + GoRoute( + path: "$pathPicture/:id", + builder: (context, state) { + var idStr = state.pathParameters['id']; + var id = idStr == null ? null : int.tryParse(idStr); + return PictureWidget(id: id ?? -1); + }, ), ], redirect: (context, state) { diff --git a/hartmann-foto-documentation-frontend/lib/utils/global_stack.dart b/hartmann-foto-documentation-frontend/lib/utils/global_stack.dart deleted file mode 100644 index a206d9e..0000000 --- a/hartmann-foto-documentation-frontend/lib/utils/global_stack.dart +++ /dev/null @@ -1,16 +0,0 @@ -class GlobalStack { - final _list = []; - - void push(T value) => _list.add(value); - - T pop() => _list.removeLast(); - - T peek() => _list.last; - - bool get isEmpty => _list.isEmpty; - - bool get isNotEmpty => _list.isNotEmpty; - - @override - String toString() => _list.toString(); -} diff --git a/hartmann-foto-documentation-frontend/lib/utils/password_utils.dart b/hartmann-foto-documentation-frontend/lib/utils/password_utils.dart deleted file mode 100644 index 6ae9d61..0000000 --- a/hartmann-foto-documentation-frontend/lib/utils/password_utils.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:basic_utils/basic_utils.dart' show StringUtils; - -abstract interface class PasswordUtils { - String create(); -} - -class PasswordUtilsImpl implements PasswordUtils { - @override - String create() { - return StringUtils.generateRandomString(8, special: false); - } -} diff --git a/hartmann-foto-documentation-frontend/pubspec.yaml b/hartmann-foto-documentation-frontend/pubspec.yaml index 7e8743c..dcb5fde 100644 --- a/hartmann-foto-documentation-frontend/pubspec.yaml +++ b/hartmann-foto-documentation-frontend/pubspec.yaml @@ -94,6 +94,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - assets/theme/appainter_theme.json + - assets/images/logo.png # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware diff --git a/hartmann-foto-documentation-frontend/test/testing/test_utils.dart b/hartmann-foto-documentation-frontend/test/testing/test_utils.dart index d9ed5fa..45852bc 100644 --- a/hartmann-foto-documentation-frontend/test/testing/test_utils.dart +++ b/hartmann-foto-documentation-frontend/test/testing/test_utils.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fotodocumentation/controller/customer_controller.dart'; import 'package:http/http.dart' as http; import 'package:mockito/annotations.dart'; import 'package:fotodocumentation/l10n/app_localizations.dart'; import 'package:fotodocumentation/pages/ui_utils/dialog/snackbar_utils.dart'; import 'package:fotodocumentation/pages/ui_utils/header_utils.dart'; import 'package:fotodocumentation/utils/login_credentials.dart'; -import 'package:fotodocumentation/utils/password_utils.dart'; import 'package:fotodocumentation/utils/jwt_token_storage.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:fotodocumentation/utils/global_router.dart'; @@ -58,10 +58,10 @@ Future pumpAppConfig(WidgetTester tester, String initialLocation) async { // dart run build_runner build @GenerateMocks([ LoginCredentials, + CustomerController, HeaderUtils, - PasswordUtils, SnackbarUtils, JwtTokenStorage, - http.Client + http.Client, ]) void main() {} diff --git a/hartmann-foto-documentation-frontend/test/testing/test_utils.mocks.dart b/hartmann-foto-documentation-frontend/test/testing/test_utils.mocks.dart index c5115ed..b8cff77 100644 --- a/hartmann-foto-documentation-frontend/test/testing/test_utils.mocks.dart +++ b/hartmann-foto-documentation-frontend/test/testing/test_utils.mocks.dart @@ -14,7 +14,6 @@ import 'package:fotodocumentation/pages/ui_utils/dialog/snackbar_utils.dart' import 'package:fotodocumentation/pages/ui_utils/header_utils.dart' as _i7; import 'package:fotodocumentation/utils/jwt_token_storage.dart' as _i10; import 'package:fotodocumentation/utils/login_credentials.dart' as _i4; -import 'package:fotodocumentation/utils/password_utils.dart' as _i8; import 'package:http/http.dart' as _i3; import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/src/dummies.dart' as _i5; @@ -162,30 +161,6 @@ class MockHeaderUtils extends _i1.Mock implements _i7.HeaderUtils { ) as _i2.Widget); } -/// A class which mocks [PasswordUtils]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockPasswordUtils extends _i1.Mock implements _i8.PasswordUtils { - MockPasswordUtils() { - _i1.throwOnMissingStub(this); - } - - @override - String create() => (super.noSuchMethod( - Invocation.method( - #create, - [], - ), - returnValue: _i5.dummyValue( - this, - Invocation.method( - #create, - [], - ), - ), - ) as String); -} - /// A class which mocks [SnackbarUtils]. /// /// See the documentation for Mockito's code generation for more information. diff --git a/hartmann-foto-documentation-web/src/main/webapp/WEB-INF/web.xml b/hartmann-foto-documentation-web/src/main/webapp/WEB-INF/web.xml index 5c7886d..54a191d 100644 --- a/hartmann-foto-documentation-web/src/main/webapp/WEB-INF/web.xml +++ b/hartmann-foto-documentation-web/src/main/webapp/WEB-INF/web.xml @@ -5,6 +5,7 @@ Secure /api/customer + /api/picture GET POST