diff --git a/backend/src/main/java/online/wesal/wesal/controller/InvitationController.java b/backend/src/main/java/online/wesal/wesal/controller/InvitationController.java new file mode 100644 index 0000000..425fcfc --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/controller/InvitationController.java @@ -0,0 +1,133 @@ +package online.wesal.wesal.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import online.wesal.wesal.dto.AcceptInvitationRequest; +import online.wesal.wesal.dto.ApiResponse; +import online.wesal.wesal.dto.CancelInvitationRequest; +import online.wesal.wesal.dto.CategorizedInvitationsResponse; +import online.wesal.wesal.dto.CreateInvitationRequest; +import online.wesal.wesal.dto.InvitationResponse; +import online.wesal.wesal.service.InvitationService; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; + +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/invitations") +@CrossOrigin(origins = "*") +@Tag(name = "Invitations", description = "Invitation management endpoints") +public class InvitationController { + + @Autowired + private InvitationService invitationService; + + @PostMapping(value = "/create", consumes = "application/json", produces = "application/json") + @Operation(summary = "Create invitation", description = "Create a new invitation") + public ResponseEntity> createInvitation( + @Valid @RequestBody CreateInvitationRequest request, + BindingResult bindingResult, + Authentication authentication) { + + if (bindingResult.hasErrors()) { + String errorMessage = bindingResult.getFieldErrors().stream() + .map(error -> { + String field = error.getField(); + if ("title".equals(field)) return "Title is required"; + if ("description".equals(field)) return "Description is required"; + if ("maxParticipants".equals(field)) return "Maximum participants must be a positive number"; + if ("tagId".equals(field)) return "Tag ID is required"; + return error.getDefaultMessage(); + }) + .collect(Collectors.joining(", ")); + return ResponseEntity.badRequest().body(ApiResponse.error(errorMessage)); + } + + try { + String userEmail = authentication.getName(); + InvitationResponse response = invitationService.createInvitation(request, userEmail); + return ResponseEntity.ok(ApiResponse.success(response)); + } catch (RuntimeException e) { + String message; + if (e.getMessage().contains("User not found")) { + message = "Authentication error. Please log in again."; + } else if (e.getMessage().contains("Tag not found")) { + message = "The selected tag does not exist."; + } else { + message = "Something went wrong.. We're sorry but try again later"; + } + return ResponseEntity.badRequest().body(ApiResponse.error(message)); + } + } + + @GetMapping("/get") + @Operation(summary = "Get invitation", description = "Get invitation by ID") + public ResponseEntity> getInvitation(@RequestParam Long id) { + if (id == null || id <= 0) { + return ResponseEntity.badRequest().body(ApiResponse.error("Valid invitation ID is required")); + } + + try { + return invitationService.getInvitationById(id) + .map(invitation -> ResponseEntity.ok(ApiResponse.success(invitation))) + .orElse(ResponseEntity.status(404).body(ApiResponse.error("Invitation not found"))); + } catch (Exception e) { + return ResponseEntity.status(500).body(ApiResponse.error("Something went wrong.. We're sorry but try again later")); + } + } + + @GetMapping("/all") + @Operation(summary = "Get all categorized invitations", description = "Get invitations in three categories: created, accepted, and available") + public ResponseEntity> getAllCategorizedInvitations(Authentication authentication) { + try { + String userEmail = authentication.getName(); + CategorizedInvitationsResponse response = invitationService.getAllCategorizedInvitations(userEmail); + return ResponseEntity.ok(ApiResponse.success(response)); + } catch (RuntimeException e) { + return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); + } catch (Exception e) { + return ResponseEntity.status(500).body(ApiResponse.error("Something went wrong.. We're sorry but try again later")); + } + } + + @PostMapping("/accept") + @Operation(summary = "Accept invitation", description = "Accept an invitation by ID") + public ResponseEntity> acceptInvitation( + @Valid @RequestBody AcceptInvitationRequest request, + Authentication authentication) { + + try { + String userEmail = authentication.getName(); + invitationService.acceptInvitation(request.getId(), userEmail); + return ResponseEntity.ok(new ApiResponse<>(true)); + } catch (RuntimeException e) { + return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); + } catch (Exception e) { + return ResponseEntity.status(500).body(ApiResponse.error("Something went wrong.. We're sorry but try again later")); + } + } + + @PostMapping("/cancel") + @Operation(summary = "Cancel invitation", description = "Cancel an invitation - attendee leaves or organizer deletes entire invitation") + public ResponseEntity> cancelInvitation( + @Valid @RequestBody CancelInvitationRequest request, + Authentication authentication) { + + try { + String userEmail = authentication.getName(); + invitationService.cancelInvitation(request.getId(), userEmail); + return ResponseEntity.ok(new ApiResponse<>(true)); + } catch (RuntimeException e) { + return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage())); + } catch (Exception e) { + return ResponseEntity.status(500).body(ApiResponse.error("Something went wrong.. We're sorry but try again later")); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/AcceptInvitationRequest.java b/backend/src/main/java/online/wesal/wesal/dto/AcceptInvitationRequest.java new file mode 100644 index 0000000..088e8cc --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/AcceptInvitationRequest.java @@ -0,0 +1,25 @@ +package online.wesal.wesal.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public class AcceptInvitationRequest { + + @NotNull(message = "Invitation ID is required") + @Positive(message = "Invitation ID must be positive") + private Long id; + + public AcceptInvitationRequest() {} + + public AcceptInvitationRequest(Long id) { + this.id = id; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/ApiResponse.java b/backend/src/main/java/online/wesal/wesal/dto/ApiResponse.java new file mode 100644 index 0000000..a2cf922 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/ApiResponse.java @@ -0,0 +1,62 @@ +package online.wesal.wesal.dto; + +public class ApiResponse { + + private boolean status; + private String message; + private T data; + + public ApiResponse() {} + + public ApiResponse(boolean status) { + this.status = status; + } + + public ApiResponse(boolean status, String message) { + this.status = status; + this.message = message; + } + + public ApiResponse(boolean status, T data) { + this.status = status; + this.data = data; + } + + public ApiResponse(boolean status, String message, T data) { + this.status = status; + this.message = message; + this.data = data; + } + + public static ApiResponse success(T data) { + return new ApiResponse<>(true, data); + } + + public static ApiResponse error(String message) { + return new ApiResponse<>(false, message); + } + + public boolean isStatus() { + return status; + } + + public void setStatus(boolean status) { + this.status = status; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/CancelInvitationRequest.java b/backend/src/main/java/online/wesal/wesal/dto/CancelInvitationRequest.java new file mode 100644 index 0000000..0b71766 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/CancelInvitationRequest.java @@ -0,0 +1,25 @@ +package online.wesal.wesal.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public class CancelInvitationRequest { + + @NotNull(message = "Invitation ID is required") + @Positive(message = "Invitation ID must be positive") + private Long id; + + public CancelInvitationRequest() {} + + public CancelInvitationRequest(Long id) { + this.id = id; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/CategorizedInvitationsResponse.java b/backend/src/main/java/online/wesal/wesal/dto/CategorizedInvitationsResponse.java new file mode 100644 index 0000000..8bc0158 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/CategorizedInvitationsResponse.java @@ -0,0 +1,44 @@ +package online.wesal.wesal.dto; + +import java.util.List; + +public class CategorizedInvitationsResponse { + + private List created; + private List accepted; + private List available; + + public CategorizedInvitationsResponse() {} + + public CategorizedInvitationsResponse(List created, + List accepted, + List available) { + this.created = created; + this.accepted = accepted; + this.available = available; + } + + public List getCreated() { + return created; + } + + public void setCreated(List created) { + this.created = created; + } + + public List getAccepted() { + return accepted; + } + + public void setAccepted(List accepted) { + this.accepted = accepted; + } + + public List getAvailable() { + return available; + } + + public void setAvailable(List available) { + this.available = available; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/CreateInvitationRequest.java b/backend/src/main/java/online/wesal/wesal/dto/CreateInvitationRequest.java new file mode 100644 index 0000000..7ace87e --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/CreateInvitationRequest.java @@ -0,0 +1,76 @@ +package online.wesal.wesal.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.time.LocalDateTime; + +public class CreateInvitationRequest { + + @NotBlank + private String title; + + @NotBlank + private String description; + + private LocalDateTime dateTime; + + private String location; + + @NotNull + @Positive + private Integer maxParticipants; + + @NotNull + private Long tagId; + + public CreateInvitationRequest() {} + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public LocalDateTime getDateTime() { + return dateTime; + } + + public void setDateTime(LocalDateTime dateTime) { + this.dateTime = dateTime; + } + + public String getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = location; + } + + public Integer getMaxParticipants() { + return maxParticipants; + } + + public void setMaxParticipants(Integer maxParticipants) { + this.maxParticipants = maxParticipants; + } + + public Long getTagId() { + return tagId; + } + + public void setTagId(Long tagId) { + this.tagId = tagId; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/InvitationResponse.java b/backend/src/main/java/online/wesal/wesal/dto/InvitationResponse.java new file mode 100644 index 0000000..2e5ec8a --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/InvitationResponse.java @@ -0,0 +1,222 @@ +package online.wesal.wesal.dto; + +import java.time.LocalDateTime; + +public class InvitationResponse { + + private Long id; + private String title; + private String description; + private LocalDateTime dateTime; + private String location; + private Integer maxParticipants; + private Integer currentAttendees; + private TagDto tag; + private UserDto creator; + private LocalDateTime createdAt; + private java.util.List attendees; + + public InvitationResponse() {} + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public LocalDateTime getDateTime() { + return dateTime; + } + + public void setDateTime(LocalDateTime dateTime) { + this.dateTime = dateTime; + } + + public String getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = location; + } + + public Integer getMaxParticipants() { + return maxParticipants; + } + + public void setMaxParticipants(Integer maxParticipants) { + this.maxParticipants = maxParticipants; + } + + public Integer getCurrentAttendees() { + return currentAttendees; + } + + public void setCurrentAttendees(Integer currentAttendees) { + this.currentAttendees = currentAttendees; + } + + public TagDto getTag() { + return tag; + } + + public void setTag(TagDto tag) { + this.tag = tag; + } + + public UserDto getCreator() { + return creator; + } + + public void setCreator(UserDto creator) { + this.creator = creator; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public java.util.List getAttendees() { + return attendees; + } + + public void setAttendees(java.util.List attendees) { + this.attendees = attendees; + } + + public static class TagDto { + private Long id; + private String name; + private String colorHex; + private String iconName; + + public TagDto() {} + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getColorHex() { + return colorHex; + } + + public void setColorHex(String colorHex) { + this.colorHex = colorHex; + } + + public String getIconName() { + return iconName; + } + + public void setIconName(String iconName) { + this.iconName = iconName; + } + } + + public static class UserDto { + private Long id; + private String displayName; + private String avatar; + + public UserDto() {} + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getAvatar() { + return avatar; + } + + public void setAvatar(String avatar) { + this.avatar = avatar; + } + } + + public static class AttendeeDto { + private Long id; + private String displayName; + private String avatar; + private LocalDateTime joinedAt; + + public AttendeeDto() {} + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getAvatar() { + return avatar; + } + + public void setAvatar(String avatar) { + this.avatar = avatar; + } + + public LocalDateTime getJoinedAt() { + return joinedAt; + } + + public void setJoinedAt(LocalDateTime joinedAt) { + this.joinedAt = joinedAt; + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/entity/Attendee.java b/backend/src/main/java/online/wesal/wesal/entity/Attendee.java new file mode 100644 index 0000000..6e57ae9 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/entity/Attendee.java @@ -0,0 +1,67 @@ +package online.wesal.wesal.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; + +@Entity +@Table(name = "attendees", + uniqueConstraints = @UniqueConstraint(columnNames = {"invitation_id", "user_id"})) +public class Attendee { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "invitation_id", nullable = false) + @NotNull + private Invitation invitation; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + @NotNull + private User user; + + @Column(nullable = false) + private LocalDateTime joinedAt = LocalDateTime.now(); + + public Attendee() {} + + public Attendee(Invitation invitation, User user) { + this.invitation = invitation; + this.user = user; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Invitation getInvitation() { + return invitation; + } + + public void setInvitation(Invitation invitation) { + this.invitation = invitation; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public LocalDateTime getJoinedAt() { + return joinedAt; + } + + public void setJoinedAt(LocalDateTime joinedAt) { + this.joinedAt = joinedAt; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/entity/Invitation.java b/backend/src/main/java/online/wesal/wesal/entity/Invitation.java new file mode 100644 index 0000000..4414901 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/entity/Invitation.java @@ -0,0 +1,138 @@ +package online.wesal.wesal.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.time.LocalDateTime; + +@Entity +@Table(name = "invitations") +public class Invitation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + @NotBlank + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + @NotBlank + private String description; + + private LocalDateTime dateTime; + + private String location; + + @Column(nullable = false) + @Positive + private Integer maxParticipants; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tag_id", nullable = false) + @NotNull + private Tag tag; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "creator_id", nullable = false) + @NotNull + private User creator; + + @Column(nullable = false) + private Integer currentAttendees = 0; + + @Column(nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + public Invitation() {} + + public Invitation(String title, String description, Integer maxParticipants, Tag tag, User creator) { + this.title = title; + this.description = description; + this.maxParticipants = maxParticipants; + this.tag = tag; + this.creator = creator; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public LocalDateTime getDateTime() { + return dateTime; + } + + public void setDateTime(LocalDateTime dateTime) { + this.dateTime = dateTime; + } + + public String getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = location; + } + + public Integer getMaxParticipants() { + return maxParticipants; + } + + public void setMaxParticipants(Integer maxParticipants) { + this.maxParticipants = maxParticipants; + } + + public Tag getTag() { + return tag; + } + + public void setTag(Tag tag) { + this.tag = tag; + } + + public User getCreator() { + return creator; + } + + public void setCreator(User creator) { + this.creator = creator; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public Integer getCurrentAttendees() { + return currentAttendees; + } + + public void setCurrentAttendees(Integer currentAttendees) { + this.currentAttendees = currentAttendees; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/entity/Tag.java b/backend/src/main/java/online/wesal/wesal/entity/Tag.java new file mode 100644 index 0000000..766a010 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/entity/Tag.java @@ -0,0 +1,65 @@ +package online.wesal.wesal.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; + +@Entity +@Table(name = "tags") +public class Tag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + @NotBlank + private String name; + + @Column(nullable = false) + @NotBlank + private String colorHex; + + @Column(nullable = false) + @NotBlank + private String iconName; + + public Tag() {} + + public Tag(String name, String colorHex, String iconName) { + this.name = name; + this.colorHex = colorHex; + this.iconName = iconName; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getColorHex() { + return colorHex; + } + + public void setColorHex(String colorHex) { + this.colorHex = colorHex; + } + + public String getIconName() { + return iconName; + } + + public void setIconName(String iconName) { + this.iconName = iconName; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/repository/AttendeeRepository.java b/backend/src/main/java/online/wesal/wesal/repository/AttendeeRepository.java new file mode 100644 index 0000000..2aa1819 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/repository/AttendeeRepository.java @@ -0,0 +1,18 @@ +package online.wesal.wesal.repository; + +import online.wesal.wesal.entity.Attendee; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +public interface AttendeeRepository extends JpaRepository { + + boolean existsByInvitationIdAndUserId(Long invitationId, Long userId); + + List findByInvitationId(Long invitationId); + + void deleteByInvitationId(Long invitationId); + + void deleteByInvitationIdAndUserId(Long invitationId, Long userId); +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/repository/InvitationRepository.java b/backend/src/main/java/online/wesal/wesal/repository/InvitationRepository.java new file mode 100644 index 0000000..a7d03f6 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/repository/InvitationRepository.java @@ -0,0 +1,21 @@ +package online.wesal.wesal.repository; + +import online.wesal.wesal.entity.Invitation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface InvitationRepository extends JpaRepository { + + @Query("SELECT i FROM Invitation i WHERE i.currentAttendees < i.maxParticipants AND (i.dateTime IS NULL OR i.dateTime > CURRENT_TIMESTAMP) ORDER BY i.id ASC") + List findAvailableInvitationsOrderByCreationDate(); + + @Query("SELECT i FROM Invitation i WHERE i.creator.id = :userId ORDER BY i.id DESC") + List findByCreatorIdOrderByCreationDateDesc(Long userId); + + @Query("SELECT i FROM Invitation i JOIN Attendee a ON a.invitation.id = i.id WHERE a.user.id = :userId ORDER BY i.id DESC") + List findAcceptedInvitationsByUserIdOrderByCreationDateDesc(Long userId); +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/repository/TagRepository.java b/backend/src/main/java/online/wesal/wesal/repository/TagRepository.java new file mode 100644 index 0000000..4e46072 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/repository/TagRepository.java @@ -0,0 +1,9 @@ +package online.wesal.wesal.repository; + +import online.wesal.wesal.entity.Tag; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TagRepository extends JpaRepository { +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/service/InvitationService.java b/backend/src/main/java/online/wesal/wesal/service/InvitationService.java new file mode 100644 index 0000000..57fbbdb --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/service/InvitationService.java @@ -0,0 +1,202 @@ +package online.wesal.wesal.service; + +import online.wesal.wesal.dto.CategorizedInvitationsResponse; +import online.wesal.wesal.dto.CreateInvitationRequest; +import online.wesal.wesal.dto.InvitationResponse; +import online.wesal.wesal.entity.Attendee; +import online.wesal.wesal.entity.Invitation; +import online.wesal.wesal.entity.Tag; +import online.wesal.wesal.entity.User; +import online.wesal.wesal.repository.AttendeeRepository; +import online.wesal.wesal.repository.InvitationRepository; +import online.wesal.wesal.repository.TagRepository; +import online.wesal.wesal.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +public class InvitationService { + + @Autowired + private InvitationRepository invitationRepository; + + @Autowired + private TagRepository tagRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AttendeeRepository attendeeRepository; + + @Transactional + public InvitationResponse createInvitation(CreateInvitationRequest request, String userEmail) { + User creator = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + Tag tag = tagRepository.findById(request.getTagId()) + .orElseThrow(() -> new RuntimeException("Tag not found")); + + Invitation invitation = new Invitation( + request.getTitle(), + request.getDescription(), + request.getMaxParticipants(), + tag, + creator + ); + + invitation.setDateTime(request.getDateTime()); + invitation.setLocation(request.getLocation()); + + Invitation saved = invitationRepository.save(invitation); + return mapToResponse(saved); + } + + public Optional getInvitationById(Long id) { + return invitationRepository.findById(id) + .map(this::mapToResponseWithAttendees); + } + + public List getAvailableInvitations() { + return invitationRepository.findAvailableInvitationsOrderByCreationDate() + .stream() + .map(this::mapToResponse) + .collect(Collectors.toList()); + } + + public CategorizedInvitationsResponse getAllCategorizedInvitations(String userEmail) { + User user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("Authentication error. Please log in again")); + + List created = invitationRepository.findByCreatorIdOrderByCreationDateDesc(user.getId()) + .stream() + .map(this::mapToResponse) + .collect(Collectors.toList()); + + List accepted = invitationRepository.findAcceptedInvitationsByUserIdOrderByCreationDateDesc(user.getId()) + .stream() + .map(this::mapToResponse) + .collect(Collectors.toList()); + + List available = invitationRepository.findAvailableInvitationsOrderByCreationDate() + .stream() + .filter(invitation -> !invitation.getCreator().getId().equals(user.getId())) + .filter(invitation -> !attendeeRepository.existsByInvitationIdAndUserId(invitation.getId(), user.getId())) + .map(this::mapToResponse) + .collect(Collectors.toList()); + + return new CategorizedInvitationsResponse(created, accepted, available); + } + + @Transactional + public void acceptInvitation(Long invitationId, String userEmail) { + User user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("Authentication error. Please log in again")); + + Invitation invitation = invitationRepository.findById(invitationId) + .orElseThrow(() -> new RuntimeException("This invitation does not exist")); + + if (invitation.getCreator().getId().equals(user.getId())) { + throw new RuntimeException("You cannot accept your own invitation"); + } + + if (invitation.getCurrentAttendees() >= invitation.getMaxParticipants()) { + throw new RuntimeException("This invitation is already full"); + } + + if (attendeeRepository.existsByInvitationIdAndUserId(invitationId, user.getId())) { + throw new RuntimeException("You have already accepted this invitation"); + } + + Attendee attendee = new Attendee(invitation, user); + attendeeRepository.save(attendee); + + invitation.setCurrentAttendees(invitation.getCurrentAttendees() + 1); + invitationRepository.save(invitation); + } + + @Transactional + public void cancelInvitation(Long invitationId, String userEmail) { + User user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new RuntimeException("Authentication error. Please log in again")); + + Invitation invitation = invitationRepository.findById(invitationId) + .orElse(null); + + if (invitation == null) { + throw new RuntimeException("Invitation not found or already cancelled by organizer"); + } + + if (invitation.getDateTime() != null && invitation.getDateTime().isBefore(LocalDateTime.now())) { + throw new RuntimeException("Cannot cancel expired invitation"); + } + + boolean isOrganizer = invitation.getCreator().getId().equals(user.getId()); + boolean isAttendee = attendeeRepository.existsByInvitationIdAndUserId(invitationId, user.getId()); + + if (!isOrganizer && !isAttendee) { + throw new RuntimeException("You are not associated with this invitation"); + } + + if (isOrganizer) { + attendeeRepository.deleteByInvitationId(invitationId); + invitationRepository.deleteById(invitationId); + } else { + attendeeRepository.deleteByInvitationIdAndUserId(invitationId, user.getId()); + invitation.setCurrentAttendees(invitation.getCurrentAttendees() - 1); + invitationRepository.save(invitation); + } + } + + private InvitationResponse mapToResponse(Invitation invitation) { + InvitationResponse response = new InvitationResponse(); + response.setId(invitation.getId()); + response.setTitle(invitation.getTitle()); + response.setDescription(invitation.getDescription()); + response.setDateTime(invitation.getDateTime()); + response.setLocation(invitation.getLocation()); + response.setMaxParticipants(invitation.getMaxParticipants()); + response.setCurrentAttendees(invitation.getCurrentAttendees()); + response.setCreatedAt(invitation.getCreatedAt()); + + InvitationResponse.TagDto tagDto = new InvitationResponse.TagDto(); + tagDto.setId(invitation.getTag().getId()); + tagDto.setName(invitation.getTag().getName()); + tagDto.setColorHex(invitation.getTag().getColorHex()); + tagDto.setIconName(invitation.getTag().getIconName()); + response.setTag(tagDto); + + InvitationResponse.UserDto userDto = new InvitationResponse.UserDto(); + userDto.setId(invitation.getCreator().getId()); + userDto.setDisplayName(invitation.getCreator().getDisplayName()); + userDto.setAvatar(invitation.getCreator().getAvatar()); + response.setCreator(userDto); + + return response; + } + + private InvitationResponse mapToResponseWithAttendees(Invitation invitation) { + InvitationResponse response = mapToResponse(invitation); + + List attendees = attendeeRepository.findByInvitationId(invitation.getId()); + List attendeeDtos = attendees.stream() + .map(attendee -> { + InvitationResponse.AttendeeDto dto = new InvitationResponse.AttendeeDto(); + dto.setId(attendee.getUser().getId()); + dto.setDisplayName(attendee.getUser().getDisplayName()); + dto.setAvatar(attendee.getUser().getAvatar()); + dto.setJoinedAt(attendee.getJoinedAt()); + return dto; + }) + .collect(Collectors.toList()); + + response.setAttendees(attendeeDtos); + return response; + } +} \ No newline at end of file diff --git a/frontend/lib/constants/api_constants.dart b/frontend/lib/constants/api_constants.dart index cff2042..26e058d 100644 --- a/frontend/lib/constants/api_constants.dart +++ b/frontend/lib/constants/api_constants.dart @@ -7,4 +7,10 @@ class ApiConstants { // User endpoints static const String getUserEndpoint = '/getUser'; static const String updateUserEndpoint = '/updateUser'; + + // Invitation endpoints + static const String invitationsEndpoint = '/invitations'; + static const String getAllInvitationsEndpoint = '/invitations/all'; + static const String acceptInvitationEndpoint = '/invitations/accept'; + static const String createInvitationEndpoint = '/invitations/create'; } diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index d30af18..615882b 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -42,7 +42,7 @@ class _SplashScreenState extends State { await Future.delayed(Duration(milliseconds: 500)); final isLoggedIn = await AuthService.isLoggedIn(); - + if (isLoggedIn) { final userResult = await UserService.getCurrentUser(); if (userResult['success'] == true) { @@ -56,9 +56,9 @@ class _SplashScreenState extends State { ); } } else { - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (context) => LandingPage()), - ); + Navigator.of( + context, + ).pushReplacement(MaterialPageRoute(builder: (context) => LandingPage())); } } @@ -70,10 +70,7 @@ class _SplashScreenState extends State { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [ - Color(0xFF32B0A5), - Color(0xFF4600B9), - ], + colors: [Color(0xFF32B0A5), Color(0xFF4600B9)], stops: [0.0, 0.5], ), ), @@ -334,9 +331,11 @@ class _SignInPageState extends State { if (result['success'] == true) { final userResult = await UserService.getCurrentUser(forceRefresh: true); - + Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (context) => NotificationPermissionScreen()), + MaterialPageRoute( + builder: (context) => NotificationPermissionScreen(), + ), ); } else { _showErrorAlert(result['message'] ?? 'Login failed'); @@ -353,10 +352,7 @@ class _SignInPageState extends State { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: Text( - 'OK', - style: TextStyle(color: Color(0xFF6A4C93)), - ), + child: Text('OK', style: TextStyle(color: Color(0xFF6A4C93))), ), ], ), diff --git a/frontend/lib/models/invitation_models.dart b/frontend/lib/models/invitation_models.dart new file mode 100644 index 0000000..1857afc --- /dev/null +++ b/frontend/lib/models/invitation_models.dart @@ -0,0 +1,207 @@ +class InvitationTag { + final int id; + final String name; + final String colorHex; + final String iconName; + + InvitationTag({ + required this.id, + required this.name, + required this.colorHex, + required this.iconName, + }); + + factory InvitationTag.fromJson(Map json) { + return InvitationTag( + id: json['id'], + name: json['name'], + colorHex: json['colorHex'], + iconName: json['iconName'], + ); + } +} + +class InvitationCreator { + final int id; + final String displayName; + final String? avatar; + + InvitationCreator({ + required this.id, + required this.displayName, + this.avatar, + }); + + factory InvitationCreator.fromJson(Map json) { + return InvitationCreator( + id: json['id'], + displayName: json['displayName'], + avatar: json['avatar'], + ); + } +} + +class InvitationAttendee { + final int id; + final String displayName; + final String? avatar; + final DateTime joinedAt; + + InvitationAttendee({ + required this.id, + required this.displayName, + this.avatar, + required this.joinedAt, + }); + + factory InvitationAttendee.fromJson(Map json) { + return InvitationAttendee( + id: json['id'], + displayName: json['displayName'], + avatar: json['avatar'], + joinedAt: DateTime.parse(json['joinedAt']), + ); + } +} + +class Invitation { + final int id; + final String title; + final String? description; + final DateTime? dateTime; + final String? location; + final int maxParticipants; + final int currentAttendees; + final InvitationTag tag; + final InvitationCreator creator; + final DateTime createdAt; + + Invitation({ + required this.id, + required this.title, + this.description, + this.dateTime, + this.location, + required this.maxParticipants, + required this.currentAttendees, + required this.tag, + required this.creator, + required this.createdAt, + }); + + factory Invitation.fromJson(Map json) { + return Invitation( + id: json['id'], + title: json['title'], + description: json['description'], + dateTime: json['dateTime'] != null ? DateTime.parse(json['dateTime']) : null, + location: json['location'], + maxParticipants: json['maxParticipants'], + currentAttendees: json['currentAttendees'], + tag: InvitationTag.fromJson(json['tag']), + creator: InvitationCreator.fromJson(json['creator']), + createdAt: DateTime.parse(json['createdAt']), + ); + } +} + +class InvitationsResponse { + final bool status; + final String? message; + final InvitationsData? data; + + InvitationsResponse({ + required this.status, + this.message, + this.data, + }); + + factory InvitationsResponse.fromJson(Map json) { + return InvitationsResponse( + status: json['status'], + message: json['message'], + data: json['data'] != null ? InvitationsData.fromJson(json['data']) : null, + ); + } +} + +class InvitationsData { + final List created; + final List accepted; + final List available; + + InvitationsData({ + required this.created, + required this.accepted, + required this.available, + }); + + factory InvitationsData.fromJson(Map json) { + return InvitationsData( + created: (json['created'] as List) + .map((item) => Invitation.fromJson(item)) + .toList(), + accepted: (json['accepted'] as List) + .map((item) => Invitation.fromJson(item)) + .toList(), + available: (json['available'] as List) + .map((item) => Invitation.fromJson(item)) + .toList(), + ); + } + + bool get isEmpty => created.isEmpty && accepted.isEmpty && available.isEmpty; +} + +class InvitationDetails { + final int id; + final String title; + final String? description; + final DateTime? dateTime; + final String? location; + final int maxParticipants; + final int currentAttendees; + final InvitationTag tag; + final InvitationCreator creator; + final DateTime createdAt; + final List attendees; + + InvitationDetails({ + required this.id, + required this.title, + this.description, + this.dateTime, + this.location, + required this.maxParticipants, + required this.currentAttendees, + required this.tag, + required this.creator, + required this.createdAt, + required this.attendees, + }); + + factory InvitationDetails.fromJson(Map json) { + return InvitationDetails( + id: json['id'], + title: json['title'], + description: json['description'], + dateTime: json['dateTime'] != null ? DateTime.parse(json['dateTime']) : null, + location: json['location'], + maxParticipants: json['maxParticipants'], + currentAttendees: json['currentAttendees'], + tag: InvitationTag.fromJson(json['tag']), + creator: InvitationCreator.fromJson(json['creator']), + createdAt: DateTime.parse(json['createdAt']), + attendees: (json['attendees'] as List) + .map((item) => InvitationAttendee.fromJson(item)) + .toList(), + ); + } + + String get status { + if (currentAttendees >= maxParticipants) { + return 'Full'; + } + return 'Available'; + } +} \ No newline at end of file diff --git a/frontend/lib/screens/pages/invitation_details_page.dart b/frontend/lib/screens/pages/invitation_details_page.dart new file mode 100644 index 0000000..84e25d1 --- /dev/null +++ b/frontend/lib/screens/pages/invitation_details_page.dart @@ -0,0 +1,550 @@ +import 'package:flutter/material.dart'; +import '../../models/invitation_models.dart'; +import '../../services/invitations_service.dart'; +import '../../utils/invitation_utils.dart'; + +class InvitationDetailsPage extends StatefulWidget { + final int invitationId; + final bool isOwner; + final bool isParticipant; + + const InvitationDetailsPage({ + Key? key, + required this.invitationId, + required this.isOwner, + this.isParticipant = true, + }) : super(key: key); + + @override + _InvitationDetailsPageState createState() => _InvitationDetailsPageState(); +} + +class _InvitationDetailsPageState extends State { + InvitationDetails? _invitationDetails; + bool _isLoading = true; + bool _isCancelling = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadInvitationDetails(); + } + + Future _loadInvitationDetails() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + final result = await InvitationsService.getInvitationDetails(widget.invitationId); + + if (mounted) { + setState(() { + _isLoading = false; + if (result['success']) { + _invitationDetails = result['data']; + } else { + _errorMessage = result['message']; + } + }); + } + } + + Widget _buildAvatarOrInitial(String displayName, String? avatar) { + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Color(0xFF6A4C93).withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: avatar != null + ? ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Image.network( + avatar, + width: 40, + height: 40, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Center( + child: Text( + displayName.isNotEmpty ? displayName[0].toUpperCase() : '?', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF6A4C93), + ), + ), + ); + }, + ), + ) + : Center( + child: Text( + displayName.isNotEmpty ? displayName[0].toUpperCase() : '?', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF6A4C93), + ), + ), + ), + ); + } + + Future _cancelInvitation() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(widget.isOwner ? 'Cancel Invitation' : 'Leave Invitation'), + content: Text( + widget.isOwner + ? 'Are you sure you want to cancel this invitation? This action cannot be undone.' + : 'Are you sure you want to leave this invitation?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text('No'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text('Yes'), + ), + ], + ), + ); + + if (confirmed == true) { + setState(() { + _isCancelling = true; + }); + + final result = await InvitationsService.cancelInvitation(widget.invitationId); + + if (mounted) { + setState(() { + _isCancelling = false; + }); + + if (result['success']) { + Navigator.of(context).pop(true); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message'] ?? 'Action completed successfully'), + backgroundColor: Colors.green, + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message'] ?? 'Failed to complete action'), + backgroundColor: Colors.red, + ), + ); + } + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.grey[50], + appBar: AppBar( + title: Text( + 'Invitation Details', + style: TextStyle(fontWeight: FontWeight.w600), + ), + backgroundColor: Colors.white, + foregroundColor: Color(0xFF6A4C93), + elevation: 0, + bottom: PreferredSize( + preferredSize: Size.fromHeight(1), + child: Container(height: 1, color: Colors.grey[200]), + ), + ), + body: _isLoading + ? Center( + child: CircularProgressIndicator(color: Color(0xFF6A4C93)), + ) + : _errorMessage != null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 80, color: Colors.red[400]), + SizedBox(height: 16), + Text( + _errorMessage!, + style: TextStyle(fontSize: 16, color: Colors.red[600]), + textAlign: TextAlign.center, + ), + SizedBox(height: 16), + ElevatedButton( + onPressed: _loadInvitationDetails, + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFF6A4C93), + foregroundColor: Colors.white, + ), + child: Text('Retry'), + ), + ], + ), + ) + : SingleChildScrollView( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + padding: EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: InvitationUtils.getColorFromHex( + _invitationDetails!.tag.colorHex, + ).withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + InvitationUtils.getIconFromName( + _invitationDetails!.tag.iconName, + ), + color: InvitationUtils.getColorFromHex( + _invitationDetails!.tag.colorHex, + ), + size: 32, + ), + ), + SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _invitationDetails!.title, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + Container( + margin: EdgeInsets.only(top: 8), + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: InvitationUtils.getColorFromHex( + _invitationDetails!.tag.colorHex, + ).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _invitationDetails!.tag.name, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: InvitationUtils.getColorFromHex( + _invitationDetails!.tag.colorHex, + ), + ), + ), + ), + ], + ), + ), + Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _invitationDetails!.status == 'Available' + ? Colors.green.withOpacity(0.1) + : Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _invitationDetails!.status, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _invitationDetails!.status == 'Available' + ? Colors.green[700] + : Colors.orange[700], + ), + ), + ), + ], + ), + if (_invitationDetails!.description != null && + _invitationDetails!.description!.isNotEmpty) ...[ + SizedBox(height: 20), + Text( + 'Description', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + SizedBox(height: 8), + Text( + _invitationDetails!.description!, + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + height: 1.5, + ), + ), + ], + SizedBox(height: 20), + Row( + children: [ + if (_invitationDetails!.location != null) ...[ + Expanded( + child: Row( + children: [ + Icon(Icons.location_on, size: 20, color: Colors.grey[600]), + SizedBox(width: 8), + Expanded( + child: Text( + _invitationDetails!.location!, + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + if (_invitationDetails!.dateTime != null) ...[ + if (_invitationDetails!.location != null) SizedBox(width: 16), + Expanded( + child: Row( + children: [ + Icon(Icons.schedule, size: 20, color: Colors.grey[600]), + SizedBox(width: 8), + Expanded( + child: Text( + InvitationUtils.getRelativeDateTime( + _invitationDetails!.dateTime!, + ), + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ], + ), + SizedBox(height: 16), + Row( + children: [ + Icon(Icons.people, size: 20, color: Colors.grey[600]), + SizedBox(width: 8), + Text( + '${_invitationDetails!.currentAttendees} / ${_invitationDetails!.maxParticipants} participants', + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + ], + ), + ], + ), + ), + SizedBox(height: 24), + Container( + width: double.infinity, + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Organizer', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + SizedBox(width: 8), + Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Color(0xFF6A4C93).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'ORGANIZER', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Color(0xFF6A4C93), + ), + ), + ), + ], + ), + SizedBox(height: 12), + Row( + children: [ + _buildAvatarOrInitial( + _invitationDetails!.creator.displayName, + _invitationDetails!.creator.avatar, + ), + SizedBox(width: 12), + Text( + _invitationDetails!.creator.displayName, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ], + ), + ], + ), + ), + if (_invitationDetails!.attendees.isNotEmpty) ...[ + SizedBox(height: 24), + Container( + width: double.infinity, + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Attendees (${_invitationDetails!.attendees.length})', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + SizedBox(height: 16), + ...List.generate(_invitationDetails!.attendees.length, (index) { + final attendee = _invitationDetails!.attendees[index]; + return Container( + margin: EdgeInsets.only(bottom: index < _invitationDetails!.attendees.length - 1 ? 12 : 0), + child: Row( + children: [ + _buildAvatarOrInitial( + attendee.displayName, + attendee.avatar, + ), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + attendee.displayName, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + Text( + 'Joined ${InvitationUtils.getRelativeTime(attendee.joinedAt)}', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ], + ), + ); + }), + ], + ), + ), + ], + if (widget.isParticipant) ...[ + SizedBox(height: 32), + Container( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: _isCancelling ? null : _cancelInvitation, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 2, + ), + child: _isCancelling + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + widget.isOwner ? 'Cancel Invitation' : 'Leave Invitation', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + SizedBox(height: 32), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/screens/pages/invitations_page.dart b/frontend/lib/screens/pages/invitations_page.dart index a4e6367..c5acd22 100644 --- a/frontend/lib/screens/pages/invitations_page.dart +++ b/frontend/lib/screens/pages/invitations_page.dart @@ -1,69 +1,498 @@ import 'package:flutter/material.dart'; -import '../../services/notification_service.dart'; +import '../../services/invitations_service.dart'; +import '../../models/invitation_models.dart'; +import '../../utils/invitation_utils.dart'; +import 'invitation_details_page.dart'; class InvitationsPage extends StatefulWidget { @override _InvitationsPageState createState() => _InvitationsPageState(); } -class _InvitationsPageState extends State { - bool _isAccepted = false; - bool _isLoading = false; +class _InvitationsPageState extends State + with TickerProviderStateMixin { + InvitationsData? _invitationsData; + bool _isLoading = true; + String? _errorMessage; + Map _acceptingInvitations = {}; + Map _acceptedInvitations = {}; + Map _animationControllers = {}; - Future _acceptCoffeeInvite() async { + @override + void initState() { + super.initState(); + _loadInvitations(); + } + + @override + void dispose() { + for (final controller in _animationControllers.values) { + controller.dispose(); + } + super.dispose(); + } + + Future _loadInvitations() async { setState(() { _isLoading = true; + _errorMessage = null; }); - try { - final success = await NotificationService() - .sendCoffeeInviteAcceptedNotification(); + final result = await InvitationsService.getAllInvitations(); - if (success) { - setState(() { - _isAccepted = true; - _isLoading = false; - }); + if (mounted) { + setState(() { + _isLoading = false; + if (result['success']) { + _invitationsData = result['data']; + } else { + _errorMessage = result['message']; + } + }); + } + } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Coffee invite accepted! Notification sent to everyone.', + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.event_busy, size: 80, color: Colors.grey[400]), + SizedBox(height: 16), + Text( + 'Nothing here!', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + color: Colors.grey[600], ), - backgroundColor: Colors.green, - duration: Duration(seconds: 3), ), - ); - } else { + SizedBox(height: 8), + Text( + 'Create the first invitation now!', + style: TextStyle(fontSize: 16, color: Colors.grey[500]), + ), + ], + ), + ); + } + + Widget _buildErrorState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 80, color: Colors.red[400]), + SizedBox(height: 16), + Text( + _errorMessage ?? 'Something went wrong', + style: TextStyle(fontSize: 16, color: Colors.red[600]), + textAlign: TextAlign.center, + ), + SizedBox(height: 16), + ElevatedButton( + onPressed: _loadInvitations, + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFF6A4C93), + foregroundColor: Colors.white, + ), + child: Text('Retry'), + ), + ], + ), + ); + } + + Future _acceptInvitation(Invitation invitation) async { + final invitationKey = invitation.id.toString(); + setState(() { + _acceptingInvitations[invitationKey] = true; + }); + + final result = await InvitationsService.acceptInvitation(invitation.id); + + if (mounted) { + setState(() { + _acceptingInvitations[invitationKey] = false; + }); + + if (result['success']) { setState(() { - _isLoading = false; + _acceptedInvitations[invitationKey] = true; }); + final controller = AnimationController( + duration: Duration(milliseconds: 1500), + vsync: this, + ); + _animationControllers[invitationKey] = controller; + controller.forward(); + + Future.delayed(Duration(milliseconds: 2000), () { + if (mounted) { + _loadInvitations(); + } + }); + } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - 'Failed to send notification. Check Firebase service account configuration.', - ), - backgroundColor: Colors.orange, - duration: Duration(seconds: 3), + content: Text(result['message'] ?? 'Failed to accept invitation'), + backgroundColor: Colors.red, ), ); } - } catch (e) { - setState(() { - _isLoading = false; - }); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error: $e'), - backgroundColor: Colors.red, - duration: Duration(seconds: 3), - ), - ); } } + Future _navigateToInvitationDetails( + Invitation invitation, + bool isOwner, { + bool isParticipant = true, + }) async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => InvitationDetailsPage( + invitationId: invitation.id, + isOwner: isOwner, + isParticipant: isParticipant, + ), + ), + ); + + if (result == true) { + _loadInvitations(); + } + } + + Widget _buildInvitationCard(Invitation invitation, String section) { + final invitationKey = invitation.id.toString(); + bool isOwned = section == 'created'; + bool isAccepted = section == 'accepted'; + bool isAccepting = _acceptingInvitations[invitationKey] ?? false; + bool hasBeenAccepted = _acceptedInvitations[invitationKey] ?? false; + AnimationController? animController = _animationControllers[invitationKey]; + + return Container( + margin: EdgeInsets.only(bottom: 16), + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + border: Border.all(color: Colors.grey.withOpacity(0.2), width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: InvitationUtils.getColorFromHex( + invitation.tag.colorHex, + ).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + InvitationUtils.getIconFromName(invitation.tag.iconName), + color: InvitationUtils.getColorFromHex( + invitation.tag.colorHex, + ), + size: 24, + ), + ), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + invitation.title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + Text( + 'by ${invitation.creator.displayName}', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + ), + Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: InvitationUtils.getColorFromHex( + invitation.tag.colorHex, + ).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + invitation.tag.name, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: InvitationUtils.getColorFromHex( + invitation.tag.colorHex, + ), + ), + ), + ), + ], + ), + if (invitation.description != null && + invitation.description!.isNotEmpty) ...[ + SizedBox(height: 12), + Text( + InvitationUtils.truncateDescription(invitation.description), + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + height: 1.4, + ), + ), + ], + if (invitation.location != null) ...[ + SizedBox(height: 8), + Row( + children: [ + Icon(Icons.location_on, size: 16, color: Colors.grey[600]), + SizedBox(width: 4), + Text( + invitation.location!, + style: TextStyle(fontSize: 13, color: Colors.grey[600]), + ), + ], + ), + ], + if (invitation.dateTime != null) ...[ + SizedBox(height: 8), + Row( + children: [ + Icon(Icons.schedule, size: 16, color: Colors.grey[600]), + SizedBox(width: 4), + Text( + InvitationUtils.getRelativeDateTime(invitation.dateTime!), + style: TextStyle(fontSize: 13, color: Colors.grey[600]), + ), + ], + ), + ], + SizedBox(height: 12), + Row( + children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: InvitationUtils.getParticipantsStatusColor( + invitation.currentAttendees, + invitation.maxParticipants, + ).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + InvitationUtils.getParticipantsStatus( + invitation.currentAttendees, + invitation.maxParticipants, + ), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: InvitationUtils.getParticipantsStatusColor( + invitation.currentAttendees, + invitation.maxParticipants, + ), + ), + ), + ), + Spacer(), + Text( + InvitationUtils.getRelativeTime(invitation.createdAt), + style: TextStyle(fontSize: 12, color: Colors.grey[500]), + ), + ], + ), + SizedBox(height: 16), + if (!isOwned && !isAccepted && !hasBeenAccepted) + Row( + children: [ + Expanded( + child: SizedBox( + height: 44, + child: ElevatedButton( + onPressed: () { + _navigateToInvitationDetails( + invitation, + false, + isParticipant: false, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey[600], + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + elevation: 2, + ), + child: Text( + 'View', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + SizedBox(width: 12), + Expanded( + child: SizedBox( + height: 44, + child: ElevatedButton( + onPressed: isAccepting + ? null + : () { + _acceptInvitation(invitation); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFF6A4C93), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + elevation: 2, + ), + child: isAccepting + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : Text( + 'Accept Invite', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ) + else + SizedBox( + width: double.infinity, + height: 44, + child: ElevatedButton( + onPressed: isAccepting + ? null + : (isOwned || isAccepted || hasBeenAccepted) + ? () { + _navigateToInvitationDetails(invitation, isOwned); + } + : () { + _acceptInvitation(invitation); + }, + style: ElevatedButton.styleFrom( + backgroundColor: (isAccepted || hasBeenAccepted) + ? Colors.green + : Color(0xFF6A4C93), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + elevation: 2, + ), + child: isAccepting + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : hasBeenAccepted && animController != null + ? AnimatedBuilder( + animation: animController, + builder: (context, child) { + return Text( + 'View', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ); + }, + ) + : Text( + (isAccepted || hasBeenAccepted) + ? 'View' + : (isOwned ? 'View' : 'Accept Invite'), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildInvitationSection( + String title, + List invitations, + String section, + ) { + if (invitations.isEmpty) return SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + title, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Color(0xFF6A4C93), + ), + ), + ), + ...invitations + .map( + (invitation) => Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: _buildInvitationCard(invitation, section), + ), + ) + .toList(), + ], + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -80,191 +509,560 @@ class _InvitationsPageState extends State { child: Container(height: 1, color: Colors.grey[200]), ), automaticallyImplyLeading: false, + actions: [ + IconButton(icon: Icon(Icons.refresh), onPressed: _loadInvitations), + ], ), - body: Padding( - padding: EdgeInsets.all(16), - child: Column( - children: [ - // Coffee Invitation Card - Container( - margin: EdgeInsets.only(bottom: 16), - padding: EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.08), - blurRadius: 10, - offset: Offset(0, 2), - ), - ], - border: Border.all( - color: Colors.grey.withOpacity(0.2), - width: 1, + body: _isLoading + ? Center(child: CircularProgressIndicator(color: Color(0xFF6A4C93))) + : _errorMessage != null + ? _buildErrorState() + : _invitationsData?.isEmpty ?? true + ? _buildEmptyState() + : RefreshIndicator( + onRefresh: _loadInvitations, + color: Color(0xFF6A4C93), + child: SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: Column( + children: [ + SizedBox(height: 16), + _buildInvitationSection( + 'My Invitations', + _invitationsData?.created ?? [], + 'created', + ), + _buildInvitationSection( + 'Accepted Invitations', + _invitationsData?.accepted ?? [], + 'accepted', + ), + _buildInvitationSection( + 'Available Invitations', + _invitationsData?.available ?? [], + 'available', + ), + SizedBox(height: 80), + ], ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header with icon and title - Row( - children: [ - Container( - padding: EdgeInsets.all(8), - decoration: BoxDecoration( - color: Color(0xFF6A4C93).withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.coffee, - color: Color(0xFF6A4C93), - size: 24, - ), - ), - SizedBox(width: 12), - Expanded( - child: Text( - 'Who\'s down for coffee?', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - ), - ), - ], - ), - - SizedBox(height: 16), - - // Description - Text( - 'Quick coffee break at the campus café. Let\'s catch up!', - style: TextStyle( - fontSize: 14, - color: Colors.grey[700], - height: 1.4, - ), - ), - - SizedBox(height: 16), - - // Status indicator - Row( - children: [ - Container( - padding: EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: _isAccepted - ? Colors.green.withOpacity(0.1) - : Colors.orange.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - _isAccepted ? 'Accepted ✓' : '1 more person needed', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: _isAccepted - ? Colors.green[700] - : Colors.orange[700], - ), - ), - ), - Spacer(), - Text( - '10 min ago', - style: TextStyle(fontSize: 12, color: Colors.grey[500]), - ), - ], - ), - - SizedBox(height: 16), - - // Accept button - SizedBox( - width: double.infinity, - height: 44, - child: ElevatedButton( - onPressed: _isAccepted - ? null - : (_isLoading ? null : _acceptCoffeeInvite), - style: ElevatedButton.styleFrom( - backgroundColor: _isAccepted - ? Colors.grey[300] - : Color(0xFF6A4C93), - foregroundColor: _isAccepted - ? Colors.grey[600] - : Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - elevation: _isAccepted ? 0 : 2, - ), - child: _isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation( - Colors.white, - ), - strokeWidth: 2, - ), - ) - : Text( - _isAccepted ? 'Accepted' : 'Accept Invite', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ], - ), ), + floatingActionButton: FloatingActionButton( + onPressed: () async { + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => CreateInvitationPage()), + ); + if (result == true) { + _loadInvitations(); + } + }, + backgroundColor: Color(0xFF6A4C93), + child: Icon(Icons.add, color: Colors.white), + ), + ); + } +} - // Info text - if (!_isAccepted) - Container( +class CreateInvitationPage extends StatefulWidget { + @override + _CreateInvitationPageState createState() => _CreateInvitationPageState(); +} + +class _CreateInvitationPageState extends State { + final _formKey = GlobalKey(); + final _titleController = TextEditingController(); + final _descriptionController = TextEditingController(); + final _locationController = TextEditingController(); + final _maxParticipantsController = TextEditingController(); + + DateTime? _selectedDate; + TimeOfDay? _selectedTime; + int? _selectedTagIndex; + bool _isSubmitting = false; + + // Use centralized tag configuration from InvitationUtils + List> get _availableTags => + InvitationUtils.availableTags; + + @override + void dispose() { + _titleController.dispose(); + _descriptionController.dispose(); + _locationController.dispose(); + _maxParticipantsController.dispose(); + super.dispose(); + } + + Future _selectDate() async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime.now(), + lastDate: DateTime.now().add(Duration(days: 365)), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: ColorScheme.light(primary: Color(0xFF6A4C93)), + ), + child: child!, + ); + }, + ); + if (picked != null && picked != _selectedDate) { + setState(() { + _selectedDate = picked; + }); + } + } + + Future _selectTime() async { + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: ColorScheme.light(primary: Color(0xFF6A4C93)), + ), + child: child!, + ); + }, + ); + if (picked != null && picked != _selectedTime) { + setState(() { + _selectedTime = picked; + }); + } + } + + Future _handleSubmit() async { + if (_formKey.currentState!.validate()) { + if (_selectedTagIndex == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Please select a tag for your invitation'), + backgroundColor: Colors.red, + ), + ); + return; + } + + setState(() { + _isSubmitting = true; + }); + + DateTime? combinedDateTime; + if (_selectedDate != null) { + if (_selectedTime != null) { + combinedDateTime = DateTime( + _selectedDate!.year, + _selectedDate!.month, + _selectedDate!.day, + _selectedTime!.hour, + _selectedTime!.minute, + ); + } else { + combinedDateTime = _selectedDate; + } + } + + final invitationData = { + "title": _titleController.text.trim(), + "description": _descriptionController.text.trim(), + "dateTime": combinedDateTime?.toIso8601String(), + "location": _locationController.text.trim().isEmpty + ? null + : _locationController.text.trim(), + "maxParticipants": int.parse(_maxParticipantsController.text), + "tagId": _availableTags[_selectedTagIndex!]["id"], + }; + + final result = await InvitationsService.createInvitation(invitationData); + + if (mounted) { + setState(() { + _isSubmitting = false; + }); + + if (result['success']) { + Navigator.of(context).pop(true); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message'] ?? 'Failed to create invitation'), + backgroundColor: Colors.red, + ), + ); + } + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF32B0A5), Color(0xFF4600B9)], + stops: [0.0, 0.5], + ), + ), + child: SafeArea( + child: Column( + children: [ + Padding( padding: EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.blue.withOpacity(0.05), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.blue.withOpacity(0.2)), - ), child: Row( children: [ - Icon(Icons.info_outline, color: Colors.blue[600], size: 20), - SizedBox(width: 8), - Expanded( - child: Text( - 'This is a test invitation :)', - style: TextStyle(fontSize: 13, color: Colors.blue[700]), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: Icon(Icons.arrow_back, color: Colors.white), + ), + Text( + 'Create Invitation', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Colors.white, ), ), ], ), ), - ], + Expanded( + child: Container( + margin: EdgeInsets.all(16), + padding: EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 20, + offset: Offset(0, 5), + ), + ], + ), + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Create New Invitation', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: 24), + + TextFormField( + controller: _titleController, + decoration: InputDecoration( + labelText: 'Title *', + prefixIcon: Icon(Icons.title), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Color(0xFF6A4C93), + ), + ), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter a title'; + } + return null; + }, + ), + SizedBox(height: 16), + + TextFormField( + controller: _descriptionController, + maxLines: 3, + decoration: InputDecoration( + labelText: 'Description *', + prefixIcon: Icon(Icons.description), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Color(0xFF6A4C93), + ), + ), + alignLabelWithHint: true, + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter a description'; + } + return null; + }, + ), + SizedBox(height: 16), + + Row( + children: [ + Expanded( + child: InkWell( + onTap: _selectDate, + child: Container( + padding: EdgeInsets.symmetric( + vertical: 16, + horizontal: 12, + ), + decoration: BoxDecoration( + border: Border.all( + color: Colors.grey[400]!, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.calendar_today, + color: Colors.grey[600], + ), + SizedBox(width: 12), + Text( + _selectedDate == null + ? 'Select Date (Optional)' + : '${_selectedDate!.day}/${_selectedDate!.month}/${_selectedDate!.year}', + style: TextStyle( + color: _selectedDate == null + ? Colors.grey[600] + : Colors.black87, + fontSize: 16, + ), + ), + ], + ), + ), + ), + ), + SizedBox(width: 12), + Expanded( + child: InkWell( + onTap: _selectTime, + child: Container( + padding: EdgeInsets.symmetric( + vertical: 16, + horizontal: 12, + ), + decoration: BoxDecoration( + border: Border.all( + color: Colors.grey[400]!, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.access_time, + color: Colors.grey[600], + ), + SizedBox(width: 12), + Text( + _selectedTime == null + ? 'Select Time (Optional)' + : '${_selectedTime!.hour}:${_selectedTime!.minute.toString().padLeft(2, '0')}', + style: TextStyle( + color: _selectedTime == null + ? Colors.grey[600] + : Colors.black87, + fontSize: 16, + ), + ), + ], + ), + ), + ), + ), + ], + ), + SizedBox(height: 16), + + TextFormField( + controller: _locationController, + decoration: InputDecoration( + labelText: 'Location (Optional)', + prefixIcon: Icon(Icons.location_on), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Color(0xFF6A4C93), + ), + ), + ), + ), + SizedBox(height: 16), + + TextFormField( + controller: _maxParticipantsController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: 'Maximum Participants *', + prefixIcon: Icon(Icons.people), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Color(0xFF6A4C93), + ), + ), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter maximum participants'; + } + final intValue = int.tryParse(value); + if (intValue == null || intValue < 1) { + return 'Please enter a valid number greater than 0'; + } + return null; + }, + ), + SizedBox(height: 20), + + Text( + 'Select Tag *', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + SizedBox(height: 12), + + Wrap( + spacing: 8, + runSpacing: 8, + children: List.generate(_availableTags.length, ( + index, + ) { + final tag = _availableTags[index]; + final isSelected = _selectedTagIndex == index; + return GestureDetector( + onTap: () { + setState(() { + _selectedTagIndex = index; + }); + }, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: isSelected + ? InvitationUtils.getColorFromHex( + tag['color_hex'], + ) + : Colors.grey[100], + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected + ? InvitationUtils.getColorFromHex( + tag['color_hex'], + ) + : Colors.grey[300]!, + width: 2, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + InvitationUtils.getIconFromName( + tag['icon_name'], + ), + size: 18, + color: isSelected + ? Colors.white + : InvitationUtils.getColorFromHex( + tag['color_hex'], + ), + ), + SizedBox(width: 6), + Text( + tag['name'], + style: TextStyle( + color: isSelected + ? Colors.white + : Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + }), + ), + SizedBox(height: 32), + + Container( + height: 56, + child: ElevatedButton( + onPressed: _isSubmitting ? null : _handleSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFF6A4C93), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 2, + ), + child: _isSubmitting + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : Text( + 'Create Invitation', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), ), ), - floatingActionButton: FloatingActionButton( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Create invitation functionality coming soon!'), - ), - ); - }, - backgroundColor: Color(0xFF6A4C93), - child: Icon(Icons.person_add, color: Colors.white), - ), ); } } diff --git a/frontend/lib/screens/pages/profile_page.dart b/frontend/lib/screens/pages/profile_page.dart index de8ef20..e0c9482 100644 --- a/frontend/lib/screens/pages/profile_page.dart +++ b/frontend/lib/screens/pages/profile_page.dart @@ -493,7 +493,8 @@ class _SettingsPageState extends State { bottom: PreferredSize( preferredSize: Size.fromHeight(1), child: Container(height: 1, color: Colors.grey[200]), - ), + ), + automaticallyImplyLeading: true, ), body: Padding( padding: EdgeInsets.all(16), diff --git a/frontend/lib/services/invitations_service.dart b/frontend/lib/services/invitations_service.dart new file mode 100644 index 0000000..fe1a3a2 --- /dev/null +++ b/frontend/lib/services/invitations_service.dart @@ -0,0 +1,217 @@ +import 'dart:convert'; +import '../constants/api_constants.dart'; +import '../models/invitation_models.dart'; +import 'http_service.dart'; + +class InvitationsService { + static Future> getAllInvitations() async { + try { + final response = await HttpService.get( + ApiConstants.getAllInvitationsEndpoint, + ); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + final invitationsResponse = InvitationsResponse.fromJson(responseData); + + if (invitationsResponse.status) { + return {'success': true, 'data': invitationsResponse.data}; + } else { + return { + 'success': false, + 'message': + invitationsResponse.message ?? 'Failed to fetch invitations', + }; + } + } else if (response.statusCode == 401) { + return { + 'success': false, + 'message': 'Session expired. Please login again.', + }; + } else if (response.statusCode == 403) { + return { + 'success': false, + 'message': 'Access denied. Invalid credentials.', + }; + } else { + return { + 'success': false, + 'message': 'Server error (${response.statusCode})', + }; + } + } catch (e) { + print('Error fetching invitations: $e'); + return { + 'success': false, + 'message': 'Network error. Please check your connection.', + }; + } + } + + static Future> acceptInvitation(int invitationId) async { + try { + final response = await HttpService.post( + ApiConstants.acceptInvitationEndpoint, + {'id': invitationId}, + ); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + return { + 'success': responseData['status'] ?? false, + 'message': responseData['message'] ?? 'Request completed', + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'message': 'Session expired. Please login again.', + }; + } else if (response.statusCode == 403) { + return { + 'success': false, + 'message': 'Access denied. Invalid credentials.', + }; + } else { + return { + 'success': false, + 'message': 'Server error (${response.statusCode})', + }; + } + } catch (e) { + print('Error accepting invitation: $e'); + return { + 'success': false, + 'message': 'Network error. Please check your connection.', + }; + } + } + + static Future> createInvitation(Map invitationData) async { + try { + final response = await HttpService.post( + ApiConstants.createInvitationEndpoint, + invitationData, + ); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + if (responseData['status'] == true) { + return { + 'success': true, + 'data': responseData['data'], + 'message': responseData['message'], + }; + } else { + return { + 'success': false, + 'message': responseData['message'] ?? 'Failed to create invitation', + }; + } + } else if (response.statusCode == 401) { + return { + 'success': false, + 'message': 'Session expired. Please login again.', + }; + } else if (response.statusCode == 403) { + return { + 'success': false, + 'message': 'Access denied. Invalid credentials.', + }; + } else { + return { + 'success': false, + 'message': 'Server error (${response.statusCode})', + }; + } + } catch (e) { + print('Error creating invitation: $e'); + return { + 'success': false, + 'message': 'Network error. Please check your connection.', + }; + } + } + + static Future> getInvitationDetails(int invitationId) async { + try { + final response = await HttpService.get( + '${ApiConstants.invitationsEndpoint}/get?id=$invitationId', + ); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + if (responseData['status'] == true) { + final invitationDetails = InvitationDetails.fromJson(responseData['data']); + return { + 'success': true, + 'data': invitationDetails, + }; + } else { + return { + 'success': false, + 'message': responseData['message'] ?? 'Failed to get invitation details', + }; + } + } else if (response.statusCode == 401) { + return { + 'success': false, + 'message': 'Session expired. Please login again.', + }; + } else if (response.statusCode == 403) { + return { + 'success': false, + 'message': 'Access denied. Invalid credentials.', + }; + } else { + return { + 'success': false, + 'message': 'Server error (${response.statusCode})', + }; + } + } catch (e) { + print('Error getting invitation details: $e'); + return { + 'success': false, + 'message': 'Network error. Please check your connection.', + }; + } + } + + static Future> cancelInvitation(int invitationId) async { + try { + final response = await HttpService.post( + '${ApiConstants.invitationsEndpoint}/cancel', + {'id': invitationId}, + ); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + return { + 'success': responseData['status'] ?? false, + 'message': responseData['message'] ?? 'Request completed', + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'message': 'Session expired. Please login again.', + }; + } else if (response.statusCode == 403) { + return { + 'success': false, + 'message': 'Access denied. Invalid credentials.', + }; + } else { + return { + 'success': false, + 'message': 'Server error (${response.statusCode})', + }; + } + } catch (e) { + print('Error canceling invitation: $e'); + return { + 'success': false, + 'message': 'Network error. Please check your connection.', + }; + } + } +} diff --git a/frontend/lib/utils/invitation_utils.dart b/frontend/lib/utils/invitation_utils.dart new file mode 100644 index 0000000..868a731 --- /dev/null +++ b/frontend/lib/utils/invitation_utils.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; + +class InvitationUtils { + // Centralized tag configuration + static const List> availableTags = [ + {"id": 1, "name": "Coffee", "color_hex": "#8B4513", "icon_name": "coffee"}, + { + "id": 2, + "name": "Lunch", + "color_hex": "#FF6347", + "icon_name": "restaurant", + }, + { + "id": 3, + "name": "Exercise", + "color_hex": "#32CD32", + "icon_name": "fitness_center", + }, + { + "id": 4, + "name": "Sports", + "color_hex": "#FF4500", + "icon_name": "sports_soccer", + }, + { + "id": 5, + "name": "Gaming", + "color_hex": "#9932CC", + "icon_name": "sports_esports", + }, + { + "id": 6, + "name": "Learning/Networking", + "color_hex": "#4169E1", + "icon_name": "school", + }, + {"id": 7, "name": "Other", "color_hex": "#708090", "icon_name": "category"}, + ]; + + static IconData getIconFromName(String iconName) { + switch (iconName) { + // Primary supported icons (from availableTags) + case 'coffee': + return Icons.coffee; + case 'restaurant': + return Icons.restaurant; + case 'fitness_center': + return Icons.fitness_center; + case 'sports_soccer': + return Icons.sports_soccer; + case 'sports_esports': + return Icons.sports_esports; + case 'school': + return Icons.school; + case 'category': + return Icons.category; + default: + return Icons.category; + } + } + + static Color getColorFromHex(String hexColor) { + String cleanHex = hexColor.replaceAll('#', ''); + if (cleanHex.length == 6) { + cleanHex = 'FF' + cleanHex; + } + return Color(int.parse(cleanHex, radix: 16)); + } + + static String getRelativeTime(DateTime dateTime) { + final now = DateTime.now(); + final difference = now.difference(dateTime); + + if (difference.inMinutes < 1) { + return 'just now'; + } else if (difference.inMinutes < 60) { + return '${difference.inMinutes}m ago'; + } else if (difference.inHours < 24) { + return '${difference.inHours}h ago'; + } else if (difference.inDays == 1) { + return 'yesterday'; + } else if (difference.inDays < 7) { + return '${difference.inDays}d ago'; + } else { + return '${dateTime.day}/${dateTime.month}/${dateTime.year}'; + } + } + + static String getRelativeDateTime(DateTime dateTime) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final tomorrow = today.add(Duration(days: 1)); + final eventDate = DateTime(dateTime.year, dateTime.month, dateTime.day); + + String timeString = _formatTime(dateTime); + + if (eventDate == today) { + return 'today at $timeString'; + } else if (eventDate == tomorrow) { + return 'tomorrow at $timeString'; + } else if (eventDate.isAfter(today) && + eventDate.isBefore(today.add(Duration(days: 7)))) { + List weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + return '${weekdays[eventDate.weekday - 1]} at $timeString'; + } else { + return '${eventDate.day}/${eventDate.month} at $timeString'; + } + } + + static String _formatTime(DateTime dateTime) { + int hour = dateTime.hour; + String period = hour >= 12 ? 'PM' : 'AM'; + hour = hour > 12 ? hour - 12 : (hour == 0 ? 12 : hour); + String minute = dateTime.minute.toString().padLeft(2, '0'); + return '$hour:$minute $period'; + } + + static String truncateDescription(String? description, {int maxLength = 80}) { + if (description == null || description.isEmpty) return ''; + if (description.length <= maxLength) return description; + return '${description.substring(0, maxLength)}...'; + } + + static String getParticipantsStatus(int current, int max) { + return '$current/$max'; + } + + static Color getParticipantsStatusColor(int current, int max) { + int needed = max - current; + if (needed <= 2 && needed > 0) { + return Colors.orange; + } else if (current == max) { + return Colors.green; + } else { + return Colors.blue; + } + } +}