From 638e6343c9d7aaeeb1166dbd4c5d04e33159d980 Mon Sep 17 00:00:00 2001 From: sBubshait Date: Wed, 23 Jul 2025 13:35:56 +0300 Subject: [PATCH 01/13] feat: API documentation UI --- .../java/online/wesal/wesal/config/OpenApiConfig.java | 10 +++++++++- .../java/online/wesal/wesal/config/SecurityConfig.java | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) 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() ) From a932b42adbb77a034d73f279cf7b4158269befbb Mon Sep 17 00:00:00 2001 From: sBubshait Date: Wed, 23 Jul 2025 13:36:27 +0300 Subject: [PATCH 02/13] feat: enable API docs through /docs via Swagger --- backend/src/main/resources/application.yml | 6 ++++++ 1 file changed, 6 insertions(+) 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 From 2733ce82691494f476c6a43c3fb5ca7a46afbc34 Mon Sep 17 00:00:00 2001 From: sBubshait Date: Wed, 23 Jul 2025 13:36:45 +0300 Subject: [PATCH 03/13] feat: Post entity and endpoint to create posts --- .../wesal/controller/PostController.java | 61 +++++++++++++ .../wesal/wesal/dto/PostCreateRequestDTO.java | 25 ++++++ .../wesal/wesal/dto/PostResponseDTO.java | 73 ++++++++++++++++ .../java/online/wesal/wesal/entity/Post.java | 85 +++++++++++++++++++ .../wesal/repository/PostRepository.java | 12 +++ .../wesal/wesal/service/PostService.java | 23 +++++ 6 files changed, 279 insertions(+) create mode 100644 backend/src/main/java/online/wesal/wesal/controller/PostController.java create mode 100644 backend/src/main/java/online/wesal/wesal/dto/PostCreateRequestDTO.java create mode 100644 backend/src/main/java/online/wesal/wesal/dto/PostResponseDTO.java create mode 100644 backend/src/main/java/online/wesal/wesal/entity/Post.java create mode 100644 backend/src/main/java/online/wesal/wesal/repository/PostRepository.java create mode 100644 backend/src/main/java/online/wesal/wesal/service/PostService.java 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..bed1765 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/controller/PostController.java @@ -0,0 +1,61 @@ +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.PostResponseDTO; +import online.wesal.wesal.entity.Post; +import online.wesal.wesal.service.PostService; +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.*; + +@RestController +@RequestMapping("/posts") +@CrossOrigin(origins = "*") +@Tag(name = "Posts", description = "Post management endpoints") +public class PostController { + + @Autowired + private PostService postService; + + @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 { + Post post = postService.createPost(request.getBody()); + PostResponseDTO response = new PostResponseDTO(post); + 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")); + } + } +} \ 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/PostResponseDTO.java b/backend/src/main/java/online/wesal/wesal/dto/PostResponseDTO.java new file mode 100644 index 0000000..0391268 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/PostResponseDTO.java @@ -0,0 +1,73 @@ +package online.wesal.wesal.dto; + +import online.wesal.wesal.entity.Post; +import java.time.LocalDateTime; + +public class PostResponseDTO { + + private String id; + private String creatorId; + private String body; + private String likes; + private String comments; + private LocalDateTime creationDate; + + public PostResponseDTO() {} + + public PostResponseDTO(Post post) { + this.id = String.valueOf(post.getId()); + this.creatorId = String.valueOf(post.getCreatorId()); + this.body = post.getBody(); + this.likes = String.valueOf(post.getLikes()); + this.comments = String.valueOf(post.getComments()); + this.creationDate = post.getCreationDate(); + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getCreatorId() { + return creatorId; + } + + public void setCreatorId(String creatorId) { + this.creatorId = creatorId; + } + + 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 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/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/repository/PostRepository.java b/backend/src/main/java/online/wesal/wesal/repository/PostRepository.java new file mode 100644 index 0000000..867dc48 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/repository/PostRepository.java @@ -0,0 +1,12 @@ +package online.wesal.wesal.repository; + +import online.wesal.wesal.entity.Post; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PostRepository extends JpaRepository { + List findByCreatorIdOrderByCreationDateDesc(Long creatorId); +} \ 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..30796cb --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/service/PostService.java @@ -0,0 +1,23 @@ +package online.wesal.wesal.service; + +import online.wesal.wesal.entity.Post; +import online.wesal.wesal.entity.User; +import online.wesal.wesal.repository.PostRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class PostService { + + @Autowired + private PostRepository postRepository; + + @Autowired + private UserService userService; + + public Post createPost(String body) { + User currentUser = userService.getCurrentUser(); + Post post = new Post(currentUser.getId(), body); + return postRepository.save(post); + } +} \ No newline at end of file From e146b18f1dac96a91aa2184cf688365d92909cdc Mon Sep 17 00:00:00 2001 From: sBubshait Date: Wed, 23 Jul 2025 13:36:53 +0300 Subject: [PATCH 04/13] feat: UI for creating posts --- frontend/lib/constants/api_constants.dart | 7 +- frontend/lib/models/post_models.dart | 55 ++++ frontend/lib/screens/create_post_screen.dart | 251 +++++++++++++++++++ frontend/lib/screens/pages/feed_page.dart | 47 +++- frontend/lib/services/post_service.dart | 38 +++ 5 files changed, 385 insertions(+), 13 deletions(-) create mode 100644 frontend/lib/models/post_models.dart create mode 100644 frontend/lib/screens/create_post_screen.dart create mode 100644 frontend/lib/services/post_service.dart 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..ebcdb8c --- /dev/null +++ b/frontend/lib/models/post_models.dart @@ -0,0 +1,55 @@ +class Post { + final String id; + final String body; + final String userId; + final String username; + final String displayName; + final DateTime createdAt; + final DateTime updatedAt; + + Post({ + required this.id, + required this.body, + required this.userId, + required this.username, + required this.displayName, + required this.createdAt, + required this.updatedAt, + }); + + factory Post.fromJson(Map json) { + return Post( + id: json['id'] ?? '', + body: json['body'] ?? '', + userId: json['userId'] ?? '', + username: json['username'] ?? '', + displayName: json['displayName'] ?? '', + createdAt: DateTime.parse(json['createdAt'] ?? DateTime.now().toIso8601String()), + updatedAt: DateTime.parse(json['updatedAt'] ?? DateTime.now().toIso8601String()), + ); + } + + Map toJson() { + return { + 'id': id, + 'body': body, + 'userId': userId, + 'username': username, + 'displayName': displayName, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + }; + } +} + +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..9d28087 100644 --- a/frontend/lib/screens/pages/feed_page.dart +++ b/frontend/lib/screens/pages/feed_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../create_post_screen.dart'; class FeedPage extends StatefulWidget { @override @@ -6,6 +7,7 @@ class FeedPage extends StatefulWidget { } class _FeedPageState extends State { + bool _isRefreshing = false; final List> mockPosts = [ { 'id': '1', @@ -79,12 +81,7 @@ class _FeedPageState extends State { automaticallyImplyLeading: false, ), body: RefreshIndicator( - onRefresh: () async { - await Future.delayed(Duration(seconds: 1)); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Feed refreshed!'))); - }, + onRefresh: _refreshFeed, child: ListView.builder( padding: EdgeInsets.symmetric(vertical: 8), itemCount: mockPosts.length, @@ -107,16 +104,44 @@ class _FeedPageState extends State { ), ), 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 _refreshFeed() async { + setState(() { + _isRefreshing = true; + }); + + // Simulate API call for refreshing feed + await Future.delayed(Duration(seconds: 1)); + + setState(() { + _isRefreshing = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Feed refreshed!'), + backgroundColor: Color(0xFF6A4C93), + ), + ); + } + + Future _navigateToCreatePost() async { + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => CreatePostScreen()), + ); + + // If post was created successfully, refresh the feed + if (result == true) { + _refreshFeed(); + } + } } class PostCard extends StatefulWidget { diff --git a/frontend/lib/services/post_service.dart b/frontend/lib/services/post_service.dart new file mode 100644 index 0000000..bfb672f --- /dev/null +++ b/frontend/lib/services/post_service.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; +import '../models/post_models.dart'; +import 'http_service.dart'; + +class PostService { + 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, + }; + } + } +} \ No newline at end of file From 32cec6da12e580946946e09fec83da59c9a67012 Mon Sep 17 00:00:00 2001 From: sBubshait Date: Wed, 23 Jul 2025 13:48:27 +0300 Subject: [PATCH 05/13] feat: getting all posts and user's posts. --- .../wesal/controller/PostController.java | 40 ++++++++++++++++- .../online/wesal/wesal/dto/CreatorDTO.java | 32 ++++++++++++++ .../wesal/wesal/dto/PostResponseDTO.java | 15 ++++--- .../wesal/repository/PostRepository.java | 8 ++++ .../wesal/wesal/service/PostService.java | 44 +++++++++++++++++++ 5 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 backend/src/main/java/online/wesal/wesal/dto/CreatorDTO.java diff --git a/backend/src/main/java/online/wesal/wesal/controller/PostController.java b/backend/src/main/java/online/wesal/wesal/controller/PostController.java index bed1765..2d34d88 100644 --- a/backend/src/main/java/online/wesal/wesal/controller/PostController.java +++ b/backend/src/main/java/online/wesal/wesal/controller/PostController.java @@ -14,6 +14,9 @@ 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 = "*") @@ -43,8 +46,7 @@ public class PostController { } try { - Post post = postService.createPost(request.getBody()); - PostResponseDTO response = new PostResponseDTO(post); + PostResponseDTO response = postService.createPostWithResponse(request.getBody()); return ResponseEntity.ok(ApiResponse.success(response)); } catch (RuntimeException e) { String message; @@ -58,4 +60,38 @@ public class PostController { 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)") + public ResponseEntity>> getUserPosts(@RequestParam Long id) { + if (id == null || id <= 0) { + return ResponseEntity.badRequest().body(ApiResponse.error("Valid user ID is required")); + } + + try { + List response = postService.getUserPosts(id); + 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")); + } + } } \ No newline at end of file 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/PostResponseDTO.java b/backend/src/main/java/online/wesal/wesal/dto/PostResponseDTO.java index 0391268..a3a5602 100644 --- a/backend/src/main/java/online/wesal/wesal/dto/PostResponseDTO.java +++ b/backend/src/main/java/online/wesal/wesal/dto/PostResponseDTO.java @@ -1,12 +1,13 @@ 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 String creatorId; + private CreatorDTO creator; private String body; private String likes; private String comments; @@ -14,9 +15,9 @@ public class PostResponseDTO { public PostResponseDTO() {} - public PostResponseDTO(Post post) { + public PostResponseDTO(Post post, User creator) { this.id = String.valueOf(post.getId()); - this.creatorId = String.valueOf(post.getCreatorId()); + this.creator = new CreatorDTO(creator); this.body = post.getBody(); this.likes = String.valueOf(post.getLikes()); this.comments = String.valueOf(post.getComments()); @@ -31,12 +32,12 @@ public class PostResponseDTO { this.id = id; } - public String getCreatorId() { - return creatorId; + public CreatorDTO getCreator() { + return creator; } - public void setCreatorId(String creatorId) { - this.creatorId = creatorId; + public void setCreator(CreatorDTO creator) { + this.creator = creator; } public String getBody() { diff --git a/backend/src/main/java/online/wesal/wesal/repository/PostRepository.java b/backend/src/main/java/online/wesal/wesal/repository/PostRepository.java index 867dc48..c505859 100644 --- a/backend/src/main/java/online/wesal/wesal/repository/PostRepository.java +++ b/backend/src/main/java/online/wesal/wesal/repository/PostRepository.java @@ -2,11 +2,19 @@ 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 index 30796cb..e11e2e7 100644 --- a/backend/src/main/java/online/wesal/wesal/service/PostService.java +++ b/backend/src/main/java/online/wesal/wesal/service/PostService.java @@ -1,17 +1,27 @@ package online.wesal.wesal.service; +import online.wesal.wesal.dto.PostResponseDTO; import online.wesal.wesal.entity.Post; import online.wesal.wesal.entity.User; 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 java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + @Service public class PostService { @Autowired private PostRepository postRepository; + @Autowired + private UserRepository userRepository; + @Autowired private UserService userService; @@ -20,4 +30,38 @@ public class PostService { 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); + + List creatorIds = posts.stream() + .map(Post::getCreatorId) + .distinct() + .collect(Collectors.toList()); + + Map creators = userRepository.findAllById(creatorIds).stream() + .collect(Collectors.toMap(User::getId, user -> user)); + + return posts.stream() + .map(post -> new PostResponseDTO(post, creators.get(post.getCreatorId()))) + .collect(Collectors.toList()); + } + + public List getUserPosts(Long userId) { + List posts = postRepository.findByCreatorIdOrderByCreationDateDesc(userId); + User creator = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("User not found")); + + return posts.stream() + .map(post -> new PostResponseDTO(post, creator)) + .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); + } } \ No newline at end of file From 05fb7b19aab817992a693eba87d1a94531c5c71f Mon Sep 17 00:00:00 2001 From: sBubshait Date: Wed, 23 Jul 2025 13:50:49 +0300 Subject: [PATCH 06/13] feat: feed page connected with backend --- frontend/lib/models/post_models.dart | 74 ++++-- frontend/lib/screens/pages/feed_page.dart | 269 ++++++++++++++-------- frontend/lib/services/post_service.dart | 47 +++- 3 files changed, 263 insertions(+), 127 deletions(-) diff --git a/frontend/lib/models/post_models.dart b/frontend/lib/models/post_models.dart index ebcdb8c..a70eb19 100644 --- a/frontend/lib/models/post_models.dart +++ b/frontend/lib/models/post_models.dart @@ -1,43 +1,67 @@ -class Post { +class PostCreator { final String id; - final String body; - final String userId; - final String username; final String displayName; - final DateTime createdAt; - final DateTime updatedAt; - Post({ + PostCreator({ required this.id, - required this.body, - required this.userId, - required this.username, required this.displayName, - required this.createdAt, - required this.updatedAt, }); - factory Post.fromJson(Map json) { - return Post( - id: json['id'] ?? '', - body: json['body'] ?? '', - userId: json['userId'] ?? '', - username: json['username'] ?? '', + factory PostCreator.fromJson(Map json) { + return PostCreator( + id: json['id']?.toString() ?? '', displayName: json['displayName'] ?? '', - createdAt: DateTime.parse(json['createdAt'] ?? DateTime.now().toIso8601String()), - updatedAt: DateTime.parse(json['updatedAt'] ?? DateTime.now().toIso8601String()), ); } Map toJson() { return { 'id': id, - 'body': body, - 'userId': userId, - 'username': username, 'displayName': displayName, - 'createdAt': createdAt.toIso8601String(), - 'updatedAt': updatedAt.toIso8601String(), + }; + } +} + +class Post { + final String id; + final String creatorId; + final PostCreator creator; + final String body; + final int likes; + final int comments; + final DateTime creationDate; + + Post({ + required this.id, + required this.creatorId, + required this.creator, + required this.body, + required this.likes, + required this.comments, + required this.creationDate, + }); + + 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()), + ); + } + + Map toJson() { + return { + 'id': id, + 'creatorId': creatorId, + 'creator': creator.toJson(), + 'body': body, + 'likes': likes, + 'comments': comments, + 'creationDate': creationDate.toIso8601String(), }; } } diff --git a/frontend/lib/screens/pages/feed_page.dart b/frontend/lib/screens/pages/feed_page.dart index 9d28087..2aed7e7 100644 --- a/frontend/lib/screens/pages/feed_page.dart +++ b/frontend/lib/screens/pages/feed_page.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; import '../create_post_screen.dart'; +import '../../services/post_service.dart'; +import '../../models/post_models.dart'; +import '../../utils/invitation_utils.dart'; class FeedPage extends StatefulWidget { @override @@ -8,6 +11,40 @@ class FeedPage extends StatefulWidget { class _FeedPageState extends State { bool _isRefreshing = false; + bool _isLoading = true; + List _posts = []; + String _errorMessage = ''; + + @override + void initState() { + super.initState(); + _loadPosts(); + } + + Future _loadPosts() async { + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + final result = await PostService.getAllPosts(); + 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; + }); + } + } + final List> mockPosts = [ { 'id': '1', @@ -79,29 +116,17 @@ class _FeedPageState extends State { child: Container(height: 1, color: Colors.grey[200]), ), automaticallyImplyLeading: false, + actions: [ + IconButton( + onPressed: _loadPosts, + icon: Icon(Icons.refresh), + tooltip: 'Refresh', + ), + ], ), body: RefreshIndicator( onRefresh: _refreshFeed, - 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; - } - }); - }, - ); - }, - ), + child: _buildBody(), ), floatingActionButton: FloatingActionButton( onPressed: _navigateToCreatePost, @@ -111,17 +136,93 @@ class _FeedPageState extends State { ); } + 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( + 'Nothing here..', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.grey[600], + ), + ), + SizedBox(height: 8), + Text( + 'Create the first post!', + 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], + ); + }, + ); + } + Future _refreshFeed() async { - setState(() { - _isRefreshing = true; - }); - - // Simulate API call for refreshing feed - await Future.delayed(Duration(seconds: 1)); - - setState(() { - _isRefreshing = false; - }); + await _loadPosts(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -139,59 +240,46 @@ class _FeedPageState extends State { // If post was created successfully, refresh the feed if (result == true) { - _refreshFeed(); + _loadPosts(); } } } class PostCard extends StatefulWidget { - final Map post; - final VoidCallback onLikePressed; + final Post post; - const PostCard({Key? key, required this.post, required this.onLikePressed}) + const PostCard({Key? key, required this.post}) : 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, - ), - ); +class _PostCardState extends State { + 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]; } - @override - void dispose() { - _likeAnimationController.dispose(); - super.dispose(); - } - - void _handleLike() { - widget.onLikePressed(); - _likeAnimationController.forward().then((_) { - _likeAnimationController.reverse(); - }); + String _getAvatarLetter(String displayName) { + return displayName.isNotEmpty ? displayName[0].toUpperCase() : '?'; } @override Widget build(BuildContext context) { - final user = widget.post['user']; - final isLiked = widget.post['isLiked']; + final creator = widget.post.creator; + final avatarColor = _getAvatarColor(creator.displayName); + final avatarLetter = _getAvatarLetter(creator.displayName); + final relativeTime = InvitationUtils.getRelativeTime(widget.post.creationDate); return Container( margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -215,9 +303,9 @@ class _PostCardState extends State children: [ CircleAvatar( radius: 20, - backgroundColor: user['avatar_color'], + backgroundColor: avatarColor, child: Text( - user['avatar'], + avatarLetter, style: TextStyle( color: Colors.white, fontWeight: FontWeight.bold, @@ -231,7 +319,7 @@ class _PostCardState extends State crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - user['displayName'], + creator.displayName, style: TextStyle( fontWeight: FontWeight.w600, fontSize: 16, @@ -240,7 +328,7 @@ class _PostCardState extends State ), SizedBox(height: 2), Text( - widget.post['timestamp'], + relativeTime, style: TextStyle(color: Colors.grey[600], fontSize: 12), ), ], @@ -258,7 +346,7 @@ class _PostCardState extends State ), SizedBox(height: 12), Text( - widget.post['content'], + widget.post.body, style: TextStyle( fontSize: 15, height: 1.4, @@ -268,27 +356,20 @@ class _PostCardState extends State 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, - ), - ), - ), + IconButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Like feature coming soon!')), ); }, + icon: Icon( + Icons.favorite_border, + color: Colors.grey[600], + size: 24, + ), ), Text( - '${widget.post['likes']}', + '${widget.post.likes}', style: TextStyle( color: Colors.grey[700], fontWeight: FontWeight.w500, @@ -297,9 +378,9 @@ class _PostCardState extends State SizedBox(width: 16), IconButton( onPressed: () { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Comments pressed'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Comments feature coming soon!')), + ); }, icon: Icon( Icons.chat_bubble_outline, @@ -308,7 +389,7 @@ class _PostCardState extends State ), ), Text( - '${widget.post['comments']}', + '${widget.post.comments}', style: TextStyle( color: Colors.grey[700], fontWeight: FontWeight.w500, @@ -317,9 +398,9 @@ class _PostCardState extends State Spacer(), IconButton( onPressed: () { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Share pressed'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Share feature coming soon!')), + ); }, icon: Icon( Icons.share_outlined, diff --git a/frontend/lib/services/post_service.dart b/frontend/lib/services/post_service.dart index bfb672f..2773220 100644 --- a/frontend/lib/services/post_service.dart +++ b/frontend/lib/services/post_service.dart @@ -3,6 +3,39 @@ 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> createPost(String body) async { try { final createPostRequest = CreatePostRequest(body: body); @@ -12,12 +45,14 @@ class PostService { ); 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, + 'post': responseData['data'] != null + ? Post.fromJson(responseData['data']) + : null, }; } else { return { @@ -28,11 +63,7 @@ class PostService { } } catch (e) { print('Error creating post: $e'); - return { - 'success': false, - 'message': 'Network error: $e', - 'post': null, - }; + return {'success': false, 'message': 'Network error: $e', 'post': null}; } } -} \ No newline at end of file +} From c548c7e2a3cda596d24a0091573ccb55005e666d Mon Sep 17 00:00:00 2001 From: sBubshait Date: Wed, 23 Jul 2025 14:07:20 +0300 Subject: [PATCH 07/13] feat: Get the signed in user posts from endpoint --- .../wesal/controller/PostController.java | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/online/wesal/wesal/controller/PostController.java b/backend/src/main/java/online/wesal/wesal/controller/PostController.java index 2d34d88..812cb9f 100644 --- a/backend/src/main/java/online/wesal/wesal/controller/PostController.java +++ b/backend/src/main/java/online/wesal/wesal/controller/PostController.java @@ -8,6 +8,7 @@ import online.wesal.wesal.dto.PostCreateRequestDTO; 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; @@ -26,6 +27,9 @@ 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( @@ -73,14 +77,25 @@ public class PostController { } @GetMapping("/user") - @Operation(summary = "Get user posts", description = "Get all posts by a specific user, ordered by creation date (latest first)") - public ResponseEntity>> getUserPosts(@RequestParam Long id) { - if (id == null || id <= 0) { - return ResponseEntity.badRequest().body(ApiResponse.error("Valid user ID is required")); - } + @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 { - List response = postService.getUserPosts(id); + 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; From 86a4535f9aaa5eccc685b37cf6adc11074211ff5 Mon Sep 17 00:00:00 2001 From: sBubshait Date: Wed, 23 Jul 2025 14:07:34 +0300 Subject: [PATCH 08/13] feat: implement profile page user feed --- frontend/lib/screens/pages/profile_page.dart | 344 +++++++++++-------- frontend/lib/services/post_service.dart | 29 ++ 2 files changed, 228 insertions(+), 145 deletions(-) diff --git a/frontend/lib/screens/pages/profile_page.dart b/frontend/lib/screens/pages/profile_page.dart index e0c9482..499979c 100644 --- a/frontend/lib/screens/pages/profile_page.dart +++ b/frontend/lib/screens/pages/profile_page.dart @@ -3,6 +3,8 @@ 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'; class ProfilePage extends StatefulWidget { @override @@ -15,42 +17,16 @@ 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, - }, - ]; + List userPosts = []; + bool isLoadingPosts = true; + int totalLikes = 0; @override void initState() { super.initState(); _loadFCMToken(); _loadUserData(); + _loadUserPosts(); } @override @@ -98,6 +74,26 @@ class _ProfilePageState extends State { }); } + Future _loadUserPosts() async { + setState(() { + isLoadingPosts = true; + }); + + final result = await PostService.getUserPosts(); + + setState(() { + isLoadingPosts = false; + if (result['success'] == true) { + userPosts = result['posts'] as List; + totalLikes = userPosts.fold(0, (sum, post) => sum + post.likes); + } else { + userPosts = []; + totalLikes = 0; + _showErrorAlert(result['message'] ?? 'Failed to load posts'); + } + }); + } + void _showErrorAlert(String message) { showDialog( context: context, @@ -132,6 +128,21 @@ class _ProfilePageState extends State { ).push(MaterialPageRoute(builder: (context) => SettingsPage())); } + String _formatTimestamp(DateTime dateTime) { + final now = DateTime.now(); + final difference = now.difference(dateTime); + + if (difference.inDays > 0) { + return '${difference.inDays}d ago'; + } else if (difference.inHours > 0) { + return '${difference.inHours}h ago'; + } else if (difference.inMinutes > 0) { + return '${difference.inMinutes}m ago'; + } else { + return 'Just now'; + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -223,7 +234,7 @@ class _ProfilePageState extends State { Column( children: [ Text( - '${mockUserPosts.length}', + '${userPosts.length}', style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, @@ -242,7 +253,7 @@ class _ProfilePageState extends State { Column( children: [ Text( - '127', + '$totalLikes', style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, @@ -250,7 +261,7 @@ class _ProfilePageState extends State { ), ), Text( - 'Followers', + 'Likes Received', style: TextStyle( fontSize: 14, color: Colors.grey[600], @@ -270,124 +281,167 @@ 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), + if (isLoadingPosts) + Container( + padding: EdgeInsets.all(32), + child: Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Color(0xFF6A4C93), + ), + ), + ), + ) + else if (userPosts.isEmpty) + 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], + ), ), ], ), - 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, - ), - ), - ], + ), + ) + else + ListView.builder( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + padding: EdgeInsets.symmetric(horizontal: 16), + itemCount: userPosts.length, + itemBuilder: (context, index) { + final post = userPosts[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( + post.creator.displayName.isNotEmpty + ? post.creator.displayName + .substring(0, 1) + .toUpperCase() + : 'U', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + post.creator.displayName.isNotEmpty + ? post.creator.displayName + : 'Unknown User', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: Colors.black87, + ), + ), + Text( + _formatTimestamp(post.creationDate), + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + SizedBox(height: 12), + Text( + post.body, + style: TextStyle( + fontSize: 15, + height: 1.4, + color: Colors.black87, + ), + ), + SizedBox(height: 12), + Row( + children: [ + Icon( + Icons.favorite_border, + color: 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, + ), + ), + ], + ), + ], + ), + ), + ); + }, + ), ], SizedBox(height: 24), ], diff --git a/frontend/lib/services/post_service.dart b/frontend/lib/services/post_service.dart index 2773220..e735550 100644 --- a/frontend/lib/services/post_service.dart +++ b/frontend/lib/services/post_service.dart @@ -36,6 +36,35 @@ class PostService { } } + 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); From 066e0321206dc349f2d2ffc54346b1d04c4e01f0 Mon Sep 17 00:00:00 2001 From: sBubshait Date: Wed, 23 Jul 2025 14:16:08 +0300 Subject: [PATCH 09/13] feat: like and unlike backend endpoints --- .../wesal/controller/PostController.java | 75 +++++++++++++++++++ .../wesal/wesal/dto/PostLikeRequestDTO.java | 25 +++++++ .../online/wesal/wesal/entity/PostLike.java | 63 ++++++++++++++++ .../wesal/repository/PostLikeRepository.java | 15 ++++ .../wesal/wesal/service/PostService.java | 59 +++++++++++++++ 5 files changed, 237 insertions(+) create mode 100644 backend/src/main/java/online/wesal/wesal/dto/PostLikeRequestDTO.java create mode 100644 backend/src/main/java/online/wesal/wesal/entity/PostLike.java create mode 100644 backend/src/main/java/online/wesal/wesal/repository/PostLikeRepository.java diff --git a/backend/src/main/java/online/wesal/wesal/controller/PostController.java b/backend/src/main/java/online/wesal/wesal/controller/PostController.java index 812cb9f..c649145 100644 --- a/backend/src/main/java/online/wesal/wesal/controller/PostController.java +++ b/backend/src/main/java/online/wesal/wesal/controller/PostController.java @@ -5,6 +5,7 @@ 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; @@ -109,4 +110,78 @@ public class PostController { 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/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/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/repository/PostLikeRepository.java b/backend/src/main/java/online/wesal/wesal/repository/PostLikeRepository.java new file mode 100644 index 0000000..9a85d0f --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/repository/PostLikeRepository.java @@ -0,0 +1,15 @@ +package online.wesal.wesal.repository; + +import online.wesal.wesal.entity.PostLike; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@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); +} \ 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 index e11e2e7..e79cb16 100644 --- a/backend/src/main/java/online/wesal/wesal/service/PostService.java +++ b/backend/src/main/java/online/wesal/wesal/service/PostService.java @@ -2,11 +2,14 @@ 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; @@ -19,6 +22,9 @@ public class PostService { @Autowired private PostRepository postRepository; + @Autowired + private PostLikeRepository postLikeRepository; + @Autowired private UserRepository userRepository; @@ -64,4 +70,57 @@ public class PostService { post = postRepository.save(post); return new PostResponseDTO(post, currentUser); } + + @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")); + + return new PostResponseDTO(post, creator); + } + + @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")); + + return new PostResponseDTO(post, creator); + } } \ No newline at end of file From 221136b4eeaec95df22dbbe85337157b99c978d0 Mon Sep 17 00:00:00 2001 From: sBubshait Date: Wed, 23 Jul 2025 14:16:39 +0300 Subject: [PATCH 10/13] feat: implement share button on posts --- frontend/lib/screens/pages/feed_page.dart | 12 ++- .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + frontend/pubspec.lock | 96 +++++++++++++++++++ frontend/pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 6 ++ .../windows/flutter/generated_plugins.cmake | 2 + 8 files changed, 119 insertions(+), 5 deletions(-) diff --git a/frontend/lib/screens/pages/feed_page.dart b/frontend/lib/screens/pages/feed_page.dart index 2aed7e7..3729d2f 100644 --- a/frontend/lib/screens/pages/feed_page.dart +++ b/frontend/lib/screens/pages/feed_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:share_plus/share_plus.dart'; import '../create_post_screen.dart'; import '../../services/post_service.dart'; import '../../models/post_models.dart'; @@ -274,6 +275,11 @@ class _PostCardState extends State { 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); + } + @override Widget build(BuildContext context) { final creator = widget.post.creator; @@ -397,11 +403,7 @@ class _PostCardState extends State { ), Spacer(), IconButton( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Share feature coming soon!')), - ); - }, + onPressed: () => _sharePost(widget.post), icon: Icon( Icons.share_outlined, color: Colors.grey[600], 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 From 037bdf64b8a6eb796d075c3749ed824c5a05f56f Mon Sep 17 00:00:00 2001 From: sBubshait Date: Wed, 23 Jul 2025 14:23:12 +0300 Subject: [PATCH 11/13] feat: backend returns whether user liked each post (efficiently using sets). --- .../wesal/wesal/dto/PostResponseDTO.java | 20 +++++++++ .../wesal/repository/PostLikeRepository.java | 10 +++++ .../wesal/wesal/service/PostService.java | 42 ++++++++++++++++--- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/backend/src/main/java/online/wesal/wesal/dto/PostResponseDTO.java b/backend/src/main/java/online/wesal/wesal/dto/PostResponseDTO.java index a3a5602..267bbfd 100644 --- a/backend/src/main/java/online/wesal/wesal/dto/PostResponseDTO.java +++ b/backend/src/main/java/online/wesal/wesal/dto/PostResponseDTO.java @@ -11,6 +11,7 @@ public class PostResponseDTO { private String body; private String likes; private String comments; + private boolean liked; private LocalDateTime creationDate; public PostResponseDTO() {} @@ -21,6 +22,17 @@ public class PostResponseDTO { 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(); } @@ -64,6 +76,14 @@ public class PostResponseDTO { this.comments = comments; } + public boolean isLiked() { + return liked; + } + + public void setLiked(boolean liked) { + this.liked = liked; + } + public LocalDateTime getCreationDate() { return creationDate; } diff --git a/backend/src/main/java/online/wesal/wesal/repository/PostLikeRepository.java b/backend/src/main/java/online/wesal/wesal/repository/PostLikeRepository.java index 9a85d0f..ca40e23 100644 --- a/backend/src/main/java/online/wesal/wesal/repository/PostLikeRepository.java +++ b/backend/src/main/java/online/wesal/wesal/repository/PostLikeRepository.java @@ -2,9 +2,13 @@ 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 { @@ -12,4 +16,10 @@ public interface PostLikeRepository extends JpaRepository { 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/service/PostService.java b/backend/src/main/java/online/wesal/wesal/service/PostService.java index e79cb16..3cbb492 100644 --- a/backend/src/main/java/online/wesal/wesal/service/PostService.java +++ b/backend/src/main/java/online/wesal/wesal/service/PostService.java @@ -14,6 +14,7 @@ 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 @@ -41,16 +42,30 @@ public class PostService { 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()))) + .map(post -> new PostResponseDTO(post, creators.get(post.getCreatorId()), + likedPostIds.contains(post.getId()))) .collect(Collectors.toList()); } @@ -59,8 +74,19 @@ public class PostService { 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)) + .map(post -> new PostResponseDTO(post, creator, likedPostIds.contains(post.getId()))) .collect(Collectors.toList()); } @@ -68,7 +94,7 @@ public class PostService { User currentUser = userService.getCurrentUser(); Post post = new Post(currentUser.getId(), body); post = postRepository.save(post); - return new PostResponseDTO(post, currentUser); + return new PostResponseDTO(post, currentUser, false); // New post is not liked by default } @Transactional @@ -95,7 +121,10 @@ public class PostService { User creator = userRepository.findById(post.getCreatorId()) .orElseThrow(() -> new RuntimeException("Creator not found")); - return new PostResponseDTO(post, creator); + // Check if user now likes this post + boolean isLiked = postLikeRepository.existsByPostIdAndUserId(postId, userId); + + return new PostResponseDTO(post, creator, isLiked); } @Transactional @@ -121,6 +150,9 @@ public class PostService { User creator = userRepository.findById(post.getCreatorId()) .orElseThrow(() -> new RuntimeException("Creator not found")); - return new PostResponseDTO(post, creator); + // 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 From e4ab34b97f08d09ff68b165b180865eacf82a3aa Mon Sep 17 00:00:00 2001 From: sBubshait Date: Wed, 23 Jul 2025 14:38:36 +0300 Subject: [PATCH 12/13] feat: implement like/unlike and refactor posts --- frontend/lib/models/post_models.dart | 26 + frontend/lib/screens/pages/feed_page.dart | 376 +----------- frontend/lib/screens/pages/profile_page.dart | 211 +------ frontend/lib/services/post_service.dart | 60 ++ frontend/lib/widgets/posts_list.dart | 577 +++++++++++++++++++ 5 files changed, 692 insertions(+), 558 deletions(-) create mode 100644 frontend/lib/widgets/posts_list.dart diff --git a/frontend/lib/models/post_models.dart b/frontend/lib/models/post_models.dart index a70eb19..2b24ede 100644 --- a/frontend/lib/models/post_models.dart +++ b/frontend/lib/models/post_models.dart @@ -30,6 +30,7 @@ class Post { final int likes; final int comments; final DateTime creationDate; + final bool liked; Post({ required this.id, @@ -39,6 +40,7 @@ class Post { required this.likes, required this.comments, required this.creationDate, + this.liked = false, }); factory Post.fromJson(Map json) { @@ -50,6 +52,7 @@ class Post { 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, ); } @@ -62,8 +65,31 @@ class Post { '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 { diff --git a/frontend/lib/screens/pages/feed_page.dart b/frontend/lib/screens/pages/feed_page.dart index 3729d2f..99b7a31 100644 --- a/frontend/lib/screens/pages/feed_page.dart +++ b/frontend/lib/screens/pages/feed_page.dart @@ -1,9 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:share_plus/share_plus.dart'; import '../create_post_screen.dart'; import '../../services/post_service.dart'; -import '../../models/post_models.dart'; -import '../../utils/invitation_utils.dart'; +import '../../widgets/posts_list.dart'; class FeedPage extends StatefulWidget { @override @@ -11,98 +9,8 @@ class FeedPage extends StatefulWidget { } class _FeedPageState extends State { - bool _isRefreshing = false; - bool _isLoading = true; - List _posts = []; - String _errorMessage = ''; + final GlobalKey _postsListKey = GlobalKey(); - @override - void initState() { - super.initState(); - _loadPosts(); - } - - Future _loadPosts() async { - setState(() { - _isLoading = true; - _errorMessage = ''; - }); - - try { - final result = await PostService.getAllPosts(); - 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; - }); - } - } - - 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, - }, - ]; @override Widget build(BuildContext context) { @@ -119,15 +27,18 @@ class _FeedPageState extends State { automaticallyImplyLeading: false, actions: [ IconButton( - onPressed: _loadPosts, + onPressed: () => _postsListKey.currentState?.refreshPosts(), icon: Icon(Icons.refresh), tooltip: 'Refresh', ), ], ), - body: RefreshIndicator( - onRefresh: _refreshFeed, - child: _buildBody(), + body: PostsList( + key: _postsListKey, + fetchPosts: PostService.getAllPosts, + emptyStateTitle: 'Nothing here..', + emptyStateSubtitle: 'Create the first post!', + showRefreshIndicator: true, ), floatingActionButton: FloatingActionButton( onPressed: _navigateToCreatePost, @@ -137,101 +48,6 @@ class _FeedPageState extends State { ); } - 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( - 'Nothing here..', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Colors.grey[600], - ), - ), - SizedBox(height: 8), - Text( - 'Create the first post!', - 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], - ); - }, - ); - } - - Future _refreshFeed() async { - await _loadPosts(); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Feed refreshed!'), - backgroundColor: Color(0xFF6A4C93), - ), - ); - } Future _navigateToCreatePost() async { final result = await Navigator.push( @@ -241,180 +57,8 @@ class _FeedPageState extends State { // If post was created successfully, refresh the feed if (result == true) { - _loadPosts(); + _postsListKey.currentState?.refreshPosts(); } } } -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 { - 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); - } - - @override - Widget build(BuildContext context) { - final creator = widget.post.creator; - final avatarColor = _getAvatarColor(creator.displayName); - final avatarLetter = _getAvatarLetter(creator.displayName); - final relativeTime = InvitationUtils.getRelativeTime(widget.post.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( - widget.post.body, - style: TextStyle( - fontSize: 15, - height: 1.4, - color: Colors.black87, - ), - ), - SizedBox(height: 16), - Row( - children: [ - IconButton( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Like feature coming soon!')), - ); - }, - icon: Icon( - Icons.favorite_border, - color: 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 feature coming soon!')), - ); - }, - 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: () => _sharePost(widget.post), - 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 499979c..2dcc633 100644 --- a/frontend/lib/screens/pages/profile_page.dart +++ b/frontend/lib/screens/pages/profile_page.dart @@ -5,6 +5,7 @@ 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'; class ProfilePage extends StatefulWidget { @override @@ -17,16 +18,15 @@ class _ProfilePageState extends State { bool isLoading = false; Map? userData; bool isLoadingUser = true; - List userPosts = []; - bool isLoadingPosts = true; int totalLikes = 0; + int totalPosts = 0; @override void initState() { super.initState(); _loadFCMToken(); _loadUserData(); - _loadUserPosts(); + _loadUserStats(); } @override @@ -74,22 +74,17 @@ class _ProfilePageState extends State { }); } - Future _loadUserPosts() async { - setState(() { - isLoadingPosts = true; - }); - + Future _loadUserStats() async { final result = await PostService.getUserPosts(); setState(() { - isLoadingPosts = false; if (result['success'] == true) { - userPosts = result['posts'] as List; - totalLikes = userPosts.fold(0, (sum, post) => sum + post.likes); + final posts = result['posts'] as List; + totalPosts = posts.length; + totalLikes = posts.fold(0, (sum, post) => sum + post.likes); } else { - userPosts = []; + totalPosts = 0; totalLikes = 0; - _showErrorAlert(result['message'] ?? 'Failed to load posts'); } }); } @@ -128,21 +123,6 @@ class _ProfilePageState extends State { ).push(MaterialPageRoute(builder: (context) => SettingsPage())); } - String _formatTimestamp(DateTime dateTime) { - final now = DateTime.now(); - final difference = now.difference(dateTime); - - if (difference.inDays > 0) { - return '${difference.inDays}d ago'; - } else if (difference.inHours > 0) { - return '${difference.inHours}h ago'; - } else if (difference.inMinutes > 0) { - return '${difference.inMinutes}m ago'; - } else { - return 'Just now'; - } - } - @override Widget build(BuildContext context) { return Scaffold( @@ -234,7 +214,7 @@ class _ProfilePageState extends State { Column( children: [ Text( - '${userPosts.length}', + '$totalPosts', style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, @@ -281,169 +261,16 @@ class _ProfilePageState extends State { margin: EdgeInsets.symmetric(horizontal: 16), ), SizedBox(height: 16), - if (isLoadingPosts) - Container( - padding: EdgeInsets.all(32), - child: Center( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation( - Color(0xFF6A4C93), - ), - ), - ), - ) - else if (userPosts.isEmpty) - 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], - ), - ), - ], - ), - ), - ) - else - ListView.builder( - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - padding: EdgeInsets.symmetric(horizontal: 16), - itemCount: userPosts.length, - itemBuilder: (context, index) { - final post = userPosts[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( - post.creator.displayName.isNotEmpty - ? post.creator.displayName - .substring(0, 1) - .toUpperCase() - : 'U', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - ), - SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - post.creator.displayName.isNotEmpty - ? post.creator.displayName - : 'Unknown User', - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - color: Colors.black87, - ), - ), - Text( - _formatTimestamp(post.creationDate), - style: TextStyle( - color: Colors.grey[600], - fontSize: 12, - ), - ), - ], - ), - ), - ], - ), - SizedBox(height: 12), - Text( - post.body, - style: TextStyle( - fontSize: 15, - height: 1.4, - color: Colors.black87, - ), - ), - SizedBox(height: 12), - Row( - children: [ - Icon( - Icons.favorite_border, - color: 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), ], ), ), @@ -547,7 +374,7 @@ class _SettingsPageState extends State { bottom: PreferredSize( preferredSize: Size.fromHeight(1), child: Container(height: 1, color: Colors.grey[200]), - ), + ), automaticallyImplyLeading: true, ), body: Padding( diff --git a/frontend/lib/services/post_service.dart b/frontend/lib/services/post_service.dart index e735550..5149d27 100644 --- a/frontend/lib/services/post_service.dart +++ b/frontend/lib/services/post_service.dart @@ -95,4 +95,64 @@ class PostService { 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/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 From 100c51c865fc2621890b316bcc5368b0c2e4dd8f Mon Sep 17 00:00:00 2001 From: sBubshait Date: Wed, 23 Jul 2025 15:10:50 +0300 Subject: [PATCH 13/13] feat: creating users from the front end for admin users --- .../wesal/wesal/dto/CreateUserRequest.java | 2 +- .../wesal/wesal/dto/UpdateUserRequest.java | 2 +- .../java/online/wesal/wesal/entity/User.java | 2 +- frontend/lib/screens/pages/profile_page.dart | 294 ++++++++++++++++++ frontend/lib/services/user_service.dart | 35 +++ frontend/lib/utils/password_generator.dart | 16 + 6 files changed, 348 insertions(+), 3 deletions(-) create mode 100644 frontend/lib/utils/password_generator.dart 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/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/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/frontend/lib/screens/pages/profile_page.dart b/frontend/lib/screens/pages/profile_page.dart index 2dcc633..360e4b3 100644 --- a/frontend/lib/screens/pages/profile_page.dart +++ b/frontend/lib/screens/pages/profile_page.dart @@ -6,6 +6,7 @@ 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 @@ -288,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(); } @@ -333,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, @@ -363,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( @@ -382,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/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