diff --git a/README.md b/README.md index b410ad58..42e63747 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@

CMPE352/451 Group 1
Disaster Response Platform

+## Software +You can find the alpha version of our software in the `resq` subdirectory + *** ## ⛑ About Us diff --git a/resq/README.md b/resq/README.md new file mode 100644 index 00000000..f3d99975 --- /dev/null +++ b/resq/README.md @@ -0,0 +1,48 @@ +# ResQ + +ResQ is REST based web service for the Disaster Response Application ResQ developed by CMPE451-GroupA1 + +## Getting Started + +These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. + +### Prerequisites + +- Docker and Docker-Compose + +### Config Directory Structure + +The backend application gets the actual path from `resq.appdir` property and checks for **resq** directory under that path. + +Under main directory structure is: +- **conf** + - **appparam.txt**: application configuration items are located under this file (web service url, username and pass etc.) + - **logConf.xml**: log configuration file. +- **log**: Application writes all logs under this directory + +### Installing with Docker + +First clone all files into local repo. + +Place the config folder named as project_env under the directory bounswe2023group1\resq\backend\resq. +Then, run the following commands: + +docker-compose up + +## Project Specific Information + +The local buld of the frontend application is available on http://localhost:3000 + +Production frontend URL: https://resq.org.tr + + +Backend application context root is /resq/api/v1/. You can reach the backend application via +https://localhost:8081/resq/api/v1/. +To reach the API documentation: +https://localhost:8081/resq/api/v1/swagger-ui.html + + +Production backend URL: https://api.resq.org.tr/resq/api/v1 + +Production backend API Documentation: +https://api.resq.org.tr/resq/api/v1/swagger-ui/index.html \ No newline at end of file diff --git a/resq/backend/resq/docker-compose.yml b/resq/backend/resq/docker-compose.yml index 42c4d465..3378cda5 100644 --- a/resq/backend/resq/docker-compose.yml +++ b/resq/backend/resq/docker-compose.yml @@ -29,6 +29,16 @@ services: - POSTGRES_PASSWORD=compose-postgres networks: - app_network + + frontend: + container_name: frontend + image: frontend:latest + build: + context: ../../frontend + ports: + - "3000:3000" + expose: + - "3000" networks: app_network: diff --git a/resq/backend/resq/pom.xml b/resq/backend/resq/pom.xml index 7eebeb48..990bc3e6 100644 --- a/resq/backend/resq/pom.xml +++ b/resq/backend/resq/pom.xml @@ -82,6 +82,14 @@ commons-beanutils 1.9.4 + + diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/controller/EventController.java b/resq/backend/resq/src/main/java/com/groupa1/resq/controller/EventController.java new file mode 100644 index 00000000..3262bf84 --- /dev/null +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/controller/EventController.java @@ -0,0 +1,38 @@ +package com.groupa1.resq.controller; + + +import com.groupa1.resq.request.CreateEventRequest; +import com.groupa1.resq.service.EventService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import com.groupa1.resq.response.EventResponse; +import java.util.List; + +@RestController +@RequestMapping("/event") +public class EventController { + + @Autowired + private EventService eventService; + + @PreAuthorize("hasRole('RESPONDER') or hasRole('COORDINATOR') or hasRole('FACILITATOR') or hasRole('VICTIM')") + @PostMapping("/createEvent") + public ResponseEntity createEvent(@RequestBody CreateEventRequest createEventRequest){ + return eventService.createEvent(createEventRequest); + } + + @PreAuthorize("hasRole('RESPONDER') or hasRole('COORDINATOR') or hasRole('FACILITATOR') or hasRole('VICTIM')") + @GetMapping("/viewEvents") + public ResponseEntity> viewEvents(@RequestParam Long reporterId) { + return eventService.viewEvents( reporterId); + } + + +} diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/controller/NeedController.java b/resq/backend/resq/src/main/java/com/groupa1/resq/controller/NeedController.java index 45e7518d..7414ca43 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/controller/NeedController.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/controller/NeedController.java @@ -28,12 +28,14 @@ public class NeedController { @GetMapping("/viewNeedsByFilter") @PreAuthorize("hasRole('FACILITATOR') or hasRole('COORDINATOR')") - public ResponseEntity> viewNeedsByFilter(@RequestParam(required = false) BigDecimal longitude, - @RequestParam(required = false) BigDecimal latitude, + public ResponseEntity> viewNeedsByFilter(@RequestParam(required = false) BigDecimal longitude1, + @RequestParam(required = false) BigDecimal latitude1, + @RequestParam(required = false) BigDecimal longitude2, + @RequestParam(required = false) BigDecimal latitude2, @RequestParam(required = false) String categoryTreeId, @RequestParam(required = false) Long userId) { - log.info("Viewing needs for location: {}, {}, category: {}, user: {}", longitude, latitude, categoryTreeId, userId); - return needService.viewNeedsByFilter(longitude, latitude, categoryTreeId, userId); + log.info("Viewing needs for location: {}-{} / {}-{}, category: {}, user: {}", longitude1, latitude1, longitude2, latitude2, categoryTreeId, userId); + return needService.viewNeedsByFilter(longitude1, latitude1, longitude2, latitude2, categoryTreeId, userId); } @PostMapping("/createNeed") diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/controller/RequestController.java b/resq/backend/resq/src/main/java/com/groupa1/resq/controller/RequestController.java index 8247097b..fdc87587 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/controller/RequestController.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/controller/RequestController.java @@ -1,6 +1,7 @@ package com.groupa1.resq.controller; import com.groupa1.resq.config.ResqAppProperties; +import com.groupa1.resq.dto.NeedDto; import com.groupa1.resq.dto.RequestDto; import com.groupa1.resq.entity.Need; import com.groupa1.resq.entity.Request; @@ -11,6 +12,7 @@ import com.groupa1.resq.service.RequestService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; @@ -31,13 +33,15 @@ public class RequestController { @GetMapping("/viewRequestsByFilter") @PreAuthorize("hasRole('FACILITATOR')") - public List viewRequestsByFilter(@RequestParam(required = false) BigDecimal longitude, - @RequestParam(required = false) BigDecimal latitude, + public List viewRequestsByFilter(@RequestParam(required = false) BigDecimal longitude1, + @RequestParam(required = false) BigDecimal latitude1, + @RequestParam(required = false) BigDecimal longitude2, + @RequestParam(required = false) BigDecimal latitude2, @RequestParam(required = false) EStatus status, @RequestParam(required = false) EUrgency urgency, @RequestParam(required = false) Long userId) { - log.info("Viewing requests for location: {}, {}, status: {}, urgency: {}, user: {}", longitude, latitude, status, urgency, userId); - return requestService.viewRequestsByFilter(longitude, latitude, status, urgency, userId); + log.info("Viewing requests for location: {}-{} / {}-{}, status: {}, urgency: {}, user: {}", longitude1, latitude1, longitude2, latitude2, status, urgency, userId); + return requestService.viewRequestsByFilter(longitude1, latitude1, longitude2, latitude2, status, urgency, userId); } @@ -72,6 +76,15 @@ public String deleteRequest(@RequestParam Long userId, @RequestParam Long reques return "Request successfully deleted."; } + @GetMapping("/filterByDistance") + @PreAuthorize("hasRole('FACILITATOR') or hasRole('COORDINATOR')") + public ResponseEntity> filterByDistance(@RequestParam BigDecimal longitude, + @RequestParam BigDecimal latitude, + @RequestParam BigDecimal distance) { + log.info("Filtering requests by distance"); + return requestService.filterByDistance(longitude, latitude, distance); + } + diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/converter/NeedConverter.java b/resq/backend/resq/src/main/java/com/groupa1/resq/converter/NeedConverter.java index a977071a..8f9b4ddd 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/converter/NeedConverter.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/converter/NeedConverter.java @@ -31,6 +31,7 @@ public NeedDto convertToDto(Need need) { needDto.setLatitude(need.getLatitude()); needDto.setLongitude(need.getLongitude()); needDto.setCreatedDate(need.getCreatedAt()); + needDto.setSize(need.getSize()); Request request = need.getRequest(); if (request != null) { needDto.setRequestId(request.getId()); @@ -51,6 +52,7 @@ public Need convertToEntity(NeedDto needDto) { need.setLongitude(needDto.getLongitude()); need.setStatus(needDto.getStatus()); need.setCreatedAt(needDto.getCreatedDate()); + need.setSize(needDto.getSize()); Request request = requestRepository.findById(needDto.getRequestId()).orElse(null); need.setRequest(request); return need; diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/converter/ProfileConverter.java b/resq/backend/resq/src/main/java/com/groupa1/resq/converter/ProfileConverter.java index 339672ad..89463780 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/converter/ProfileConverter.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/converter/ProfileConverter.java @@ -2,6 +2,7 @@ import com.groupa1.resq.dto.ProfileDto; import com.groupa1.resq.entity.UserProfile; +import com.groupa1.resq.exception.EntityNotFoundException; import org.modelmapper.ModelMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -13,6 +14,9 @@ public class ProfileConverter { ModelMapper modelMapper; public ProfileDto convertToDto(UserProfile userProfile) { + if(userProfile == null) { + throw new EntityNotFoundException("User profile not found"); + } ProfileDto profileDto = modelMapper.map(userProfile, ProfileDto.class); return profileDto; } diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/converter/ResourceConverter.java b/resq/backend/resq/src/main/java/com/groupa1/resq/converter/ResourceConverter.java index 8ae5d0a6..966cb069 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/converter/ResourceConverter.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/converter/ResourceConverter.java @@ -2,6 +2,7 @@ import com.groupa1.resq.dto.ResourceDto; import com.groupa1.resq.entity.Resource; +import com.groupa1.resq.entity.enums.ESize; import com.groupa1.resq.repository.UserRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -26,6 +27,7 @@ public ResourceDto convertToDto(Resource resource){ resourceDto.setLatitude(resource.getLatitude()); resourceDto.setLongitude(resource.getLongitude()); resourceDto.setCreatedDate(resource.getCreatedAt()); + resourceDto.setSize(resource.getSize().toString()); return resourceDto; } @@ -47,6 +49,7 @@ public Resource convertToEntity(ResourceDto resourceDto){ resource.setLatitude(resourceDto.getLatitude()); resource.setLongitude(resourceDto.getLongitude()); resource.setCreatedAt(resourceDto.getCreatedDate()); + resource.setSize(ESize.valueOf(resourceDto.getSize())); return resource; } } diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/dto/EventDto.java b/resq/backend/resq/src/main/java/com/groupa1/resq/dto/EventDto.java new file mode 100644 index 00000000..242e975d --- /dev/null +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/dto/EventDto.java @@ -0,0 +1,25 @@ +package com.groupa1.resq.dto; + + +import com.groupa1.resq.entity.User; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +public class EventDto { + + private Long reporterId; + + private String description; + + private LocalDateTime reportDate; + + private boolean isVerified; + + private BigDecimal eventLatitude; + private BigDecimal eventLongitude; + + +} diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/dto/NeedDto.java b/resq/backend/resq/src/main/java/com/groupa1/resq/dto/NeedDto.java index 42d0ebbd..a148d7c1 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/dto/NeedDto.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/dto/NeedDto.java @@ -1,6 +1,7 @@ package com.groupa1.resq.dto; import com.groupa1.resq.entity.enums.ENeedStatus; +import com.groupa1.resq.entity.enums.ESize; import lombok.Data; import java.math.BigDecimal; @@ -18,4 +19,5 @@ public class NeedDto { private Long requestId; private ENeedStatus status; private LocalDateTime createdDate; + private ESize size; } diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/dto/ResourceDto.java b/resq/backend/resq/src/main/java/com/groupa1/resq/dto/ResourceDto.java index 6b8733d1..19da9b50 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/dto/ResourceDto.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/dto/ResourceDto.java @@ -17,4 +17,5 @@ public class ResourceDto { private BigDecimal latitude; private BigDecimal longitude; private LocalDateTime createdDate; + private String size; } diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Action.java b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Action.java index cdc478ec..6db223a8 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Action.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Action.java @@ -24,8 +24,7 @@ public class Action extends BaseEntity { @JoinColumn(name = "verifier_id") private User verifier; - @Lob - @Column(length = 3000) + @Column(length = 2048) private String description; private LocalDateTime dueDate; diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Event.java b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Event.java new file mode 100644 index 00000000..a16db4a9 --- /dev/null +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Event.java @@ -0,0 +1,38 @@ +package com.groupa1.resq.entity; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@NoArgsConstructor +@Entity +@Table( name = "EVENT") +@Data +@EqualsAndHashCode(callSuper = true, exclude = {"reporter"}) +@ToString(callSuper = true, exclude = {"reporter"}) +public class Event extends BaseEntity { + + //@Enumerated(EnumType.STRING) + //private EEventType eventType; + + @ManyToOne + @JoinColumn(name = "reporter_id") + private User reporter; + + @Column(length = 2048) + private String description; + + private LocalDateTime reportDate; + + private boolean isVerified; + + private BigDecimal eventLatitude; + private BigDecimal eventLongitude; + +} + diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Feedback.java b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Feedback.java index 292616e5..e8a59c8b 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Feedback.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Feedback.java @@ -24,7 +24,6 @@ public class Feedback extends BaseEntity { @JoinColumn(name = "creator_id") private User creator; - @Lob - @Column(length = 3000) + @Column(length = 2048) private String message; } diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Info.java b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Info.java index 87e0f417..c1eeaede 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Info.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Info.java @@ -21,8 +21,7 @@ public class Info extends BaseEntity{ @JoinColumn(name = "user_id") private User user; - @Lob - @Column(length = 3000) + @Column(length = 2048) private String description; private BigDecimal latitude; diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Need.java b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Need.java index f4f95d3b..acfe4f25 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Need.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Need.java @@ -1,6 +1,8 @@ package com.groupa1.resq.entity; +import com.groupa1.resq.entity.enums.EGender; import com.groupa1.resq.entity.enums.ENeedStatus; +import com.groupa1.resq.entity.enums.ESize; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -34,6 +36,13 @@ public class Need extends BaseEntity{ private BigDecimal latitude; private BigDecimal longitude; + @Enumerated(EnumType.STRING) + private EGender gender; + + // These field only for clothing + @Enumerated(EnumType.STRING) + private ESize size; + @ManyToOne @JoinColumn(name = "request_id") private Request request; diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Notification.java b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Notification.java index d31ded24..741df185 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Notification.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Notification.java @@ -21,8 +21,7 @@ public class Notification extends BaseEntity{ private String title; - @Lob - @Column(length = 1000) + @Column(length = 2048) private String body; private boolean isRead; diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Resource.java b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Resource.java index 8c288c72..d9ef1a32 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Resource.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Resource.java @@ -1,10 +1,8 @@ package com.groupa1.resq.entity; import com.groupa1.resq.entity.enums.EGender; -import jakarta.persistence.Entity; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; +import com.groupa1.resq.entity.enums.ESize; +import jakarta.persistence.*; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @@ -32,6 +30,10 @@ public class Resource extends BaseEntity { private EGender gender; + // These field only for clothing + @Enumerated(EnumType.STRING) + private ESize size; + private Integer quantity; private BigDecimal latitude; diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Task.java b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Task.java index b5f7cf3f..3c762b35 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Task.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/Task.java @@ -5,7 +5,6 @@ import jakarta.persistence.*; import lombok.Data; import lombok.EqualsAndHashCode; -import lombok.NoArgsConstructor; import lombok.ToString; import java.util.HashSet; @@ -41,8 +40,7 @@ public class Task extends BaseEntity { @Enumerated(EnumType.STRING) private EStatus status; - @Lob - @Column(length = 3000) + @Column(length = 2048) private String description; public Task() { diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/User.java b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/User.java index cf968108..97fe17bc 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/User.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/User.java @@ -18,8 +18,9 @@ @UniqueConstraint(columnNames = "email") }) @Data -@EqualsAndHashCode(callSuper = true, exclude = {"userProfile", "requests", "needs", "resourcesReceived","resourcesSent", "tasksAssigned", "tasksAssignedTo", "feedbacks", "actions", "infos", "notifications"}) -@ToString(callSuper = true, exclude = {"userProfile"}) +@EqualsAndHashCode(callSuper = true, exclude = {"userProfile", "requests", "needs", "resourcesReceived","resourcesSent", "tasksAssigned", "tasksAssignedTo", "feedbacks", "actions", "infos", "notifications", "reportedEvents"}) +@ToString(callSuper = true, exclude = {"userProfile", "requests", "needs", "resourcesReceived","resourcesSent", "tasksAssigned", "tasksAssignedTo", "feedbacks", "actions", "infos", "notifications", "reportedEvents"}) + public class User extends BaseEntity { @NotBlank @@ -46,6 +47,7 @@ public class User extends BaseEntity { @OneToOne(fetch= FetchType.LAZY, mappedBy = "user") @JsonManagedReference + private UserProfile userProfile; @OneToMany(fetch = FetchType.LAZY, mappedBy="requester") @@ -69,6 +71,9 @@ public class User extends BaseEntity { @OneToMany(fetch = FetchType.LAZY, mappedBy = "assignee") private Set tasksAssignedTo; + @OneToMany(fetch = FetchType.LAZY, mappedBy = "reporter") + private Set reportedEvents; + @OneToMany(fetch = FetchType.LAZY, mappedBy = "creator") private Set feedbacks; @@ -100,5 +105,6 @@ public User() { this.actions = new HashSet<>(); this.infos = new HashSet<>(); this.notifications = new HashSet<>(); + this.reportedEvents = new HashSet<>(); } } diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/UserProfile.java b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/UserProfile.java index 3aee42a5..03478761 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/UserProfile.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/UserProfile.java @@ -4,7 +4,9 @@ import com.groupa1.resq.entity.enums.EGender; import jakarta.persistence.*; import lombok.Data; +import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; +import lombok.ToString; import java.time.LocalDate; @@ -12,6 +14,8 @@ @Entity @Table( name = "USER_PROFILE") @Data +@EqualsAndHashCode(callSuper = true, exclude = {"user"}) +@ToString(exclude = {"user"}) public class UserProfile extends BaseEntity{ private String name; diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/enums/EEventType.java b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/enums/EEventType.java new file mode 100644 index 00000000..26650bf3 --- /dev/null +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/enums/EEventType.java @@ -0,0 +1,9 @@ +package com.groupa1.resq.entity.enums; + +public enum EEventType { + FIRE, + EARTHQUAKE, + FLOOD, + TSUNAMI, + OTHER +} diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/enums/EGender.java b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/enums/EGender.java index 9907de81..8e41e575 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/enums/EGender.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/enums/EGender.java @@ -1,6 +1,8 @@ package com.groupa1.resq.entity.enums; public enum EGender { - MALE, - FEMALE + MAN, + WOMAN, + CHILDREN_BOY, CHILDREN_GIRL, + BABY } diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/entity/enums/ESize.java b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/enums/ESize.java new file mode 100644 index 00000000..cf3ddea3 --- /dev/null +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/entity/enums/ESize.java @@ -0,0 +1,7 @@ +package com.groupa1.resq.entity.enums; + +public enum ESize { + XL, L, M, S, XS, // For Gender: MAN, WOMAN + AGE_0_2, AGE_2_4, // For Gender: BABY + AGE_4_8, AGE_8_12, AGE_12_18, // For Gender: CHILDREN_BOY, CHILDREN_GIRL +} diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/repository/EventRepository.java b/resq/backend/resq/src/main/java/com/groupa1/resq/repository/EventRepository.java new file mode 100644 index 00000000..dca54991 --- /dev/null +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/repository/EventRepository.java @@ -0,0 +1,15 @@ +package com.groupa1.resq.repository; + + +import com.groupa1.resq.entity.Event; +import com.groupa1.resq.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface EventRepository extends JpaRepository { + List findByReporter(User reporter); + +} diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/repository/RequestRepository.java b/resq/backend/resq/src/main/java/com/groupa1/resq/repository/RequestRepository.java index c2499165..abb57261 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/repository/RequestRepository.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/repository/RequestRepository.java @@ -1,9 +1,11 @@ package com.groupa1.resq.repository; +import com.groupa1.resq.entity.Need; import com.groupa1.resq.entity.Request; import com.groupa1.resq.entity.User; import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.math.BigDecimal; import java.util.List; @@ -16,6 +18,10 @@ public interface RequestRepository extends JpaRepository { List findAll(Specification specification); + //Haversine formula + @Query(value = "SELECT * FROM NEED WHERE (6371 * acos(cos(radians(?1)) * cos(radians(latitude)) * cos(radians(longitude) - radians(?2)) + sin(radians(?1)) * sin(radians(latitude)))) < ?3", nativeQuery = true) + List filterByDistance(BigDecimal longitude, BigDecimal latitude, BigDecimal distance); + } \ No newline at end of file diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/request/CreateEventRequest.java b/resq/backend/resq/src/main/java/com/groupa1/resq/request/CreateEventRequest.java new file mode 100644 index 00000000..b8f3a8a8 --- /dev/null +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/request/CreateEventRequest.java @@ -0,0 +1,23 @@ +package com.groupa1.resq.request; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + + +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CreateEventRequest { + private Long reporterId; + private String description; + + private BigDecimal eventLatitude; + private BigDecimal eventLongitude; + + //private LocalDateTime reportDate; + + + +} diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/request/CreateNeedRequest.java b/resq/backend/resq/src/main/java/com/groupa1/resq/request/CreateNeedRequest.java index 2a957ac7..e388f353 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/request/CreateNeedRequest.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/request/CreateNeedRequest.java @@ -11,4 +11,5 @@ public class CreateNeedRequest { private BigDecimal longitude; private String categoryTreeId; private Integer quantity; + private String size; } diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/request/CreateResourceRequest.java b/resq/backend/resq/src/main/java/com/groupa1/resq/request/CreateResourceRequest.java index 0393f8e9..87d1c672 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/request/CreateResourceRequest.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/request/CreateResourceRequest.java @@ -15,5 +15,5 @@ public class CreateResourceRequest { private BigDecimal latitude; private BigDecimal longitude; private EGender gender; - + private String size; } diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/request/UpdateNeedRequest.java b/resq/backend/resq/src/main/java/com/groupa1/resq/request/UpdateNeedRequest.java index e4a1e165..a4b30e93 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/request/UpdateNeedRequest.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/request/UpdateNeedRequest.java @@ -11,4 +11,5 @@ public class UpdateNeedRequest { private BigDecimal longitude; private String categoryTreeId; private Integer quantity; + private String size; } diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/response/EventResponse.java b/resq/backend/resq/src/main/java/com/groupa1/resq/response/EventResponse.java new file mode 100644 index 00000000..2045d1ca --- /dev/null +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/response/EventResponse.java @@ -0,0 +1,22 @@ +package com.groupa1.resq.response; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@Accessors(chain = true) +public class EventResponse { + private long id; + private long reporterId; + private String description; + private boolean isCompleted; + private BigDecimal eventLatitude; + private BigDecimal eventLongitude; + private LocalDateTime reportDate; + + + +} diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/security/WebSecurityConfig.java b/resq/backend/resq/src/main/java/com/groupa1/resq/security/WebSecurityConfig.java index 0deeb2b4..be6aa7b9 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/security/WebSecurityConfig.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/security/WebSecurityConfig.java @@ -8,6 +8,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -17,6 +18,11 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; @EnableWebSecurity @Configuration @@ -57,11 +63,12 @@ public PasswordEncoder passwordEncoder() { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // No need to give context-path, just give requestMapping and related Post, Get mappings etc. - http.csrf(csrf -> csrf.disable()) + http.csrf(csrf -> csrf.disable()).cors(Customizer.withDefaults()) .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth.requestMatchers("/auth/**").permitAll() + .requestMatchers("/error").permitAll() .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() .anyRequest().authenticated() ); @@ -72,4 +79,15 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http.build(); } + + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList("*")); + configuration.setAllowedMethods(Arrays.asList("*")); + configuration.setAllowedHeaders(Arrays.asList("*")); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } } diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/service/EventService.java b/resq/backend/resq/src/main/java/com/groupa1/resq/service/EventService.java new file mode 100644 index 00000000..396993aa --- /dev/null +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/service/EventService.java @@ -0,0 +1,94 @@ +package com.groupa1.resq.service; + +import com.fasterxml.jackson.databind.deser.std.NumberDeserializers; +import com.groupa1.resq.dto.EventDto; +import com.groupa1.resq.dto.NeedDto; +import com.groupa1.resq.entity.Event; +import com.groupa1.resq.entity.Event; +import com.groupa1.resq.entity.Task; +import com.groupa1.resq.entity.User; +import com.groupa1.resq.exception.EntityNotFoundException; +import com.groupa1.resq.repository.EventRepository; +import com.groupa1.resq.repository.UserRepository; +import com.groupa1.resq.request.CreateEventRequest; +import com.groupa1.resq.request.CreateEventRequest; +import com.groupa1.resq.response.EventResponse; +import com.groupa1.resq.response.EventResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.RequestBody; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Service +@Slf4j +public class EventService { + + @Autowired + private EventRepository eventRepository; + + @Autowired + private UserRepository userRepository; + + + public ResponseEntity createEvent(CreateEventRequest createEventRequest) { + + // creating event for a user + Event eventEntity = new Event(); + User reporter = userRepository.findById(createEventRequest.getReporterId()).orElseThrow(()-> new EntityNotFoundException("No user found")); + String description = createEventRequest.getDescription(); + BigDecimal startLatitude = createEventRequest.getEventLatitude(); + BigDecimal startLongitude =createEventRequest.getEventLongitude(); + eventEntity.setReporter(reporter); + eventEntity.setVerified(false); // default + eventEntity.setDescription(description); + eventEntity.setEventLatitude(startLatitude); + eventEntity.setEventLongitude(startLongitude); + eventEntity.setCreatedAt(LocalDateTime.now()); + eventEntity.setModifiedAt(LocalDateTime.now()); + eventEntity.setReportDate(LocalDateTime.now()); + + + eventRepository.save(eventEntity); + return ResponseEntity.ok("Event saved successfully!"); + + } + + public ResponseEntity> viewEvents(Long reporterId){ + User reporter = userRepository.findById(reporterId).orElseThrow(()-> new EntityNotFoundException("No user found")); + + List events = eventRepository.findByReporter(reporter); + + List eventResponses = new ArrayList<>(); +// Set events = reporter.getReportedEvents(); + + + System.out.println(reporter); + System.out.println(reporter.getReportedEvents()); + events.forEach(event -> { + EventResponse eventResponse = new EventResponse(); + eventResponse.setId(event.getId()) + .setReporterId(event.getReporter().getId()) + .setDescription(event.getDescription()) + .setCompleted(event.isVerified()) + .setEventLatitude(event.getEventLatitude()) + .setEventLongitude(event.getEventLongitude()) + .setReportDate(LocalDateTime.now()); + eventResponses.add(eventResponse); + }); + System.out.println(eventResponses.get(0)); + return ResponseEntity.ok(eventResponses); + } + + + + +} diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/service/NeedService.java b/resq/backend/resq/src/main/java/com/groupa1/resq/service/NeedService.java index 975d4b9f..03c4899b 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/service/NeedService.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/service/NeedService.java @@ -5,6 +5,7 @@ import com.groupa1.resq.entity.Need; import com.groupa1.resq.entity.User; import com.groupa1.resq.entity.enums.ENeedStatus; +import com.groupa1.resq.entity.enums.ESize; import com.groupa1.resq.exception.EntityNotFoundException; import com.groupa1.resq.exception.NotOwnerException; import com.groupa1.resq.repository.NeedRepository; @@ -34,6 +35,17 @@ public class NeedService { @Autowired NeedConverter needConverter; + public void setNeedConverter(NeedConverter needConverter) { + this.needConverter = needConverter; + } + + public void setUserRepository(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public void setNeedRepository(NeedRepository needRepository) { + this.needRepository = needRepository; + } public Long save(Long userId, CreateNeedRequest createNeedRequest) { User requester = userRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException("User not found")); @@ -44,6 +56,7 @@ public Long save(Long userId, CreateNeedRequest createNeedRequest) { need.setLatitude(createNeedRequest.getLatitude()); need.setQuantity(createNeedRequest.getQuantity()); need.setCategoryTreeId(createNeedRequest.getCategoryTreeId()); + need.setSize(ESize.valueOf(createNeedRequest.getSize())); need.setStatus(ENeedStatus.NOT_INVOLVED); return needRepository.save(need).getId(); @@ -91,18 +104,19 @@ public ResponseEntity update (UpdateNeedRequest updateNeedRequest, Long need.setLatitude(updateNeedRequest.getLatitude()); need.setQuantity(updateNeedRequest.getQuantity()); need.setCategoryTreeId(updateNeedRequest.getCategoryTreeId()); + need.setSize(ESize.valueOf(updateNeedRequest.getSize())); needRepository.save(need); return ResponseEntity.ok("Need updated successfully"); } - public ResponseEntity> viewNeedsByFilter(BigDecimal longitude, BigDecimal latitude, String categoryTreeId, Long userId) { + public ResponseEntity> viewNeedsByFilter(BigDecimal longitude1, BigDecimal latitude1, BigDecimal longitude2, BigDecimal latitude2, String categoryTreeId, Long userId) { Specification spec = Specification.where(null); - if (longitude != null && latitude != null) { - spec = spec.and(NeedSpecifications.hasLongitude(longitude)); - spec = spec.and(NeedSpecifications.hasLatitude(latitude)); + if (longitude1 != null && latitude1 != null && longitude2 != null && latitude2 != null) { + spec = spec.and(NeedSpecifications.isWithinRectangleScope(longitude1, longitude2, latitude1, latitude2)); + } if (categoryTreeId != null) { spec = spec.and(NeedSpecifications.hasCategoryTreeId(categoryTreeId)); diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/service/RequestService.java b/resq/backend/resq/src/main/java/com/groupa1/resq/service/RequestService.java index 8971819f..249c9605 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/service/RequestService.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/service/RequestService.java @@ -1,6 +1,7 @@ package com.groupa1.resq.service; import com.groupa1.resq.converter.RequestConverter; +import com.groupa1.resq.dto.NeedDto; import com.groupa1.resq.dto.RequestDto; import com.groupa1.resq.entity.Need; import com.groupa1.resq.entity.Request; @@ -20,6 +21,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.jpa.domain.Specification; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import java.math.BigDecimal; @@ -46,6 +48,22 @@ public class RequestService { @Autowired RequestConverter requestConverter; + public void setNeedRepository(NeedRepository needRepository) { + this.needRepository = needRepository; + } + + public void setUserRepository(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public void setRequestRepository(RequestRepository requestRepository) { + this.requestRepository = requestRepository; + } + + public void setNotificationService(NotificationService notificationService) { + this.notificationService = notificationService; + } + public Long save(Long userId, CreateReqRequest createReqRequest) { User requester = userRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException("User not found")); Request request = new Request(); @@ -76,13 +94,12 @@ public List viewAllRequests() { return requestRepository.findAll().stream().map(request -> requestConverter.convertToDto(request)).toList(); } - public List viewRequestsByFilter(BigDecimal longitude, BigDecimal latitude, EStatus status, EUrgency urgency, Long userId) { + public List viewRequestsByFilter(BigDecimal longitude1, BigDecimal latitude1, BigDecimal longitude2, BigDecimal latitude2, EStatus status, EUrgency urgency, Long userId) { Specification spec = Specification.where(null); - if (longitude != null && latitude != null) { - spec = spec.and(RequestSpecifications.hasLongitude(longitude)); - spec = spec.and(RequestSpecifications.hasLatitude(latitude)); + if (longitude1 != null && latitude1 != null && longitude2 != null && latitude2 != null) { + spec = spec.and(RequestSpecifications.isWithinRectangleScope(longitude1, longitude2, latitude1, latitude2)); } if (status != null) { spec = spec.and(RequestSpecifications.hasStatus(status)); @@ -120,4 +137,10 @@ public void deleteRequest(Long userId, Long needId) { requestRepository.deleteById(needId); } + public ResponseEntity> filterByDistance(BigDecimal longitude, + BigDecimal latitude, + BigDecimal distance) { + return ResponseEntity.ok(requestRepository.filterByDistance(longitude, latitude, distance).stream().map(request -> requestConverter.convertToDto(request)).toList()); + } + } \ No newline at end of file diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/service/ResourceService.java b/resq/backend/resq/src/main/java/com/groupa1/resq/service/ResourceService.java index ce6eebc6..b1dce64d 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/service/ResourceService.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/service/ResourceService.java @@ -4,6 +4,7 @@ import com.groupa1.resq.dto.ResourceDto; import com.groupa1.resq.entity.Resource; import com.groupa1.resq.entity.User; +import com.groupa1.resq.entity.enums.ESize; import com.groupa1.resq.exception.EntityNotFoundException; import com.groupa1.resq.repository.ResourceRepository; import com.groupa1.resq.request.CreateResourceRequest; @@ -54,6 +55,7 @@ public ResponseEntity createResource(CreateResourceRequest createResourc resource.setLatitude(createResourceRequest.getLatitude()); resource.setQuantity(createResourceRequest.getQuantity()); resource.setCategoryTreeId(createResourceRequest.getCategoryTreeId()); + resource.setSize(ESize.valueOf(createResourceRequest.getSize())); Long resourceId = resourceRepository.save(resource).getId(); return ResponseEntity.ok(resourceId); } @@ -64,6 +66,7 @@ public ResponseEntity updateResource(CreateResourceRequest createResourc resource.setLatitude(createResourceRequest.getLatitude()); resource.setLongitude(createResourceRequest.getLongitude()); resource.setCategoryTreeId(createResourceRequest.getCategoryTreeId()); + resource.setSize(ESize.valueOf(createResourceRequest.getSize())); resourceRepository.save(resource); return ResponseEntity.ok("Resource updated successfully"); } diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/service/UserProfileService.java b/resq/backend/resq/src/main/java/com/groupa1/resq/service/UserProfileService.java index 299b4b5d..28f11229 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/service/UserProfileService.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/service/UserProfileService.java @@ -37,8 +37,8 @@ public class UserProfileService { - public void saveProfile(UserProfile userProfile) { - userProfileRepository.save(userProfile); + public UserProfile saveProfile(UserProfile userProfile) { + return userProfileRepository.save(userProfile); } public String updateProfile(Long userId, ProfileDto profileDto) diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/specification/NeedSpecifications.java b/resq/backend/resq/src/main/java/com/groupa1/resq/specification/NeedSpecifications.java index 2f692bd2..502c46c4 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/specification/NeedSpecifications.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/specification/NeedSpecifications.java @@ -37,6 +37,18 @@ public static Specification hasRequester(Long requesterId) { criteriaBuilder.equal(root.get("requester").get("id"), requesterId); } + public static Specification isWithinRectangleScope(BigDecimal longitude1, BigDecimal longitude2, + BigDecimal latitude1, BigDecimal latitude2) { + return (root, query, criteriaBuilder) -> + criteriaBuilder.and( + criteriaBuilder.between(root.get("longitude"), longitude1, longitude2), + criteriaBuilder.between(root.get("latitude"), latitude1, latitude2) + ); + } + + + + diff --git a/resq/backend/resq/src/main/java/com/groupa1/resq/specification/RequestSpecifications.java b/resq/backend/resq/src/main/java/com/groupa1/resq/specification/RequestSpecifications.java index ebb59eab..8fe88eb0 100644 --- a/resq/backend/resq/src/main/java/com/groupa1/resq/specification/RequestSpecifications.java +++ b/resq/backend/resq/src/main/java/com/groupa1/resq/specification/RequestSpecifications.java @@ -1,5 +1,6 @@ package com.groupa1.resq.specification; +import com.groupa1.resq.entity.Need; import com.groupa1.resq.entity.Request; import com.groupa1.resq.entity.enums.EStatus; import com.groupa1.resq.entity.enums.EUrgency; @@ -34,6 +35,18 @@ public static Specification hasRequester(Long requesterId) { criteriaBuilder.equal(root.get("requester").get("id"), requesterId); } + public static Specification isWithinRectangleScope(BigDecimal longitude1, BigDecimal longitude2, + BigDecimal latitude1, BigDecimal latitude2) { + return (root, query, criteriaBuilder) -> + criteriaBuilder.and( + criteriaBuilder.between(root.get("longitude"), longitude1, longitude2), + criteriaBuilder.between(root.get("latitude"), latitude1, latitude2) + ); + } + + + + diff --git a/resq/backend/resq/src/test/java/com/groupa1/resq/service/AuthServiceTest.java b/resq/backend/resq/src/test/java/com/groupa1/resq/service/AuthServiceTest.java index aaf87766..b8777489 100644 --- a/resq/backend/resq/src/test/java/com/groupa1/resq/service/AuthServiceTest.java +++ b/resq/backend/resq/src/test/java/com/groupa1/resq/service/AuthServiceTest.java @@ -2,6 +2,7 @@ import com.groupa1.resq.auth.UserDetailsImpl; import com.groupa1.resq.entity.User; +import com.groupa1.resq.entity.UserProfile; import com.groupa1.resq.entity.enums.EUserRole; import com.groupa1.resq.request.LoginUserRequest; import com.groupa1.resq.request.RegisterUserRequest; @@ -39,6 +40,9 @@ class AuthServiceTest { @Mock private AuthenticationManager authenticationManager; + @Mock + private UserProfileService userProfileService; + @Mock private PasswordEncoder encoder; @@ -75,11 +79,17 @@ void testSignup_ifEmailNotAlreadyInUse_success() { mockUser.setEmail("test-email"); mockUser.setPassword(encodedPassword); + UserProfile mockUserProfile = new UserProfile(); + mockUserProfile.setName("test-name"); + mockUserProfile.setSurname("test-surname"); + mockUserProfile.setUser(mockUser); + mockUser.setRoles(Set.of(EUserRole.VICTIM)); // when when(userService.existsByEmail("test-email")).thenReturn(false); when(encoder.encode("test-password")).thenReturn(encodedPassword); + when(userProfileService.saveProfile(mockUserProfile)).thenReturn(mockUserProfile); when(userService.save(mockUser)).thenReturn(mockUser); // then diff --git a/resq/backend/resq/src/test/java/com/groupa1/resq/service/NeedServiceTest.java b/resq/backend/resq/src/test/java/com/groupa1/resq/service/NeedServiceTest.java index 3475e332..6f62919b 100644 --- a/resq/backend/resq/src/test/java/com/groupa1/resq/service/NeedServiceTest.java +++ b/resq/backend/resq/src/test/java/com/groupa1/resq/service/NeedServiceTest.java @@ -1,17 +1,32 @@ package com.groupa1.resq.service; +import com.groupa1.resq.converter.NeedConverter; +import com.groupa1.resq.dto.NeedDto; import com.groupa1.resq.entity.Need; +import com.groupa1.resq.entity.Request; import com.groupa1.resq.entity.User; +import com.groupa1.resq.entity.enums.ENeedStatus; +import com.groupa1.resq.entity.enums.ENotificationEntityType; +import com.groupa1.resq.entity.enums.EStatus; +import com.groupa1.resq.entity.enums.EUrgency; import com.groupa1.resq.repository.NeedRepository; +import com.groupa1.resq.repository.RequestRepository; import com.groupa1.resq.repository.UserRepository; import com.groupa1.resq.request.CreateNeedRequest; +import com.groupa1.resq.request.CreateReqRequest; import com.groupa1.resq.request.UpdateNeedRequest; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.jpa.domain.Specification; + +import java.math.BigDecimal; +import java.util.ArrayList; import java.util.Optional; +import java.util.Set; + import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -26,16 +41,28 @@ public class NeedServiceTest { @Mock private UserRepository userRepository; + @Test void test_save() { // Given + Long userId = 1L; CreateNeedRequest createNeedRequest = new CreateNeedRequest(); - User user = new User(); - when(userRepository.findById(any(Long.class))).thenReturn(Optional.of(user)); + User requester = new User(); + Need need = new Need(); + NeedService needService = new NeedService(); + UserRepository userRepository = mock(UserRepository.class); + NeedRepository needRepository = mock(NeedRepository.class); + need.setRequester(requester); + needService.setUserRepository(userRepository); + needService.setNeedRepository(needRepository); + when(userRepository.findById(userId)).thenReturn(Optional.of(requester)); + when(needRepository.save(any())).thenReturn(need); // when - needService.save(1L, createNeedRequest); + needService.save(userId, createNeedRequest); + // then + verify(userRepository, times(1)).findById(userId); verify(needRepository, times(1)).save(any()); } @@ -64,9 +91,15 @@ void test_view_need() { // Given User user = new User(); Need need = new Need(); + NeedConverter needConverterMock = mock(NeedConverter.class); + NeedService needService = new NeedService(); + needService.setNeedConverter(needConverterMock); + needService.setUserRepository(userRepository); + needService.setNeedRepository(needRepository); when(userRepository.findById(any(Long.class))).thenReturn(Optional.of(user)); when(needRepository.findById(any(Long.class))).thenReturn(Optional.of(need)); need.setRequester(user); + when(needConverterMock.convertToDto(any(Need.class))).thenReturn(new NeedDto()); // when needService.viewNeed(1L, 1L); // then @@ -101,6 +134,36 @@ void test_update() { verify(needRepository, times(1)).save(any()); } + @Test + void test_view_needs_by_filter() { + // Given + User user = new User(); + when(userRepository.findById(any(Long.class))).thenReturn(Optional.of(user)); + // when + needService.viewNeedsByFilter(BigDecimal.valueOf(1), BigDecimal.valueOf(1), BigDecimal.valueOf(1), BigDecimal.valueOf(1),"1", 1L); + // then + verify(needRepository, times(1)).findAll(any(Specification.class)); + } + + @Test + void test_cancel_need() { + // Given + Need need = new Need(); + when(needRepository.findById(any(Long.class))).thenReturn(Optional.of(need)); + // when + needService.cancelNeed(1L); + // then + verify(needRepository, times(1)).save(any()); + } + + @Test + void test_filter_by_distance() { + // Given + // when + needService.filterByDistance(BigDecimal.valueOf(1), BigDecimal.valueOf(1), BigDecimal.valueOf(1)); + // then + verify(needRepository, times(1)).filterByDistance(any(), any(), any()); + } diff --git a/resq/backend/resq/src/test/java/com/groupa1/resq/service/RequestServiceTest.java b/resq/backend/resq/src/test/java/com/groupa1/resq/service/RequestServiceTest.java index 671e34a2..c0915130 100644 --- a/resq/backend/resq/src/test/java/com/groupa1/resq/service/RequestServiceTest.java +++ b/resq/backend/resq/src/test/java/com/groupa1/resq/service/RequestServiceTest.java @@ -1,7 +1,12 @@ package com.groupa1.resq.service; +import com.groupa1.resq.entity.Need; import com.groupa1.resq.entity.User; import com.groupa1.resq.entity.Request; +import com.groupa1.resq.entity.enums.ENotificationEntityType; +import com.groupa1.resq.entity.enums.EStatus; +import com.groupa1.resq.entity.enums.EUrgency; +import com.groupa1.resq.repository.NeedRepository; import com.groupa1.resq.repository.RequestRepository; import com.groupa1.resq.repository.UserRepository; import com.groupa1.resq.request.CreateReqRequest; @@ -12,7 +17,12 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashSet; import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.*; import org.springframework.data.jpa.domain.Specification; @@ -31,14 +41,40 @@ public class RequestServiceTest { @Test void test_save() { // Given + Long userId = 1L; CreateReqRequest createReqRequest = new CreateReqRequest(); - User user = new User(); - when(userRepository.findById(any(Long.class))).thenReturn(Optional.of(user)); + User requester = new User(); + Request request = new Request(); + Need need = new Need(); + RequestService requestService = new RequestService(); + UserRepository userRepository = mock(UserRepository.class); + NeedRepository needRepository = mock(NeedRepository.class); + RequestRepository requestRepository = mock(RequestRepository.class); + NotificationService notificationService = mock(NotificationService.class); + need.setRequester(requester); + requestService.setUserRepository(userRepository); + requestService.setNeedRepository(needRepository); + requestService.setRequestRepository(requestRepository); + requestService.setNotificationService(notificationService); + when(userRepository.findById(userId)).thenReturn(Optional.of(requester)); + when(requestRepository.save(any())).thenReturn(request); + when(needRepository.findAllById(any())).thenReturn(new ArrayList<>(Set.of(need))); // when - requestService.save(1L, createReqRequest); + Long result = requestService.save(userId, createReqRequest); + // then + verify(userRepository, times(1)).findById(userId); verify(requestRepository, times(1)).save(any()); + verify(needRepository, times(1)).findAllById(any()); + verify(needRepository, times(1)).saveAll(any()); + verify(notificationService, times(1)).sendNotification( + eq("Request Created"), + anyString(), + eq(requester.getId()), + eq(result), + eq(ENotificationEntityType.REQUEST) + ); } @Test @@ -56,7 +92,7 @@ void test_viewRequestsByFilter() { User user = new User(); when(userRepository.findById(any(Long.class))).thenReturn(Optional.of(user)); // when - requestService.viewRequestsByFilter(BigDecimal.valueOf(1), BigDecimal.valueOf(1), null, null, 1L); + requestService.viewRequestsByFilter(BigDecimal.valueOf(1), BigDecimal.valueOf(1), BigDecimal.valueOf(1), BigDecimal.valueOf(1), EStatus.valueOf("DONE"), EUrgency.valueOf("MEDIUM"), 1L); // then verify(requestRepository, times(1)).findAll(any(Specification.class)); } @@ -90,5 +126,14 @@ void test_deleteRequest() { verify(requestRepository, times(1)).deleteById(any()); } + @Test + void test_filterByDistance() { + // Given + // when + requestService.filterByDistance(BigDecimal.valueOf(1), BigDecimal.valueOf(1), BigDecimal.valueOf(1)); + // then + verify(requestRepository, times(1)).filterByDistance(any(), any(), any()); + } + } diff --git a/resq/frontend/Dockerfile b/resq/frontend/Dockerfile new file mode 100644 index 00000000..eca7b43e --- /dev/null +++ b/resq/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:18-alpine + +WORKDIR /react/ + +COPY ./public/ /react/public +COPY ./src/ /react/src +COPY package.json /react/ + +RUN npm install + +ENTRYPOINT npm start diff --git a/resq/frontend/package.json b/resq/frontend/package.json index cb349cf0..ab66d1b3 100644 --- a/resq/frontend/package.json +++ b/resq/frontend/package.json @@ -7,15 +7,15 @@ "@emotion/styled": "^11.11.0", "@fontsource/inter": "^5.0.15", "@mui/icons-material": "^5.14.14", - "@mui/joy": "^5.0.0-beta.15", "@mui/material": "^5.14.14", "@mui/styled-engine": "^5.14.14", "@mui/x-data-grid": "^6.18.1", "@mui/x-date-pickers": "^6.18.2", + "@tanstack/react-query": "^5.8.7", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", - "axios": "^1.6.0", + "axios": "^1.6.2", "bootstrap": "^5.3.2", "dayjs": "^1.11.10", "next": "^14.0.0", diff --git a/resq/frontend/src/App.js b/resq/frontend/src/App.js index ad52daf0..74c957c1 100644 --- a/resq/frontend/src/App.js +++ b/resq/frontend/src/App.js @@ -1,16 +1,22 @@ import React, {useEffect, useRef, useState} from 'react'; import {BrowserRouter as Router, Routes, Route, Navigate} from "react-router-dom"; import {Navbar, Container, Nav} from 'react-bootstrap'; -import UserRoles from "./pages/UserRoles"; import SignIn from "./pages/SignIn"; import SignUp from "./pages/SignUp"; -import MapDemo from "./pages/MapDemo"; import Account from "./pages/Account"; import RoleRequest from "./pages/RoleRequest"; import LogoutIcon from '@mui/icons-material/Logout'; -import Request from "./pages/RequestCreation"; +import NotificationsIcon from '@mui/icons-material/Notifications'; +import Request from "./components/Request/RequestCreation"; +import Resource from "./components/Resource/ResourceCreation"; import {LocalizationProvider} from "@mui/x-date-pickers"; import {AdapterDayjs} from "@mui/x-date-pickers/AdapterDayjs"; +import {QueryClient, QueryClientProvider} from "@tanstack/react-query"; +import axios from "axios"; +import VictimMapPage from "./pages/VictimMapPage"; +import {Badge} from "@mui/material"; +import {createTheme, ThemeProvider} from "@mui/material/styles"; +import Notifications from "./pages/Notifications"; const SmallRedCircle = () => +const queryClient = new QueryClient() + function App() { const [token, _setToken] = useState(localStorage.getItem("token")) const [role, setRole] = useState("") @@ -34,19 +42,28 @@ function App() { return () => window.removeEventListener("resize", updateDimensions); }, []); + useEffect(() => { + axios.defaults.headers.common['Authorization'] = `Bearer ${token}` + }, [token]) + + const [notifications, setNotifications] = useState([ + { + title: "Ongoing fire on Cami Sk.", + desc: "A fire has been reported on Rumeli Hisarı Mh, Cami Sk, Sarıyer/İstanbul.", + read: false + }, { + title: "New Soup Kitchen Near You", + desc: "A soup kitchen has opened on Etiler, Hisar Üstü Nispetiye Cd No:1, Beşiktaş/İstanbul.", + read: true + } + ]) const setToken = t => { localStorage.setItem("token", t || "") _setToken(t) } const navLinks = [ - {path: '/map', label: Map Demo, component: MapDemo, icon: }, - token && { - path: '/userroles', - label: User Roles, - component: UserRoles, - icon: - }, + {path: '/map', label: Victim Map, component: VictimMapPage, icon: }, (role === "responder") && { path: '/responder', label: Responder Panel, @@ -61,85 +78,128 @@ function App() { const ref = useRef(null) + const theme = createTheme({ + palette: { + primary: { + main: "#ff0000" + }, + secondary: { + main: '#00f0ff' + } + } + }); + return ( - - -
- - - - - ResQ - - - - - - - - -
- - {navLinks.map(({path, component}) => ( - - ))} - }/> - - { - token ? <> - - - - : <> - - - - } - -
-
-
-
- ); + + + + + + + ); } export default App; \ No newline at end of file diff --git a/resq/frontend/src/AppService.js b/resq/frontend/src/AppService.js index d7d66d97..e7c33ccb 100644 --- a/resq/frontend/src/AppService.js +++ b/resq/frontend/src/AppService.js @@ -1,4 +1,5 @@ import axios from 'axios'; +import {RootNode} from "./CategoryTree"; const API_BASE_URL = 'https://api.resq.org.tr' const USER_API_BASE_URL = API_BASE_URL + '/resq/api/v1/user'; @@ -19,8 +20,13 @@ export function postRequestRole(userId, role) { return axios.post(`${USER_API_BASE_URL}/requestRole`, requestBody); } -export function getUserInfo(userId) { - return axios.get(`${USER_API_BASE_URL}/getUserInfo?userId=${userId}`); +export async function getUserInfo(userId) { + const {data} = await axios.get(`${USER_API_BASE_URL}/getUserInfo?userId=${userId}`, { + headers: { + "X-Selected-Role": "VICTIM" + }, + }); + return data } export function getAllAccess() { @@ -76,7 +82,7 @@ export function getSubCategoryByName(name) { } export function viewNeedsByFilter(filterParams) { - return axios.get(`${NEED_API_BASE_URL}/viewNeedsByFilter`, {params: filterParams}); + return axios.get(`${NEED_API_BASE_URL}/viewNeedsByFilter`, { params: filterParams }); } export function createNeed(userId, createNeedRequest) { @@ -120,7 +126,29 @@ export function viewAllRequests() { } export function createResource(createResourceRequest) { - return axios.post(`${RESOURCE_API_BASE_URL}/createResource`, createResourceRequest); + return axios.post(`${RESOURCE_API_BASE_URL}/createResource`, createResourceRequest, { + headers: { + "X-Selected-Role": "RESPONDER" + }, + }); +} + +export function getAllResources() { + return axios.get(`${RESOURCE_API_BASE_URL}/filterByDistance?latitude=39.5&longitude=34.5&distance=10000`, { + headers: { + "X-Selected-Role": "COORDINATOR" + }, + }); +} + +export async function getCategoryTree() { + const {data} = await axios.get(`${CATEGORY_API_BASE_URL}/getMainCategories`, { + headers: { + "X-Selected-Role": "VICTIM" + }, + }); + + return new RootNode(data) } export function createTask(createTaskRequest) { diff --git a/resq/frontend/src/CategoryTree.js b/resq/frontend/src/CategoryTree.js new file mode 100644 index 00000000..3585cc00 --- /dev/null +++ b/resq/frontend/src/CategoryTree.js @@ -0,0 +1,64 @@ +export class Node { + id + data + children + parent + + constructor(id, data, children, parent) { + this.id = id + this.data = data + this.children = children + this.parent = parent + } + + getAllParentCategories() { + if (this.parent.data === "") + return [this] + return [this, ...this.parent.getAllParentCategories()] + } + + findCategoryWithId(id) { + if (this.id === id) { + return this + } else { + for (const child of this.children) { + let cat = child.findCategoryWithId(id) + if (cat) + return cat + } + return null + } + } + + isChildCategory(id) { + if (this.id === id) { + return true + } else { + return !this.children.every(child => !child.isChildCategory(id)) + } + } + + getLeafCategories() { + if (this.children.length === 0) { + return [this] + } else { + return this.children.map(e => e.getLeafCategories()).flat() + } + } + + static createFromArray(json, parent) { + return json.map(item => { + const children = [] + const node = new Node(item.id, item.data, children, parent) + children.push(...Node.createFromArray(item.children, node)) + return node + }) + } +} + +export class RootNode extends Node { + constructor(json) { + super("-1", "", [], null); + this.children.push(...Node.createFromArray(json, this)) + } +} \ No newline at end of file diff --git a/resq/frontend/src/components/AnnotationCard.js b/resq/frontend/src/components/AnnotationCard.js new file mode 100644 index 00000000..f567cbae --- /dev/null +++ b/resq/frontend/src/components/AnnotationCard.js @@ -0,0 +1,83 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Typography, Button, Chip, Dialog, DialogTitle, DialogContent, DialogContentText } from '@mui/material'; +import reverseGeocode from './Geolocation'; + +const AnnotationCard = ({ annotation }) => { + const [locationName, setLocationName] = useState('Unknown Location'); + const [open, setOpen] = useState(false); + const [longDescription, setLongDescription] = useState(''); + + useEffect(() => { + if (annotation.latitude && annotation.longitude) { + reverseGeocode(annotation.latitude, annotation.longitude) + .then((name) => setLocationName(name)) + .catch((error) => console.error('Error fetching location name:', error)); + } + }, [annotation.latitude, annotation.longitude]); + + const handleViewMore = () => { + setLongDescription(annotation.long_description || 'Long description not available.'); + setOpen(true); + }; + + + const handleClose = () => { + setOpen(false); + }; + + const LongDescriptionDialog = () => ( + + {annotation.title} + + + {longDescription || 'Long description not available.'} + + + + ); + + + return ( + + {annotation.title} + + {annotation.category} + + {annotation.short_description} + + {/* Date and Location Data */} + {annotation.date && ( + Date: {new Date(annotation.date).toLocaleDateString()} + )} + {locationName !== 'Unknown Location' && ( + Location: {locationName} + )} + + {/* Additional Metadata */} + {annotation.additionalMetadata && ( + + {Object.entries(annotation.additionalMetadata).map(([key, value]) => ( + + ))} + + )} + + + + {/* Long Description Dialog */} + + + ); +}; + +export default AnnotationCard; diff --git a/resq/frontend/src/components/Cards/AnnotationCard.js b/resq/frontend/src/components/Cards/AnnotationCard.js new file mode 100644 index 00000000..da3a7c15 --- /dev/null +++ b/resq/frontend/src/components/Cards/AnnotationCard.js @@ -0,0 +1,82 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Typography, Button, Chip, Dialog, DialogTitle, DialogContent, DialogContentText } from '@mui/material'; +import reverseGeocode from '../Geolocation'; + +const AnnotationCard = ({ item }) => { + const [locationName, setLocationName] = useState('Unknown Location'); + const [open, setOpen] = useState(false); // Added for Dialog control + const [longDescription, setLongDescription] = useState(''); // State to hold long description + + useEffect(() => { + if (item.latitude && item.longitude) { + reverseGeocode(item.latitude, item.longitude) + .then((name) => setLocationName(name)) + .catch((error) => console.error('Error fetching location name:', error)); + } + }, [item.latitude, item.longitude]); + + const handleViewMore = () => { + setLongDescription(item.long_description || 'Long description not available.'); + setOpen(true); // Open the dialog + }; + + + const handleClose = () => { + setOpen(false); // Close the dialog + }; + + // Dialog component to show long description + const LongDescriptionDialog = () => ( + + {item.title} + + + {longDescription || 'Long description not available.'} + + + + ); + + return ( + + {item.title} + + {item.category} + + {item.short_description} + + {/* Date and Location Data */} + {item.date && ( + Date: {new Date(item.date).toLocaleDateString()} + )} + {locationName !== 'Unknown Location' && ( + Location: {locationName} + )} + + {/* Additional Metadata */} + {item.additionalMetadata && ( + + {Object.entries(item.additionalMetadata).map(([key, value]) => ( + + ))} + + )} + + + + {/* Long Description Dialog */} + + + ); +}; + +export default AnnotationCard; diff --git a/resq/frontend/src/components/ListCards.js b/resq/frontend/src/components/Cards/ListCards.js similarity index 57% rename from resq/frontend/src/components/ListCards.js rename to resq/frontend/src/components/Cards/ListCards.js index a89cb5ce..f9c5400c 100644 --- a/resq/frontend/src/components/ListCards.js +++ b/resq/frontend/src/components/Cards/ListCards.js @@ -3,11 +3,13 @@ import {useEffect, useState} from "react"; import axios from "axios"; import {Card, CardActions, CardContent, CardHeader, Collapse, IconButton} from "@mui/material"; import Avatar from "@mui/material/Avatar"; -import {type_colors} from "../Colors"; +import {type_colors} from "../../Colors"; import Typography from "@mui/material/Typography"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import styled from "styled-components"; -import {AnnotationIcon} from "./MapIcons"; +import {useQuery} from "@tanstack/react-query"; +import {getCategoryTree, getUserInfo} from "../../AppService"; +import AnnotationCard from "./AnnotationCard"; const ExpandMore = styled(IconButton)` transform: ${({expand}) => !expand ? 'rotate(0deg)' : 'rotate(180deg)'}; @@ -33,53 +35,6 @@ async function getAddress(latitude, longitude) { } } - -export const AnnotationCard = ({item: {title, short_description, long_description, latitude, longitude, category, date}}) => { - const [expanded, setExpanded] = useState(false); - const [locationName, setLocationName] = useState(''); - - useEffect(() => { - (async () => setLocationName(await getAddress(latitude, longitude)))(); - }, [latitude, longitude]); - - return - - - - } - titleTypographyProps={{variant: 'h6'}} - title={title} - /> - - - {short_description} - - - Location: {`${locationName}`} - - - - setExpanded(!expanded)} - aria-expanded={expanded} - aria-label="show more" - > - - - - - - Added on: {date} -
- {long_description} -
-
-
; -} - export const RequestCard = ({item: {requester, urgency, needs, status, longitude, latitude}}) => { const [expanded, setExpanded] = useState(false); const [locationName, setLocationName] = useState(''); @@ -140,8 +95,13 @@ export const RequestCard = ({item: {requester, urgency, needs, status, longitude } -export const ResourceCard = ({item: {owner, urgency, resources, status, longitude, latitude}}) => { - const [expanded, setExpanded] = useState(false); +export const ResourceCard = ({item: {senderId, quantity, categoryTreeId, longitude, latitude}}) => { + const categoryTree = useQuery({ + queryKey: ['categoryTree'], + queryFn: () => getCategoryTree() + }) + const owner = useQuery({queryKey: ['user', senderId], queryFn: () => getUserInfo(senderId)}) + const [locationName, setLocationName] = useState(''); useEffect(() => { @@ -157,45 +117,16 @@ export const ResourceCard = ({item: {owner, urgency, resources, status, longitud } titleTypographyProps={{variant: 'h6'}} - title={resources.map(({name, quantity}) => `${quantity} ${name}`).join(", ")} + title={`${quantity} ${categoryTree?.data?.findCategoryWithId(parseInt(categoryTreeId))?.data || categoryTreeId}`} /> - - Urgency: {urgency} | Status: {status} - - Owner: {owner.name} {owner.surname} + Owner: {owner?.data?.name} {owner?.data?.surname} Location: {`${locationName}`} - - {/* - - - - - */} - setExpanded(!expanded)} - aria-expanded={expanded} - aria-label="show more" - > - - - - - - {resources.map(({name, description, quantity}) => - - {quantity} {name}: {description} - - )} - - ; } export const cards = { diff --git a/resq/frontend/src/components/DisasterMap.js b/resq/frontend/src/components/DisasterMap.js index 7447897f..cd364b76 100644 --- a/resq/frontend/src/components/DisasterMap.js +++ b/resq/frontend/src/components/DisasterMap.js @@ -1,23 +1,20 @@ -import * as React from 'react'; -import {useState} from 'react'; -import {Map, Marker, ZoomControl} from 'pigeon-maps'; -import {type_colors} from "../Colors"; -import {AnnotationIcon, MarkerIcon} from "./MapIcons"; +import React, { useState } from 'react'; +import { Map, Marker, ZoomControl } from 'pigeon-maps'; +import { type_colors } from "../Colors"; +import { AnnotationIcon, MarkerIcon } from "./MapIcons"; const MAPBOX_TOKEN = "pk.eyJ1IjoiaWxnYXplciIsImEiOiJjbG80Nzg4Z3gwMjZ4MmtxcTR3bGI5enR3In0.QdNftxZYpJ79K0M0DfYHUw" const MAPBOX_STYLE = "mapbox/streets-v12" function mapboxProvider(x, y, z, dpr) { - return `https://api.mapbox.com/styles/v1/${ - MAPBOX_STYLE - }/tiles/512/${z}/${x}/${y}${dpr >= 2 ? '@2x' : ''}?access_token=${ - MAPBOX_TOKEN - }`; + return `https://api.mapbox.com/styles/v1/${MAPBOX_STYLE + }/tiles/512/${z}/${x}/${y}${dpr >= 2 ? '@2x' : ''}?access_token=${MAPBOX_TOKEN + }`; } const marker_order = ["Annotation", "Request", "Resource"] -export default function DisasterMap({onPointSelected, markers = [], mapCenter, setMapCenter, onBoundsChanged}) { +export default function DisasterMap({ onPointSelected, markers = [], mapCenter, setMapCenter, onBoundsChanged }) { const [zoom, setZoom] = useState(6.5); const renderMarker = (marker) => { @@ -26,45 +23,40 @@ export default function DisasterMap({onPointSelected, markers = [], mapCenter, s width={33} anchor={[marker.latitude, marker.longitude]} key={marker.id} - onClick={({event}) => { + onClick={({ event }) => { onPointSelected(marker); event.preventDefault() }} > - {marker.type === "Annotation" ? : - } + {marker.type === "Annotation" ? : + } ); }; - // noinspection JSValidateTypes return ( -
-
+
+
{ + onClick={({ event }) => { onPointSelected(null); event.preventDefault() }} - onBoundsChanged={({center, zoom, bounds}) => { + onBoundsChanged={({ center, zoom, bounds }) => { setMapCenter(center) setZoom(zoom) onBoundsChanged(bounds) }}> - + {markers - .sort(({type}) => -marker_order.indexOf(type)) + .sort(({ type }) => -marker_order.indexOf(type)) .map(renderMarker)}
); } - - diff --git a/resq/frontend/src/components/Geolocation.js b/resq/frontend/src/components/Geolocation.js new file mode 100644 index 00000000..6dc5bbb1 --- /dev/null +++ b/resq/frontend/src/components/Geolocation.js @@ -0,0 +1,18 @@ +// geocode.js +import axios from 'axios'; + +const GOOGLE_API_KEY = "AIzaSyCehlfJwJ-V_xOWZ9JK3s0rcjkV2ga0DVg"; + +// Function to perform reverse geocoding +const reverseGeocode = async (latitude, longitude) => { + try { + const response = await axios.get(`https://maps.googleapis.com/maps/api/geocode/json?latlng=${latitude},${longitude}&key=AIzaSyCehlfJwJ-V_xOWZ9JK3s0rcjkV2ga0DVg`); + const cityName = response.data.results[0]?.formatted_address || 'Unknown Location'; + return cityName; + } catch (error) { + console.error('Error fetching location name:', error); + return 'Unknown Location'; + } +}; + +export default reverseGeocode; \ No newline at end of file diff --git a/resq/frontend/src/components/MapIcons.js b/resq/frontend/src/components/MapIcons.js index 8d13d09f..abf5e5a6 100644 --- a/resq/frontend/src/components/MapIcons.js +++ b/resq/frontend/src/components/MapIcons.js @@ -1,11 +1,13 @@ -import {Cancel, LocalFireDepartment, LocalHospital} from "@mui/icons-material"; +import {Cancel, Home, LocalFireDepartment, LocalHospital, SoupKitchen} from "@mui/icons-material"; import * as React from "react"; export const AnnotationIcon = ({icon, color}) => ({ Fire: , Health: , - "Road Closure": + "Road Closure": , + Shelter: , + Food: })[icon] export const MarkerIcon = ({color}) => ( { }; useEffect(() => { - onChosenChanged && onChosenChanged(currentChoices) - }, [onChosenChanged, currentChoices]) + onChosenChanged && onChosenChanged(choices.filter(a => currentChoices.indexOf(a.id) !== -1)) + }, [onChosenChanged, currentChoices, choices]) return {name} @@ -67,20 +67,20 @@ export const MultiCheckbox = ({name, choices, onChosenChanged}) => { input={} renderValue={(selected) => ( - {selected.map((value) => ( - + {choices.filter(a => selected.indexOf(a.id) !== -1).map((value) => ( + ))} )} MenuProps={MenuProps} > - {choices.map((name) => ( + {choices.map((value) => ( - {name} + {value.data} ))} diff --git a/resq/frontend/src/components/RequestAddress.js b/resq/frontend/src/components/Request/RequestAddress.js similarity index 95% rename from resq/frontend/src/components/RequestAddress.js rename to resq/frontend/src/components/Request/RequestAddress.js index 3ca79897..43a481e7 100644 --- a/resq/frontend/src/components/RequestAddress.js +++ b/resq/frontend/src/components/Request/RequestAddress.js @@ -6,7 +6,7 @@ import Typography from '@mui/material/Typography'; import FormControlLabel from '@mui/material/FormControlLabel'; import Checkbox from '@mui/material/Checkbox'; import { createTheme, ThemeProvider } from '@mui/material/styles'; -import disasterImage from '../disaster.png'; +import disasterImage from '../../disaster.png'; import Avatar from '@mui/material/Avatar'; import CssBaseline from '@mui/material/CssBaseline'; import Container from '@mui/material/Container'; @@ -91,7 +91,7 @@ export default function CreateRequestForm() { name="address1" label="Address line 1" fullWidth - autoComplete="shipping address-line1" + autoComplete="address1" variant="standard" /> @@ -101,7 +101,7 @@ export default function CreateRequestForm() { name="address2" label="Address line 2" fullWidth - autoComplete="shipping address-line2" + autoComplete="address2" variant="standard" /> @@ -112,7 +112,7 @@ export default function CreateRequestForm() { name="city" label="City" fullWidth - autoComplete="shipping address-level2" + autoComplete="city" variant="standard" /> diff --git a/resq/frontend/src/pages/RequestCreation.js b/resq/frontend/src/components/Request/RequestCreation.js similarity index 94% rename from resq/frontend/src/pages/RequestCreation.js rename to resq/frontend/src/components/Request/RequestCreation.js index 1423c90b..da1d16be 100644 --- a/resq/frontend/src/pages/RequestCreation.js +++ b/resq/frontend/src/components/Request/RequestCreation.js @@ -11,11 +11,11 @@ import StepLabel from '@mui/material/StepLabel'; import Button from '@mui/material/Button'; import Link from '@mui/material/Link'; import Typography from '@mui/material/Typography'; -import RequestAddress from '../components/RequestAddress'; -import RequestDetails1 from '../components/RequestDetails1'; -import RequestDetails2 from '../components/RequestDetails2'; -import RequestDetails3 from '../components/RequestDetails3'; -import RequestReview from '../components/RequestReview'; +import RequestAddress from './RequestAddress'; +import RequestDetails1 from './RequestDetails1'; +import RequestDetails2 from './RequestDetails2'; +import RequestDetails3 from './RequestDetails3'; +import RequestReview from './RequestReview'; import { createTheme, ThemeProvider } from '@mui/material/styles'; function Copyright(props) { diff --git a/resq/frontend/src/components/RequestDetails1.js b/resq/frontend/src/components/Request/RequestDetails1.js similarity index 100% rename from resq/frontend/src/components/RequestDetails1.js rename to resq/frontend/src/components/Request/RequestDetails1.js diff --git a/resq/frontend/src/components/RequestDetails2.js b/resq/frontend/src/components/Request/RequestDetails2.js similarity index 100% rename from resq/frontend/src/components/RequestDetails2.js rename to resq/frontend/src/components/Request/RequestDetails2.js diff --git a/resq/frontend/src/components/RequestDetails3.js b/resq/frontend/src/components/Request/RequestDetails3.js similarity index 100% rename from resq/frontend/src/components/RequestDetails3.js rename to resq/frontend/src/components/Request/RequestDetails3.js diff --git a/resq/frontend/src/components/RequestReview.js b/resq/frontend/src/components/Request/RequestReview.js similarity index 100% rename from resq/frontend/src/components/RequestReview.js rename to resq/frontend/src/components/Request/RequestReview.js diff --git a/resq/frontend/src/components/Resource/ResourceAddress.js b/resq/frontend/src/components/Resource/ResourceAddress.js new file mode 100644 index 00000000..3bfad2da --- /dev/null +++ b/resq/frontend/src/components/Resource/ResourceAddress.js @@ -0,0 +1,180 @@ +import React, {useState, useEffect} from 'react'; +import {TextField, Button, FormControl, InputLabel, Select, MenuItem, Box} from '@mui/material'; +import '@fontsource/inter'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import {createTheme, ThemeProvider} from '@mui/material/styles'; +import CssBaseline from '@mui/material/CssBaseline'; +import Container from '@mui/material/Container'; +import {ResourceContext} from './ResourceContext'; +import {useContext} from 'react'; + +const customTheme = createTheme({ + palette: { + primary: { + main: '#FF0000', + }, + }, +}); + +export default function ResourceAddress({resourceData, setResourceData}) { + const [address1, setAddress1] = useState("") + const [address2, setAddress2] = useState("") + const [city, setCity] = useState("") + const [state, setState] = useState("") + const [country, setCountry] = useState("") + const [nop, setNop] = useState("") + + + const handleGeocode = async () => { + const address = `${address1}, ${address2}, ${city}, ${state}, ${country}`; + const apiKey = 'AIzaSyCehlfJwJ-V_xOWZ9JK3s0rcjkV2ga0DVg'; + + try { + const response = await fetch( + `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent( + address + )}&key=${apiKey}` + ); + + if (response.ok) { + const data = await response.json(); + if (data.results && data.results.length > 0) { + const location = data.results[0].geometry.location; + setResourceData( + {...resourceData, latitude: location.lat, longitude: location.lng} + ) + } else { + console.error('Geocoding failed: No results found'); + } + } else { + console.error('Geocoding request failed'); + } + } catch (error) { + console.error('Geocoding error:', error); + } + }; + + useEffect(() => { + handleGeocode(); + }, [address1, address2, city, state, country, nop]); + + return ( + + + + + + Resource Delivery Address + + +
+ + + setAddress1(e.target.value)} + /> + + + setAddress2(e.target.value)} + /> + + + setCity(e.target.value)} + /> + + + setState(e.target.value)} + /> + + + setCountry(e.target.value)} + /> + + + setNop(e.target.value)} + /> + + + + } + label="I have delivered my resources to the above mentioned address." + /> + + +
+
+
+
+
+ ); +} diff --git a/resq/frontend/src/components/Resource/ResourceContext.js b/resq/frontend/src/components/Resource/ResourceContext.js new file mode 100644 index 00000000..77cc19e4 --- /dev/null +++ b/resq/frontend/src/components/Resource/ResourceContext.js @@ -0,0 +1,19 @@ +import React, { createContext, useState, useContext } from 'react'; + +export const ResourceContext = createContext({}); + +export const useResource = () => useContext(ResourceContext); + +export const ResourceProvider = ({ children }) => { + const [resourceData, setResourceData] = useState({}); + + const updateResourceData = (newData) => { + setResourceData(prev => ({ ...prev, ...newData })); + }; + + return ( + + {children} + + ); +}; diff --git a/resq/frontend/src/components/Resource/ResourceCreation.js b/resq/frontend/src/components/Resource/ResourceCreation.js new file mode 100644 index 00000000..333d1ba9 --- /dev/null +++ b/resq/frontend/src/components/Resource/ResourceCreation.js @@ -0,0 +1,141 @@ +import React, {createContext, useState, useContext} from 'react'; +import CssBaseline from '@mui/material/CssBaseline'; +import AppBar from '@mui/material/AppBar'; +import Box from '@mui/material/Box'; +import Container from '@mui/material/Container'; +import Toolbar from '@mui/material/Toolbar'; +import Paper from '@mui/material/Paper'; +import Stepper from '@mui/material/Stepper'; +import Step from '@mui/material/Step'; +import StepLabel from '@mui/material/StepLabel'; +import Button from '@mui/material/Button'; +import Link from '@mui/material/Link'; +import Typography from '@mui/material/Typography'; +import {createTheme, ThemeProvider} from '@mui/material/styles'; +import axios from 'axios'; + +import {ResourceContext, ResourceProvider, useResource} from './ResourceContext'; +import ResourceDetail1 from "./ResourceDetail1"; +import ResourceDetail2 from "./ResourceDetail2"; +import ResourceAddress from "./ResourceAddress"; +import {createResource} from "../../AppService"; + +function Copyright(props) { + return ( +
+ + {'Copyright © '} + + ResQ + {' '} + {new Date().getFullYear()} + {'.'} + +
+ ); +} + +const customTheme = createTheme({ + palette: { + primary: { + main: '#FF0000', + }, + }, +}); + +const steps = ['Resource delivery address', 'Type of Resource', 'Resource Details']; + +export default function Resource() { + const [activeStep, setActiveStep] = React.useState(0); + const [resourceData, setResourceData] = useState({ + senderId: parseInt(localStorage.getItem('userId')), + gender: "MALE" + }); + + function getStepContent(step) { + switch (step) { + case 0: + return ; + case 1: + return ; + case 2: + return ; + default: + throw new Error('Unknown step'); + } + } + + const handleNext = async () => { + if (activeStep === steps.length - 1) { + await createResource(resourceData) + alert('Resource created successfully!'); + } else { + setActiveStep(activeStep + 1); + } + }; + + const handleBack = () => { + setActiveStep(activeStep - 1); + }; + + return ( + + + + + `1px solid ${t.palette.divider}`, + }} + > + + + + + {steps.map((label) => ( + + {label} + + ))} + + {activeStep === steps.length ? ( + + + Resource is on its way! + + + Resource is saved with the details. + + + ) : ( + + {getStepContent(activeStep)} + + {activeStep !== 0 && ( + + )} + + + + + )} + + + + + + + ); +} diff --git a/resq/frontend/src/components/Resource/ResourceDetail1.js b/resq/frontend/src/components/Resource/ResourceDetail1.js new file mode 100644 index 00000000..90a8c1a5 --- /dev/null +++ b/resq/frontend/src/components/Resource/ResourceDetail1.js @@ -0,0 +1,157 @@ +import React, {useState, useEffect} from 'react'; +import { + Typography, + Grid, + FormControlLabel, + Checkbox, + FormControl, + InputLabel, + Select, + MenuItem, + OutlinedInput, + Autocomplete, TextField +} from '@mui/material'; +import {createTheme} from '@mui/material/styles'; +import {Theme, useTheme} from '@mui/material/styles'; +import {useResource} from './ResourceContext'; +import {useContext} from 'react'; +import {ResourceContext} from './ResourceContext'; +import {useQuery} from "@tanstack/react-query"; +import {getCategoryTree} from "../../AppService"; + +const humanResources = [ + 'Doctor', + 'Nurse', + 'Translator', + 'Rescue Team', + 'Lorry Driver', + 'Food Service', + 'District Responsible', +]; + +export default function ResourceDetails1({resourceData, setResourceData}) { + + const ITEM_HEIGHT = 48; + const ITEM_PADDING_TOP = 8; + const MenuProps = { + PaperProps: { + style: { + maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP, + width: 250, + }, + }, + }; + + const categoryTree = useQuery({queryKey: ['categoryTree'], queryFn: getCategoryTree}) + + const [isMaterialResourceChecked, setIsMaterialResourceChecked] = useState(false); + const [isHumanResourceChecked, setIsHumanResourceChecked] = useState(false); + + const [selectedMaterialValue, setSelectedMaterial] = useState(null); + const [selectedHumanValues, setSelectedHumanValues] = useState([]); + + const getStyles = (item, selectedItems, theme) => { + return { + fontWeight: + selectedItems.indexOf(item) === -1 + ? theme.typography.fontWeightRegular + : theme.typography.fontWeightMedium, + }; + }; + + const handleHumanChange = (event) => { + setSelectedHumanValues(event.target.value); + }; + + const theme = useTheme(); + + useEffect(() => { + const description = { + categoryTreeId: (isMaterialResourceChecked && selectedMaterialValue?.id) || '', + //human: isHumanResourceChecked ? selectedHumanValues.join(', ') : '', + }; + setResourceData({...resourceData, ...description}); + }, [isMaterialResourceChecked, selectedMaterialValue, setResourceData, resourceData]); + + const comboBoxItems = (categoryTree.data?.getLeafCategories() || []) + .map(cat => ({label: cat.data, id: cat.id})) + .sort((a, b) => { + if (a.label === b.label) return 0; + return a.label > b.label ? 1 : -1; + }); + + return ( + + + Resource Type + + + + setIsMaterialResourceChecked(e.target.checked)}/>} + label="Material Resource" + /> + {isMaterialResourceChecked && ( + <> + + { + setSelectedMaterial(newValue); + }} + sx={{width: 300}} + renderInput={(params) => } + /> + + + )} + + + setIsHumanResourceChecked(e.target.checked)}/>} + label="Human Resource" + /> + {isHumanResourceChecked && ( + <> + + + + + )} + + + + ); +} diff --git a/resq/frontend/src/components/Resource/ResourceDetail2.js b/resq/frontend/src/components/Resource/ResourceDetail2.js new file mode 100644 index 00000000..50859b7c --- /dev/null +++ b/resq/frontend/src/components/Resource/ResourceDetail2.js @@ -0,0 +1,50 @@ +import React, {useState, useEffect} from 'react'; +import {Typography, Grid, TextField} from '@mui/material'; +import {ResourceContext} from './ResourceContext'; +import {useContext} from 'react'; + +export default function ResourceDetail2({resourceData, setResourceData}) { + + const ITEM_HEIGHT = 48; + const ITEM_PADDING_TOP = 8; + const MenuProps = { + PaperProps: { + style: { + maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP, width: 250, + }, + }, + }; + + const [materialQuantity, setMaterialQuantity] = useState(''); + const [materialDescription, setMaterialDescription] = useState(''); + + useEffect(() => { + setResourceData({...resourceData, quantity: materialQuantity}); + }, [materialQuantity, resourceData, setResourceData]); + + return ( + + Resource Type + + + + setMaterialQuantity(event.target.value)} + /> + + + setMaterialDescription(event.target.value)} + /> + + + ); +} diff --git a/resq/frontend/src/pages/MapDemo.js b/resq/frontend/src/pages/MapPage.js similarity index 61% rename from resq/frontend/src/pages/MapDemo.js rename to resq/frontend/src/pages/MapPage.js index b5749633..0b3ece74 100644 --- a/resq/frontend/src/pages/MapDemo.js +++ b/resq/frontend/src/pages/MapPage.js @@ -7,11 +7,12 @@ import Box from '@mui/material/Box'; import {createTheme, ThemeProvider} from '@mui/material/styles'; import Container from '@mui/material/Container'; import DisasterMap from "../components/DisasterMap"; -import {cards} from "../components/ListCards"; +import {cards} from "../components/Cards/ListCards"; import {AmountSelector, MultiCheckbox} from "../components/MultiCheckbox"; import {DatePicker} from "@mui/x-date-pickers"; import dayjs from "dayjs"; - +import {useQuery} from "@tanstack/react-query"; +import {getCategoryTree} from "../AppService"; const customTheme = createTheme({ palette: { @@ -21,100 +22,18 @@ const customTheme = createTheme({ }, }); - -const mock_markers = [ - { - type: "Annotation", - latitude: 41.089, - longitude: 29.053, - category: "Health", - title: "First Aid Clinic", - short_description: "First aid clinic and emergency wound care. Open 24 hours.", - long_description: "Welcome to our First Aid Clinic, a dedicated facility committed to providing immediate and " + - "compassionate healthcare 24 hours a day. Our experienced team of healthcare professionals specializes in " + - "emergency wound care and first aid assistance, ensuring you receive prompt attention when you need it most. " + - "From minor cuts to more serious injuries, our clinic is equipped to handle a range of medical concerns, " + - "promoting healing and preventing complications.", - date: "26/11/2023" - }, - { - type: "Request", - latitude: 37.08, - longitude: 31.05, - requester: { - name: "Müslüm", - surname: "Ertürk" - }, - urgency: "HIGH", - needs: [ - { - name: "Canned food", - description: "Preferably a variety of different foodstuffs.", - quantity: 3 - }, - { - name: "Diapers", - description: "Preferably individually packed.", - quantity: 2 +const getAllCategories = categoryTree => { + if (categoryTree) { + return item => { + switch (item.type) { + case "Annotation": + return [{id: item?.category, data: item?.category}] + default: + return categoryTree.findCategoryWithId(parseInt(item.categoryTreeId))?.getAllParentCategories() } - ], - status: "TODO" - }, - { - type: "Request", - latitude: 41.1, - longitude: 29.15, - requester: { - name: "Ali", - surname: "Er" - }, - urgency: "LOW", - needs: [ - { - name: "Power banks", - category: "Other", - description: "Power banks for the staff, preferably with cables included.", - quantity: 30 - }, - ], - status: "IN_PROGRESS" - }, - { - type: "Resource", - latitude: 41.1, - longitude: 29.04, - owner: { - name: "Te", - surname: "St" - }, - urgency: "HIGH", - resources: [ - { - name: "Bottled Water", - category: "Water", - description: "1.5 liters bottles", - quantity: 300, - }, - { - name: "Canned Beans", - category: "Food", - description: "400 gram cans", - quantity: 500, - }, - ], - } -] - -function getAllCategories(item) { - switch (item.type) { - case "Annotation": - return [item?.category] - case "Resource": - return item.resources.map(resource => resource?.category) - case "Request": - return item.needs.map(need => need?.category) - default: - return [] + }; + } else { + return () => ([]); } } @@ -135,14 +54,18 @@ const applyFilterTo = (predicate) => const makeFilterByCategory = categories => { if (categories.length === 0) return () => true - return applyFilterTo( - function (item) { - return categories.indexOf(item?.category) !== -1; + + return item => { + switch (item.type) { + case "Annotation": + return categories.map(a => a.id).indexOf(item?.category) !== -1; + default: + return !categories.every(a => !a.findCategoryWithId || !(a.findCategoryWithId(parseInt(item.categoryTreeId)))) } - ) + } }; -const makeFilterByType = (typeFilter) => item => typeFilter.length === 0 || typeFilter.indexOf(item.type) !== -1 +const makeFilterByType = (typeFilter) => item => typeFilter.length === 0 || typeFilter.map(t=>t.id).indexOf(item.type) !== -1 const makeFilterByAmount = ([amount]) => { if (typeof amount !== "string" || amount.indexOf("-") === -1) @@ -165,9 +88,7 @@ const makeFilterByBounds = ({ne: [ne_lat, ne_lng], sw: [sw_lat, sw_lng]}) => } -export default function MapDemo() { - // eslint-disable-next-line no-unused-vars - const [allMarkers, setAllMarkers] = useState(mock_markers) +export default function MapPage({allMarkers}) { const [shownMarkers, setShownMarkers] = useState(allMarkers) const [selectedPoint, setSelectedPoint] = useState(null) const [mapCenter, setMapCenter] = useState([39, 34.5]) @@ -179,10 +100,16 @@ export default function MapDemo() { const [categoryFilter, setCategoryFilter] = useState([]) const [mapBounds, setMapBounds] = useState({ne: [0, 0], sw: [0, 0]}) + const categoryTree = useQuery({queryKey: ['categoryTree'], queryFn: getCategoryTree}) + + useEffect(() => { - if (selectedPoint) - setMapCenter([selectedPoint.latitude, selectedPoint.longitude]) - }, [selectedPoint]) + if (selectedPoint) { + setMapCenter([selectedPoint.latitude, selectedPoint.longitude]); + } + }, [selectedPoint]); + + useEffect(() => setShownMarkers(allMarkers), [allMarkers]) useEffect(() => setShownMarkers( allMarkers @@ -194,6 +121,15 @@ export default function MapDemo() { .filter(makeFilterByBounds(mapBounds)) ), [allMarkers, amountFilter, categoryFilter, dateFromFilter, dateToFilter, mapBounds, typeFilter]) + const choices = new Map([ + ...allMarkers + .filter(makeFilterByBounds(mapBounds)) + .map(getAllCategories(categoryTree?.data)) + .flat(), + ...categoryFilter + ] + .filter(a => a) + .map(a => [a?.id, a])) // noinspection JSValidateTypes return ( @@ -203,15 +139,11 @@ export default function MapDemo() { display: "flex", flexDirection: "row", flexWrap: 'nowrap', margin: "12px", width: "100%", justifyContent: "center" }}> - ({id: i, data: i}))} onChosenChanged={setTypeFilter}/> v && array.indexOf(v) === i)} + choices={[...choices.values()]} onChosenChanged={setCategoryFilter}/> @@ -235,7 +167,7 @@ export default function MapDemo() { flexDirection: "row", flexWrap: 'nowrap', margin: "12px", - height: "100%", + height: "100px", flexGrow: 100 }}> {shownMarkers.map((marker) => { const SelectedCard = cards[marker.type] - return < SelectedCard item={marker} onClick={() => setSelectedPoint(marker)}/> + return
setSelectedPoint(marker)}>< SelectedCard item={marker}/>
})}
diff --git a/resq/frontend/src/pages/Mock_markers.js b/resq/frontend/src/pages/Mock_markers.js new file mode 100644 index 00000000..175aaea8 --- /dev/null +++ b/resq/frontend/src/pages/Mock_markers.js @@ -0,0 +1,46 @@ +export const mock_markers = [ + { + type: "Annotation", + latitude: 41.089, + longitude: 29.053, + category: "Health", + title: "First Aid Clinic", + short_description: "First aid clinic and emergency wound care. Open 24 hours.", + long_description: "Welcome to our First Aid Clinic, a dedicated facility committed to providing immediate and " + + "compassionate healthcare 24 hours a day. Our experienced team of healthcare professionals specializes in " + + "emergency wound care and first aid assistance, ensuring you receive prompt attention when you need it most. " + + "From minor cuts to more serious injuries, our clinic is equipped to handle a range of medical concerns, " + + "promoting healing and preventing complications.", + date: "26/11/2023" + }, + { + type: "Annotation", + latitude: 41.085, + longitude: 29.056, + category: "Shelter", + title: "Temporary Shelter Camp", + short_description: "Temporary shelter camp for displaced individuals and families.", + long_description: "Our Temporary Shelter Camp offers a safe haven for individuals and families displaced by the disaster. We provide temporary accommodation, basic necessities, and support services. Our goal is to ensure the well-being and comfort of those affected by the disaster while they await further assistance.", + date: "27/11/2023" + }, + { + type: "Annotation", + latitude: 41.099, + longitude: 29.047, + category: "Food", + title: "Food Distribution Center", + short_description: "Food distribution center providing meals to disaster survivors.", + long_description: "The Food Distribution Center is committed to providing nutritious meals to disaster survivors. We offer a variety of food options to meet the dietary needs of individuals and families affected by the disaster. Our team works tirelessly to ensure that no one goes hungry during these challenging times.", + date: "28/11/2023" + }, + { + type: "Annotation", + latitude: 40.092, + longitude: 29.008, + category: "Health", + title: "Emergency Medical Center", + short_description: "Emergency medical center with specialized trauma care.", + long_description: "Our Emergency Medical Center is equipped with state-of-the-art facilities to provide specialized trauma care in the aftermath of a disaster. Our medical team is trained to handle critical injuries, perform life-saving procedures, and ensure the well-being of patients. We are available 24/7 to respond to emergencies and provide immediate medical assistance.", + date: "27/11/2023" + } +] \ No newline at end of file diff --git a/resq/frontend/src/pages/Notifications.js b/resq/frontend/src/pages/Notifications.js new file mode 100644 index 00000000..f3b19b44 --- /dev/null +++ b/resq/frontend/src/pages/Notifications.js @@ -0,0 +1,77 @@ +import * as React from 'react'; +import CssBaseline from '@mui/material/CssBaseline'; +import Link from '@mui/material/Link'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import {createTheme, ThemeProvider} from '@mui/material/styles'; +import Container from '@mui/material/Container'; +import {Card, CardContent, CardHeader} from "@mui/material"; + + +function Copyright(props) { + return ( +
+ + {'Copyright © '} + + ResQ + {' '} + {new Date().getFullYear()} + {'.'} + +
+ ); +} + +const customTheme = createTheme({ + palette: { + primary: { + main: '#FF0000', + }, + }, +}); + +const NotificationCard = ({notif, onClick}) => + + + + {notif.desc} + + + + +// noinspection JSUnusedLocalSymbols +export default function Notifications({token, notifications, setNotifications}) { + + const handleNotifRead = (i) => { + const new_notifs = JSON.parse(JSON.stringify(notifications)) + new_notifs[i].read = true + setNotifications(new_notifs) + } + + return ( + + + + + { + notifications.map((notif, i) => notif.read || handleNotifRead(i)}/>) + } + + + + + + ); +} + diff --git a/resq/frontend/src/pages/SignIn.js b/resq/frontend/src/pages/SignIn.js index e8ae3dad..06b2e073 100644 --- a/resq/frontend/src/pages/SignIn.js +++ b/resq/frontend/src/pages/SignIn.js @@ -65,7 +65,8 @@ export default function SignIn({ token, setToken }) { if (response?.data?.jwt) { setToken(response.data.jwt); - navigate('/userroles'); + localStorage.setItem('userId', response.data.id); + navigate('/'); } else { alert('Signin failed. Please check your credentials.'); } diff --git a/resq/frontend/src/pages/VictimMapPage.js b/resq/frontend/src/pages/VictimMapPage.js new file mode 100644 index 00000000..4f8bc5e7 --- /dev/null +++ b/resq/frontend/src/pages/VictimMapPage.js @@ -0,0 +1,42 @@ +import MapPage from "./MapPage"; +import {useQuery} from "@tanstack/react-query"; +import {getAllResources} from "../AppService"; +import {mock_markers} from "./Mock_markers"; +import {Fab} from "@mui/material"; +import {useNavigate} from "react-router-dom"; +import {Add} from "@mui/icons-material"; + + +export default function VictimMapPage() { + + //const queryClient = useQueryClient() + + const navigate = useNavigate() + const resources = useQuery({queryKey: ['getAllResources'], queryFn: getAllResources}) + + const resourceMarkers = (resources.data?.data || []).map(a => ({...a, type: "Resource"})) + const allMarkers = [...mock_markers, ...resourceMarkers] + /*/ Mutations + const mutation = useMutation({ + mutationFn: postTodo, + onSuccess: () => { + // Invalidate and refetch + queryClient.invalidateQueries({queryKey: ['todos']}) + }, + })*/ + + return ( + <> + + navigate("/requestcreation")} sx={{ + position: 'absolute', + bottom: 32, + right: 32, + }}> + + Add Need + + + + ) +} \ No newline at end of file diff --git a/resq/mobile/ResQ/app/build.gradle.kts b/resq/mobile/ResQ/app/build.gradle.kts index 4924a96c..27384fdd 100644 --- a/resq/mobile/ResQ/app/build.gradle.kts +++ b/resq/mobile/ResQ/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") + id ("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") } android { @@ -37,6 +38,7 @@ android { jvmTarget = "1.8" } buildFeatures { + buildConfig = true compose = true } composeOptions { @@ -50,8 +52,13 @@ android { } dependencies { - val nav_version = "2.7.4" + implementation ("com.google.maps.android:maps-ktx:3.2.1") + implementation ("com.google.maps.android:maps-utils-ktx:3.2.1") + implementation ("com.google.android.gms:play-services-location:21.0.1") + implementation ("com.google.android.gms:play-services-maps:18.2.0") + implementation ("com.google.maps.android:maps-compose:4.1.1") + val nav_version = "2.7.4" // Java language implementation implementation("androidx.navigation:navigation-fragment:$nav_version") implementation("androidx.navigation:navigation-ui:$nav_version") @@ -94,5 +101,12 @@ dependencies { androidTestImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") + implementation("io.coil-kt:coil-compose:2.4.0") +} + +secrets{ + propertiesFileName = "secrets.properties" + ignoreList.add("keyToIgnore") // Ignore the key "keyToIgnore" + ignoreList.add("sdk.*") // Ignore all keys matching the regexp "sdk.*" } \ No newline at end of file diff --git a/resq/mobile/ResQ/app/src/main/AndroidManifest.xml b/resq/mobile/ResQ/app/src/main/AndroidManifest.xml index 180d94f4..044868a6 100644 --- a/resq/mobile/ResQ/app/src/main/AndroidManifest.xml +++ b/resq/mobile/ResQ/app/src/main/AndroidManifest.xml @@ -3,6 +3,9 @@ xmlns:tools="http://schemas.android.com/tools" > + + + + + + if (isGranted) { + mapViewModel.getDeviceLocation(fusedLocationProviderClient) + } + } + + private fun askPermissions() = when { + ContextCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED -> { + mapViewModel.getDeviceLocation(fusedLocationProviderClient) + } + else -> { + requestPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) + } + } + + private lateinit var fusedLocationProviderClient: FusedLocationProviderClient + private val mapViewModel: MapViewModel by viewModels() + @RequiresApi(Build.VERSION_CODES.O) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + fusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(this) + askPermissions() setContent { val appContext = applicationContext ResQTheme { - MainScreen(appContext) + MainScreen(appContext, mapViewModel) } } } @@ -55,7 +90,7 @@ class MainActivity : ComponentActivity() { @RequiresApi(Build.VERSION_CODES.O) @OptIn(ExperimentalMaterial3Api::class) @Composable -fun MainScreen(appContext: Context) { +fun MainScreen(appContext: Context, mapViewModel: MapViewModel) { val navController = rememberNavController() Scaffold( @@ -68,7 +103,8 @@ fun MainScreen(appContext: Context) { ) { NavGraph( navController = navController, - appContext = appContext + appContext = appContext, + mapViewModel = mapViewModel ) } },) @@ -78,7 +114,8 @@ fun MainScreen(appContext: Context) { @Composable fun NavGraph( navController: NavHostController, - appContext: Context + appContext: Context, + mapViewModel: MapViewModel, ) { NavHost( navController = navController, @@ -91,13 +128,13 @@ fun NavGraph( LoginScreen(navController, appContext) } composable(NavigationItem.Map.route) { - MapScreen(navController, appContext) + MapScreen(navController, appContext, mapViewModel) } composable(NavigationItem.Request.route) { RequestScreen(navController, appContext) } composable(NavigationItem.Resource.route) { - ResourceScreen(navController) + ResourceScreen(navController, appContext) } composable(NavigationItem.OngoingTasks.route) { OngoingTasksScreen(navController) diff --git a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/models/CategoryTreeNode.kt b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/models/CategoryTreeNode.kt new file mode 100644 index 00000000..c5959c4d --- /dev/null +++ b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/models/CategoryTreeNode.kt @@ -0,0 +1,7 @@ +package com.cmpe451.resq.data.models + +data class CategoryTreeNode( + val id: Int, + val data: String, + val children: List +) \ No newline at end of file diff --git a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/models/Need.kt b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/models/Need.kt new file mode 100644 index 00000000..30c564fc --- /dev/null +++ b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/models/Need.kt @@ -0,0 +1,14 @@ +package com.cmpe451.resq.data.models + +data class Need( + val id: Int, + val userId: Int, + val categoryTreeId: String, + val description: String, + val quantity: Int, + val latitude: Double, + val longitude: Double, + val requestId: Int, + val status: String, + val createdDate: String +) \ No newline at end of file diff --git a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/models/Profile.kt b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/models/Profile.kt index 939051bd..bd81f655 100644 --- a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/models/Profile.kt +++ b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/models/Profile.kt @@ -3,25 +3,32 @@ package com.cmpe451.resq.data.models data class ProfileData( var name: String?, var surname: String?, - var email: String?, - var roles: List?, - var selectedRole: String?, + var birthdate: String?, + var gender:String?, + var bloodType: String?, var phoneNumber: String?, var country: String?, var city: String?, var state: String?, - var bloodType: String?, - var weight: String?, - var gender:String?, - var height: String?, - var year: String?, - var month: String?, - var day: String? + var weight: Int?, + var height: Int?, + val emailConfirmed: Boolean? = false, + val privacyPolicyAccepted: Boolean? = false, ) -data class UserInfoResponse( - val name: String, - val surname: String, - val email: String, - val roles: List + +data class UserInfoRequest( + var name: String?, + var surname: String?, + var birthdate: String?, + var gender:String?, + var bloodType: String?, + var phoneNumber: String?, + var country: String?, + var city: String?, + var state: String?, + var weight: Int?, + var height: Int?, + val emailConfirmed: Boolean? = false, + val privacyPolicyAccepted: Boolean? = false, ) \ No newline at end of file diff --git a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/models/Request.kt b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/models/Request.kt index 577a257f..b52aa85b 100644 --- a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/models/Request.kt +++ b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/models/Request.kt @@ -1,10 +1,5 @@ package com.cmpe451.resq.data.models -data class CategoryNode( - val id: Int, - val data: String, - val children: List -) data class CreateNeedRequestBody( val description: String, diff --git a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/models/Resource.kt b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/models/Resource.kt new file mode 100644 index 00000000..9fff75c7 --- /dev/null +++ b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/models/Resource.kt @@ -0,0 +1,10 @@ +package com.cmpe451.resq.data.models + +data class CreateResourceRequestBody( + var senderId: Int?, + val categoryTreeId: String, + val quantity: Int, + val latitude: Double, + val longitude: Double, + val gender: String +) diff --git a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/remote/ResqService.kt b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/remote/ResqService.kt index 456b7438..bceb198c 100644 --- a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/remote/ResqService.kt +++ b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/data/remote/ResqService.kt @@ -1,29 +1,43 @@ package com.cmpe451.resq.data.remote import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi import com.cmpe451.resq.data.Constants import com.cmpe451.resq.data.manager.UserSessionManager -import com.cmpe451.resq.data.models.CategoryNode +import com.cmpe451.resq.data.models.CategoryTreeNode import com.cmpe451.resq.data.models.CreateNeedRequestBody +import com.cmpe451.resq.data.models.CreateResourceRequestBody import com.cmpe451.resq.data.models.LoginRequestBody import com.cmpe451.resq.data.models.LoginResponse +import com.cmpe451.resq.data.models.Need import com.cmpe451.resq.data.models.ProfileData import com.cmpe451.resq.data.models.RegisterRequestBody -import com.cmpe451.resq.data.models.UserInfoResponse +import com.cmpe451.resq.data.models.UserInfoRequest import okhttp3.ResponseBody import retrofit2.Response import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.http.* +import java.time.LocalDate +import java.time.format.DateTimeParseException interface CategoryTreeNodeService { @GET("categorytreenode/getMainCategories") suspend fun getMainCategories( @Header("Authorization") jwtToken: String, @Header("X-Selected-Role") role: String - ): Response> + ): Response> } +interface ResourceService { + @POST("resource/createResource") + suspend fun createResource( + @Header("Authorization") jwtToken: String, + @Header("X-Selected-Role") role: String, + @Body requestBody: CreateResourceRequestBody + ): Response +} interface NeedService { @POST("need/createNeed") suspend fun createNeed( @@ -32,6 +46,17 @@ interface NeedService { @Header("X-Selected-Role") role: String, @Body requestBody: CreateNeedRequestBody ): Response + + + @GET("need/filterByDistance") + fun filterNeedByDistance( + @Query("latitude") latitude: Double, + @Query("longitude") longitude: Double, + @Query("distance") distance: Double, + @Header("Authorization") jwtToken: String, + @Header("X-Selected-Role") role: String, + ): Response> + } interface AuthService { @@ -43,12 +68,30 @@ interface AuthService { } interface ProfileService { - @GET("user/getUserInfo") + @GET("profile/getProfileInfo") suspend fun getUserInfo( @Query("userId") userId: Int, @Header("Authorization") jwtToken: String, @Header("X-Selected-Role") role: String - ): Response + ): Response + + @POST("user/requestRole") + suspend fun selectRole( + @Query("userId") userId: Int, + @Query("role") requestedRole: String, + @Header("Authorization") jwtToken: String, + @Header("X-Selected-Role") role: String + ): Response + + + @POST("profile/updateProfile") + suspend fun updateProfile( + @Query("userId") userId: Int, + @Header("Authorization") jwtToken: String, + @Header("X-Selected-Role") role: String, + @Body request: UserInfoRequest + ): Response + } class ResqService(appContext: Context) { @@ -58,6 +101,7 @@ class ResqService(appContext: Context) { .build() private val categoryTreeNodeService: CategoryTreeNodeService = retrofit.create(CategoryTreeNodeService::class.java) + private val resourceService: ResourceService = retrofit.create(ResourceService::class.java) private val needService: NeedService = retrofit.create(NeedService::class.java) private val authService: AuthService = retrofit.create(AuthService::class.java) private val profileService: ProfileService = retrofit.create(ProfileService::class.java) @@ -65,7 +109,7 @@ class ResqService(appContext: Context) { private val userSessionManager: UserSessionManager = UserSessionManager.getInstance(appContext) // Category Tree Node methods - suspend fun getMainCategories(): Response> { + suspend fun getMainCategories(): Response> { val token = userSessionManager.getUserToken() ?: "" val selectedRole = userSessionManager.getSelectedRole() ?: "" @@ -75,30 +119,82 @@ class ResqService(appContext: Context) { ) } + // Resource methods + suspend fun createResource(request: CreateResourceRequestBody): Response { + val userId = userSessionManager.getUserId() + val token = userSessionManager.getUserToken() ?: "" + // val selectedRole = userSessionManager.getSelectedRole() ?: "" + + request.senderId = userId + + return resourceService.createResource( + jwtToken = "Bearer $token", + role = "RESPONDER", + requestBody = request + ) + } + // Need methods suspend fun createNeed(request: CreateNeedRequestBody): Response { val userId = userSessionManager.getUserId() val token = userSessionManager.getUserToken() ?: "" - val selectedRole = userSessionManager.getSelectedRole() ?: "" + // val selectedRole = userSessionManager.getSelectedRole() ?: "" return needService.createNeed( userId = userId, jwtToken = "Bearer $token", - role = selectedRole, + role = "VICTIM", requestBody = request ) } + suspend fun filterNeedByDistance( + latitude: Double, + longitude: Double, + distance: Double + ): Response> { + val selectedRole = userSessionManager.getSelectedRole() ?: "" + val token = userSessionManager.getUserToken() ?: "" + return needService.filterNeedByDistance( + latitude = latitude, + longitude = longitude, + distance = distance, + role = selectedRole, + jwtToken = "Bearer $token" + ) + } + // Auth methods suspend fun login(request: LoginRequestBody): Response = authService.login(request) suspend fun register(request: RegisterRequestBody): Response = authService.register(request) // Profile methods + @RequiresApi(Build.VERSION_CODES.O) + fun parseBirthDate(birthDate: String?): Triple? { + if (birthDate.isNullOrBlank()) { + return null + } + + return try { + val date = LocalDate.parse(birthDate) + val year = date.year.toString() + val month = date.monthValue.toString() + val day = date.dayOfMonth.toString() + + return Triple(year, month, day) + } catch (e: DateTimeParseException) { + //TO DO Handle parsing error if needed + null + } + } + + @RequiresApi(Build.VERSION_CODES.O) suspend fun getUserInfo(): ProfileData { - val userId = userSessionManager.getUserId() val token = userSessionManager.getUserToken() ?: "" + val userId = userSessionManager.getUserId() val selectedRole = userSessionManager.getSelectedRole() ?: "" + val response = profileService.getUserInfo( userId = userId, jwtToken = "Bearer $token", @@ -106,13 +202,60 @@ class ResqService(appContext: Context) { ) return ProfileData( - name = response.body()?.name, surname = response.body()?.surname, - email = response.body()?.email, - roles = response.body()?.roles, selectedRole = selectedRole, - year = "1990", month = "05", day = "29", - city = "Istanbul", country = "Turkey", - gender = "Female", bloodType = "0 rh-", height = "180", weight = "80", - phoneNumber = "05321234567", state = "Kadikoy", + name = response.body()?.name, + surname = response.body()?.surname, + city = response.body()?.city, + country = response.body()?.country, + gender = response.body()?.gender, + bloodType = response.body()?.bloodType, + height = response.body()?.height, + weight = response.body()?.weight, + phoneNumber = response.body()?.phoneNumber, + state = response.body()?.state, + emailConfirmed = response.body()?.emailConfirmed, + privacyPolicyAccepted = response.body()?.privacyPolicyAccepted, + birthdate = response.body()?.birthdate.toString(), ) } -} + @RequiresApi(Build.VERSION_CODES.O) + suspend fun updateUserData(profileData: ProfileData): Response { + val token = userSessionManager.getUserToken() ?: "" + val userId = userSessionManager.getUserId() + val selectedRole = userSessionManager.getSelectedRole() ?: "" + + val request = UserInfoRequest( + name = profileData.name ?: "", + surname = profileData.surname ?: "", + birthdate = null, + country = profileData.country ?: "", + city = profileData.city ?: "", + state = profileData.state ?: "", + bloodType = profileData.bloodType ?: "", + height = profileData.height, + weight = profileData.weight, + gender = profileData.gender ?: "", + phoneNumber = profileData.phoneNumber ?: "", + ) + return profileService.updateProfile( + userId = userId, + jwtToken = "Bearer $token", + role = selectedRole, + request = request + ) + } + + suspend fun selectRole(requestedRole: String): Response { + val userId = userSessionManager.getUserId() + val token = userSessionManager.getUserToken() ?: "" + val role = userSessionManager.getSelectedRole() ?: "" + + val response = profileService.selectRole( + userId = userId, + requestedRole = requestedRole, + jwtToken = "Bearer $token", + role = requestedRole + ) + + return response + } +} \ No newline at end of file diff --git a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/theme/Color.kt b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/theme/Color.kt index 4043aa33..72df4920 100644 --- a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/theme/Color.kt +++ b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/theme/Color.kt @@ -16,4 +16,6 @@ val LightGreen = Color(0xff20df7f) val ResourceColor = Color(0xFF397FE7) val RequestColor = Color(0xFFB356AF) +val MyTasksColor = Color(0XFFE7A139) +val OngoingTasksColor = Color(0xFFE16834) val BackgroundColor = Color(0xFFEFEFEF) \ No newline at end of file diff --git a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/components/DropdownMenuComponent.kt b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/components/DropdownMenuComponent.kt new file mode 100644 index 00000000..466e7cfb --- /dev/null +++ b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/components/DropdownMenuComponent.kt @@ -0,0 +1,68 @@ +package com.cmpe451.resq.ui.views.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun DropdownMenuComponent( + label: String, + items: List, + selectedItem: T, + itemToString: (T) -> String, + onItemSelected: (T) -> Unit +) { + var expandState by remember { mutableStateOf(false) } + Box(modifier = Modifier + .fillMaxWidth() + .wrapContentWidth(Alignment.Start) + ) { + OutlinedTextField( + value = itemToString(selectedItem), + onValueChange = {}, + label = { Text(label) }, + readOnly = true, + modifier = Modifier + .fillMaxWidth() + .clickable { expandState = true }, + trailingIcon = { + Icon( + imageVector = if (expandState) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, + contentDescription = "Dropdown Icon", + modifier = Modifier.clickable { expandState = !expandState } + ) + } + ) + DropdownMenu( + expanded = expandState, + onDismissRequest = { expandState = false }, + modifier = Modifier + .fillMaxWidth() + ) { + items.forEach { item -> + DropdownMenuItem(onClick = { + onItemSelected(item) + expandState = false + }) { + Text(text = itemToString(item)) + } + } + } + } +} \ No newline at end of file diff --git a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/LoginScreen.kt b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/LoginScreen.kt index 9f962376..d717e5db 100644 --- a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/LoginScreen.kt +++ b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/LoginScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -93,7 +92,7 @@ fun LoginScreen(navController: NavController, appContext: Context) { modifier = Modifier.fillMaxWidth(), colors = TextFieldDefaults.textFieldColors( containerColor = DeepBlue, - textColor = Color.White, + focusedTextColor = Color.White, cursorColor = Color.White ) ) @@ -110,7 +109,7 @@ fun LoginScreen(navController: NavController, appContext: Context) { keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), colors = TextFieldDefaults.textFieldColors( containerColor = DeepBlue, - textColor = Color.White, + focusedTextColor = Color.White, cursorColor = Color.White ) ) diff --git a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/MapScreen.kt b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/MapScreen.kt index d09b55c6..2798498f 100644 --- a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/MapScreen.kt +++ b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/MapScreen.kt @@ -1,7 +1,6 @@ package com.cmpe451.resq.ui.views.screens import android.content.Context -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,27 +22,33 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.AddCircle import androidx.compose.material.icons.filled.Search import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController -import com.cmpe451.resq.R import com.cmpe451.resq.data.manager.UserSessionManager import com.cmpe451.resq.ui.theme.DeepBlue import com.cmpe451.resq.ui.theme.RequestColor import com.cmpe451.resq.ui.theme.ResourceColor import com.cmpe451.resq.utils.NavigationItem import com.cmpe451.resq.viewmodels.MapViewModel +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.Marker +import com.google.maps.android.compose.MarkerState +import com.google.maps.android.compose.rememberCameraPositionState @Composable -fun MapScreen(navController: NavController, appContext: Context) { - val viewModel: MapViewModel = viewModel() +fun MapScreen(navController: NavController, appContext: Context, mapViewModel: MapViewModel) { val userSessionManager = UserSessionManager.getInstance(appContext) val userRoles = userSessionManager.getUserRoles() + + Box(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier @@ -77,12 +82,42 @@ fun MapScreen(navController: NavController, appContext: Context) { } Spacer(modifier = Modifier.height(16.dp)) - SearchBar(viewModel) - Image( - painter = painterResource(id = R.drawable.mock_map), - contentDescription = "Mock Map", - modifier = Modifier.fillMaxSize() - ) + SearchBar(mapViewModel) + val singapore = LatLng(41.086571, 29.046109) + val cameraPositionState = rememberCameraPositionState { + position = CameraPosition.fromLatLngZoom(mapViewModel.lastKnownLocation.value?.let { + LatLng(it.latitude, it.longitude) + } ?: singapore, 12f) + } + LaunchedEffect(Unit) { + // mapViewModel.getNeedByDistance(appContext) + } + GoogleMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState + ) { + mapViewModel.lastKnownLocation.value?.let { + val latLng = LatLng(it.latitude, it.longitude) + cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 12f) + } + mapViewModel.needMarkerList.value.forEach { need -> + Marker( + state = MarkerState(position = LatLng(need.latitude, need.longitude)), + title = need.description, + snippet = "Quantity: ${need.quantity}" + ) + } + Marker( + state = MarkerState(position = LatLng(41.086571, 29.046109)), + ) + } + LaunchedEffect(mapViewModel.needMarkerList.value) { + if (mapViewModel.needMarkerList.value.isNotEmpty()) { + // Move camera to the first marker or any specific logic you want + val firstNeed = mapViewModel.needMarkerList.value.first() + cameraPositionState.move(CameraUpdateFactory.newLatLngZoom(LatLng(firstNeed.latitude, firstNeed.longitude), 12f)) + } + } } } } diff --git a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/ProfileScreen.kt b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/ProfileScreen.kt index 14fa7b21..84660f1e 100644 --- a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/ProfileScreen.kt +++ b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/ProfileScreen.kt @@ -1,6 +1,12 @@ +@file:Suppress("DEPRECATION") + package com.cmpe451.resq.ui.views.screens + import android.content.Context +import android.net.Uri import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -17,13 +23,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.DropdownMenu -import androidx.compose.material.DropdownMenuItem -import androidx.compose.material.Icon -import androidx.compose.material.IconButton +import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.ArrowBack @@ -31,6 +35,7 @@ import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost @@ -39,26 +44,35 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController -import com.cmpe451.resq.viewmodels.ProfileViewModel +import coil.compose.rememberAsyncImagePainter +import com.cmpe451.resq.data.manager.UserSessionManager import com.cmpe451.resq.data.models.ProfileData -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.KeyboardType -import java.time.Year - +import com.cmpe451.resq.ui.theme.MyTasksColor +import com.cmpe451.resq.ui.theme.OngoingTasksColor +import com.cmpe451.resq.ui.theme.RequestColor +import com.cmpe451.resq.ui.theme.ResourceColor +import com.cmpe451.resq.viewmodels.ProfileViewModel +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -114,7 +128,58 @@ fun TextListSelectionWithColorChange( } } +@Composable +fun ProfileButton(color: Color, text:String, route: String, navController: NavController) { + Button( + onClick = { + if (route.isNotEmpty()){ + navController.navigate(route) + } + + }, + colors = ButtonDefaults.buttonColors(color), + modifier = Modifier + .size(170.dp, 60.dp) + ) { + Text(text = text) + } +} +@Composable +fun ProfilePhoto() { + var imageUri = rememberSaveable { mutableStateOf("") } + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + uri?.let { imageUri.value = it.toString() } + } + if (imageUri.value.isEmpty()) { + Image( + Icons.Default.AccountCircle, + contentDescription = "User Profile", + modifier = Modifier + .size(150.dp) + .clickable { launcher.launch("image/*") }, + ) + } + else{ + val painter = rememberAsyncImagePainter(imageUri.value) + Image( + painter = painter, + contentDescription = "User Profile", + + modifier = Modifier + .size(150.dp) + .clip(CircleShape) + .clickable { launcher.launch("image/*") }, + contentScale = ContentScale.Crop + ) + } + // TO DO: Save imageUri.value to database, and retrieve it when the user logs in again. + // TO DO: Add deletion option +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @RequiresApi(Build.VERSION_CODES.O) @Composable fun ProfileScreen(navController: NavController, appContext: Context) { @@ -122,6 +187,10 @@ fun ProfileScreen(navController: NavController, appContext: Context) { viewModel.getUserData(appContext) + val allRoles = listOf("VICTIM", "RESPONDER", "FACILITATOR") + val userRoles = UserSessionManager.getInstance(appContext).getUserRoles() + val availableRoles = allRoles.filter { !userRoles.contains(it) } + val profileData by viewModel.profile when (profileData) { null -> { @@ -129,10 +198,11 @@ fun ProfileScreen(navController: NavController, appContext: Context) { Text("Loading...") } else -> { - val userRoles = profileData!!.roles + val userRoles = UserSessionManager.getInstance(appContext).getUserRoles() if (userRoles != null) { if (userRoles.contains("VICTIM") || userRoles.contains("RESPONDER") || userRoles.contains("FACILITATOR")) { - Profile(profileData = profileData!!, navController = navController) + + Profile(profileData = profileData!!, navController = navController, availableRoles, viewModel, appContext) } else { Text("Unknown Role") @@ -175,106 +245,114 @@ fun generateDays(month: String): List{ } @RequiresApi(Build.VERSION_CODES.O) -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable -fun Profile(profileData:ProfileData, navController: NavController) { +fun Profile(profileData:ProfileData, navController: NavController, availableRoles: List, viewModel: ProfileViewModel, appContext: Context) { val genders = listOf("Male", "Female") val bloodTypes = listOf("AB Rh+", "AB Rh-", "A Rh+", "A Rh-", "B Rh+", "B Rh-", "O Rh+", "O Rh-") - val years = generateYears(1900, Year.now().value) - val months = generateMonths() - var selectedRole = { mutableStateOf(profileData.selectedRole ?: "") } + val userSessionManager = UserSessionManager.getInstance(appContext) var name by remember { mutableStateOf(profileData.name ?: "") } var surname by remember { mutableStateOf(profileData.surname ?: "") } - var year by remember { mutableStateOf(profileData.year ?: "") } - var month by remember { mutableStateOf(profileData.month ?: "") } - var day by remember { mutableStateOf(profileData.day ?: "") } - var email by remember { mutableStateOf(profileData.email ?: "") } - var weight by remember { mutableStateOf(profileData.weight ?: "") } + var weight by remember { mutableStateOf(profileData.weight?.toString() ?: "") } var gender by remember { mutableStateOf(profileData.gender ?: "") } - var height by remember { mutableStateOf(profileData.height ?: "") } + var height by remember { mutableStateOf(profileData.height?.toString() ?: "") } var country by remember { mutableStateOf(profileData.country ?: "") } var city by remember { mutableStateOf(profileData.city ?: "") } var state by remember { mutableStateOf(profileData.state ?: "") } var phoneNumber by remember { mutableStateOf(profileData.phoneNumber ?: "") } var bloodType by remember { mutableStateOf(profileData.bloodType ?: "") } - var isEmailValid by remember { mutableStateOf(false) } - var isPhoneValid by remember { mutableStateOf(false) } + var isPhoneValid by remember { mutableStateOf(false) } var message by remember { mutableStateOf("") } val snackbarHostState = remember { SnackbarHostState() } + var profileColor = Color(0xFFFFFFFF) + val coroutineScope = rememberCoroutineScope() + val modalBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .background(Color.White) - ) { - TopAppBar( - navigationIcon = { - IconButton(onClick = { navController.navigateUp() }) { - Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "Back") + + ModalBottomSheetLayout( + sheetContent = { + BottomSheetContent( + availableRoles = availableRoles, + onRoleSelected = { selectedRole -> + viewModel.selectRole(selectedRole, appContext) + // Handle the role selection + coroutineScope.launch { modalBottomSheetState.hide() } } - }, - title = { - Text( - text = "Account", - style = TextStyle( - fontSize = 25.sp, - color = Color(0xFF224957), - textAlign = TextAlign.Center - ), - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - }, - modifier = Modifier.fillMaxWidth() - ) - Row (verticalAlignment = Alignment.CenterVertically){ - Image( - Icons.Default.AccountCircle, - contentDescription = "User Profile", - modifier = Modifier - .size(150.dp) - .weight(1f) - ) - Text( - text = "$name $surname", - modifier = Modifier.weight(1f), - style = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) ) - } - + }, + sheetState = modalBottomSheetState, - Box( + ) { + Column( modifier = Modifier .fillMaxSize() - .background(color = Color.White) + .verticalScroll(rememberScrollState()) + .background(Color.White) ) { - Column( + TopAppBar( + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + title = { + Text( + text = "Account", + style = TextStyle( + fontSize = 25.sp, + color = Color(0xFF224957), + textAlign = TextAlign.Center + ), + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + }, + modifier = Modifier.fillMaxWidth() + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp) + ) { + ProfilePhoto() + } + Text( + text = "$name $surname", + modifier = Modifier.weight(1f), + style = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + ) + } + Box( modifier = Modifier - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally + .fillMaxSize() + .background(color = Color.White) ) { - - Spacer(modifier = Modifier.height(16.dp)) - Column( modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally ) { - Row( + + Spacer(modifier = Modifier.height(16.dp)) + + Column( modifier = Modifier .fillMaxWidth() - .padding(4.dp) - .background(Color.White) + .padding(16.dp) ) { - name?.let { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + .background(Color.White) + ) { OutlinedTextField( - value = it, + value = name, onValueChange = { name = it.letterOrSpace() }, label = { Text("First Name") }, shape = RoundedCornerShape(15), @@ -287,19 +365,13 @@ fun Profile(profileData:ProfileData, navController: NavController) { ), colors = TextFieldDefaults.outlinedTextFieldColors( containerColor = Color.White, - textColor = Color.Black, + focusedTextColor = Color.Black, cursorColor = Color.Black - ) - ) - } - - - surname?.let { OutlinedTextField( - value = it, + value = surname, onValueChange = { surname = it.letterOrSpace() }, label = { Text("Last Name") }, shape = RoundedCornerShape(15), @@ -312,64 +384,28 @@ fun Profile(profileData:ProfileData, navController: NavController) { ), colors = TextFieldDefaults.outlinedTextFieldColors( containerColor = Color.White, - textColor = Color.Black, + focusedTextColor = Color.Black, cursorColor = Color.Black ) ) } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(4.dp) - - ) { - - email?.let { - val isValidEmail = android.util.Patterns.EMAIL_ADDRESS.matcher(it).matches() - isEmailValid = isValidEmail - - OutlinedTextField( - value = it, - onValueChange = { - email = it - isEmailValid = android.util.Patterns.EMAIL_ADDRESS.matcher(it).matches() - }, - label = { Text("Email") }, - shape = RoundedCornerShape(15), - modifier = Modifier - .weight(1f) - .padding(4.dp) - .background(Color.White), - colors = TextFieldDefaults.outlinedTextFieldColors( - containerColor = Color.White, - textColor = Color.Black, - cursorColor = Color.Black, - focusedBorderColor = if (isEmailValid) Color.Black else Color.Red - ), - singleLine = true, - keyboardOptions = KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Email - ) - ) - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(4.dp) - ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { - phoneNumber?.let { + isPhoneValid = android.util.Patterns.PHONE.matcher(phoneNumber).matches() OutlinedTextField( - value = it, + value = phoneNumber, keyboardOptions = KeyboardOptions.Default.copy( keyboardType = KeyboardType.Phone ), - onValueChange = { phoneNumber = it.isDigit() - isPhoneValid = android.util.Patterns.PHONE.matcher(it).matches()}, + onValueChange = { + phoneNumber = it.isDigit() + isPhoneValid = android.util.Patterns.PHONE.matcher(it).matches() + }, label = { Text("Phone Number") }, shape = RoundedCornerShape(15), @@ -380,23 +416,19 @@ fun Profile(profileData:ProfileData, navController: NavController) { .background(Color.White), colors = TextFieldDefaults.outlinedTextFieldColors( containerColor = Color.White, - textColor = Color.Black, + focusedTextColor = Color.Black, cursorColor = Color.Black, focusedBorderColor = if (isPhoneValid) Color.Black else Color.Red ) ) - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(4.dp) - ) { - - country?.let { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { OutlinedTextField( - value = it, + value = country, onValueChange = { country = it.letterOrSpace() }, label = { Text("Country") }, shape = RoundedCornerShape(15), @@ -406,15 +438,13 @@ fun Profile(profileData:ProfileData, navController: NavController) { .background(Color.White), colors = TextFieldDefaults.outlinedTextFieldColors( containerColor = Color.White, - textColor = Color.Black, + focusedTextColor = Color.Black, cursorColor = Color.Black, ) ) - } - city?.let { OutlinedTextField( - value = it, + value = city, onValueChange = { city = it.letterOrSpace() }, label = { Text("City") }, shape = RoundedCornerShape(15), @@ -424,15 +454,12 @@ fun Profile(profileData:ProfileData, navController: NavController) { .background(Color.White), colors = TextFieldDefaults.outlinedTextFieldColors( containerColor = Color.White, - textColor = Color.Black, + focusedTextColor = Color.Black, cursorColor = Color.Black, ) ) - - } - state?.let { OutlinedTextField( - value = it, + value = state, onValueChange = { state = it.letterOrSpace() }, label = { Text("State") }, shape = RoundedCornerShape(15), @@ -442,23 +469,18 @@ fun Profile(profileData:ProfileData, navController: NavController) { .background(Color.White), colors = TextFieldDefaults.outlinedTextFieldColors( containerColor = Color.White, - textColor = Color.Black, + focusedTextColor = Color.Black, cursorColor = Color.Black, ) ) - - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(4.dp) - ) { - - weight?.let { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { OutlinedTextField( - value = it, + value = weight, keyboardOptions = KeyboardOptions.Default.copy( keyboardType = KeyboardType.Number ), @@ -471,15 +493,12 @@ fun Profile(profileData:ProfileData, navController: NavController) { .background(Color.White), colors = TextFieldDefaults.outlinedTextFieldColors( containerColor = Color.White, - textColor = Color.Black, - cursorColor = Color.Black + focusedTextColor = Color.Black, + cursorColor = Color.Black, ) ) - - } - height?.let { OutlinedTextField( - value = it, + value = height, keyboardOptions = KeyboardOptions.Default.copy( keyboardType = KeyboardType.Number ), @@ -492,172 +511,249 @@ fun Profile(profileData:ProfileData, navController: NavController) { .background(Color.White), colors = TextFieldDefaults.outlinedTextFieldColors( containerColor = Color.White, - textColor = Color.Black, + focusedTextColor = Color.Black, cursorColor = Color.Black, ) ) } - } - Row( - modifier = Modifier - .fillMaxWidth(), + Row( + modifier = Modifier + .fillMaxWidth(), - ) { - Column ( modifier = Modifier.weight(1f)){ - gender?.let { + ) { + Column(modifier = Modifier.weight(1f)) { TextListSelectionWithColorChange( items = genders, selectedItem = gender, onItemSelected = { gender = it }, label = "Gender", - color = Color(0xFFB356AF) + color = profileColor ) } - } - - Column ( modifier = Modifier.weight(1f)){ - bloodType?.let { + Column(modifier = Modifier.weight(1f)) { TextListSelectionWithColorChange( items = bloodTypes, selectedItem = bloodType, onItemSelected = { bloodType = it }, label = "Blood Type", - color = Color(0xFFB356AF) - ) - } - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(4.dp) - ) { - Column ( modifier = Modifier.weight(1f)){ - year?.let { - TextListSelectionWithColorChange( - items = years, - selectedItem = year, - onItemSelected = { year = it }, - label = "Year", - color = Color(0xFFB356AF) - ) - } - } - Column ( modifier = Modifier.weight(1f)){ - month?.let { - TextListSelectionWithColorChange( - items = months, - selectedItem = month, - onItemSelected = { month = it }, - label = "Month", - color = Color(0xFFB356AF) - ) - } - } - Column ( modifier = Modifier.weight(1f)){ - val days = generateDays(month) - day?.let { - TextListSelectionWithColorChange( - items = days, - selectedItem = day, - onItemSelected = { day = it }, - label = "Day", - color = Color(0xFFB356AF) + color = profileColor ) } } } } } - } + Spacer(modifier = Modifier.weight(1f)) - Spacer(modifier = Modifier.weight(1f)) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - modifier = Modifier.align(Alignment.Start) - ) { - Button( - onClick = { - // @TO DO: Handle button click - }, - colors = ButtonDefaults.buttonColors(Color(0xFFB356AF)), - modifier = Modifier - .size(170.dp, 60.dp) - ) { - Text(text = "My Requests") - } - } - Spacer(modifier = Modifier.height(20.dp)) - Row( - modifier = Modifier.align(Alignment.Start) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(4.dp) - ) { - Button( - onClick = { - if (!isEmailValid and !isPhoneValid) { - // @TO DO: Save details - message = "Please check your email address and phone number." - - } - else if (!isPhoneValid){ - message = "Please check your phone number." + when (UserSessionManager.getInstance(appContext).getSelectedRole()) { + "VICTIM" -> { + VictimProfileButtons(navController = navController) + } - } - else if (!isEmailValid){ - message = "Please check your email address." - } - else{ - // @TO DO Handle Save Details button click - message = "Details saved successfully." - } + "RESPONDER" -> { + ResponderProfileButtons(navController = navController) + } - }, - colors = ButtonDefaults.buttonColors(Color(0xFF224957)), - modifier = Modifier.size(170.dp, 60.dp) - ) { - Text(text = "Save Details") + "FACILITATOR" -> { + FacilitatorProfileButtons(navController = navController) } + } - Spacer(modifier = Modifier.width(25.dp)) - Button( - onClick = { - //@ TO DO Handle button click - }, - colors = ButtonDefaults.buttonColors(Color(0xFF224957)), + Spacer(modifier = Modifier.height(20.dp)) + Row( + modifier = Modifier.align(Alignment.Start) + ) { + Row( modifier = Modifier - .size(170.dp, 60.dp) + .fillMaxWidth() + .padding(4.dp) ) { - Text(text = "Request Role") + Button( + onClick = { + if (!isPhoneValid) { + message = "Please check your phone number." + + } else { + // @TO DO Handle Save Details button click + viewModel.updateProfile(appContext, ProfileData( + name = name, + surname = surname, + bloodType = bloodType, + country = country, + city = city, + state = state, + gender = gender.takeIf { it.isNotEmpty() } , + height = height.takeIf { it.isNotEmpty() }?.toInt(), + weight = weight.takeIf { it.isNotEmpty() }?.toInt(), + phoneNumber = phoneNumber, + birthdate = null + )) + if (viewModel.updateMessage.value != null) { + message = "Details saved successfully." + } + + else if (viewModel.errorMessage.value != null) { + message = viewModel.errorMessage.value!! + } + else{ + message = "Details saved successfully." + } + + } + + }, + colors = ButtonDefaults.buttonColors(Color(0xFF224957)), + modifier = Modifier.size(170.dp, 60.dp) + ) { + Text(text = "Save Details") + } + + Spacer(modifier = Modifier.width(30.dp)) + Button( + onClick = { + //@ TO DO Handle button click + coroutineScope.launch { + modalBottomSheetState.show() + } + }, + colors = ButtonDefaults.buttonColors(Color(0xFF224957)), + modifier = Modifier + .size(170.dp, 60.dp) + ) { + Text(text = "Request Role") + } } } + } } - + LaunchedEffect(key1 = message) { + if (message.isNotEmpty()) { + snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short) + } + } + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) } - LaunchedEffect(key1 = message) { - if (message.isNotEmpty()) { - snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short) + +} + +@Composable +fun BottomSheetContent(availableRoles: List, onRoleSelected: (String) -> Unit) { + Column(modifier = Modifier.fillMaxWidth()) { + Text("Select a Role", style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(16.dp)) + Divider() + // List the available roles for the user to choose + availableRoles.forEach { role -> + TextButton( + onClick = { onRoleSelected(role) }, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Text(role, style = MaterialTheme.typography.bodyMedium) + } + Divider() } } - SnackbarHost( - hostState = snackbarHostState, +} + +@Composable +fun FacilitatorProfileButtons(navController: NavController) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { + ProfileButton( + color = ResourceColor, + text = "My Resources", + route = "", + navController = navController + ) + Spacer(modifier = Modifier.width(30.dp)) + ProfileButton( + color = MyTasksColor, + text = "My Tasks", + route = "", + navController = navController + ) + } + Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp) - ) + .padding(4.dp) + ) { + ProfileButton( + color = RequestColor, + text = "My Request", + route = "", + navController = navController + ) + Spacer(modifier = Modifier.width(30.dp)) + ProfileButton( + color = OngoingTasksColor, + text = "Ongoing Tasks", + route = "OngoingTasks", + navController = navController + ) + } +} +@Composable +fun ResponderProfileButtons(navController: NavController) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { + ProfileButton( + color = ResourceColor, + text = "My Resources", + route = "", + navController = navController + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { + ProfileButton( + color = MyTasksColor, + text = "My Tasks", + route = "", + navController = navController + ) + } } + +@Composable +fun VictimProfileButtons(navController: NavController) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { + ProfileButton( + color = RequestColor, + text = "My Request", + route = "", + navController = navController + ) + } +} \ No newline at end of file diff --git a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/RegistrationScreen.kt b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/RegistrationScreen.kt index db748d27..8bcf9103 100644 --- a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/RegistrationScreen.kt +++ b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/RegistrationScreen.kt @@ -88,7 +88,7 @@ modifier = Modifier.weight(1f).padding(end = 8.dp), // half width and add some padding colors = TextFieldDefaults.textFieldColors( containerColor = DeepBlue, - textColor = Color.White, + focusedTextColor = Color.White, cursorColor = Color.White ) ) @@ -100,7 +100,7 @@ modifier = Modifier.weight(1f).padding(start = 8.dp), // half width and add some padding colors = TextFieldDefaults.textFieldColors( containerColor = DeepBlue, - textColor = Color.White, + focusedTextColor = Color.White, cursorColor = Color.White ) ) @@ -116,7 +116,7 @@ modifier = Modifier.fillMaxWidth(), colors = TextFieldDefaults.textFieldColors( containerColor = DeepBlue, - textColor = Color.White, + focusedTextColor = Color.White, cursorColor = Color.White ) ) @@ -133,7 +133,7 @@ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), colors = TextFieldDefaults.textFieldColors( containerColor = DeepBlue, - textColor = Color.White, + focusedTextColor = Color.White, cursorColor = Color.White ) ) @@ -150,7 +150,7 @@ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), colors = TextFieldDefaults.textFieldColors( containerColor = DeepBlue, - textColor = Color.White, + focusedTextColor = Color.White, cursorColor = Color.White ) ) diff --git a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/RequestScreen.kt b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/RequestScreen.kt index 90619039..30376c90 100644 --- a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/RequestScreen.kt +++ b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/RequestScreen.kt @@ -2,7 +2,6 @@ package com.cmpe451.resq.ui.views.screens import android.content.Context import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -10,13 +9,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults -import androidx.compose.material.DropdownMenu -import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.OutlinedTextField @@ -24,8 +20,6 @@ import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -42,9 +36,10 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController -import com.cmpe451.resq.data.models.CategoryNode +import com.cmpe451.resq.data.models.CategoryTreeNode import com.cmpe451.resq.ui.theme.LightGreen import com.cmpe451.resq.ui.theme.RequestColor +import com.cmpe451.resq.ui.views.components.DropdownMenuComponent import com.cmpe451.resq.viewmodels.RequestViewModel @Composable @@ -100,7 +95,7 @@ fun RequestScreen( DropdownMenuComponent( label = "Category", items = categories, - selectedItem = selectedCategoryState.value ?: CategoryNode(-1, "Select a Category", emptyList()), + selectedItem = selectedCategoryState.value ?: CategoryTreeNode(-1, "Select a Category", emptyList()), itemToString = { it.data }, onItemSelected = { category -> viewModel.updateCategory(category) @@ -112,7 +107,7 @@ fun RequestScreen( DropdownMenuComponent( label = "Type", items = viewModel.types.value, - selectedItem = viewModel.selectedType.value ?: CategoryNode(-1, "Select a Type", emptyList()), + selectedItem = viewModel.selectedType.value ?: CategoryTreeNode(-1, "Select a Type", emptyList()), itemToString = { it.data }, onItemSelected = { type -> viewModel.updateType(type) @@ -125,7 +120,7 @@ fun RequestScreen( DropdownMenuComponent( label = "Item", items = viewModel.items.value, - selectedItem = viewModel.selectedItem.value ?: CategoryNode(-1, "Select an Item", emptyList()), + selectedItem = viewModel.selectedItem.value ?: CategoryTreeNode(-1, "Select an Item", emptyList()), itemToString = { it.data }, onItemSelected = { item -> viewModel.updateItem(item) @@ -176,50 +171,3 @@ fun RequestScreen( SnackbarHost(hostState = snackbarHostState) } } - -@Composable -fun DropdownMenuComponent( - label: String, - items: List, - selectedItem: T, - itemToString: (T) -> String, - onItemSelected: (T) -> Unit -) { - var expandState by remember { mutableStateOf(false) } - Box(modifier = Modifier - .fillMaxWidth() - .wrapContentWidth(Alignment.Start) - ) { - OutlinedTextField( - value = itemToString(selectedItem), - onValueChange = {}, - label = { Text(label) }, - readOnly = true, - modifier = Modifier - .fillMaxWidth() - .clickable { expandState = true }, - trailingIcon = { - Icon( - imageVector = if (expandState) Icons.Filled.KeyboardArrowUp else Icons.Filled.ArrowDropDown, - contentDescription = "Dropdown Icon", - modifier = Modifier.clickable { expandState = !expandState } - ) - } - ) - DropdownMenu( - expanded = expandState, - onDismissRequest = { expandState = false }, - modifier = Modifier - .fillMaxWidth() - ) { - items.forEach { item -> - DropdownMenuItem(onClick = { - onItemSelected(item) - expandState = false - }) { - Text(text = itemToString(item)) - } - } - } - } -} \ No newline at end of file diff --git a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/ResourceScreen.kt b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/ResourceScreen.kt index 3745181e..8b913999 100644 --- a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/ResourceScreen.kt +++ b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/ui/views/screens/ResourceScreen.kt @@ -1,7 +1,7 @@ package com.cmpe451.resq.ui.views.screens +import android.content.Context import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -13,8 +13,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults -import androidx.compose.material.DropdownMenu -import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.OutlinedTextField @@ -22,8 +20,11 @@ import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -35,88 +36,39 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController +import com.cmpe451.resq.data.models.CategoryTreeNode import com.cmpe451.resq.ui.theme.LightGreen import com.cmpe451.resq.ui.theme.ResourceColor +import com.cmpe451.resq.ui.views.components.DropdownMenuComponent import com.cmpe451.resq.viewmodels.ResourceViewModel -@Composable -fun DropdownMenuComponentWithColorChange( - label: String, - items: List, - selectedItem: String, - expanded: Boolean, - onItemSelected: (String) -> Unit -) { - var expanded by remember { mutableStateOf(expanded) } - var selected by remember { mutableStateOf(selectedItem) } - - Box( - modifier = Modifier - .fillMaxWidth() - .clickable { expanded = true } - ) { - OutlinedTextField( - value = selectedItem, - onValueChange = {}, - label = { Text(label) }, - readOnly = true, - modifier = Modifier.fillMaxWidth(), - trailingIcon = { - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = "Dropdown Icon", - modifier = Modifier.clickable { expanded = !expanded } - ) - } - ) - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - items.forEach { item -> - DropdownMenuItem( - onClick = { - selected = item - onItemSelected(item) - expanded = false - }, - modifier = Modifier.background(if (item == selected) ResourceColor else Color.Transparent) - ) { - Text(text = item, color = if (item == selected) Color.White else Color.Black) - } - } - } - } -} - @Composable fun ResourceScreen( navController: NavController, + appContext: Context ) { val viewModel: ResourceViewModel = viewModel() - // Variables for dropdown menu - var typeExpanded by remember { mutableStateOf(false) } - val types = listOf("Human Resource", "Clothes", "Food", "Medicine") - var selectedType by remember { mutableStateOf(types[0]) } - var professionExpanded by remember { mutableStateOf(false) } - val professions = listOf("Truck Driver", "Medic", "Crane Operator") - var selectedProfession by remember { mutableStateOf(professions[0]) } + LaunchedEffect(key1 = true) { + viewModel.fetchMainCategories(appContext) + } + + val selectedCategoryState = viewModel.selectedCategory + val categories = viewModel.categories.value var quantity by remember { mutableStateOf("") } + + val snackbarHostState = remember { SnackbarHostState() } + Column( modifier = Modifier .background(Color.White) .fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { + // Top Bar with back button and title TopAppBar( - title = { Text(text = "Resource", - color = ResourceColor) }, - modifier = Modifier - .fillMaxWidth() - .align(Alignment.CenterHorizontally), + title = { Text(text = "Resource", color = ResourceColor) }, navigationIcon = { IconButton(onClick = { navController.navigateUp() }) { Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "Back") @@ -136,34 +88,49 @@ fun ResourceScreen( modifier = Modifier .fillMaxWidth() .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally) { - DropdownMenuComponentWithColorChange( - label = "Type", - items = types, - selectedItem = selectedType, - expanded = false - ) { - selectedType = it - typeExpanded = false - } + horizontalAlignment = Alignment.CenterHorizontally + ) { + + DropdownMenuComponent( + label = "Category", + items = categories, + selectedItem = selectedCategoryState.value ?: CategoryTreeNode(-1, "Select a Category", emptyList()), + itemToString = { it.data }, + onItemSelected = { category -> + viewModel.updateCategory(category) + } + ) Spacer(modifier = Modifier.height(16.dp)) - if (selectedType == "Human Resource") { - DropdownMenuComponentWithColorChange( - label = "Profession", - items = professions, - selectedItem = selectedProfession, - expanded = false - ) { selectedProfession = it } + DropdownMenuComponent( + label = "Type", + items = viewModel.types.value, + selectedItem = viewModel.selectedType.value ?: CategoryTreeNode(-1, "Select a Type", emptyList()), + itemToString = { it.data }, + onItemSelected = { type -> + viewModel.updateType(type) + viewModel.fetchItemsForType(type.id) + } + ) - Spacer(modifier = Modifier.height(16.dp)) - } + Spacer(modifier = Modifier.height(16.dp)) + DropdownMenuComponent( + label = "Item", + items = viewModel.items.value, + selectedItem = viewModel.selectedItem.value ?: CategoryTreeNode(-1, "Select an Item", emptyList()), + itemToString = { it.data }, + onItemSelected = { item -> + viewModel.updateItem(item) + } + ) + + Spacer(modifier = Modifier.height(16.dp)) OutlinedTextField( - value = quantity, // you can bind this to a state variable to store the quantity - onValueChange = { quantity= it }, + value = quantity, + onValueChange = { quantity = it }, label = { Text("Quantity") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth() @@ -173,7 +140,7 @@ fun ResourceScreen( // Enter Button Button( - onClick = { viewModel.onEnter() }, + onClick = { viewModel.onEnter(quantity, appContext) }, colors = ButtonDefaults.buttonColors(backgroundColor = LightGreen), shape = RoundedCornerShape(50) ) { @@ -181,8 +148,16 @@ fun ResourceScreen( } } } + // Success and Error messages + if (viewModel.createResourceResponse.value != null) { + LaunchedEffect(key1 = viewModel.createResourceResponse.value) { + snackbarHostState.showSnackbar( + message = "Resource created successfully.", + duration = SnackbarDuration.Long + ) + } + } + SnackbarHost(hostState = snackbarHostState) } } - - diff --git a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/viewmodels/MapViewModel.kt b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/viewmodels/MapViewModel.kt index 37a65b74..cdc725a5 100644 --- a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/viewmodels/MapViewModel.kt +++ b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/viewmodels/MapViewModel.kt @@ -1,8 +1,51 @@ package com.cmpe451.resq.viewmodels +import android.annotation.SuppressLint +import android.content.Context +import android.location.Location import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import com.cmpe451.resq.data.models.Need +import com.cmpe451.resq.data.remote.ResqService +import com.google.android.gms.location.FusedLocationProviderClient class MapViewModel : ViewModel() { val searchQuery = mutableStateOf("") + val lastKnownLocation = mutableStateOf(null) + val needMarkerList = mutableStateOf>(emptyList()) + suspend fun getNeedByDistance(appContext: Context) { + val api = ResqService(appContext) + + val response = api.filterNeedByDistance( + latitude = lastKnownLocation.value?.latitude ?: 41.086571, + longitude = lastKnownLocation.value?.longitude ?:29.046109, + distance = 1000.0, + ) + + if (response.isSuccessful) { + val needList = response.body() + needList?.let { + needMarkerList.value = it + } + } + } + @SuppressLint("MissingPermission") + fun getDeviceLocation( + fusedLocationProviderClient: FusedLocationProviderClient + ) { + /* + * Get the best and most recent location of the device, which may be null in rare + * cases when a location is not available. + */ + try { + val locationResult = fusedLocationProviderClient.lastLocation + locationResult.addOnCompleteListener { task -> + if (task.isSuccessful) { + lastKnownLocation.value = task.result + } + } + } catch (e: SecurityException) { + // Show error or something + } + } } \ No newline at end of file diff --git a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/viewmodels/ProfileViewModel.kt b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/viewmodels/ProfileViewModel.kt index 7fed99d9..5aeed2f4 100644 --- a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/viewmodels/ProfileViewModel.kt +++ b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/viewmodels/ProfileViewModel.kt @@ -1,10 +1,15 @@ package com.cmpe451.resq.viewmodels import android.content.Context +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.cmpe451.resq.data.manager.UserSessionManager import com.cmpe451.resq.data.models.ProfileData import com.cmpe451.resq.data.remote.ResqService import kotlinx.coroutines.flow.MutableStateFlow @@ -13,14 +18,21 @@ import kotlinx.coroutines.launch class ProfileViewModel() : ViewModel() { private var _profileData: MutableState = mutableStateOf(null) val profile get() = _profileData - private val errorMessage = MutableStateFlow(null) + private val _errorMessage = MutableStateFlow(null) + val errorMessage = _errorMessage + private val _updateMessage = mutableStateOf(null) + val updateMessage: State = _updateMessage + + + @RequiresApi(Build.VERSION_CODES.O) fun getUserData(appContext: Context) { val api = ResqService(appContext) viewModelScope.launch { try { val data = api.getUserInfo() + Log.d("Service", "getUserData: $data") _profileData.value = data } catch (e: Exception) { errorMessage.value = e.message @@ -28,4 +40,39 @@ class ProfileViewModel() : ViewModel() { } } -} + + @RequiresApi(Build.VERSION_CODES.O) + fun updateProfile(appContext: Context, profileData: ProfileData){ + val api = ResqService(appContext) + viewModelScope.launch { + try { + Log.d("Update DATA", "") + val response = api.updateUserData(profileData) + + if (response.isSuccessful) { + _updateMessage.value = response.body() + _errorMessage.value = null + } + else { + _errorMessage.value = response.message() + ?: "Profile update failed. Please try again." + } + } catch (e: Exception) { + _errorMessage.value = e.message ?: "Unexpected error occurred." + } + } + } + fun selectRole(role: String, appContext: Context) { + val userSessionManager: UserSessionManager = UserSessionManager.getInstance(appContext) + val api = ResqService(appContext) + val roles = userSessionManager.getUserRoles() + viewModelScope.launch { + try { + val response = api.selectRole(role) + Log.d("ProfileViewModel", "selectRole: $response") + } catch (e: Exception) { + errorMessage.value = e.message + } + } + } +} \ No newline at end of file diff --git a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/viewmodels/RequestViewModel.kt b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/viewmodels/RequestViewModel.kt index 9b7307c6..6d6d4b2d 100644 --- a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/viewmodels/RequestViewModel.kt +++ b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/viewmodels/RequestViewModel.kt @@ -5,47 +5,44 @@ import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.cmpe451.resq.data.models.CategoryNode +import com.cmpe451.resq.data.models.CategoryTreeNode import com.cmpe451.resq.data.models.CreateNeedRequestBody -import com.cmpe451.resq.data.models.LoginRequestBody -import com.cmpe451.resq.data.models.LoginResponse import com.cmpe451.resq.data.remote.ResqService -import com.cmpe451.resq.utils.NavigationItem import kotlinx.coroutines.launch class RequestViewModel : ViewModel() { - private val _selectedCategory = mutableStateOf(null) - val selectedCategory: State = _selectedCategory + private val _selectedCategory = mutableStateOf(null) + val selectedCategory: State = _selectedCategory - private val _selectedType = mutableStateOf(null) - val selectedType: State = _selectedType + private val _selectedType = mutableStateOf(null) + val selectedType: State = _selectedType - private val _selectedItem = mutableStateOf(null) - val selectedItem: State = _selectedItem + private val _selectedItem = mutableStateOf(null) + val selectedItem: State = _selectedItem - private val _categories = mutableStateOf>(emptyList()) - val categories: State> = _categories + private val _categories = mutableStateOf>(emptyList()) + val categories: State> = _categories - private val _types = mutableStateOf>(emptyList()) - val types: State> = _types + private val _types = mutableStateOf>(emptyList()) + val types: State> = _types - private val _items = mutableStateOf>(emptyList()) - val items: State> = _items + private val _items = mutableStateOf>(emptyList()) + val items: State> = _items private val _createNeedResponse = mutableStateOf(null) val createNeedResponse: State = _createNeedResponse - fun updateCategory(category: CategoryNode) { + fun updateCategory(category: CategoryTreeNode) { _selectedCategory.value = category fetchTypesForCategory(category.id) } - fun updateType(type: CategoryNode) { + fun updateType(type: CategoryTreeNode) { _selectedType.value = type fetchItemsForType(type.id) } - fun updateItem(item: CategoryNode) { + fun updateItem(item: CategoryTreeNode) { _selectedItem.value = item } diff --git a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/viewmodels/ResourceViewModel.kt b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/viewmodels/ResourceViewModel.kt index 0dc8e4c4..37b34fea 100644 --- a/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/viewmodels/ResourceViewModel.kt +++ b/resq/mobile/ResQ/app/src/main/java/com/cmpe451/resq/viewmodels/ResourceViewModel.kt @@ -1,33 +1,112 @@ package com.cmpe451.resq.viewmodels +import android.content.Context import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.cmpe451.resq.data.models.CategoryTreeNode +import com.cmpe451.resq.data.models.CreateResourceRequestBody +import com.cmpe451.resq.data.remote.ResqService +import kotlinx.coroutines.launch class ResourceViewModel : ViewModel() { + private val _selectedCategory = mutableStateOf(null) + val selectedCategory: State = _selectedCategory - private val _selectedType = mutableStateOf("Food") - val selectedType: State = _selectedType + private val _selectedType = mutableStateOf(null) + val selectedType: State = _selectedType - private val _selectedProfesion = mutableStateOf("Truck Driver") - val selectedProfession: State = _selectedProfesion + private val _selectedItem = mutableStateOf(null) + val selectedItem: State = _selectedItem - private val _quantity = mutableStateOf(null) - val quantity: State = _quantity - // Update functions - fun updateType(type: String) { + private val _categories = mutableStateOf>(emptyList()) + val categories: State> = _categories + + private val _types = mutableStateOf>(emptyList()) + val types: State> = _types + + private val _items = mutableStateOf>(emptyList()) + val items: State> = _items + + private val _createResourceResponse = mutableStateOf(null) + val createResourceResponse: State = _createResourceResponse + + fun updateCategory(category: CategoryTreeNode) { + _selectedCategory.value = category + fetchTypesForCategory(category.id) + } + + fun updateType(type: CategoryTreeNode) { _selectedType.value = type + fetchItemsForType(type.id) + } + + fun updateItem(item: CategoryTreeNode) { + _selectedItem.value = item } - fun updateProfession(profession: String) { - _selectedProfesion.value = profession + private fun fetchTypesForCategory(categoryId: Int) { + _types.value = _categories.value.find { it.id == categoryId }?.children ?: emptyList() + _selectedType.value = null + _selectedItem.value = null } - fun updateQuantity(quantity: String) { - _quantity.value = quantity + fun fetchItemsForType(typeId: Int) { + val selectedCategoryChildren = _selectedCategory.value?.children ?: emptyList() + _items.value = selectedCategoryChildren.find { it.id == typeId }?.children ?: emptyList() + _selectedItem.value = null } - // Handle the enter action - fun onEnter() { + fun fetchMainCategories(appContext: Context) { + viewModelScope.launch { + val api = ResqService(appContext) + + val response = api.getMainCategories() + if (response.isSuccessful) { + _categories.value = response.body() ?: emptyList() + if (_categories.value.isNotEmpty()) { + _selectedCategory.value = _categories.value.first() + } + } else { + // TODO: Handle error + } + } + } + + fun onEnter(quantity: String, appContext: Context) { + viewModelScope.launch { + val result = getCreateResourceResponse(quantity, appContext) + + if (result.isSuccess) { + _createResourceResponse.value = result.getOrNull().toString() + } else { + _createResourceResponse.value = result.exceptionOrNull()?.message + } + } + } + + private suspend fun getCreateResourceResponse(quantity: String, appContext: Context): Result { + val api = ResqService(appContext) + val categoryId = _selectedItem.value?.id?.toString() ?: _selectedType.value?.id?.toString() ?: "" + + if (categoryId.isNotEmpty()) { + val requestBody = CreateResourceRequestBody( + senderId = null, + categoryTreeId = categoryId, + quantity = quantity.toIntOrNull() ?: 0, + latitude = 0.0, + longitude = 0.0, + gender = "FEMALE" + ) + val response = api.createResource(requestBody) + if (response.isSuccessful) { + response.body()?.let { + return Result.success(it) + } + } + return Result.failure(Throwable(response.message())) + } + return Result.failure(Throwable(message = "No category")) } } diff --git a/resq/mobile/ResQ/build.gradle.kts b/resq/mobile/ResQ/build.gradle.kts index 914c55c7..8cdf4be4 100644 --- a/resq/mobile/ResQ/build.gradle.kts +++ b/resq/mobile/ResQ/build.gradle.kts @@ -2,4 +2,10 @@ plugins { id("com.android.application") version "8.1.2" apply false id("org.jetbrains.kotlin.android") version "1.9.10" apply false -} \ No newline at end of file +} + +buildscript { + dependencies { + classpath("com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1") + } +} diff --git a/resq/mobile/ResQ/secrets.properties b/resq/mobile/ResQ/secrets.properties new file mode 100644 index 00000000..4fc93b7d --- /dev/null +++ b/resq/mobile/ResQ/secrets.properties @@ -0,0 +1 @@ +MAPS_API_KEY=AIzaSyANcTSGzEKneY4AGgvYs720ZNUgiTUIDb0 \ No newline at end of file