diff --git a/backend/src/main/java/online/wesal/wesal/config/OpenApiConfig.java b/backend/src/main/java/online/wesal/wesal/config/OpenApiConfig.java index 5b85f6f..9168809 100644 --- a/backend/src/main/java/online/wesal/wesal/config/OpenApiConfig.java +++ b/backend/src/main/java/online/wesal/wesal/config/OpenApiConfig.java @@ -4,9 +4,12 @@ import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.List; + @Configuration public class OpenApiConfig { @@ -17,12 +20,17 @@ public class OpenApiConfig { .title("Wesal API") .description("Social media application API") .version("1.0.0")) + .servers(List.of( + new Server().url("http://localhost:8080").description("Development server"), + new Server().url("https://api.wesal.online").description("Production server") + )) .addSecurityItem(new SecurityRequirement().addList("Bearer Authentication")) .components(new io.swagger.v3.oas.models.Components() .addSecuritySchemes("Bearer Authentication", new SecurityScheme() .type(SecurityScheme.Type.HTTP) .scheme("bearer") - .bearerFormat("JWT"))); + .bearerFormat("JWT") + .description("Enter JWT token (without 'Bearer ' prefix)"))); } } \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java b/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java index c5f860a..dededc8 100644 --- a/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java +++ b/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java @@ -67,7 +67,7 @@ public class SecurityConfig { .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/", "/login", "/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/", "/login", "/swagger-ui/**", "/v3/api-docs/**", "/docs/**", "/docs").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) diff --git a/backend/src/main/java/online/wesal/wesal/controller/PostController.java b/backend/src/main/java/online/wesal/wesal/controller/PostController.java new file mode 100644 index 0000000..c649145 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/controller/PostController.java @@ -0,0 +1,187 @@ +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.ApiResponse; +import online.wesal.wesal.dto.PostCreateRequestDTO; +import online.wesal.wesal.dto.PostLikeRequestDTO; +import online.wesal.wesal.dto.PostResponseDTO; +import online.wesal.wesal.entity.Post; +import online.wesal.wesal.service.PostService; +import online.wesal.wesal.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/posts") +@CrossOrigin(origins = "*") +@Tag(name = "Posts", description = "Post management endpoints") +public class PostController { + + @Autowired + private PostService postService; + + @Autowired + private UserService userService; + + @PostMapping(value = "/create", consumes = "application/json", produces = "application/json") + @Operation(summary = "Create post", description = "Create a new post") + public ResponseEntity> createPost( + @Valid @RequestBody PostCreateRequestDTO request, + BindingResult bindingResult, + Authentication authentication) { + + if (bindingResult.hasErrors()) { + String errorMessage = bindingResult.getFieldErrors().stream() + .map(error -> { + String field = error.getField(); + if ("body".equals(field)) return "Post body is required and cannot exceed 2000 characters"; + return error.getDefaultMessage(); + }) + .findFirst() + .orElse("Invalid input"); + return ResponseEntity.badRequest().body(ApiResponse.error(errorMessage)); + } + + try { + PostResponseDTO response = postService.createPostWithResponse(request.getBody()); + 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 { + message = "Something went wrong.. We're sorry but try again later"; + } + return ResponseEntity.badRequest().body(ApiResponse.error(message)); + } 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 recent posts", description = "Get all posts from the last 7 days, ordered by creation date (latest first)") + public ResponseEntity>> getAllRecentPosts() { + try { + List response = postService.getAllRecentPosts(); + return ResponseEntity.ok(ApiResponse.success(response)); + } catch (Exception e) { + return ResponseEntity.status(500).body(ApiResponse.error("Something went wrong.. We're sorry but try again later")); + } + } + + @GetMapping("/user") + @Operation(summary = "Get user posts", description = "Get all posts by a specific user, ordered by creation date (latest first). If no id provided, returns authenticated user's posts.") + public ResponseEntity>> getUserPosts( + @RequestParam(required = false) Long id, + Authentication authentication) { + + try { + Long targetUserId; + if (id == null) { + // Use authenticated user's ID when no id is provided + String userEmail = authentication.getName(); + targetUserId = userService.getCurrentUser().getId(); + } else { + if (id <= 0) { + return ResponseEntity.badRequest().body(ApiResponse.error("Valid user ID is required")); + } + targetUserId = id; + } + + List response = postService.getUserPosts(targetUserId); + return ResponseEntity.ok(ApiResponse.success(response)); + } catch (RuntimeException e) { + String message; + if (e.getMessage().contains("User not found")) { + message = "User not found"; + } else { + message = "Something went wrong.. We're sorry but try again later"; + } + return ResponseEntity.badRequest().body(ApiResponse.error(message)); + } catch (Exception e) { + return ResponseEntity.status(500).body(ApiResponse.error("Something went wrong.. We're sorry but try again later")); + } + } + + @PostMapping(value = "/like", consumes = "application/json", produces = "application/json") + @Operation(summary = "Like post", description = "Like a post. Returns the updated post with new like count.") + public ResponseEntity> likePost( + @Valid @RequestBody PostLikeRequestDTO request, + BindingResult bindingResult, + Authentication authentication) { + + if (bindingResult.hasErrors()) { + String errorMessage = bindingResult.getFieldErrors().stream() + .map(error -> { + String field = error.getField(); + if ("postId".equals(field)) return "Valid post ID is required"; + return error.getDefaultMessage(); + }) + .findFirst() + .orElse("Invalid input"); + return ResponseEntity.badRequest().body(ApiResponse.error(errorMessage)); + } + + try { + PostResponseDTO response = postService.likePost(request.getPostId()); + return ResponseEntity.ok(ApiResponse.success(response)); + } catch (RuntimeException e) { + String message; + if (e.getMessage().contains("Post not found")) { + message = "Post not found"; + } else if (e.getMessage().contains("User not found")) { + message = "Authentication error. Please log in again."; + } else { + message = "Something went wrong.. We're sorry but try again later"; + } + return ResponseEntity.badRequest().body(ApiResponse.error(message)); + } catch (Exception e) { + return ResponseEntity.status(500).body(ApiResponse.error("Something went wrong.. We're sorry but try again later")); + } + } + + @PostMapping(value = "/unlike", consumes = "application/json", produces = "application/json") + @Operation(summary = "Unlike post", description = "Unlike a post. Returns the updated post with new like count.") + public ResponseEntity> unlikePost( + @Valid @RequestBody PostLikeRequestDTO request, + BindingResult bindingResult, + Authentication authentication) { + + if (bindingResult.hasErrors()) { + String errorMessage = bindingResult.getFieldErrors().stream() + .map(error -> { + String field = error.getField(); + if ("postId".equals(field)) return "Valid post ID is required"; + return error.getDefaultMessage(); + }) + .findFirst() + .orElse("Invalid input"); + return ResponseEntity.badRequest().body(ApiResponse.error(errorMessage)); + } + + try { + PostResponseDTO response = postService.unlikePost(request.getPostId()); + return ResponseEntity.ok(ApiResponse.success(response)); + } catch (RuntimeException e) { + String message; + if (e.getMessage().contains("Post not found")) { + message = "Post not found"; + } else if (e.getMessage().contains("User not found")) { + message = "Authentication error. Please log in again."; + } else { + message = "Something went wrong.. We're sorry but try again later"; + } + return ResponseEntity.badRequest().body(ApiResponse.error(message)); + } 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/CreateUserRequest.java b/backend/src/main/java/online/wesal/wesal/dto/CreateUserRequest.java index 579aa49..eada67c 100644 --- a/backend/src/main/java/online/wesal/wesal/dto/CreateUserRequest.java +++ b/backend/src/main/java/online/wesal/wesal/dto/CreateUserRequest.java @@ -11,7 +11,7 @@ public class CreateUserRequest { private String email; @NotBlank - @Size(min = 8) + @Size(min = 6) private String password; @NotBlank diff --git a/backend/src/main/java/online/wesal/wesal/dto/CreatorDTO.java b/backend/src/main/java/online/wesal/wesal/dto/CreatorDTO.java new file mode 100644 index 0000000..7b125d3 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/CreatorDTO.java @@ -0,0 +1,32 @@ +package online.wesal.wesal.dto; + +import online.wesal.wesal.entity.User; + +public class CreatorDTO { + + private String id; + private String displayName; + + public CreatorDTO() {} + + public CreatorDTO(User user) { + this.id = String.valueOf(user.getId()); + this.displayName = user.getDisplayName(); + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/PostCreateRequestDTO.java b/backend/src/main/java/online/wesal/wesal/dto/PostCreateRequestDTO.java new file mode 100644 index 0000000..cc1fa9c --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/PostCreateRequestDTO.java @@ -0,0 +1,25 @@ +package online.wesal.wesal.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public class PostCreateRequestDTO { + + @NotBlank(message = "Post body cannot be empty") + @Size(max = 2000, message = "Post body cannot exceed 2000 characters") + private String body; + + public PostCreateRequestDTO() {} + + public PostCreateRequestDTO(String body) { + this.body = body; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/PostLikeRequestDTO.java b/backend/src/main/java/online/wesal/wesal/dto/PostLikeRequestDTO.java new file mode 100644 index 0000000..ff7410d --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/PostLikeRequestDTO.java @@ -0,0 +1,25 @@ +package online.wesal.wesal.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public class PostLikeRequestDTO { + + @NotNull(message = "Post ID is required") + @Positive(message = "Post ID must be a positive number") + private Long postId; + + public PostLikeRequestDTO() {} + + public PostLikeRequestDTO(Long postId) { + this.postId = postId; + } + + public Long getPostId() { + return postId; + } + + public void setPostId(Long postId) { + this.postId = postId; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/PostResponseDTO.java b/backend/src/main/java/online/wesal/wesal/dto/PostResponseDTO.java new file mode 100644 index 0000000..267bbfd --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/PostResponseDTO.java @@ -0,0 +1,94 @@ +package online.wesal.wesal.dto; + +import online.wesal.wesal.entity.Post; +import online.wesal.wesal.entity.User; +import java.time.LocalDateTime; + +public class PostResponseDTO { + + private String id; + private CreatorDTO creator; + private String body; + private String likes; + private String comments; + private boolean liked; + private LocalDateTime creationDate; + + public PostResponseDTO() {} + + public PostResponseDTO(Post post, User creator) { + this.id = String.valueOf(post.getId()); + this.creator = new CreatorDTO(creator); + this.body = post.getBody(); + this.likes = String.valueOf(post.getLikes()); + this.comments = String.valueOf(post.getComments()); + this.liked = false; // Default value, will be set by service + this.creationDate = post.getCreationDate(); + } + + public PostResponseDTO(Post post, User creator, boolean liked) { + this.id = String.valueOf(post.getId()); + this.creator = new CreatorDTO(creator); + this.body = post.getBody(); + this.likes = String.valueOf(post.getLikes()); + this.comments = String.valueOf(post.getComments()); + this.liked = liked; + this.creationDate = post.getCreationDate(); + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public CreatorDTO getCreator() { + return creator; + } + + public void setCreator(CreatorDTO creator) { + this.creator = creator; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public String getLikes() { + return likes; + } + + public void setLikes(String likes) { + this.likes = likes; + } + + public String getComments() { + return comments; + } + + public void setComments(String comments) { + this.comments = comments; + } + + public boolean isLiked() { + return liked; + } + + public void setLiked(boolean liked) { + this.liked = liked; + } + + public LocalDateTime getCreationDate() { + return creationDate; + } + + public void setCreationDate(LocalDateTime creationDate) { + this.creationDate = creationDate; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/UpdateUserRequest.java b/backend/src/main/java/online/wesal/wesal/dto/UpdateUserRequest.java index 1f5b171..7556c3a 100644 --- a/backend/src/main/java/online/wesal/wesal/dto/UpdateUserRequest.java +++ b/backend/src/main/java/online/wesal/wesal/dto/UpdateUserRequest.java @@ -10,7 +10,7 @@ public class UpdateUserRequest { private String avatar; - @Size(min = 8, message = "Password must be at least 8 characters long") + @Size(min = 6, message = "Password must be at least 8 characters long") private String password; public UpdateUserRequest() {} diff --git a/backend/src/main/java/online/wesal/wesal/entity/Post.java b/backend/src/main/java/online/wesal/wesal/entity/Post.java new file mode 100644 index 0000000..c6e7335 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/entity/Post.java @@ -0,0 +1,85 @@ +package online.wesal.wesal.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import java.time.LocalDateTime; + +@Entity +@Table(name = "posts") +public class Post { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long creatorId; + + @Column(nullable = false, length = 2000) + @NotBlank + private String body; + + @Column(nullable = false) + private int likes = 0; + + @Column(nullable = false) + private int comments = 0; + + @Column(nullable = false) + private LocalDateTime creationDate = LocalDateTime.now(); + + public Post() {} + + public Post(Long creatorId, String body) { + this.creatorId = creatorId; + this.body = body; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getCreatorId() { + return creatorId; + } + + public void setCreatorId(Long creatorId) { + this.creatorId = creatorId; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public int getLikes() { + return likes; + } + + public void setLikes(int likes) { + this.likes = likes; + } + + public int getComments() { + return comments; + } + + public void setComments(int comments) { + this.comments = comments; + } + + public LocalDateTime getCreationDate() { + return creationDate; + } + + public void setCreationDate(LocalDateTime creationDate) { + this.creationDate = creationDate; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/entity/PostLike.java b/backend/src/main/java/online/wesal/wesal/entity/PostLike.java new file mode 100644 index 0000000..6edd44b --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/entity/PostLike.java @@ -0,0 +1,63 @@ +package online.wesal.wesal.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "post_likes", uniqueConstraints = { + @UniqueConstraint(columnNames = {"post_id", "user_id"}) +}) +public class PostLike { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, name = "post_id") + private Long postId; + + @Column(nullable = false, name = "user_id") + private Long userId; + + @Column(nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + public PostLike() {} + + public PostLike(Long postId, Long userId) { + this.postId = postId; + this.userId = userId; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getPostId() { + return postId; + } + + public void setPostId(Long postId) { + this.postId = postId; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/entity/User.java b/backend/src/main/java/online/wesal/wesal/entity/User.java index 75f8ee4..25e900e 100644 --- a/backend/src/main/java/online/wesal/wesal/entity/User.java +++ b/backend/src/main/java/online/wesal/wesal/entity/User.java @@ -23,7 +23,7 @@ public class User { @Column(nullable = false) @NotBlank - @Size(min = 8) + @Size(min = 6) private String password; @Column(nullable = false) diff --git a/backend/src/main/java/online/wesal/wesal/repository/PostLikeRepository.java b/backend/src/main/java/online/wesal/wesal/repository/PostLikeRepository.java new file mode 100644 index 0000000..ca40e23 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/repository/PostLikeRepository.java @@ -0,0 +1,25 @@ +package online.wesal.wesal.repository; + +import online.wesal.wesal.entity.PostLike; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Repository +public interface PostLikeRepository extends JpaRepository { + Optional findByPostIdAndUserId(Long postId, Long userId); + boolean existsByPostIdAndUserId(Long postId, Long userId); + void deleteByPostIdAndUserId(Long postId, Long userId); + long countByPostId(Long postId); + + @Query("SELECT pl.postId FROM PostLike pl WHERE pl.userId = :userId AND pl.postId IN :postIds") + Set findLikedPostIdsByUserIdAndPostIds(@Param("userId") Long userId, @Param("postIds") List postIds); + + @Query("SELECT pl.postId FROM PostLike pl WHERE pl.userId = :userId") + Set findAllLikedPostIdsByUserId(@Param("userId") Long userId); +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/repository/PostRepository.java b/backend/src/main/java/online/wesal/wesal/repository/PostRepository.java new file mode 100644 index 0000000..c505859 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/repository/PostRepository.java @@ -0,0 +1,20 @@ +package online.wesal.wesal.repository; + +import online.wesal.wesal.entity.Post; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface PostRepository extends JpaRepository { + List findByCreatorIdOrderByCreationDateDesc(Long creatorId); + + @Query("SELECT p FROM Post p WHERE p.creationDate >= :sevenDaysAgo ORDER BY p.creationDate DESC") + List findAllPostsWithinLast7Days(@Param("sevenDaysAgo") LocalDateTime sevenDaysAgo); + + List findAllByOrderByCreationDateDesc(); +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/service/PostService.java b/backend/src/main/java/online/wesal/wesal/service/PostService.java new file mode 100644 index 0000000..3cbb492 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/service/PostService.java @@ -0,0 +1,158 @@ +package online.wesal.wesal.service; + +import online.wesal.wesal.dto.PostResponseDTO; +import online.wesal.wesal.entity.Post; +import online.wesal.wesal.entity.PostLike; +import online.wesal.wesal.entity.User; +import online.wesal.wesal.repository.PostLikeRepository; +import online.wesal.wesal.repository.PostRepository; +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.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class PostService { + + @Autowired + private PostRepository postRepository; + + @Autowired + private PostLikeRepository postLikeRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserService userService; + + public Post createPost(String body) { + User currentUser = userService.getCurrentUser(); + Post post = new Post(currentUser.getId(), body); + return postRepository.save(post); + } + + public List getAllRecentPosts() { + LocalDateTime sevenDaysAgo = LocalDateTime.now().minusDays(7); + List posts = postRepository.findAllPostsWithinLast7Days(sevenDaysAgo); + + User currentUser = userService.getCurrentUser(); + Long currentUserId = currentUser.getId(); + + // Get all unique creator IDs + List creatorIds = posts.stream() + .map(Post::getCreatorId) + .distinct() + .collect(Collectors.toList()); + + // Get all post IDs + List postIds = posts.stream() + .map(Post::getId) + .collect(Collectors.toList()); + + // Fetch creators in one query + Map creators = userRepository.findAllById(creatorIds).stream() + .collect(Collectors.toMap(User::getId, user -> user)); + + // Fetch user's likes for these posts in one query + Set likedPostIds = postLikeRepository.findLikedPostIdsByUserIdAndPostIds(currentUserId, postIds); + + return posts.stream() + .map(post -> new PostResponseDTO(post, creators.get(post.getCreatorId()), + likedPostIds.contains(post.getId()))) + .collect(Collectors.toList()); + } + + public List getUserPosts(Long userId) { + List posts = postRepository.findByCreatorIdOrderByCreationDateDesc(userId); + User creator = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("User not found")); + + User currentUser = userService.getCurrentUser(); + Long currentUserId = currentUser.getId(); + + // Get all post IDs + List postIds = posts.stream() + .map(Post::getId) + .collect(Collectors.toList()); + + // Fetch user's likes for these posts in one query + Set likedPostIds = postLikeRepository.findLikedPostIdsByUserIdAndPostIds(currentUserId, postIds); + + return posts.stream() + .map(post -> new PostResponseDTO(post, creator, likedPostIds.contains(post.getId()))) + .collect(Collectors.toList()); + } + + public PostResponseDTO createPostWithResponse(String body) { + User currentUser = userService.getCurrentUser(); + Post post = new Post(currentUser.getId(), body); + post = postRepository.save(post); + return new PostResponseDTO(post, currentUser, false); // New post is not liked by default + } + + @Transactional + public PostResponseDTO likePost(Long postId) { + User currentUser = userService.getCurrentUser(); + Long userId = currentUser.getId(); + + // Check if post exists + Post post = postRepository.findById(postId) + .orElseThrow(() -> new RuntimeException("Post not found")); + + // Check if user already liked this post + if (!postLikeRepository.existsByPostIdAndUserId(postId, userId)) { + // Add like record + PostLike postLike = new PostLike(postId, userId); + postLikeRepository.save(postLike); + + // Update post likes count + post.setLikes(post.getLikes() + 1); + post = postRepository.save(post); + } + + // Get creator for response + User creator = userRepository.findById(post.getCreatorId()) + .orElseThrow(() -> new RuntimeException("Creator not found")); + + // Check if user now likes this post + boolean isLiked = postLikeRepository.existsByPostIdAndUserId(postId, userId); + + return new PostResponseDTO(post, creator, isLiked); + } + + @Transactional + public PostResponseDTO unlikePost(Long postId) { + User currentUser = userService.getCurrentUser(); + Long userId = currentUser.getId(); + + // Check if post exists + Post post = postRepository.findById(postId) + .orElseThrow(() -> new RuntimeException("Post not found")); + + // Check if user has liked this post + if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { + // Remove like record + postLikeRepository.deleteByPostIdAndUserId(postId, userId); + + // Update post likes count + post.setLikes(Math.max(0, post.getLikes() - 1)); + post = postRepository.save(post); + } + + // Get creator for response + User creator = userRepository.findById(post.getCreatorId()) + .orElseThrow(() -> new RuntimeException("Creator not found")); + + // Check if user now likes this post + boolean isLiked = postLikeRepository.existsByPostIdAndUserId(postId, userId); + + return new PostResponseDTO(post, creator, isLiked); + } +} \ No newline at end of file diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 541a4fe..44b757a 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -19,3 +19,9 @@ spring: server: port: 8080 + +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /docs diff --git a/frontend/lib/constants/api_constants.dart b/frontend/lib/constants/api_constants.dart index 26e058d..e0f177f 100644 --- a/frontend/lib/constants/api_constants.dart +++ b/frontend/lib/constants/api_constants.dart @@ -1,5 +1,5 @@ class ApiConstants { - static const String baseUrl = 'https://api.wesal.online'; + static const String baseUrl = 'http://localhost:8080'; // Auth endpoints static const String loginEndpoint = '/login'; @@ -7,10 +7,13 @@ 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'; + + // Post endpoints + static const String createPostEndpoint = '/posts/create'; } diff --git a/frontend/lib/models/post_models.dart b/frontend/lib/models/post_models.dart new file mode 100644 index 0000000..2b24ede --- /dev/null +++ b/frontend/lib/models/post_models.dart @@ -0,0 +1,105 @@ +class PostCreator { + final String id; + final String displayName; + + PostCreator({ + required this.id, + required this.displayName, + }); + + factory PostCreator.fromJson(Map json) { + return PostCreator( + id: json['id']?.toString() ?? '', + displayName: json['displayName'] ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'displayName': displayName, + }; + } +} + +class Post { + final String id; + final String creatorId; + final PostCreator creator; + final String body; + final int likes; + final int comments; + final DateTime creationDate; + final bool liked; + + Post({ + required this.id, + required this.creatorId, + required this.creator, + required this.body, + required this.likes, + required this.comments, + required this.creationDate, + this.liked = false, + }); + + factory Post.fromJson(Map json) { + return Post( + id: json['id']?.toString() ?? '', + creatorId: json['creatorId']?.toString() ?? '', + creator: PostCreator.fromJson(json['creator'] ?? {}), + body: json['body'] ?? '', + likes: int.tryParse(json['likes']?.toString() ?? '0') ?? 0, + comments: int.tryParse(json['comments']?.toString() ?? '0') ?? 0, + creationDate: DateTime.parse(json['creationDate'] ?? DateTime.now().toIso8601String()), + liked: json['liked'] == true, + ); + } + + Map toJson() { + return { + 'id': id, + 'creatorId': creatorId, + 'creator': creator.toJson(), + 'body': body, + 'likes': likes, + 'comments': comments, + 'creationDate': creationDate.toIso8601String(), + 'liked': liked, + }; + } + + Post copyWith({ + String? id, + String? creatorId, + PostCreator? creator, + String? body, + int? likes, + int? comments, + DateTime? creationDate, + bool? liked, + }) { + return Post( + id: id ?? this.id, + creatorId: creatorId ?? this.creatorId, + creator: creator ?? this.creator, + body: body ?? this.body, + likes: likes ?? this.likes, + comments: comments ?? this.comments, + creationDate: creationDate ?? this.creationDate, + liked: liked ?? this.liked, + ); + } +} + +class CreatePostRequest { + final String body; + + CreatePostRequest({required this.body}); + + Map toJson() { + return { + 'body': body, + }; + } +} \ No newline at end of file diff --git a/frontend/lib/screens/create_post_screen.dart b/frontend/lib/screens/create_post_screen.dart new file mode 100644 index 0000000..57d0e1a --- /dev/null +++ b/frontend/lib/screens/create_post_screen.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; +import '../services/post_service.dart'; + +class CreatePostScreen extends StatefulWidget { + @override + _CreatePostScreenState createState() => _CreatePostScreenState(); +} + +class _CreatePostScreenState extends State { + final TextEditingController _bodyController = TextEditingController(); + bool _isLoading = false; + final int _maxCharacters = 280; + + @override + void dispose() { + _bodyController.dispose(); + super.dispose(); + } + + Future _createPost() async { + if (_bodyController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Please write something before posting'), + backgroundColor: Colors.red, + ), + ); + return; + } + + setState(() { + _isLoading = true; + }); + + try { + final result = await PostService.createPost(_bodyController.text.trim()); + + if (result['success']) { + Navigator.of(context).pop(true); // Return true to indicate success + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Post created successfully!'), + backgroundColor: Color(0xFF6A4C93), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message']), + backgroundColor: Colors.red, + ), + ); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to create post. Please try again.'), + backgroundColor: Colors.red, + ), + ); + } finally { + setState(() { + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final remainingChars = _maxCharacters - _bodyController.text.length; + final isOverLimit = remainingChars < 0; + + return Scaffold( + appBar: AppBar( + title: Text('Create Post', 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]), + ), + actions: [ + TextButton( + onPressed: _isLoading || isOverLimit || _bodyController.text.trim().isEmpty + ? null + : _createPost, + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Color(0xFF6A4C93)), + ), + ) + : Text( + 'Post', + style: TextStyle( + color: _bodyController.text.trim().isEmpty || isOverLimit + ? Colors.grey + : Color(0xFF6A4C93), + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + ), + SizedBox(width: 16), + ], + ), + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFF32B0A5).withOpacity(0.05), + Color(0xFF4600B9).withOpacity(0.05), + ], + ), + ), + child: Padding( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Padding( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "What's on your mind?", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF6A4C93), + ), + ), + SizedBox(height: 16), + TextField( + controller: _bodyController, + maxLines: 6, + decoration: InputDecoration( + hintText: 'Share your thoughts...', + hintStyle: TextStyle(color: Colors.grey[500]), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + ), + style: TextStyle( + fontSize: 16, + height: 1.4, + color: Colors.black87, + ), + onChanged: (text) { + setState(() {}); + }, + ), + SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Share your thoughts with the community', + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + ), + ), + Text( + '$remainingChars', + style: TextStyle( + color: isOverLimit ? Colors.red : Colors.grey[600], + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + if (isOverLimit) + Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + 'Post is too long. Please keep it under $_maxCharacters characters.', + style: TextStyle( + color: Colors.red, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ), + SizedBox(height: 24), + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Padding( + padding: EdgeInsets.all(16), + child: Row( + children: [ + Icon( + Icons.lightbulb_outline, + color: Color(0xFF6A4C93), + size: 20, + ), + SizedBox(width: 12), + Expanded( + child: Text( + 'Keep it friendly and respectful. Your post will be visible to everyone in the community.', + style: TextStyle( + color: Colors.grey[700], + fontSize: 13, + height: 1.3, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/screens/pages/feed_page.dart b/frontend/lib/screens/pages/feed_page.dart index 855bd7b..99b7a31 100644 --- a/frontend/lib/screens/pages/feed_page.dart +++ b/frontend/lib/screens/pages/feed_page.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import '../create_post_screen.dart'; +import '../../services/post_service.dart'; +import '../../widgets/posts_list.dart'; class FeedPage extends StatefulWidget { @override @@ -6,63 +9,8 @@ class FeedPage extends StatefulWidget { } class _FeedPageState extends State { - final List> mockPosts = [ - { - 'id': '1', - 'user': { - 'displayName': 'Abu Khalid (Aqeel)', - 'avatar': 'A', - 'avatar_color': Color(0xFF32B0A5), - }, - 'content': 'Free hasawi khalas dates! Drop by my office to grab some 😉', - 'timestamp': '42 minutes ago', - 'likes': 12, - 'comments': 3, - 'isLiked': false, - }, - { - 'id': '2', - 'user': { - 'displayName': 'Sarah Khalid', - 'avatar': 'S', - 'avatar_color': Color(0xFF4600B9), - }, - 'content': - 'Alhamdulillah, I am happy to tell you I have been blessed with a baby ❤️', - 'timestamp': '4 hours ago', - 'likes': 28, - 'comments': 7, - 'isLiked': true, - }, - { - 'id': '3', - 'user': { - 'displayName': 'Omar Hassan', - 'avatar': 'O', - 'avatar_color': Color(0xFF6A4C93), - }, - 'content': - 'The sunset view from my balcony tonight is absolutely breathtaking. Sometimes you just need to pause and appreciate the beauty around us.', - 'timestamp': '1 day ago', - 'likes': 45, - 'comments': 12, - 'isLiked': false, - }, - { - 'id': '4', - 'user': { - 'displayName': 'Fatima Al-Zahra', - 'avatar': 'F', - 'avatar_color': Color(0xFF32B0A5), - }, - 'content': - 'Finished reading an incredible book today. "The Seven Habits of Highly Effective People" - highly recommend it to anyone looking for personal development!', - 'timestamp': '2 days ago', - 'likes': 19, - 'comments': 5, - 'isLiked': true, - }, - ]; + final GlobalKey _postsListKey = GlobalKey(); + @override Widget build(BuildContext context) { @@ -77,236 +25,40 @@ class _FeedPageState extends State { child: Container(height: 1, color: Colors.grey[200]), ), automaticallyImplyLeading: false, + actions: [ + IconButton( + onPressed: () => _postsListKey.currentState?.refreshPosts(), + icon: Icon(Icons.refresh), + tooltip: 'Refresh', + ), + ], ), - body: RefreshIndicator( - onRefresh: () async { - await Future.delayed(Duration(seconds: 1)); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Feed refreshed!'))); - }, - child: ListView.builder( - padding: EdgeInsets.symmetric(vertical: 8), - itemCount: mockPosts.length, - itemBuilder: (context, index) { - return PostCard( - post: mockPosts[index], - onLikePressed: () { - setState(() { - if (mockPosts[index]['isLiked']) { - mockPosts[index]['likes']--; - mockPosts[index]['isLiked'] = false; - } else { - mockPosts[index]['likes']++; - mockPosts[index]['isLiked'] = true; - } - }); - }, - ); - }, - ), + body: PostsList( + key: _postsListKey, + fetchPosts: PostService.getAllPosts, + emptyStateTitle: 'Nothing here..', + emptyStateSubtitle: 'Create the first post!', + showRefreshIndicator: true, ), floatingActionButton: FloatingActionButton( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Create post functionality coming soon!')), - ); - }, + onPressed: _navigateToCreatePost, backgroundColor: Color(0xFF6A4C93), child: Icon(Icons.edit, color: Colors.white), ), ); } + + + Future _navigateToCreatePost() async { + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => CreatePostScreen()), + ); + + // If post was created successfully, refresh the feed + if (result == true) { + _postsListKey.currentState?.refreshPosts(); + } + } } -class PostCard extends StatefulWidget { - final Map post; - final VoidCallback onLikePressed; - - const PostCard({Key? key, required this.post, required this.onLikePressed}) - : super(key: key); - - @override - _PostCardState createState() => _PostCardState(); -} - -class _PostCardState extends State - with SingleTickerProviderStateMixin { - late AnimationController _likeAnimationController; - late Animation _likeAnimation; - - @override - void initState() { - super.initState(); - _likeAnimationController = AnimationController( - duration: Duration(milliseconds: 300), - vsync: this, - ); - _likeAnimation = Tween(begin: 1.0, end: 1.3).animate( - CurvedAnimation( - parent: _likeAnimationController, - curve: Curves.elasticOut, - ), - ); - } - - @override - void dispose() { - _likeAnimationController.dispose(); - super.dispose(); - } - - void _handleLike() { - widget.onLikePressed(); - _likeAnimationController.forward().then((_) { - _likeAnimationController.reverse(); - }); - } - - @override - Widget build(BuildContext context) { - final user = widget.post['user']; - final isLiked = widget.post['isLiked']; - - return Container( - margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: Offset(0, 2), - ), - ], - ), - child: Padding( - padding: EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - CircleAvatar( - radius: 20, - backgroundColor: user['avatar_color'], - child: Text( - user['avatar'], - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - ), - SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - user['displayName'], - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 16, - color: Colors.black87, - ), - ), - SizedBox(height: 2), - Text( - widget.post['timestamp'], - style: TextStyle(color: Colors.grey[600], fontSize: 12), - ), - ], - ), - ), - IconButton( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('More options pressed')), - ); - }, - icon: Icon(Icons.more_horiz, color: Colors.grey[600]), - ), - ], - ), - SizedBox(height: 12), - Text( - widget.post['content'], - style: TextStyle( - fontSize: 15, - height: 1.4, - color: Colors.black87, - ), - ), - SizedBox(height: 16), - Row( - children: [ - AnimatedBuilder( - animation: _likeAnimation, - builder: (context, child) { - return Transform.scale( - scale: _likeAnimation.value, - child: GestureDetector( - onTap: _handleLike, - child: Container( - padding: EdgeInsets.all(12), - child: Icon( - isLiked ? Icons.favorite : Icons.favorite_border, - color: isLiked ? Colors.red : Colors.grey[600], - size: 24, - ), - ), - ), - ); - }, - ), - Text( - '${widget.post['likes']}', - style: TextStyle( - color: Colors.grey[700], - fontWeight: FontWeight.w500, - ), - ), - SizedBox(width: 16), - IconButton( - onPressed: () { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Comments pressed'))); - }, - icon: Icon( - Icons.chat_bubble_outline, - color: Colors.grey[600], - size: 24, - ), - ), - Text( - '${widget.post['comments']}', - style: TextStyle( - color: Colors.grey[700], - fontWeight: FontWeight.w500, - ), - ), - Spacer(), - IconButton( - onPressed: () { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Share pressed'))); - }, - icon: Icon( - Icons.share_outlined, - color: Colors.grey[600], - size: 24, - ), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/frontend/lib/screens/pages/profile_page.dart b/frontend/lib/screens/pages/profile_page.dart index e0c9482..360e4b3 100644 --- a/frontend/lib/screens/pages/profile_page.dart +++ b/frontend/lib/screens/pages/profile_page.dart @@ -3,6 +3,10 @@ import 'package:flutter/services.dart'; import '../../services/notification_service.dart'; import '../../services/user_service.dart'; import '../../services/auth_service.dart'; +import '../../services/post_service.dart'; +import '../../models/post_models.dart'; +import '../../widgets/posts_list.dart'; +import '../../utils/password_generator.dart'; class ProfilePage extends StatefulWidget { @override @@ -15,42 +19,15 @@ class _ProfilePageState extends State { bool isLoading = false; Map? userData; bool isLoadingUser = true; - - final List> mockUserPosts = [ - { - 'id': '1', - 'content': - 'Just finished working on a new Flutter project! The development experience keeps getting better.', - 'timestamp': '2 hours ago', - 'likes': 15, - 'comments': 4, - 'isLiked': true, - }, - { - 'id': '2', - 'content': - 'Beautiful sunset from my office window today. Sometimes you need to take a moment to appreciate the simple things.', - 'timestamp': '1 day ago', - 'likes': 23, - 'comments': 8, - 'isLiked': false, - }, - { - 'id': '3', - 'content': - 'Excited to share that I completed my certification today! Hard work pays off.', - 'timestamp': '3 days ago', - 'likes': 42, - 'comments': 12, - 'isLiked': true, - }, - ]; + int totalLikes = 0; + int totalPosts = 0; @override void initState() { super.initState(); _loadFCMToken(); _loadUserData(); + _loadUserStats(); } @override @@ -98,6 +75,21 @@ class _ProfilePageState extends State { }); } + Future _loadUserStats() async { + final result = await PostService.getUserPosts(); + + setState(() { + if (result['success'] == true) { + final posts = result['posts'] as List; + totalPosts = posts.length; + totalLikes = posts.fold(0, (sum, post) => sum + post.likes); + } else { + totalPosts = 0; + totalLikes = 0; + } + }); + } + void _showErrorAlert(String message) { showDialog( context: context, @@ -223,7 +215,7 @@ class _ProfilePageState extends State { Column( children: [ Text( - '${mockUserPosts.length}', + '$totalPosts', style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, @@ -242,7 +234,7 @@ class _ProfilePageState extends State { Column( children: [ Text( - '127', + '$totalLikes', style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, @@ -250,7 +242,7 @@ class _ProfilePageState extends State { ), ), Text( - 'Followers', + 'Likes Received', style: TextStyle( fontSize: 14, color: Colors.grey[600], @@ -270,126 +262,16 @@ class _ProfilePageState extends State { margin: EdgeInsets.symmetric(horizontal: 16), ), SizedBox(height: 16), - ListView.builder( - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - padding: EdgeInsets.symmetric(horizontal: 16), - itemCount: mockUserPosts.length, - itemBuilder: (context, index) { - final post = mockUserPosts[index]; - return Container( - margin: EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: Offset(0, 2), - ), - ], - ), - child: Padding( - padding: EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - CircleAvatar( - radius: 16, - backgroundColor: Color(0xFF6A4C93), - child: Text( - (userData!['displayName'] ?? 'U') - .substring(0, 1) - .toUpperCase(), - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - ), - SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - userData!['displayName'] ?? - 'Unknown User', - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - color: Colors.black87, - ), - ), - Text( - post['timestamp'], - style: TextStyle( - color: Colors.grey[600], - fontSize: 12, - ), - ), - ], - ), - ), - ], - ), - SizedBox(height: 12), - Text( - post['content'], - style: TextStyle( - fontSize: 15, - height: 1.4, - color: Colors.black87, - ), - ), - SizedBox(height: 12), - Row( - children: [ - Icon( - post['isLiked'] - ? Icons.favorite - : Icons.favorite_border, - color: post['isLiked'] - ? Colors.red - : Colors.grey[600], - size: 20, - ), - SizedBox(width: 4), - Text( - '${post['likes']}', - style: TextStyle( - color: Colors.grey[700], - fontSize: 14, - ), - ), - SizedBox(width: 16), - Icon( - Icons.chat_bubble_outline, - color: Colors.grey[600], - size: 20, - ), - SizedBox(width: 4), - Text( - '${post['comments']}', - style: TextStyle( - color: Colors.grey[700], - fontSize: 14, - ), - ), - ], - ), - ], - ), - ), - ); + ProfilePostsList( + fetchPosts: PostService.getUserPosts, + onStatsUpdate: (posts, likes) { + setState(() { + totalPosts = posts; + totalLikes = likes; + }); }, ), ], - SizedBox(height: 24), ], ), ), @@ -407,15 +289,28 @@ class _SettingsPageState extends State { final TextEditingController _tokenController = TextEditingController(); bool isLoading = false; + // Admin user creation + Map? userData; + bool isLoadingUser = true; + bool isCreatingUser = false; + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _displayNameController = TextEditingController(); + @override void initState() { super.initState(); _loadFCMToken(); + _loadUserData(); + _generatePassword(); } @override void dispose() { _tokenController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + _displayNameController.dispose(); super.dispose(); } @@ -452,6 +347,68 @@ class _SettingsPageState extends State { } } + Future _loadUserData() async { + setState(() { + isLoadingUser = true; + }); + + final result = await UserService.getCurrentUser(); + + setState(() { + isLoadingUser = false; + if (result['success'] == true) { + userData = result['data']; + } else { + userData = null; + } + }); + } + + void _generatePassword() { + _passwordController.text = PasswordGenerator.generateReadablePassword(); + } + + bool get _isAdmin { + return userData?['role'] == 'ADMIN'; + } + + Future> _createUserAccount() async { + if (_emailController.text.trim().isEmpty || + _displayNameController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Please fill in all fields'), + backgroundColor: Colors.red, + ), + ); + return {'success': false, 'message': 'Please fill in all fields'}; + } + + setState(() { + isCreatingUser = true; + }); + + try { + final result = await UserService.createUser( + email: _emailController.text.trim(), + password: _passwordController.text, + displayName: _displayNameController.text.trim(), + ); + + setState(() { + isCreatingUser = false; + }); + + return result; + } catch (e) { + setState(() { + isCreatingUser = false; + }); + + return {'success': false, 'message': 'Error creating user: $e'}; + } + } + void _signOut() async { showDialog( context: context, @@ -482,6 +439,193 @@ class _SettingsPageState extends State { ); } + void _showCredentialsDialog(String email, String password) { + final credentialsText = 'Email: $email\nPassword: $password'; + final TextEditingController credentialsController = TextEditingController( + text: credentialsText, + ); + + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: Text('Account Created Successfully!'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Share these credentials with the user:', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + SizedBox(height: 16), + + TextField( + controller: credentialsController, + decoration: InputDecoration( + border: OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon(Icons.copy), + onPressed: () async { + await Clipboard.setData( + ClipboardData(text: credentialsText), + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Credentials copied to clipboard'), + backgroundColor: Color(0xFF6A4C93), + ), + ); + }, + tooltip: 'Copy credentials', + ), + ), + maxLines: 2, + readOnly: true, + style: TextStyle(fontFamily: 'monospace', fontSize: 14), + ), + ], + ), + actions: [ + ElevatedButton( + onPressed: () => Navigator.of(dialogContext).pop(), + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFF6A4C93), + foregroundColor: Colors.white, + ), + child: Text('Done'), + ), + ], + ); + }, + ); + } + + void _showCreateAccountDialog(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: Text('Create Account'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _emailController, + decoration: InputDecoration( + labelText: 'Email', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.email), + ), + keyboardType: TextInputType.emailAddress, + ), + SizedBox(height: 16), + + TextField( + controller: _displayNameController, + decoration: InputDecoration( + labelText: 'Display Name', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person), + ), + ), + SizedBox(height: 16), + + TextField( + controller: _passwordController, + decoration: InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.lock), + suffixIcon: IconButton( + icon: Icon(Icons.refresh), + onPressed: () { + setDialogState(() { + _generatePassword(); + }); + }, + tooltip: 'Generate new password', + ), + ), + readOnly: true, + style: TextStyle( + fontFamily: 'monospace', + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 8), + + Text( + 'Password is auto-generated for security', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text('Cancel'), + ), + ElevatedButton( + onPressed: isCreatingUser + ? null + : () async { + final email = _emailController.text.trim(); + final password = _passwordController.text; + + final result = await _createUserAccount(); + + if (mounted) { + Navigator.of(dialogContext).pop(); + + if (result['success']) { + _showCredentialsDialog(email, password); + // Clear form + _emailController.clear(); + _displayNameController.clear(); + _generatePassword(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result['message'] ?? + 'Failed to create user', + ), + backgroundColor: Colors.red, + ), + ); + } + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFF6A4C93), + foregroundColor: Colors.white, + ), + child: isCreatingUser + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : Text('Create'), + ), + ], + ); + }, + ); + }, + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -493,7 +637,7 @@ class _SettingsPageState extends State { bottom: PreferredSize( preferredSize: Size.fromHeight(1), child: Container(height: 1, color: Colors.grey[200]), - ), + ), automaticallyImplyLeading: true, ), body: Padding( @@ -501,6 +645,37 @@ class _SettingsPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (!isLoadingUser && _isAdmin) ...[ + Text( + 'Admin Tools', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Color(0xFF6A4C93), + ), + ), + SizedBox(height: 16), + + Container( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _showCreateAccountDialog(context), + icon: Icon(Icons.person_add, size: 18), + label: Text('Create an Account'), + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFF6A4C93), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: EdgeInsets.symmetric(vertical: 12, horizontal: 16), + ), + ), + ), + + SizedBox(height: 32), + ], + Text( 'Development Tools', style: TextStyle( diff --git a/frontend/lib/services/post_service.dart b/frontend/lib/services/post_service.dart new file mode 100644 index 0000000..5149d27 --- /dev/null +++ b/frontend/lib/services/post_service.dart @@ -0,0 +1,158 @@ +import 'dart:convert'; +import '../models/post_models.dart'; +import 'http_service.dart'; + +class PostService { + static Future> getAllPosts() async { + try { + final response = await HttpService.get('/posts/all'); + + final responseData = jsonDecode(response.body); + + if (response.statusCode == 200) { + return { + 'success': responseData['status'] ?? false, + 'message': responseData['message'] ?? '', + 'posts': + (responseData['data'] as List?) + ?.map((post) => Post.fromJson(post)) + .toList() ?? + [], + }; + } else { + return { + 'success': false, + 'message': responseData['message'] ?? 'Failed to fetch posts', + 'posts': [], + }; + } + } catch (e) { + print('Error fetching posts: $e'); + return { + 'success': false, + 'message': 'Network error: $e', + 'posts': [], + }; + } + } + + static Future> getUserPosts() async { + try { + final response = await HttpService.get('/posts/user'); + + final responseData = jsonDecode(response.body); + + if (response.statusCode == 200) { + return { + 'success': responseData['status'] ?? false, + 'message': responseData['message'] ?? '', + 'posts': (responseData['data'] as List?)?.map((post) => Post.fromJson(post)).toList() ?? [], + }; + } else { + return { + 'success': false, + 'message': responseData['message'] ?? 'Failed to fetch user posts', + 'posts': [], + }; + } + } catch (e) { + print('Error fetching user posts: $e'); + return { + 'success': false, + 'message': 'Network error: $e', + 'posts': [], + }; + } + } + + static Future> createPost(String body) async { + try { + final createPostRequest = CreatePostRequest(body: body); + final response = await HttpService.post( + '/posts/create', + createPostRequest.toJson(), + ); + + final responseData = jsonDecode(response.body); + + if (response.statusCode == 200 || response.statusCode == 201) { + return { + 'success': responseData['status'] ?? false, + 'message': responseData['message'] ?? '', + 'post': responseData['data'] != null + ? Post.fromJson(responseData['data']) + : null, + }; + } else { + return { + 'success': false, + 'message': responseData['message'] ?? 'Failed to create post', + 'post': null, + }; + } + } catch (e) { + print('Error creating post: $e'); + return {'success': false, 'message': 'Network error: $e', 'post': null}; + } + } + + static Future> likePost(String postId) async { + try { + final response = await HttpService.post( + '/posts/like', + {'postId': postId}, + ); + + final responseData = jsonDecode(response.body); + + if (response.statusCode == 200) { + return { + 'success': responseData['status'] ?? false, + 'message': responseData['message'] ?? '', + 'post': responseData['data'] != null + ? Post.fromJson(responseData['data']) + : null, + }; + } else { + return { + 'success': false, + 'message': responseData['message'] ?? 'Failed to like post', + 'post': null, + }; + } + } catch (e) { + print('Error liking post: $e'); + return {'success': false, 'message': 'Network error: $e', 'post': null}; + } + } + + static Future> unlikePost(String postId) async { + try { + final response = await HttpService.post( + '/posts/unlike', + {'postId': postId}, + ); + + final responseData = jsonDecode(response.body); + + if (response.statusCode == 200) { + return { + 'success': responseData['status'] ?? false, + 'message': responseData['message'] ?? '', + 'post': responseData['data'] != null + ? Post.fromJson(responseData['data']) + : null, + }; + } else { + return { + 'success': false, + 'message': responseData['message'] ?? 'Failed to unlike post', + 'post': null, + }; + } + } catch (e) { + print('Error unliking post: $e'); + return {'success': false, 'message': 'Network error: $e', 'post': null}; + } + } +} diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 6793c47..5ec856d 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -78,4 +78,39 @@ class UserService { }; } } + + static Future> createUser({ + required String email, + required String password, + required String displayName, + }) async { + try { + final response = await HttpService.post('/admin/createUser', { + 'email': email, + 'password': password, + 'displayName': displayName, + }); + + final responseData = jsonDecode(response.body); + + if (response.statusCode == 200 || response.statusCode == 201) { + return { + 'success': responseData['status'] ?? false, + 'message': responseData['message'] ?? 'User created successfully', + 'data': responseData['data'], + }; + } else { + return { + 'success': false, + 'message': responseData['message'] ?? 'Failed to create user', + }; + } + } catch (e) { + print('Error creating user: $e'); + return { + 'success': false, + 'message': 'Network error. Please check your connection.', + }; + } + } } diff --git a/frontend/lib/utils/password_generator.dart b/frontend/lib/utils/password_generator.dart new file mode 100644 index 0000000..4160124 --- /dev/null +++ b/frontend/lib/utils/password_generator.dart @@ -0,0 +1,16 @@ +import 'dart:math'; + +class PasswordGenerator { + static const String _readableChars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + static final Random _random = Random(); + + static String generateReadablePassword({int length = 6}) { + StringBuffer password = StringBuffer(); + + for (int i = 0; i < length; i++) { + password.write(_readableChars[_random.nextInt(_readableChars.length)]); + } + + return password.toString(); + } +} \ No newline at end of file diff --git a/frontend/lib/widgets/posts_list.dart b/frontend/lib/widgets/posts_list.dart new file mode 100644 index 0000000..aacd0d2 --- /dev/null +++ b/frontend/lib/widgets/posts_list.dart @@ -0,0 +1,577 @@ +import 'package:flutter/material.dart'; +import 'package:share_plus/share_plus.dart'; +import '../models/post_models.dart'; +import '../services/post_service.dart'; +import '../utils/invitation_utils.dart'; + +class PostsList extends StatefulWidget { + final Future> Function() fetchPosts; + final String emptyStateTitle; + final String emptyStateSubtitle; + final bool showRefreshIndicator; + final Widget? floatingActionButton; + + const PostsList({ + Key? key, + required this.fetchPosts, + this.emptyStateTitle = 'Nothing here..', + this.emptyStateSubtitle = 'Create the first post!', + this.showRefreshIndicator = true, + this.floatingActionButton, + }) : super(key: key); + + @override + PostsListState createState() => PostsListState(); +} + +class PostsListState extends State { + bool _isLoading = true; + List _posts = []; + String _errorMessage = ''; + + @override + void initState() { + super.initState(); + _loadPosts(); + } + + Future _loadPosts() async { + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + final result = await widget.fetchPosts(); + setState(() { + if (result['success']) { + _posts = result['posts']; + } else { + _errorMessage = result['message'] ?? 'Failed to load posts'; + } + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = 'Network error: $e'; + _isLoading = false; + }); + } + } + + Future _refreshPosts() async { + await _loadPosts(); + + if (widget.showRefreshIndicator) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Posts refreshed!'), + backgroundColor: Color(0xFF6A4C93), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + Widget body = _buildBody(); + + if (widget.showRefreshIndicator) { + body = RefreshIndicator( + onRefresh: _refreshPosts, + child: body, + ); + } + + return body; + } + + Widget _buildBody() { + if (_isLoading) { + return Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Color(0xFF6A4C93)), + ), + ); + } + + if (_errorMessage.isNotEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Colors.grey[400], + ), + SizedBox(height: 16), + Text( + _errorMessage, + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + SizedBox(height: 16), + ElevatedButton( + onPressed: _loadPosts, + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFF6A4C93), + foregroundColor: Colors.white, + ), + child: Text('Try Again'), + ), + ], + ), + ); + } + + if (_posts.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.post_add, + size: 64, + color: Colors.grey[400], + ), + SizedBox(height: 16), + Text( + widget.emptyStateTitle, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.grey[600], + ), + ), + SizedBox(height: 8), + Text( + widget.emptyStateSubtitle, + style: TextStyle( + fontSize: 14, + color: Colors.grey[500], + ), + ), + ], + ), + ); + } + + return ListView.builder( + padding: EdgeInsets.symmetric(vertical: 8), + itemCount: _posts.length, + itemBuilder: (context, index) { + return PostCard( + post: _posts[index], + ); + }, + ); + } + + void refreshPosts() { + _loadPosts(); + } +} + +class PostCard extends StatefulWidget { + final Post post; + + const PostCard({Key? key, required this.post}) + : super(key: key); + + @override + _PostCardState createState() => _PostCardState(); +} + +class _PostCardState extends State { + late Post _currentPost; + bool _isLiking = false; + + @override + void initState() { + super.initState(); + _currentPost = widget.post; + } + + @override + void didUpdateWidget(PostCard oldWidget) { + super.didUpdateWidget(oldWidget); + // Update current post if the widget's post changed + if (oldWidget.post.id != widget.post.id) { + _currentPost = widget.post; + } + } + + Color _getAvatarColor(String displayName) { + final colors = [ + Color(0xFF32B0A5), + Color(0xFF4600B9), + Color(0xFF6A4C93), + Color(0xFFFF6347), + Color(0xFF32CD32), + Color(0xFF9932CC), + ]; + + int hash = displayName.hashCode; + return colors[hash.abs() % colors.length]; + } + + String _getAvatarLetter(String displayName) { + return displayName.isNotEmpty ? displayName[0].toUpperCase() : '?'; + } + + void _sharePost(Post post) { + final shareText = '${post.creator.displayName} posted on Wesal.online:\n\n${post.body}'; + Share.share(shareText); + } + + Future _toggleLike() async { + if (_isLiking) return; // Prevent multiple simultaneous requests + + setState(() { + _isLiking = true; + // Optimistic update - immediately change UI + _currentPost = _currentPost.copyWith( + liked: !_currentPost.liked, + likes: _currentPost.liked ? _currentPost.likes - 1 : _currentPost.likes + 1, + ); + }); + + try { + Map result; + if (widget.post.liked) { + result = await PostService.unlikePost(widget.post.id); + } else { + result = await PostService.likePost(widget.post.id); + } + + if (result['success'] && result['post'] != null) { + // Update with server response + setState(() { + _currentPost = result['post']; + _isLiking = false; + }); + } else { + // Revert optimistic update on failure + setState(() { + _currentPost = widget.post; + _isLiking = false; + }); + + // Show error message + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message'] ?? 'Failed to update like'), + backgroundColor: Colors.red, + ), + ); + } + } + } catch (e) { + // Revert optimistic update on error + setState(() { + _currentPost = widget.post; + _isLiking = false; + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Network error occurred'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final creator = _currentPost.creator; + final avatarColor = _getAvatarColor(creator.displayName); + final avatarLetter = _getAvatarLetter(creator.displayName); + final relativeTime = InvitationUtils.getRelativeTime(_currentPost.creationDate); + + return Container( + margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Padding( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: avatarColor, + child: Text( + avatarLetter, + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + creator.displayName, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + color: Colors.black87, + ), + ), + SizedBox(height: 2), + Text( + relativeTime, + style: TextStyle(color: Colors.grey[600], fontSize: 12), + ), + ], + ), + ), + IconButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('More options pressed')), + ); + }, + icon: Icon(Icons.more_horiz, color: Colors.grey[600]), + ), + ], + ), + SizedBox(height: 12), + Text( + _currentPost.body, + style: TextStyle( + fontSize: 15, + height: 1.4, + color: Colors.black87, + ), + ), + SizedBox(height: 16), + Row( + children: [ + IconButton( + onPressed: _toggleLike, + icon: Icon( + _currentPost.liked ? Icons.favorite : Icons.favorite_border, + color: _currentPost.liked ? Colors.red : Colors.grey[600], + size: 24, + ), + ), + Text( + '${_currentPost.likes}', + style: TextStyle( + color: Colors.grey[700], + fontWeight: FontWeight.w500, + ), + ), + SizedBox(width: 16), + IconButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Comments feature coming soon!')), + ); + }, + icon: Icon( + Icons.chat_bubble_outline, + color: Colors.grey[600], + size: 24, + ), + ), + Text( + '${_currentPost.comments}', + style: TextStyle( + color: Colors.grey[700], + fontWeight: FontWeight.w500, + ), + ), + Spacer(), + IconButton( + onPressed: () => _sharePost(_currentPost), + icon: Icon( + Icons.share_outlined, + color: Colors.grey[600], + size: 24, + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class ProfilePostsList extends StatefulWidget { + final Future> Function() fetchPosts; + final Function(int posts, int likes)? onStatsUpdate; + + const ProfilePostsList({ + Key? key, + required this.fetchPosts, + this.onStatsUpdate, + }) : super(key: key); + + @override + _ProfilePostsListState createState() => _ProfilePostsListState(); +} + +class _ProfilePostsListState extends State { + bool _isLoading = true; + List _posts = []; + String _errorMessage = ''; + + @override + void initState() { + super.initState(); + _loadPosts(); + } + + Future _loadPosts() async { + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + final result = await widget.fetchPosts(); + setState(() { + if (result['success']) { + _posts = result['posts']; + // Update parent stats + if (widget.onStatsUpdate != null) { + final totalLikes = _posts.fold(0, (sum, post) => sum + post.likes); + widget.onStatsUpdate!(_posts.length, totalLikes); + } + } else { + _errorMessage = result['message'] ?? 'Failed to load posts'; + } + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = 'Network error: $e'; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return Container( + padding: EdgeInsets.all(32), + child: Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Color(0xFF6A4C93), + ), + ), + ), + ); + } + + if (_errorMessage.isNotEmpty) { + return Container( + padding: EdgeInsets.all(32), + child: Center( + child: Column( + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Colors.grey[400], + ), + SizedBox(height: 16), + Text( + _errorMessage, + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + SizedBox(height: 16), + ElevatedButton( + onPressed: _loadPosts, + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFF6A4C93), + foregroundColor: Colors.white, + ), + child: Text('Try Again'), + ), + ], + ), + ), + ); + } + + if (_posts.isEmpty) { + return Container( + padding: EdgeInsets.all(32), + child: Center( + child: Column( + children: [ + Icon( + Icons.post_add, + size: 64, + color: Colors.grey[400], + ), + SizedBox(height: 16), + Text( + 'No posts yet', + style: TextStyle( + fontSize: 18, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + SizedBox(height: 8), + Text( + 'Start sharing your thoughts!', + style: TextStyle( + fontSize: 14, + color: Colors.grey[500], + ), + ), + ], + ), + ), + ); + } + + return Column( + children: [ + ..._posts.map((post) => + Container( + margin: EdgeInsets.only(bottom: 16, left: 16, right: 16), + child: PostCard(post: post), + ) + ), + SizedBox(height: 24), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/linux/flutter/generated_plugin_registrant.cc b/frontend/linux/flutter/generated_plugin_registrant.cc index d0e7f79..38dd0bc 100644 --- a/frontend/linux/flutter/generated_plugin_registrant.cc +++ b/frontend/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/frontend/linux/flutter/generated_plugins.cmake b/frontend/linux/flutter/generated_plugins.cmake index b29e9ba..65240e9 100644 --- a/frontend/linux/flutter/generated_plugins.cmake +++ b/frontend/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/frontend/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/macos/Flutter/GeneratedPluginRegistrant.swift index c220951..7cafb92 100644 --- a/frontend/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/frontend/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,10 +9,12 @@ import firebase_core import firebase_messaging import flutter_secure_storage_macos import path_provider_foundation +import share_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) } diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 3c6f560..0fe0702 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" crypto: dependency: transitive description: @@ -89,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" firebase_core: dependency: "direct main" description: @@ -137,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.10.9" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -304,6 +328,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" path: dependency: transitive description: @@ -376,6 +408,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: b2961506569e28948d75ec346c28775bb111986bb69dc6a20754a457e3d97fa0 + url: "https://pub.dev" + source: hosted + version: "11.0.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "1032d392bc5d2095a77447a805aa3f804d2ae6a4d5eef5e6ebb3bd94c1bc19ef" + url: "https://pub.dev" + source: hosted + version: "6.0.0" sky_engine: dependency: transitive description: flutter @@ -389,6 +437,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" stack_trace: dependency: transitive description: @@ -437,6 +493,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" vector_math: dependency: transitive description: diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 98ec267..5d9ebd1 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: http: ^1.1.0 googleapis_auth: ^1.6.0 flutter_secure_storage: ^9.2.2 + share_plus: ^11.0.0 dev_dependencies: flutter_test: diff --git a/frontend/windows/flutter/generated_plugin_registrant.cc b/frontend/windows/flutter/generated_plugin_registrant.cc index 39cedd3..d33bcaa 100644 --- a/frontend/windows/flutter/generated_plugin_registrant.cc +++ b/frontend/windows/flutter/generated_plugin_registrant.cc @@ -8,10 +8,16 @@ #include #include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/frontend/windows/flutter/generated_plugins.cmake b/frontend/windows/flutter/generated_plugins.cmake index 1f5d05f..eb935a1 100644 --- a/frontend/windows/flutter/generated_plugins.cmake +++ b/frontend/windows/flutter/generated_plugins.cmake @@ -5,6 +5,8 @@ list(APPEND FLUTTER_PLUGIN_LIST firebase_core flutter_secure_storage_windows + share_plus + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST