From c1c7ebd6782d1f29900e11b24cf1c45e6aba0b8d Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Tue, 17 Feb 2026 11:04:51 -0600 Subject: [PATCH] feat: Add Groups system - Flutter models and API methods --- sojorn_app/lib/models/group.dart | 304 +++++++++++++++++++++++ sojorn_app/lib/services/api_service.dart | 101 ++++++++ 2 files changed, 405 insertions(+) create mode 100644 sojorn_app/lib/models/group.dart diff --git a/sojorn_app/lib/models/group.dart b/sojorn_app/lib/models/group.dart new file mode 100644 index 0000000..9e457dd --- /dev/null +++ b/sojorn_app/lib/models/group.dart @@ -0,0 +1,304 @@ +import 'package:equatable/equatable.dart'; + +enum GroupCategory { + general('General', 'general'), + hobby('Hobby', 'hobby'), + sports('Sports', 'sports'), + professional('Professional', 'professional'), + localBusiness('Local Business', 'local_business'), + support('Support', 'support'), + education('Education', 'education'); + + const GroupCategory(this.displayName, this.value); + final String displayName; + final String value; + + static GroupCategory fromString(String value) { + return GroupCategory.values.firstWhere( + (cat) => cat.value == value, + orElse: () => GroupCategory.general, + ); + } +} + +enum GroupRole { + owner('Owner'), + admin('Admin'), + moderator('Moderator'), + member('Member'); + + const GroupRole(this.displayName); + final String displayName; + + static GroupRole fromString(String value) { + return GroupRole.values.firstWhere( + (role) => role.name.toLowerCase() == value.toLowerCase(), + orElse: () => GroupRole.member, + ); + } +} + +enum JoinRequestStatus { + pending('Pending'), + approved('Approved'), + rejected('Rejected'); + + const JoinRequestStatus(this.displayName); + final String displayName; + + static JoinRequestStatus fromString(String value) { + return JoinRequestStatus.values.firstWhere( + (status) => status.name.toLowerCase() == value.toLowerCase(), + orElse: () => JoinRequestStatus.pending, + ); + } +} + +class Group extends Equatable { + final String id; + final String name; + final String description; + final GroupCategory category; + final String? avatarUrl; + final String? bannerUrl; + final bool isPrivate; + final String createdBy; + final int memberCount; + final int postCount; + final DateTime createdAt; + final DateTime updatedAt; + final GroupRole? userRole; + final bool isMember; + final bool hasPendingRequest; + + const Group({ + required this.id, + required this.name, + required this.description, + required this.category, + this.avatarUrl, + this.bannerUrl, + required this.isPrivate, + required this.createdBy, + required this.memberCount, + required this.postCount, + required this.createdAt, + required this.updatedAt, + this.userRole, + this.isMember = false, + this.hasPendingRequest = false, + }); + + factory Group.fromJson(Map json) { + return Group( + id: json['id'] as String, + name: json['name'] as String, + description: json['description'] as String? ?? '', + category: GroupCategory.fromString(json['category'] as String), + avatarUrl: json['avatar_url'] as String?, + bannerUrl: json['banner_url'] as String?, + isPrivate: json['is_private'] as bool? ?? false, + createdBy: json['created_by'] as String, + memberCount: json['member_count'] as int? ?? 0, + postCount: json['post_count'] as int? ?? 0, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + userRole: json['user_role'] != null + ? GroupRole.fromString(json['user_role'] as String) + : null, + isMember: json['is_member'] as bool? ?? false, + hasPendingRequest: json['has_pending_request'] as bool? ?? false, + ); + } + + Group copyWith({ + String? id, + String? name, + String? description, + GroupCategory? category, + String? avatarUrl, + String? bannerUrl, + bool? isPrivate, + String? createdBy, + int? memberCount, + int? postCount, + DateTime? createdAt, + DateTime? updatedAt, + GroupRole? userRole, + bool? isMember, + bool? hasPendingRequest, + }) { + return Group( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + category: category ?? this.category, + avatarUrl: avatarUrl ?? this.avatarUrl, + bannerUrl: bannerUrl ?? this.bannerUrl, + isPrivate: isPrivate ?? this.isPrivate, + createdBy: createdBy ?? this.createdBy, + memberCount: memberCount ?? this.memberCount, + postCount: postCount ?? this.postCount, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + userRole: userRole ?? this.userRole, + isMember: isMember ?? this.isMember, + hasPendingRequest: hasPendingRequest ?? this.hasPendingRequest, + ); + } + + @override + List get props => [ + id, + name, + description, + category, + avatarUrl, + bannerUrl, + isPrivate, + createdBy, + memberCount, + postCount, + createdAt, + updatedAt, + userRole, + isMember, + hasPendingRequest, + ]; + + String get memberCountText { + if (memberCount >= 1000000) { + return '${(memberCount / 1000000).toStringAsFixed(1)}M members'; + } else if (memberCount >= 1000) { + return '${(memberCount / 1000).toStringAsFixed(1)}K members'; + } + return '$memberCount members'; + } + + String get postCountText { + if (postCount >= 1000) { + return '${(postCount / 1000).toStringAsFixed(1)}K posts'; + } + return '$postCount posts'; + } +} + +class GroupMember extends Equatable { + final String id; + final String groupId; + final String userId; + final GroupRole role; + final DateTime joinedAt; + final String? username; + final String? avatarUrl; + + const GroupMember({ + required this.id, + required this.groupId, + required this.userId, + required this.role, + required this.joinedAt, + this.username, + this.avatarUrl, + }); + + factory GroupMember.fromJson(Map json) { + return GroupMember( + id: json['id'] as String, + groupId: json['group_id'] as String, + userId: json['user_id'] as String, + role: GroupRole.fromString(json['role'] as String), + joinedAt: DateTime.parse(json['joined_at'] as String), + username: json['username'] as String?, + avatarUrl: json['avatar_url'] as String?, + ); + } + + @override + List get props => [ + id, + groupId, + userId, + role, + joinedAt, + username, + avatarUrl, + ]; +} + +class JoinRequest extends Equatable { + final String id; + final String groupId; + final String userId; + final JoinRequestStatus status; + final String? message; + final DateTime createdAt; + final DateTime? reviewedAt; + final String? reviewedBy; + final String? username; + final String? avatarUrl; + + const JoinRequest({ + required this.id, + required this.groupId, + required this.userId, + required this.status, + this.message, + required this.createdAt, + this.reviewedAt, + this.reviewedBy, + this.username, + this.avatarUrl, + }); + + factory JoinRequest.fromJson(Map json) { + return JoinRequest( + id: json['id'] as String, + groupId: json['group_id'] as String, + userId: json['user_id'] as String, + status: JoinRequestStatus.fromString(json['status'] as String), + message: json['message'] as String?, + createdAt: DateTime.parse(json['created_at'] as String), + reviewedAt: json['reviewed_at'] != null + ? DateTime.parse(json['reviewed_at'] as String) + : null, + reviewedBy: json['reviewed_by'] as String?, + username: json['username'] as String?, + avatarUrl: json['avatar_url'] as String?, + ); + } + + @override + List get props => [ + id, + groupId, + userId, + status, + message, + createdAt, + reviewedAt, + reviewedBy, + username, + avatarUrl, + ]; +} + +class SuggestedGroup extends Equatable { + final Group group; + final String reason; + + const SuggestedGroup({ + required this.group, + required this.reason, + }); + + factory SuggestedGroup.fromJson(Map json) { + return SuggestedGroup( + group: Group.fromJson(json), + reason: json['reason'] as String? ?? 'Suggested for you', + ); + } + + @override + List get props => [group, reason]; +} diff --git a/sojorn_app/lib/services/api_service.dart b/sojorn_app/lib/services/api_service.dart index 3a036d4..033b3ea 100644 --- a/sojorn_app/lib/services/api_service.dart +++ b/sojorn_app/lib/services/api_service.dart @@ -10,6 +10,7 @@ import '../models/user_settings.dart'; import '../models/comment.dart'; import '../models/notification.dart'; import '../models/beacon.dart'; +import '../models/group.dart'; import '../config/api_config.dart'; import '../services/auth_service.dart'; import '../models/search_results.dart'; @@ -1590,4 +1591,104 @@ class ApiService { final data = await _callGoApi('/users/$userId/following', method: 'GET'); return (data['following'] as List?)?.cast>() ?? []; } + + // Groups System + // ========================================================================= + + /// List all groups with optional category filter + Future> listGroups({String? category, int page = 0, int limit = 20}) async { + final queryParams = { + 'page': page.toString(), + 'limit': limit.toString(), + }; + if (category != null) { + queryParams['category'] = category; + } + + final data = await _callGoApi('/groups', method: 'GET', queryParams: queryParams); + final groups = (data['groups'] as List?) ?? []; + return groups.map((g) => Group.fromJson(g)).toList(); + } + + /// Get groups the user is a member of + Future> getMyGroups() async { + final data = await _callGoApi('/groups/mine', method: 'GET'); + final groups = (data['groups'] as List?) ?? []; + return groups.map((g) => Group.fromJson(g)).toList(); + } + + /// Get suggested groups for the user + Future> getSuggestedGroups({int limit = 10}) async { + final data = await _callGoApi('/groups/suggested', method: 'GET', + queryParams: {'limit': limit.toString()}); + final suggestions = (data['suggestions'] as List?) ?? []; + return suggestions.map((s) => SuggestedGroup.fromJson(s)).toList(); + } + + /// Get group details by ID + Future getGroup(String groupId) async { + final data = await _callGoApi('/groups/$groupId', method: 'GET'); + return Group.fromJson(data['group']); + } + + /// Create a new group + Future> createGroup({ + required String name, + String? description, + required GroupCategory category, + bool isPrivate = false, + String? avatarUrl, + String? bannerUrl, + }) async { + final body = { + 'name': name, + 'description': description ?? '', + 'category': category.value, + 'is_private': isPrivate, + if (avatarUrl != null) 'avatar_url': avatarUrl, + if (bannerUrl != null) 'banner_url': bannerUrl, + }; + + return await _callGoApi('/groups', method: 'POST', body: body); + } + + /// Join a group or request to join (for private groups) + Future> joinGroup(String groupId, {String? message}) async { + final body = {}; + if (message != null) { + body['message'] = message; + } + + return await _callGoApi('/groups/$groupId/join', method: 'POST', body: body); + } + + /// Leave a group + Future leaveGroup(String groupId) async { + await _callGoApi('/groups/$groupId/leave', method: 'POST'); + } + + /// Get group members + Future> getGroupMembers(String groupId, {int page = 0, int limit = 50}) async { + final data = await _callGoApi('/groups/$groupId/members', method: 'GET', + queryParams: {'page': page.toString(), 'limit': limit.toString()}); + final members = (data['members'] as List?) ?? []; + return members.map((m) => GroupMember.fromJson(m)).toList(); + } + + /// Get pending join requests (admin only) + Future> getPendingRequests(String groupId) async { + final data = await _callGoApi('/groups/$groupId/requests', method: 'GET'); + final requests = (data['requests'] as List?) ?? []; + return requests.map((r) => JoinRequest.fromJson(r)).toList(); + } + + /// Approve a join request (admin only) + Future approveJoinRequest(String groupId, String requestId) async { + await _callGoApi('/groups/$groupId/requests/$requestId/approve', method: 'POST'); + } + + /// Reject a join request (admin only) + Future rejectJoinRequest(String groupId, String requestId) async { + await _callGoApi('/groups/$groupId/requests/$requestId/reject', method: 'POST'); + } }