feat: Add Groups system - Flutter models and API methods

This commit is contained in:
Patrick Britton 2026-02-17 11:04:51 -06:00
parent abfbeb2119
commit c1c7ebd678
2 changed files with 405 additions and 0 deletions

View file

@ -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<String, dynamic> 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<Object?> 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<String, dynamic> 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<Object?> 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<String, dynamic> 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<Object?> 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<String, dynamic> json) {
return SuggestedGroup(
group: Group.fromJson(json),
reason: json['reason'] as String? ?? 'Suggested for you',
);
}
@override
List<Object?> get props => [group, reason];
}

View file

@ -10,6 +10,7 @@ import '../models/user_settings.dart';
import '../models/comment.dart'; import '../models/comment.dart';
import '../models/notification.dart'; import '../models/notification.dart';
import '../models/beacon.dart'; import '../models/beacon.dart';
import '../models/group.dart';
import '../config/api_config.dart'; import '../config/api_config.dart';
import '../services/auth_service.dart'; import '../services/auth_service.dart';
import '../models/search_results.dart'; import '../models/search_results.dart';
@ -1590,4 +1591,104 @@ class ApiService {
final data = await _callGoApi('/users/$userId/following', method: 'GET'); final data = await _callGoApi('/users/$userId/following', method: 'GET');
return (data['following'] as List?)?.cast<Map<String, dynamic>>() ?? []; return (data['following'] as List?)?.cast<Map<String, dynamic>>() ?? [];
} }
// Groups System
// =========================================================================
/// List all groups with optional category filter
Future<List<Group>> listGroups({String? category, int page = 0, int limit = 20}) async {
final queryParams = <String, String>{
'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<List<Group>> 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<List<SuggestedGroup>> 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<Group> getGroup(String groupId) async {
final data = await _callGoApi('/groups/$groupId', method: 'GET');
return Group.fromJson(data['group']);
}
/// Create a new group
Future<Map<String, dynamic>> 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<Map<String, dynamic>> joinGroup(String groupId, {String? message}) async {
final body = <String, dynamic>{};
if (message != null) {
body['message'] = message;
}
return await _callGoApi('/groups/$groupId/join', method: 'POST', body: body);
}
/// Leave a group
Future<void> leaveGroup(String groupId) async {
await _callGoApi('/groups/$groupId/leave', method: 'POST');
}
/// Get group members
Future<List<GroupMember>> 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<List<JoinRequest>> 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<void> approveJoinRequest(String groupId, String requestId) async {
await _callGoApi('/groups/$groupId/requests/$requestId/approve', method: 'POST');
}
/// Reject a join request (admin only)
Future<void> rejectJoinRequest(String groupId, String requestId) async {
await _callGoApi('/groups/$groupId/requests/$requestId/reject', method: 'POST');
}
} }