Repository: gtiosclub/CanvasPlusPlayground
Pull request #290: Groups
Author: login: azooz2003-bit
Created: 2025-03-29T03:40:57Z | Merged: 2025-03-31T03:15:53Z

Fixes #251 

## Changes Made

- Create requests for groups and group membership fetch
- Retrieve most recent membership state RE user when group is visible in scroll
- Created detail view with exhaustive display of group properties
- Join/Leave group.

## Screenshots (if applicable)

## Checklist
- [x] I have implemented all requirements for this PR and its accompanying issue.
- [x] I have verified my implementation across all edge cases.
- [x] I have ensured my changes compile on macOS & iOS.
- [x] I have resolved all SwiftLint warnings.

**NOTE: issues have been made for all below**

## Warnings / Issues
- Group count doesn't show new num when joining/leaving.
- Not entirely confident in logic for determining `actionLabel` of a group relative to curr user. I never encountered a group which I had to be `invited` to or that I `request`. Rn I assume invited groups can be accepted, and groups requested can be cancelled (cancel request) 
- If no. users in a group is large (think ALOT) then users shown in Group detail view won't be exhaustive. Was planning on adding a `More` button to fetch all, but this PR is large enough.
- Uncertain if the params for `UpdateGroupMembershipRequest` should be in the `body` section or query params of the HTTP request. Can't test because I don't have invite based requests. Dummy course page needed asap.


## Future TODO
- Support for Create Group 
- `More` button next to returned group users to display all users in group.
- Canvas seems to have a particular order when returning groups. In the future let's stick to that. When we receive each group we `tag` it with the order it arrived in for offline sort.

Linked issue #251: [Feature]: Groups
## Description

Add support for viewing groups.

Will support:
- Displaying course groups.
- Filtering for own groups in class.
- Join/Request/Leave groups

Future:
- Create/Delete/Edit groups
- Invite users to owned groups
- Display group dashboard (similar to course dashboard)
- Display all groups from all courses

## Relevant Resources

(Feature Owner, Developer) Attach links to resources such as Figma files, technical documentation, etc.

- https://canvas.instructure.com/doc/api/groups.html

## Implementation Plan (optional)

Steps
1. list groups without members (GET groups & UI)
2. group detail view with all props.
3. implement Join/Leave & Membership check (check and revise!)
4. Implement "more users" view (check and revise!)
5. extend detail view to show more users in new view inside sheet.

Models:
- `Group`
- `GroupMembership`

UI: 
- `CourseGroupsView` - fetches groups presents `GroupsListView`
- `GroupsListView` - fed fetched `Group` objects, presents as list of rows.
- `GroupRow`
- `GroupOverviewView`
- `GroupUsersView`

**get groups in course:** 
- GET /api/v1/courses/:course_id/groups,`include: permissions, group_category, users`
- Filter by owned using`only_own_groups=true` 

**group row**
- Show available action.  enable join / request if count <`max_membership`, `join` in `permissions` is true, and membership query succeeds for user.
- Show `name`
- Indicate expiry via `concluded`
- show `group_category` in footnote as secondary color
- members will be shown based on `users`, however if count > users.count, then we give an option to show more via a detail view. detail view will load spinner while we fetch it.
- `onAppear` we must fetch the group membership of the current user to determine join/leave state.

**group detail:**
Popover that can be shown by clicking on group's (i) symbol.
- Show max members via `max_membership` (nil == unlimited)
- Show current member count via `members_count`
- Show `description`

**get members in a group:** 
- GET /api/v1/groups/:group_id/memberships or GET /api/v1/groups/:group_id/users
- If membership. Will display user status relative to group via `workflow_state` prop in `GroupMembership` (one of `accepted`, `invited`, `requested`)
- If user. Will use PeopleView directly.

**check if self is group member**
- GET /api/v1/groups/:group_id/users/self
- If error (404) "The specified resource does not exist." received, then user is not member of group.

**check if group is current or expired**
- see `concluded` prop of Group

**join / request join a group**
- POST /api/v1/groups/:group_id/memberships
- should return new `Membership` object. 
- add user/membership to group's existing memberships

**leave group**
- DELETE /api/v1/groups/:group_id/users/self
- should error if leaving is disallowed, load until leaving succeeds, else display dialog

**switching groups**
1. Attempt to join group via `joinGroup`.
2. Call `fetchMembershipState()` for all fetched groups in this category to capture most live state.

## Possible/Known issues
- offline filtering for GetCourseGroupsRequest is empty
- logic for determining available action on group (Join/Leave)
- should prevent action while membership is unverified

Diff:
diff --git a/CanvasPlusPlayground.xcodeproj/project.pbxproj b/CanvasPlusPlayground.xcodeproj/project.pbxproj
index 38106fd7..77ca1aee 100644
--- a/CanvasPlusPlayground.xcodeproj/project.pbxproj
+++ b/CanvasPlusPlayground.xcodeproj/project.pbxproj
@@ -70,12 +70,24 @@
 		A324BA6A2D0817AB005F53FA /* FoldersPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A324BA692D0817AB005F53FA /* FoldersPageView.swift */; };
 		A3269E8E2CD5533F006F7D14 /* CanvasRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3269E8D2CD5533F006F7D14 /* CanvasRepository.swift */; };
 		A3269E902CD57F75006F7D14 /* Cacheable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3269E8F2CD57F75006F7D14 /* Cacheable.swift */; };
+		A33BE8752D97A6F7005C1D13 /* GetGroupMembershipRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A33BE8742D97A6F7005C1D13 /* GetGroupMembershipRequest.swift */; };
 		A351913F2D283556001E415F /* ModulesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A351913E2D283556001E415F /* ModulesListView.swift */; };
 		A35191412D283589001E415F /* ModulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35191402D283589001E415F /* ModulesViewModel.swift */; };
 		A35191432D28ED48001E415F /* GetModuleItemsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35191422D28ED48001E415F /* GetModuleItemsRequest.swift */; };
 		A352AD182D3DA423007EE6FC /* LoadingMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = A352AD172D3DA423007EE6FC /* LoadingMethod.swift */; };
 		A352AD1A2D3EF1C1007EE6FC /* GetCourseUsersRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A352AD192D3EF1C1007EE6FC /* GetCourseUsersRequest.swift */; };
 		A352AD1C2D3EF206007EE6FC /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = A352AD1B2D3EF206007EE6FC /* User.swift */; };
+		A372D0FB2D90A709005E94CA /* HTTPURLResponse+Codes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A372D0FA2D90A709005E94CA /* HTTPURLResponse+Codes.swift */; };
+		A372D0FF2D90BAB6005E94CA /* CourseGroupsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A372D0FE2D90BAB6005E94CA /* CourseGroupsView.swift */; };
+		A372D1022D90BEA3005E94CA /* APIGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = A372D1012D90BEA3005E94CA /* APIGroup.swift */; };
+		A372D1042D90BEB4005E94CA /* CanvasGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = A372D1032D90BEB4005E94CA /* CanvasGroup.swift */; };
+		A372D1062D90CE7D005E94CA /* GroupMembership.swift in Sources */ = {isa = PBXBuildFile; fileRef = A372D1052D90CE7D005E94CA /* GroupMembership.swift */; };
+		A372D1082D90CEFE005E94CA /* APIGroupMembership.swift in Sources */ = {isa = PBXBuildFile; fileRef = A372D1072D90CEFE005E94CA /* APIGroupMembership.swift */; };
+		A372D10A2D90DA7A005E94CA /* GetCourseGroupsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A372D1092D90DA7A005E94CA /* GetCourseGroupsRequest.swift */; };
+		A372D10C2D90E875005E94CA /* GroupsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A372D10B2D90E875005E94CA /* GroupsListView.swift */; };
+		A372D10E2D90F505005E94CA /* CourseGroupsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A372D10D2D90F505005E94CA /* CourseGroupsViewModel.swift */; };
+		A372D1102D91D1A1005E94CA /* GroupDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A372D10F2D91D1A1005E94CA /* GroupDetailView.swift */; };
+		A372D1142D91D2B9005E94CA /* String+SFSymbols.swift in Sources */ = {isa = PBXBuildFile; fileRef = A372D1132D91D2B9005E94CA /* String+SFSymbols.swift */; };
 		A373DC0D2D19F65700215019 /* APIGradingPeriod.swift in Sources */ = {isa = PBXBuildFile; fileRef = A373DC0C2D19F65700215019 /* APIGradingPeriod.swift */; };
 		A373DC0F2D19F71600215019 /* TypeSafeCodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A373DC0E2D19F71600215019 /* TypeSafeCodable.swift */; };
 		A373DC142D19FA2A00215019 /* APICourseSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A373DC132D19FA2A00215019 /* APICourseSection.swift */; };
@@ -89,6 +101,11 @@
 		A373DC332D2819F700215019 /* APIModuleItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A373DC322D2819F700215019 /* APIModuleItem.swift */; };
 		A373DC352D2824D700215019 /* Module.swift in Sources */ = {isa = PBXBuildFile; fileRef = A373DC342D2824D700215019 /* Module.swift */; };
 		A373DC372D28285300215019 /* ModuleItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A373DC362D28285300215019 /* ModuleItem.swift */; };
+		A37AB4A72D98670600610639 /* LeaveGroupRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A37AB4A62D98670600610639 /* LeaveGroupRequest.swift */; };
+		A37AB4A92D9867D600610639 /* UpdateGroupMembershipRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A37AB4A82D9867D600610639 /* UpdateGroupMembershipRequest.swift */; };
+		A37AB4AB2D988F5800610639 /* CreateGroupMembershipRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A37AB4AA2D988F5800610639 /* CreateGroupMembershipRequest.swift */; };
+		A37AB4AD2D98B05E00610639 /* CanvasGroup+Memberships.swift in Sources */ = {isa = PBXBuildFile; fileRef = A37AB4AC2D98B05E00610639 /* CanvasGroup+Memberships.swift */; };
+		A3C574D92D99F7DA002210FA /* GroupRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C574D82D99F7DA002210FA /* GroupRowView.swift */; };
 		A3CA02592D43294B00A9752B /* SearchResultsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3CA02582D43294B00A9752B /* SearchResultsListView.swift */; };
 		A3CA025B2D432A8700A9752B /* SearchResultListDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3CA025A2D432A8700A9752B /* SearchResultListDataSource.swift */; };
 		A3CF88D12D18E61B000ACDF3 /* APIResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3CF88D02D18E61B000ACDF3 /* APIResponse.swift */; };
@@ -113,7 +130,6 @@
 		A3E7F3932C99338B00DC4300 /* TabAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3E7F3922C99338B00DC4300 /* TabAPI.swift */; };
 		A3FFD03A2CDEC2AC006BAB51 /* LookupCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FFD0392CDEC2AC006BAB51 /* LookupCondition.swift */; };
 		A3FFD03C2CDED67C006BAB51 /* String+Numbers.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FFD03B2CDED67C006BAB51 /* String+Numbers.swift */; };
-		A3FFD03E2CE0065A006BAB51 /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FFD03D2CE0065A006BAB51 /* NetworkError.swift */; };
 		B533C0BE2CADDDE700A74AF6 /* PeopleCommonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B533C0BD2CADDDE700A74AF6 /* PeopleCommonView.swift */; };
 		B53D95A02CA0953900647EE9 /* PeopleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B53D959F2CA0952F00647EE9 /* PeopleManager.swift */; };
 		B53D95A22CA0A22A00647EE9 /* PeopleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B53D95A12CA0A22A00647EE9 /* PeopleView.swift */; };
@@ -255,12 +271,24 @@
 		A324BA692D0817AB005F53FA /* FoldersPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoldersPageView.swift; sourceTree = "<group>"; };
 		A3269E8D2CD5533F006F7D14 /* CanvasRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CanvasRepository.swift; sourceTree = "<group>"; };
 		A3269E8F2CD57F75006F7D14 /* Cacheable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cacheable.swift; sourceTree = "<group>"; };
+		A33BE8742D97A6F7005C1D13 /* GetGroupMembershipRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetGroupMembershipRequest.swift; sourceTree = "<group>"; };
 		A351913E2D283556001E415F /* ModulesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModulesListView.swift; sourceTree = "<group>"; };
 		A35191402D283589001E415F /* ModulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModulesViewModel.swift; sourceTree = "<group>"; };
 		A35191422D28ED48001E415F /* GetModuleItemsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetModuleItemsRequest.swift; sourceTree = "<group>"; };
 		A352AD172D3DA423007EE6FC /* LoadingMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingMethod.swift; sourceTree = "<group>"; };
 		A352AD192D3EF1C1007EE6FC /* GetCourseUsersRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetCourseUsersRequest.swift; sourceTree = "<group>"; };
 		A352AD1B2D3EF206007EE6FC /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = "<group>"; };
+		A372D0FA2D90A709005E94CA /* HTTPURLResponse+Codes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPURLResponse+Codes.swift"; sourceTree = "<group>"; };
+		A372D0FE2D90BAB6005E94CA /* CourseGroupsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseGroupsView.swift; sourceTree = "<group>"; };
+		A372D1012D90BEA3005E94CA /* APIGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIGroup.swift; sourceTree = "<group>"; };
+		A372D1032D90BEB4005E94CA /* CanvasGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CanvasGroup.swift; sourceTree = "<group>"; };
+		A372D1052D90CE7D005E94CA /* GroupMembership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMembership.swift; sourceTree = "<group>"; };
+		A372D1072D90CEFE005E94CA /* APIGroupMembership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIGroupMembership.swift; sourceTree = "<group>"; };
+		A372D1092D90DA7A005E94CA /* GetCourseGroupsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetCourseGroupsRequest.swift; sourceTree = "<group>"; };
+		A372D10B2D90E875005E94CA /* GroupsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupsListView.swift; sourceTree = "<group>"; };
+		A372D10D2D90F505005E94CA /* CourseGroupsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseGroupsViewModel.swift; sourceTree = "<group>"; };
+		A372D10F2D91D1A1005E94CA /* GroupDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupDetailView.swift; sourceTree = "<group>"; };
+		A372D1132D91D2B9005E94CA /* String+SFSymbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+SFSymbols.swift"; sourceTree = "<group>"; };
 		A373DC0C2D19F65700215019 /* APIGradingPeriod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIGradingPeriod.swift; sourceTree = "<group>"; };
 		A373DC0E2D19F71600215019 /* TypeSafeCodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypeSafeCodable.swift; sourceTree = "<group>"; };
 		A373DC132D19FA2A00215019 /* APICourseSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APICourseSection.swift; sourceTree = "<group>"; };
@@ -274,6 +302,11 @@
 		A373DC322D2819F700215019 /* APIModuleItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIModuleItem.swift; sourceTree = "<group>"; };
 		A373DC342D2824D700215019 /* Module.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Module.swift; sourceTree = "<group>"; };
 		A373DC362D28285300215019 /* ModuleItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleItem.swift; sourceTree = "<group>"; };
+		A37AB4A62D98670600610639 /* LeaveGroupRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveGroupRequest.swift; sourceTree = "<group>"; };
+		A37AB4A82D9867D600610639 /* UpdateGroupMembershipRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateGroupMembershipRequest.swift; sourceTree = "<group>"; };
+		A37AB4AA2D988F5800610639 /* CreateGroupMembershipRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupMembershipRequest.swift; sourceTree = "<group>"; };
+		A37AB4AC2D98B05E00610639 /* CanvasGroup+Memberships.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CanvasGroup+Memberships.swift"; sourceTree = "<group>"; };
+		A3C574D82D99F7DA002210FA /* GroupRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupRowView.swift; sourceTree = "<group>"; };
 		A3CA02582D43294B00A9752B /* SearchResultsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsListView.swift; sourceTree = "<group>"; };
 		A3CA025A2D432A8700A9752B /* SearchResultListDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultListDataSource.swift; sourceTree = "<group>"; };
 		A3CF88D02D18E61B000ACDF3 /* APIResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIResponse.swift; sourceTree = "<group>"; };
@@ -298,7 +331,6 @@
 		A3E7F3922C99338B00DC4300 /* TabAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabAPI.swift; sourceTree = "<group>"; };
 		A3FFD0392CDEC2AC006BAB51 /* LookupCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupCondition.swift; sourceTree = "<group>"; };
 		A3FFD03B2CDED67C006BAB51 /* String+Numbers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Numbers.swift"; sourceTree = "<group>"; };
-		A3FFD03D2CE0065A006BAB51 /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = "<group>"; };
 		B533C0BD2CADDDE700A74AF6 /* PeopleCommonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeopleCommonView.swift; sourceTree = "<group>"; };
 		B53D959F2CA0952F00647EE9 /* PeopleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeopleManager.swift; sourceTree = "<group>"; };
 		B53D95A12CA0A22A00647EE9 /* PeopleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeopleView.swift; sourceTree = "<group>"; };
@@ -434,6 +466,11 @@
 				A301EE082D61137400D71139 /* MarkCourseDiscussionTopicReadRequest.swift */,
 				A301EE0A2D61148C00D71139 /* MarkCourseDiscussionTopicUnreadRequest.swift */,
 				A301EE122D614A4100D71139 /* GetDiscussionTopicsRequest.swift */,
+				A372D1092D90DA7A005E94CA /* GetCourseGroupsRequest.swift */,
+				A33BE8742D97A6F7005C1D13 /* GetGroupMembershipRequest.swift */,
+				A37AB4A62D98670600610639 /* LeaveGroupRequest.swift */,
+				A37AB4A82D9867D600610639 /* UpdateGroupMembershipRequest.swift */,
+				A37AB4AA2D988F5800610639 /* CreateGroupMembershipRequest.swift */,
 				9B762BC82D7BF8E70051F1F6 /* SubmitAssignmentRequest.swift */,
 				9BFD457C2D80F6EB00BBAA01 /* UploadAssignmentFileNotificationRequest.swift */,
 				9BF8E4402D90DE5E00B5A50D /* UploadAssignmentFileConfirmationRequest.swift */,
@@ -456,6 +493,7 @@
 				A324BA5E2D079899005F53FA /* Calendar */,
 				A324BA5C2D0797D1005F53FA /* Tabs */,
 				A324BA5B2D0797B6005F53FA /* People */,
+				A372D0FD2D90BA96005E94CA /* Groups */,
 				A324BA5A2D0797A2005F53FA /* Assignments */,
 				A324BA592D079781005F53FA /* Onboarding */,
 				A324BA532D079685005F53FA /* Files */,
@@ -485,9 +523,9 @@
 			isa = PBXGroup;
 			children = (
 				A373DC182D1A40EC00215019 /* Models */,
-				B76454F62C8DF61B002DF00E /* CourseView.swift */,
 				B76454FC2C8DF61B002DF00E /* CourseManager.swift */,
 				B7CC4D452D8DFFA000254F55 /* CourseItemPicker.swift */,
+				B76454F62C8DF61B002DF00E /* CourseView.swift */,
 			);
 			path = Courses;
 			sourceTree = "<group>";
@@ -535,6 +573,8 @@
 				A3FFD0392CDEC2AC006BAB51 /* LookupCondition.swift */,
 				A3D022582D6A5BC500F8068C /* Data+Empty.swift */,
 				9A977E822D6C322F001D7F0A /* LoggerService.swift */,
+				A372D0FA2D90A709005E94CA /* HTTPURLResponse+Codes.swift */,
+				A372D1132D91D2B9005E94CA /* String+SFSymbols.swift */,
 			);
 			path = Utilities;
 			sourceTree = "<group>";
@@ -565,7 +605,6 @@
 				A3CF88D02D18E61B000ACDF3 /* APIResponse.swift */,
 				A3049B732D15DBEE002F3166 /* API Requests */,
 				A3E7F38A2C9555B200DC4300 /* CanvasService.swift */,
-				A3FFD03D2CE0065A006BAB51 /* NetworkError.swift */,
 				A352AD172D3DA423007EE6FC /* LoadingMethod.swift */,
 			);
 			path = Network;
@@ -646,6 +685,39 @@
 			path = Grades;
 			sourceTree = "<group>";
 		};
+		A372D0FD2D90BA96005E94CA /* Groups */ = {
+			isa = PBXGroup;
+			children = (
+				A3C574D82D99F7DA002210FA /* GroupRowView.swift */,
+				A372D1112D91D1A8005E94CA /* CourseGroups */,
+				A372D1002D90BE72005E94CA /* Models */,
+				A372D10B2D90E875005E94CA /* GroupsListView.swift */,
+				A372D10F2D91D1A1005E94CA /* GroupDetailView.swift */,
+			);
+			path = Groups;
+			sourceTree = "<group>";
+		};
+		A372D1002D90BE72005E94CA /* Models */ = {
+			isa = PBXGroup;
+			children = (
+				A372D1012D90BEA3005E94CA /* APIGroup.swift */,
+				A372D1032D90BEB4005E94CA /* CanvasGroup.swift */,
+				A372D1052D90CE7D005E94CA /* GroupMembership.swift */,
+				A372D1072D90CEFE005E94CA /* APIGroupMembership.swift */,
+				A37AB4AC2D98B05E00610639 /* CanvasGroup+Memberships.swift */,
+			);
+			path = Models;
+			sourceTree = "<group>";
+		};
+		A372D1112D91D1A8005E94CA /* CourseGroups */ = {
+			isa = PBXGroup;
+			children = (
+				A372D0FE2D90BAB6005E94CA /* CourseGroupsView.swift */,
+				A372D10D2D90F505005E94CA /* CourseGroupsViewModel.swift */,
+			);
+			path = CourseGroups;
+			sourceTree = "<group>";
+		};
 		A373DC182D1A40EC00215019 /* Models */ = {
 			isa = PBXGroup;
 			children = (
@@ -1017,7 +1089,7 @@
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 			shellPath = /bin/sh;
-			shellScript = "if [[ \"$(uname -m)\" == arm64 ]]; then\n    export PATH=\"/opt/homebrew/bin:$PATH\"\nfi\n\nif which swiftlint > /dev/null; then\n    swiftlint lint --fix\nelse\n    echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n";
+			shellScript = "#if [[ \"$(uname -m)\" == arm64 ]]; then\n #   export PATH=\"/opt/homebrew/bin:$PATH\"\n#fi\n\n#if which swiftlint > /dev/null; then\n #   swiftlint lint --fix\n#else\n #   echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\n#fi\n";
 		};
 /* End PBXShellScriptBuildPhase section */
 
@@ -1033,6 +1105,7 @@
 				A3CA02592D43294B00A9752B /* SearchResultsListView.swift in Sources */,
 				A3D0225D2D6A711900F8068C /* GetPagesRequest.swift in Sources */,
 				B76454FE2C8DF61B002DF00E /* Course.swift in Sources */,
+				A372D1062D90CE7D005E94CA /* GroupMembership.swift in Sources */,
 				A324BA642D07AF0B005F53FA /* CourseFileService.swift in Sources */,
 				95BA93482C9CE20D00137A91 /* CalendarView.swift in Sources */,
 				B76454FF2C8DF61B002DF00E /* Enrollment.swift in Sources */,
@@ -1041,6 +1114,7 @@
 				B76455002C8DF61B002DF00E /* File.swift in Sources */,
 				A3049B7B2D15E2B7002F3166 /* GetFilesInFolderRequest.swift in Sources */,
 				A3CF88DB2D192194000ACDF3 /* EnrollmentAPI.swift in Sources */,
+				A372D0FB2D90A709005E94CA /* HTTPURLResponse+Codes.swift in Sources */,
 				B7F950322D118EAF004BB470 /* String+StripHTML.swift in Sources */,
 				B7D7512D2D3D652300F7B8B8 /* GetAnnouncementsBatchRequest.swift in Sources */,
 				A324BA6A2D0817AB005F53FA /* FoldersPageView.swift in Sources */,
@@ -1052,6 +1126,7 @@
 				B7926D162CE95CED00BFFBE1 /* RGBColors.swift in Sources */,
 				3DAE84FE2C9A0CE8008C22E7 /* CoursePDFView.swift in Sources */,
 				A3049B632D0E6154002F3166 /* ScoringPolicy.swift in Sources */,
+				A33BE8752D97A6F7005C1D13 /* GetGroupMembershipRequest.swift in Sources */,
 				B7C0A3C92D2F87EC003E5A36 /* AsyncView.swift in Sources */,
 				9B69FA4F2CC2CCCF006101F3 /* AggregatedAssignmentsListCell.swift in Sources */,
 				B7F950372D127869004BB470 /* ProfileManager.swift in Sources */,
@@ -1069,7 +1144,9 @@
 				B7F9503B2D127AD0004BB470 /* ProfileAPI.swift in Sources */,
 				B7CC4D3F2D8DFAEC00254F55 /* PickerService.swift in Sources */,
 				A352AD1C2D3EF206007EE6FC /* User.swift in Sources */,
+				A372D1082D90CEFE005E94CA /* APIGroupMembership.swift in Sources */,
 				B76455012C8DF61B002DF00E /* StorageKeys.swift in Sources */,
+				A37AB4A72D98670600610639 /* LeaveGroupRequest.swift in Sources */,
 				A3FFD03A2CDEC2AC006BAB51 /* LookupCondition.swift in Sources */,
 				B7D95DB72D0A8D78002AD955 /* SettingsView.swift in Sources */,
 				B7A12F3F2D69441500EDFA0A /* GetAssignmentGroupsRequest.swift in Sources */,
@@ -1081,10 +1158,11 @@
 				B76455022C8DF61B002DF00E /* CourseFilesView.swift in Sources */,
 				B76455032C8DF61B002DF00E /* HomeView.swift in Sources */,
 				A3269E8E2CD5533F006F7D14 /* CanvasRepository.swift in Sources */,
+				A372D1142D91D2B9005E94CA /* String+SFSymbols.swift in Sources */,
 				B7460A962D6CCD730069CF5B /* GradeCalculatorView.swift in Sources */,
 				A3049B5B2D0E5C18002F3166 /* QuizPermissions.swift in Sources */,
 				B7C0A3C52D2F4C19003E5A36 /* GetAssignmentRequest.swift in Sources */,
-				A3FFD03E2CE0065A006BAB51 /* NetworkError.swift in Sources */,
+				A37AB4A92D9867D600610639 /* UpdateGroupMembershipRequest.swift in Sources */,
 				B7E59A142D2004A4001836FE /* CourseListCell.swift in Sources */,
 				B53D95A22CA0A22A00647EE9 /* PeopleView.swift in Sources */,
 				A373DC312D28176100215019 /* APIModule.swift in Sources */,
@@ -1092,12 +1170,14 @@
 				B7D7512F2D3D660B00F7B8B8 /* AllAnnouncementsManager.swift in Sources */,
 				A3049B752D15DC1C002F3166 /* GetCoursesRequest.swift in Sources */,
 				B76455042C8DF61B002DF00E /* CourseView.swift in Sources */,
+				A372D1042D90BEB4005E94CA /* CanvasGroup.swift in Sources */,
 				A3049B682D0F3ECA002F3166 /* QuizzesViewModel.swift in Sources */,
 				A3CF88D12D18E61B000ACDF3 /* APIResponse.swift in Sources */,
 				A373DC352D2824D700215019 /* Module.swift in Sources */,
 				9BFD45812D811B1000BBAA01 /* AssignmentSubmissionManager.swift in Sources */,
 				B74688A32D63DB59007A27FD /* Assignment.swift in Sources */,
 				B5894F112D6EBD8C00E8F527 /* PageAPI.swift in Sources */,
+				A3C574D92D99F7DA002210FA /* GroupRowView.swift in Sources */,
 				B5894F122D6EBD8C00E8F527 /* PagesListView.swift in Sources */,
 				B5894F132D6EBD8C00E8F527 /* Page.swift in Sources */,
 				A373DC2B2D23EB3D00215019 /* Submission.swift in Sources */,
@@ -1106,6 +1186,7 @@
 				9B92A4252C93856100C21CFC /* CourseAnnouncementManager.swift in Sources */,
 				A35191432D28ED48001E415F /* GetModuleItemsRequest.swift in Sources */,
 				B76455052C8DF61B002DF00E /* SetupView.swift in Sources */,
+				A372D1022D90BEA3005E94CA /* APIGroup.swift in Sources */,
 				A3049B772D15DC36002F3166 /* GetCourseRequest.swift in Sources */,
 				B7AD54F32CD41D7900FB09BB /* LLMEvaluator.swift in Sources */,
 				B7AD54F42CD41D7900FB09BB /* ModelConfiguration.swift in Sources */,
@@ -1134,6 +1215,7 @@
 				B77FD0022D52A0D60049AA5E /* ProfileView.swift in Sources */,
 				9A977E8B2D6C375E001D7F0A /* ColorPickerSheet.swift in Sources */,
 				192EC04C2C963EE600AF8528 /* CourseAssignmentsView.swift in Sources */,
+				A372D10C2D90E875005E94CA /* GroupsListView.swift in Sources */,
 				A3FFD03C2CDED67C006BAB51 /* String+Numbers.swift in Sources */,
 				A301EE132D614A4100D71139 /* GetDiscussionTopicsRequest.swift in Sources */,
 				9A977E832D6C322F001D7F0A /* LoggerService.swift in Sources */,
@@ -1142,6 +1224,7 @@
 				9BF8E4412D90DE5E00B5A50D /* UploadAssignmentFileConfirmationRequest.swift in Sources */,
 				B7C0A3CB2D2F919F003E5A36 /* PinButton.swift in Sources */,
 				B7F950392D1279A1004BB470 /* UserAPI.swift in Sources */,
+				A37AB4AB2D988F5800610639 /* CreateGroupMembershipRequest.swift in Sources */,
 				A3049B662D0F3E32002F3166 /* CourseQuizzesView.swift in Sources */,
 				B7D7512B2D3D5D8000F7B8B8 /* AllAnnouncementsView.swift in Sources */,
 				A3E7F3892C954E0500DC4300 /* CanvasRequest.swift in Sources */,
@@ -1150,10 +1233,12 @@
 				B77FD0072D5309340049AA5E /* ProfilePicture.swift in Sources */,
 				A35191412D283589001E415F /* ModulesViewModel.swift in Sources */,
 				A3E7F3912C99317100DC4300 /* CourseTabsManager.swift in Sources */,
+				A372D0FF2D90BAB6005E94CA /* CourseGroupsView.swift in Sources */,
 				9B6D4F802D6E48340033044F /* ReminderButton.swift in Sources */,
 				A3E7F38F2C96C1CE00DC4300 /* CourseTabsView.swift in Sources */,
 				B76455062C8DF61B002DF00E /* CanvasPlusPlaygroundApp.swift in Sources */,
 				B7F950342D11A00F004BB470 /* StatusToolbarItem.swift in Sources */,
+				A37AB4AD2D98B05E00610639 /* CanvasGroup+Memberships.swift in Sources */,
 				A301EE0F2D612EA800D71139 /* DiscussionTopic.swift in Sources */,
 				9B07E7122D779CAB00359B69 /* Date+RelativeDates.swift in Sources */,
 				9B69FA4B2CC2CC7F006101F3 /* AggregatedAssignmentsView.swift in Sources */,
@@ -1184,6 +1269,8 @@
 				A373DC142D19FA2A00215019 /* APICourseSection.swift in Sources */,
 				A3E7F38B2C9555B200DC4300 /* CanvasService.swift in Sources */,
 				B72EA5702D5689B20013070E /* QuickLookPreview.swift in Sources */,
+				A372D10E2D90F505005E94CA /* CourseGroupsViewModel.swift in Sources */,
+				A372D10A2D90DA7A005E94CA /* GetCourseGroupsRequest.swift in Sources */,
 				B76455082C8DF61B002DF00E /* CourseManager.swift in Sources */,
 				A3CF88E12D1921C3000ACDF3 /* AnnouncementAPI.swift in Sources */,
 				A3D1614F2D1784E3004055FB /* GetUserRequest.swift in Sources */,
@@ -1196,6 +1283,7 @@
 				B73AE0BD2D4FB68B007094A8 /* Profile.swift in Sources */,
 				B7AD550C2CD4257B00FB09BB /* IntelligenceOnboardingView.swift in Sources */,
 				B7D751312D3D688C00F7B8B8 /* AnnouncementRow.swift in Sources */,
+				A372D1102D91D1A1005E94CA /* GroupDetailView.swift in Sources */,
 				A3049B792D15E162002F3166 /* GetCourseRootFolderRequest.swift in Sources */,
 				9BF8E43F2D90DE4F00B5A50D /* UploadAssignmentFileTransmissionRequest.swift in Sources */,
 				7F8535742C98DE3C0023E384 /* CourseGradeView.swift in Sources */,
diff --git a/CanvasPlusPlayground/Common/Network/API Requests/CreateGroupMembershipRequest.swift b/CanvasPlusPlayground/Common/Network/API Requests/CreateGroupMembershipRequest.swift
new file mode 100644
index 00000000..f4cf1971
--- /dev/null
+++ b/CanvasPlusPlayground/Common/Network/API Requests/CreateGroupMembershipRequest.swift	
@@ -0,0 +1,45 @@
+//
+//  CreateGroupMembershipRequest.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/29/25.
+//
+
+import Foundation
+
+struct CreateGroupMembershipRequest: CacheableAPIRequest {
+    typealias Subject = APIGroupMembership
+
+    var path: String { "groups/\(groupId)/memberships" }
+    var queryParameters: [QueryParameter] { [] }
+
+    var method: RequestMethod { .POST }
+
+    let groupId: String
+    let userId = "self"
+
+    let boundary = UUID().uuidString
+    var body: Data? {
+        var body = Data()
+
+        body.append(Data("--\(boundary)\r\n".utf8))
+        body.append(Data("Content-Disposition: form-data; name=\"user_id\"\r\n\r\n".utf8))
+        body.append(Data("\(userId)\r\n".utf8))
+        body.append(Data("--\(boundary)--\r\n".utf8))
+
+        return body
+    }
+    var contentType: String? { "multipart/form-data; boundary=\(boundary)" }
+
+    var requestId: Int {
+        groupId.asInt ?? -1
+    }
+    var requestIdKey: ParentKeyPath<GroupMembership, Int> {
+        .createReadable(\.groupId)
+    }
+    var idPredicate: Predicate<GroupMembership> {
+        let groupIdInt = requestId
+        return #Predicate { $0.groupId == groupIdInt }
+    }
+    var customPredicate: Predicate<GroupMembership> { .true }
+}
diff --git a/CanvasPlusPlayground/Common/Network/API Requests/GetCourseGroupsRequest.swift b/CanvasPlusPlayground/Common/Network/API Requests/GetCourseGroupsRequest.swift
new file mode 100644
index 00000000..c74634ee
--- /dev/null
+++ b/CanvasPlusPlayground/Common/Network/API Requests/GetCourseGroupsRequest.swift	
@@ -0,0 +1,54 @@
+//
+//  GetCourseGroupsRequest.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import Foundation
+
+struct GetCourseGroupsRequest: CacheableArrayAPIRequest {
+    typealias Subject = APIGroup
+
+    var path: String { "courses/\(courseId)/groups" }
+    var queryParameters: [QueryParameter] {
+        [
+            ("only_own_groups", onlyOwnGroups),
+            ("per_page", perPage),
+            ("collaboration_state", collaborationState)
+        ]
+        + include.map { ("include[]", $0.rawValue) }
+    }
+
+    let courseId: String
+    let onlyOwnGroups: Bool
+    let include: [Include]
+    let collaborationState: CollaborationState
+
+    let perPage: Int
+
+    var requestId: Int? { courseId.asInt }
+    var requestIdKey: ParentKeyPath<CanvasGroup, Int?> {
+        .createWritable(\.courseId)
+    }
+    var idPredicate: Predicate<CanvasGroup> {
+        #Predicate { $0.courseId == requestId }
+    }
+    var customPredicate: Predicate<CanvasGroup> {
+        if onlyOwnGroups {
+            let accepted = GroupMembershipState.accepted as GroupMembershipState?
+            return #Predicate<CanvasGroup> { $0.currUserStatus == accepted }
+        } else { return .true }
+        // TODO: collab state filter
+    }
+}
+
+extension GetCourseGroupsRequest {
+    enum Include: String {
+        case tabs, permissions, groupCategory = "group_category", users
+    }
+
+    enum CollaborationState: String {
+        case all, collaborative, nonCollaborative = "non_collaborative"
+    }
+}
diff --git a/CanvasPlusPlayground/Common/Network/API Requests/GetGroupMembershipRequest.swift b/CanvasPlusPlayground/Common/Network/API Requests/GetGroupMembershipRequest.swift
new file mode 100644
index 00000000..d28c9ed5
--- /dev/null
+++ b/CanvasPlusPlayground/Common/Network/API Requests/GetGroupMembershipRequest.swift	
@@ -0,0 +1,72 @@
+//
+//  GetGroupMembershipRequest.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/28/25.
+//
+
+import Foundation
+
+struct GetGroupMembershipRequest: CacheableAPIRequest {
+    typealias Subject = APIGroupMembership
+
+    var path: String { "groups/\(groupId)/\(via.pathComponent)" }
+    var queryParameters: [QueryParameter] { [] }
+
+    let groupId: String
+    let via: Via
+
+    var requestId: String {
+        via.id != "self" ? via.id : "\(groupId)/self"
+    }
+    var requestIdKey: ParentKeyPath<GroupMembership, String> {
+        guard via.id != "self" else {
+            return .createWritable(\.tag)
+        }
+
+        switch via {
+        case .users:
+            return .createReadable(\.userId.asString)
+        case .memberships:
+            return .createReadable(\.id)
+        }
+    }
+    var idPredicate: Predicate<GroupMembership> {
+        guard via.id != "self" else {
+            return #Predicate { requestId == $0.tag }
+        }
+
+        switch via {
+        case .users:
+            let requestIdInt = requestId.asInt ?? -1
+            return #Predicate { requestIdInt == $0.userId }
+        case .memberships:
+            return #Predicate { requestId == $0.id }
+        }
+    }
+    var customPredicate: Predicate<GroupMembership> { .true }
+}
+
+extension GetGroupMembershipRequest {
+    enum Via {
+        case users(userId: String = "self"), memberships(membershipId: String = "self")
+
+        var pathComponent: String {
+            switch self {
+            case .users(userId: let userId):
+                return "users/\(userId)"
+            case .memberships(membershipId: let membershipId):
+                return "memberships/\(membershipId)"
+            }
+        }
+
+        var id: String {
+            switch self {
+            case .users(let userId):
+                return userId
+            case .memberships(let membershipId):
+                return membershipId
+            }
+        }
+    }
+}
diff --git a/CanvasPlusPlayground/Common/Network/API Requests/LeaveGroupRequest.swift b/CanvasPlusPlayground/Common/Network/API Requests/LeaveGroupRequest.swift
new file mode 100644
index 00000000..c3a16c09
--- /dev/null
+++ b/CanvasPlusPlayground/Common/Network/API Requests/LeaveGroupRequest.swift	
@@ -0,0 +1,42 @@
+//
+//  LeaveGroupRequest.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/29/25.
+//
+
+import Foundation
+
+struct LeaveGroupRequest: NoReturnAPIRequest {
+    var path: String { "groups/\(groupId)/\(via.pathComponent)" }
+    var queryParameters: [QueryParameter] { [] }
+
+    var method: RequestMethod { .DELETE }
+
+    let groupId: String
+    let via: Via
+}
+
+extension LeaveGroupRequest {
+    enum Via {
+        case users(userId: String = "self"), memberships(membershipId: String = "self")
+
+        var pathComponent: String {
+            switch self {
+            case .users(userId: let userId):
+                return "users/\(userId)"
+            case .memberships(membershipId: let membershipId):
+                return "memberships/\(membershipId)"
+            }
+        }
+
+        var id: String {
+            switch self {
+            case .users(let userId):
+                return userId
+            case .memberships(let membershipId):
+                return membershipId
+            }
+        }
+    }
+}
diff --git a/CanvasPlusPlayground/Common/Network/API Requests/UpdateGroupMembershipRequest.swift b/CanvasPlusPlayground/Common/Network/API Requests/UpdateGroupMembershipRequest.swift
new file mode 100644
index 00000000..d811bda2
--- /dev/null
+++ b/CanvasPlusPlayground/Common/Network/API Requests/UpdateGroupMembershipRequest.swift	
@@ -0,0 +1,85 @@
+//
+//  UpdateGroupMembershipRequest.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/29/25.
+//
+
+import Foundation
+
+struct UpdateGroupMembershipRequest: CacheableAPIRequest {
+    typealias Subject = APIGroupMembership
+
+    var path: String { "groups/\(groupId)/\(via.pathComponent)" }
+    var queryParameters: [QueryParameter] {
+        [
+            ("workflow_state", toState.rawValue)
+            // ("moderator", moderator_id)
+        ]
+    }
+
+    var method: RequestMethod { .PUT }
+
+    let groupId: String
+    let toState: NewMembershipState
+    // let moderator_id: String = "" // For adding/removing moderator rights (true or false)
+    let via: Via
+
+    var requestId: String {
+        via.id != "self" ? via.id : "\(groupId)/self"
+    }
+    var requestIdKey: ParentKeyPath<GroupMembership, String> {
+        guard via.id != "self" else {
+            return .createWritable(\.tag)
+        }
+
+        switch via {
+        case .users:
+            return .createReadable(\.userId.asString)
+        case .memberships:
+            return .createReadable(\.id)
+        }
+    }
+    var idPredicate: Predicate<GroupMembership> {
+        guard via.id != "self" else {
+            return #Predicate { requestId == $0.tag }
+        }
+
+        switch via {
+        case .users:
+            let requestIdInt = requestId.asInt ?? -1
+            return #Predicate { requestIdInt == $0.userId }
+        case .memberships:
+            return #Predicate { requestId == $0.id }
+        }
+    }
+    var customPredicate: Predicate<GroupMembership> { .true }
+}
+
+extension UpdateGroupMembershipRequest {
+    enum NewMembershipState: String {
+        case accepted
+    }
+
+    enum Via {
+        case users(userId: String = "self"), memberships(membershipId: String = "self")
+
+        var pathComponent: String {
+            switch self {
+            case .users(userId: let userId):
+                return "users/\(userId)"
+            case .memberships(membershipId: let membershipId):
+                return "memberships/\(membershipId)"
+            }
+        }
+
+        var id: String {
+            switch self {
+            case .users(let userId):
+                return userId
+            case .memberships(let membershipId):
+                return membershipId
+            }
+        }
+    }
+}
diff --git a/CanvasPlusPlayground/Common/Network/APIRequest+Network.swift b/CanvasPlusPlayground/Common/Network/APIRequest+Network.swift
index 87394676..2fa6d2ed 100644
--- a/CanvasPlusPlayground/Common/Network/APIRequest+Network.swift
+++ b/CanvasPlusPlayground/Common/Network/APIRequest+Network.swift
@@ -90,16 +90,20 @@ extension APIRequest {
         urlRequest.setValue(contentType, forHTTPHeaderField: "Content-Type")
         urlRequest.setValue(contentLength, forHTTPHeaderField: "Content-Length")
         urlRequest.setValue("Bearer \(StorageKeys.accessTokenValue)", forHTTPHeaderField: "Authorization")
-        do {
-            let (data, response) = try await URLSession.shared.data(for: urlRequest)
 
-            guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
-                LoggerService.main.error("HTTP error: $\(response)$ data $\(String(data: data, encoding: .utf8) ?? "not text")$")
-                throw URLError(.badServerResponse)
-            }
+        let (data, response) = try await URLSession.shared.data(for: urlRequest)
+
+        guard let httpResponse = response as? HTTPURLResponse else {
+            LoggerService.main.error("HTTP error: $\(response)$")
+            throw URLError(.badServerResponse)
+        }
 
-            return (data, response)
+        guard httpResponse.status?.responseType == .success else {
+           LoggerService.main.error("HTTP error: $\(httpResponse)$")
+           throw httpResponse.status ?? .unknown
         }
+
+       return (data, response)
     }
 
     func fetchPages(
diff --git a/CanvasPlusPlayground/Common/Network/CanvasRequest.swift b/CanvasPlusPlayground/Common/Network/CanvasRequest.swift
index cb711e03..b4b13699 100644
--- a/CanvasPlusPlayground/Common/Network/CanvasRequest.swift
+++ b/CanvasPlusPlayground/Common/Network/CanvasRequest.swift
@@ -266,4 +266,47 @@ enum CanvasRequest {
             perPage: perPage
         )
     }
+
+    static func getCourseGroups(
+        courseId: String,
+        onlyOwnGroups: Bool = false,
+        collaborationState: GetCourseGroupsRequest.CollaborationState = .all,
+        include: [GetCourseGroupsRequest.Include] = [.permissions, .groupCategory, .users],
+        perPage: Int = 50
+    ) -> GetCourseGroupsRequest {
+        GetCourseGroupsRequest(
+            courseId: courseId,
+            onlyOwnGroups: onlyOwnGroups,
+            include: include,
+            collaborationState: collaborationState,
+            perPage: perPage
+        )
+    }
+
+    static func getSingleGroupMembership(
+        groupId: String,
+        via: GetGroupMembershipRequest.Via
+    ) -> GetGroupMembershipRequest {
+        GetGroupMembershipRequest(groupId: groupId, via: via)
+    }
+
+    static func leaveGroup(
+        groupId: String,
+        via: LeaveGroupRequest.Via
+    ) -> LeaveGroupRequest {
+        LeaveGroupRequest(groupId: groupId, via: via)
+    }
+
+    static func updateGroupMembership(
+        groupId: String,
+        via: UpdateGroupMembershipRequest.Via
+    ) -> UpdateGroupMembershipRequest {
+        UpdateGroupMembershipRequest(groupId: groupId, toState: .accepted, via: via)
+    }
+
+    static func createGroupMembership(
+        groupId: String
+    ) -> CreateGroupMembershipRequest {
+        CreateGroupMembershipRequest(groupId: groupId)
+    }
 }
diff --git a/CanvasPlusPlayground/Common/Network/NetworkError.swift b/CanvasPlusPlayground/Common/Network/NetworkError.swift
deleted file mode 100644
index f7ff206c..00000000
--- a/CanvasPlusPlayground/Common/Network/NetworkError.swift
+++ /dev/null
@@ -1,12 +0,0 @@
-//
-//  NetworkError.swift
-//  CanvasPlusPlayground
-//
-//  Created by Abdulaziz Albahar on 11/9/24.
-//
-
-import Foundation
-
-enum NetworkError: Error {
-    case failedToDecode(msg: String), fetchFailed(msg: String), invalidURL(msg: String), failedToEncode
-}
diff --git a/CanvasPlusPlayground/Common/Storage/CanvasRepository.swift b/CanvasPlusPlayground/Common/Storage/CanvasRepository.swift
index 2da450a2..4059e67c 100644
--- a/CanvasPlusPlayground/Common/Storage/CanvasRepository.swift
+++ b/CanvasPlusPlayground/Common/Storage/CanvasRepository.swift
@@ -8,12 +8,9 @@
 import SwiftData
 import SwiftUI
 
-@MainActor
-class CanvasRepository {
-    let modelContainer: ModelContainer
-    let modelContext: ModelContext
-    init() {
-        self.modelContainer = try! ModelContainer(
+extension ModelContext {
+    static var shared: ModelContext = {
+        let modelContainer = try! ModelContainer(
             for: Course.self,
             Announcement.self,
             Assignment.self,
@@ -28,11 +25,27 @@ class CanvasRepository {
             User.self,
             Profile.self,
             DiscussionTopic.self,
-            Page.self
+            Page.self,
+            CanvasGroup.self,
+            GroupMembership.self
             // TODO: Add cacheable models here
         )
-        self.modelContext = ModelContext(modelContainer)
+
+        let modelContext = ModelContext(modelContainer)
         modelContext.autosaveEnabled = true
+
+        return modelContext
+    }()
+}
+
+@MainActor
+class CanvasRepository {
+    let modelContainer: ModelContainer
+    let modelContext: ModelContext
+
+    init() {
+        self.modelContext = ModelContext.shared
+        self.modelContainer = modelContext.container
     }
 
     func insert<T>(_ item: T) where T: Cacheable {
diff --git a/CanvasPlusPlayground/Common/Utilities/HTTPURLResponse+Codes.swift b/CanvasPlusPlayground/Common/Utilities/HTTPURLResponse+Codes.swift
new file mode 100644
index 00000000..87d713a8
--- /dev/null
+++ b/CanvasPlusPlayground/Common/Utilities/HTTPURLResponse+Codes.swift
@@ -0,0 +1,284 @@
+//
+//  URLError+Codes.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import Foundation
+
+/// From https://gist.github.com/ollieatkinson/322338df8a5220d649ac01ff11e7de12
+/// This is a list of Hypertext Transfer Protocol (HTTP) response status codes.
+/// It includes codes from IETF internet standards, other IETF RFCs, other specifications, and some additional commonly used codes.
+/// The first digit of the status code specifies one of five classes of response; an HTTP client must recognise these five classes at a minimum.
+enum HTTPStatusCode: Int, Error {
+    /// The response class representation of status codes, these get grouped by their first digit.
+    enum ResponseType {
+        /// - informational: This class of status code indicates a provisional response, consisting only of the Status-Line and optional headers, and is terminated by an empty line.
+        case informational
+
+        /// - success: This class of status codes indicates the action requested by the client was received, understood, accepted, and processed successfully.
+        case success
+
+        /// - redirection: This class of status code indicates the client must take additional action to complete the request.
+        case redirection
+
+        /// - clientError: This class of status code is intended for situations in which the client seems to have erred.
+        case clientError
+
+        /// - serverError: This class of status code indicates the server failed to fulfill an apparently valid request.
+        case serverError
+
+        /// - undefined: The class of the status code cannot be resolved.
+        case undefined
+    }
+
+    //
+    // Informational - 1xx
+    //
+
+    case unknown = 0
+
+    /// - continue: The server has received the request headers and the client should proceed to send the request body.
+    case `continue` = 100
+
+    /// - switchingProtocols: The requester has asked the server to switch protocols and the server has agreed to do so.
+    case switchingProtocols = 101
+
+    /// - processing: This code indicates that the server has received and is processing the request, but no response is available yet.
+    case processing = 102
+
+    //
+    // Success - 2xx
+    //
+
+    /// - ok: Standard response for successful HTTP requests.
+    case ok = 200
+
+    /// - created: The request has been fulfilled, resulting in the creation of a new resource.
+    case created = 201
+
+    /// - accepted: The request has been accepted for processing, but the processing has not been completed.
+    case accepted = 202
+
+    /// - nonAuthoritativeInformation: The server is a transforming proxy (e.g. a Web accelerator) that received a 200 OK from its origin, but is returning a modified version of the origin's response.
+    case nonAuthoritativeInformation = 203
+
+    /// - noContent: The server successfully processed the request and is not returning any content.
+    case noContent = 204
+
+    /// - resetContent: The server successfully processed the request, but is not returning any content.
+    case resetContent = 205
+
+    /// - partialContent: The server is delivering only part of the resource (byte serving) due to a range header sent by the client.
+    case partialContent = 206
+
+    /// - multiStatus: The message body that follows is an XML message and can contain a number of separate response codes, depending on how many sub-requests were made.
+    case multiStatus = 207
+
+    /// - alreadyReported: The members of a DAV binding have already been enumerated in a previous reply to this request, and are not being included again.
+    case alreadyReported = 208
+
+    /// - IMUsed: The server has fulfilled a request for the resource, and the response is a representation of the result of one or more instance-manipulations applied to the current instance.
+    case IMUsed = 226
+
+    //
+    // Redirection - 3xx
+    //
+
+    /// - multipleChoices: Indicates multiple options for the resource from which the client may choose
+    case multipleChoices = 300
+
+    /// - movedPermanently: This and all future requests should be directed to the given URI.
+    case movedPermanently = 301
+
+    /// - found: The resource was found.
+    case found = 302
+
+    /// - seeOther: The response to the request can be found under another URI using a GET method.
+    case seeOther = 303
+
+    /// - notModified: Indicates that the resource has not been modified since the version specified by the request headers If-Modified-Since or If-None-Match.
+    case notModified = 304
+
+    /// - useProxy: The requested resource is available only through a proxy, the address for which is provided in the response.
+    case useProxy = 305
+
+    /// - switchProxy: No longer used. Originally meant "Subsequent requests should use the specified proxy.
+    case switchProxy = 306
+
+    /// - temporaryRedirect: The request should be repeated with another URI.
+    case temporaryRedirect = 307
+
+    /// - permenantRedirect: The request and all future requests should be repeated using another URI.
+    case permenantRedirect = 308
+
+    //
+    // Client Error - 4xx
+    //
+
+    /// - badRequest: The server cannot or will not process the request due to an apparent client error.
+    case badRequest = 400
+
+    /// - unauthorized: Similar to 403 Forbidden, but specifically for use when authentication is required and has failed or has not yet been provided.
+    case unauthorized = 401
+
+    /// - paymentRequired: The content available on the server requires payment.
+    case paymentRequired = 402
+
+    /// - forbidden: The request was a valid request, but the server is refusing to respond to it.
+    case forbidden = 403
+
+    /// - notFound: The requested resource could not be found but may be available in the future.
+    case notFound = 404
+
+    /// - methodNotAllowed: A request method is not supported for the requested resource. e.g. a GET request on a form which requires data to be presented via POST
+    case methodNotAllowed = 405
+
+    /// - notAcceptable: The requested resource is capable of generating only content not acceptable according to the Accept headers sent in the request.
+    case notAcceptable = 406
+
+    /// - proxyAuthenticationRequired: The client must first authenticate itself with the proxy.
+    case proxyAuthenticationRequired = 407
+
+    /// - requestTimeout: The server timed out waiting for the request.
+    case requestTimeout = 408
+
+    /// - conflict: Indicates that the request could not be processed because of conflict in the request, such as an edit conflict between multiple simultaneous updates.
+    case conflict = 409
+
+    /// - gone: Indicates that the resource requested is no longer available and will not be available again.
+    case gone = 410
+
+    /// - lengthRequired: The request did not specify the length of its content, which is required by the requested resource.
+    case lengthRequired = 411
+
+    /// - preconditionFailed: The server does not meet one of the preconditions that the requester put on the request.
+    case preconditionFailed = 412
+
+    /// - payloadTooLarge: The request is larger than the server is willing or able to process.
+    case payloadTooLarge = 413
+
+    /// - URITooLong: The URI provided was too long for the server to process.
+    case URITooLong = 414
+
+    /// - unsupportedMediaType: The request entity has a media type which the server or resource does not support.
+    case unsupportedMediaType = 415
+
+    /// - rangeNotSatisfiable: The client has asked for a portion of the file (byte serving), but the server cannot supply that portion.
+    case rangeNotSatisfiable = 416
+
+    /// - expectationFailed: The server cannot meet the requirements of the Expect request-header field.
+    case expectationFailed = 417
+
+    /// - teapot: This HTTP status is used as an Easter egg in some websites.
+    case teapot = 418
+
+    /// - misdirectedRequest: The request was directed at a server that is not able to produce a response.
+    case misdirectedRequest = 421
+
+    /// - unprocessableEntity: The request was well-formed but was unable to be followed due to semantic errors.
+    case unprocessableEntity = 422
+
+    /// - locked: The resource that is being accessed is locked.
+    case locked = 423
+
+    /// - failedDependency: The request failed due to failure of a previous request (e.g., a PROPPATCH).
+    case failedDependency = 424
+
+    /// - upgradeRequired: The client should switch to a different protocol such as TLS/1.0, given in the Upgrade header field.
+    case upgradeRequired = 426
+
+    /// - preconditionRequired: The origin server requires the request to be conditional.
+    case preconditionRequired = 428
+
+    /// - tooManyRequests: The user has sent too many requests in a given amount of time.
+    case tooManyRequests = 429
+
+    /// - requestHeaderFieldsTooLarge: The server is unwilling to process the request because either an individual header field, or all the header fields collectively, are too large.
+    case requestHeaderFieldsTooLarge = 431
+
+    /// - noResponse: Used to indicate that the server has returned no information to the client and closed the connection.
+    case noResponse = 444
+
+    /// - unavailableForLegalReasons: A server operator has received a legal demand to deny access to a resource or to a set of resources that includes the requested resource.
+    case unavailableForLegalReasons = 451
+
+    /// - SSLCertificateError: An expansion of the 400 Bad Request response code, used when the client has provided an invalid client certificate.
+    case SSLCertificateError = 495
+
+    /// - SSLCertificateRequired: An expansion of the 400 Bad Request response code, used when a client certificate is required but not provided.
+    case SSLCertificateRequired = 496
+
+    /// - HTTPRequestSentToHTTPSPort: An expansion of the 400 Bad Request response code, used when the client has made a HTTP request to a port listening for HTTPS requests.
+    case HTTPRequestSentToHTTPSPort = 497
+
+    /// - clientClosedRequest: Used when the client has closed the request before the server could send a response.
+    case clientClosedRequest = 499
+
+    //
+    // Server Error - 5xx
+    //
+
+    /// - internalServerError: A generic error message, given when an unexpected condition was encountered and no more specific message is suitable.
+    case internalServerError = 500
+
+    /// - notImplemented: The server either does not recognize the request method, or it lacks the ability to fulfill the request.
+    case notImplemented = 501
+
+    /// - badGateway: The server was acting as a gateway or proxy and received an invalid response from the upstream server.
+    case badGateway = 502
+
+    /// - serviceUnavailable: The server is currently unavailable (because it is overloaded or down for maintenance). Generally, this is a temporary state.
+    case serviceUnavailable = 503
+
+    /// - gatewayTimeout: The server was acting as a gateway or proxy and did not receive a timely response from the upstream server.
+    case gatewayTimeout = 504
+
+    /// - HTTPVersionNotSupported: The server does not support the HTTP protocol version used in the request.
+    case HTTPVersionNotSupported = 505
+
+    /// - variantAlsoNegotiates: Transparent content negotiation for the request results in a circular reference.
+    case variantAlsoNegotiates = 506
+
+    /// - insufficientStorage: The server is unable to store the representation needed to complete the request.
+    case insufficientStorage = 507
+
+    /// - loopDetected: The server detected an infinite loop while processing the request.
+    case loopDetected = 508
+
+    /// - notExtended: Further extensions to the request are required for the server to fulfill it.
+    case notExtended = 510
+
+    /// - networkAuthenticationRequired: The client needs to authenticate to gain network access.
+    case networkAuthenticationRequired = 511
+
+    /// The class (or group) which the status code belongs to.
+    var responseType: ResponseType {
+        switch self.rawValue {
+        case 100..<200:
+            return .informational
+
+        case 200..<300:
+            return .success
+
+        case 300..<400:
+            return .redirection
+
+        case 400..<500:
+            return .clientError
+
+        case 500..<600:
+            return .serverError
+
+        default:
+            return .undefined
+        }
+    }
+}
+
+extension HTTPURLResponse {
+    var status: HTTPStatusCode? {
+        HTTPStatusCode(rawValue: statusCode)
+    }
+}
diff --git a/CanvasPlusPlayground/Common/Utilities/String+SFSymbols.swift b/CanvasPlusPlayground/Common/Utilities/String+SFSymbols.swift
new file mode 100644
index 00000000..af5597ac
--- /dev/null
+++ b/CanvasPlusPlayground/Common/Utilities/String+SFSymbols.swift
@@ -0,0 +1,16 @@
+//
+//  String+SFSymbols.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/24/25.
+//
+
+import Foundation
+
+extension String {
+    static var infoCircle = "info.circle"
+    static var infoCircleFilled = "info.circle.fill"
+
+    static var lock = "lock"
+    static var lockFilled = "lock.fill"
+}
diff --git a/CanvasPlusPlayground/Features/Announcements/Models/DiscussionTopic.swift b/CanvasPlusPlayground/Features/Announcements/Models/DiscussionTopic.swift
index bfc6f35b..533c96e7 100644
--- a/CanvasPlusPlayground/Features/Announcements/Models/DiscussionTopic.swift
+++ b/CanvasPlusPlayground/Features/Announcements/Models/DiscussionTopic.swift
@@ -215,13 +215,14 @@ extension DiscussionTopic {
 
 // MARK: Read Marking
 extension DiscussionTopic {
+    @MainActor
     func markReadStatus(_ isRead: Bool) async throws {
         guard let courseId = self.courseId else {
             LoggerService.main.debug("[markAsRead] Course id missing")
             throw Error.courseIdMissing
         }
 
-        self.isRead = true
+        self.isRead = isRead
 
         if isRead {
             let request = CanvasRequest.markCourseDiscussionTopicAsRead(courseId: courseId, discussionTopicId: self.id)
diff --git a/CanvasPlusPlayground/Features/Calendar/ICSParser.swift b/CanvasPlusPlayground/Features/Calendar/ICSParser.swift
index 4da42639..77f56c2a 100644
--- a/CanvasPlusPlayground/Features/Calendar/ICSParser.swift
+++ b/CanvasPlusPlayground/Features/Calendar/ICSParser.swift
@@ -92,17 +92,16 @@ enum ICSParser {
             let (data, response) = try await URLSession.shared.data(from: url)
 
             guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
-                throw NetworkError.fetchFailed(msg: response.description)
+                throw HTTPStatusCode.unknown
             }
 
             if let str = String(data: data, encoding: .utf8) {
                 return str
             } else {
-                throw NetworkError
-                    .failedToDecode(msg: "Could not convert data to string")
+                throw HTTPStatusCode.unknown
             }
         } catch {
-            throw NetworkError.fetchFailed(msg: error.localizedDescription)
+            throw HTTPStatusCode.unknown
         }
     }
 }
diff --git a/CanvasPlusPlayground/Features/Courses/Models/Course.swift b/CanvasPlusPlayground/Features/Courses/Models/Course.swift
index 668ae713..81e3cc66 100644
--- a/CanvasPlusPlayground/Features/Courses/Models/Course.swift
+++ b/CanvasPlusPlayground/Features/Courses/Models/Course.swift
@@ -230,3 +230,33 @@ final class Course: Cacheable {
 //        self.nickname = other.nickname
     }
 }
+
+extension Course {
+    static let sample = Course(
+        CourseAPI(
+            id: 0,
+            name: "alex",
+            course_code: "24242",
+            course_color: "",
+            workflow_state: .available,
+            account_id: nil,
+            start_at: nil,
+            end_at: nil,
+            locale: nil,
+            calendar: nil,
+            default_view: nil,
+            syllabus_body: nil,
+            term: nil,
+            permissions: nil,
+            hide_final_grades: nil,
+            homeroom_course: nil,
+            access_restricted_by_date: nil,
+            banner_image_download_url: nil,
+            image_download_url: nil,
+            sections: nil,
+            tabs: nil,
+            settings: nil,
+            grading_scheme: nil
+        )
+    )
+}
diff --git a/CanvasPlusPlayground/Features/Groups/CourseGroups/CourseGroupsView.swift b/CanvasPlusPlayground/Features/Groups/CourseGroups/CourseGroupsView.swift
new file mode 100644
index 00000000..595cf9ed
--- /dev/null
+++ b/CanvasPlusPlayground/Features/Groups/CourseGroups/CourseGroupsView.swift
@@ -0,0 +1,31 @@
+//
+//  GroupsView.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import SwiftUI
+
+struct CourseGroupsView: View {
+    let course: Course
+
+    @State private var courseGroupsVM: CourseGroupsViewModel
+    @State private var isLoading: Bool = false
+
+    init(course: Course) {
+        self.course = course
+        self._courseGroupsVM = State(initialValue: CourseGroupsViewModel())
+    }
+
+    var body: some View {
+        GroupsListView()
+            .task {
+                isLoading = true
+                await courseGroupsVM.fetchGroups(for: course.id)
+                isLoading = false
+            }
+            .statusToolbarItem("Groups", isVisible: isLoading)
+            .environment(courseGroupsVM)
+    }
+}
diff --git a/CanvasPlusPlayground/Features/Groups/CourseGroups/CourseGroupsViewModel.swift b/CanvasPlusPlayground/Features/Groups/CourseGroups/CourseGroupsViewModel.swift
new file mode 100644
index 00000000..4a4904c9
--- /dev/null
+++ b/CanvasPlusPlayground/Features/Groups/CourseGroups/CourseGroupsViewModel.swift
@@ -0,0 +1,81 @@
+//
+//  GroupsViewModel.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import Foundation
+
+@Observable
+class CourseGroupsViewModel {
+    var groups = [CanvasGroup]()
+
+    var groupsDisplayed: [CanvasGroup] {
+        groups
+            .sorted { // sort priority: (1) category name (2) group name
+                guard $0.groupCategoryId != $1.groupCategoryId else {
+                    return $0.name < $1.name
+                }
+                return ($0.groupCategoryId ?? -1) < ($1.groupCategoryId ?? -1)
+            }
+    }
+
+    func fetchGroups(for courseId: String) async {
+        let req = CanvasRequest.getCourseGroups(courseId: courseId, perPage: 15)
+
+        do {
+            try await CanvasService.shared.loadAndSync(
+                req,
+                onCacheReceive: { [weak self] in
+                    guard let groups = $0 else { return }
+                    self?.insertGroups(groups)
+                },
+                loadingMethod: .all(onNewPage: { [weak self] in
+                    self?.insertGroups($0)
+                })
+            )
+        } catch {
+            LoggerService.main.error("[CourseGroupsViewModel] Groups fetch failed: \(error)")
+        }
+    }
+
+    func fetchAllGroupMembershipsFor(categoryId: Int, excluding groupId: String?) async throws {
+        await withThrowingTaskGroup(of: Void.self, body: { taskGroup in
+            for group in groupsDisplayed where group.groupCategoryId == categoryId && group.id != groupId {
+                taskGroup.addTask {
+                    try await group.fetchMembershipState()
+                    LoggerService.main.debug("[CourseGroupsViewModel] Leaving group: \(group.name)")
+                }
+            }
+        })
+    }
+
+    /// Whether joining this group is classified as a switch - joined groups from this category are left. Only use this with groups that have join status.
+    func canOnlySwitch(to group: CanvasGroup) -> Bool {
+        if group.allowsMultipleMemberships ?? false {
+            return false // if multiple memberships's are allowed, switch isn't necessary
+        }
+
+        let groupsInCategory = groups.filter { $0.groupCategoryId == group.groupCategoryId && $0.id != group.id }
+
+        // If another group in this category has this user
+        return groupsInCategory.contains(where: {
+            $0.currUserStatus == .accepted || $0.currUserStatus == .requested
+        })
+    }
+
+    func fetchAllGroups() async {
+        // TODO: in the future when implementing generic Groups tab
+    }
+
+    private func insertGroups(_ groups: [CanvasGroup]) {
+        let newGroups = groups.filter {
+            !self.groups.contains($0)
+        }
+
+        DispatchQueue.main.async {
+            self.groups.append(contentsOf: newGroups)
+        }
+    }
+}
diff --git a/CanvasPlusPlayground/Features/Groups/GroupDetailView.swift b/CanvasPlusPlayground/Features/Groups/GroupDetailView.swift
new file mode 100644
index 00000000..b91194fb
--- /dev/null
+++ b/CanvasPlusPlayground/Features/Groups/GroupDetailView.swift
@@ -0,0 +1,82 @@
+//
+//  GroupDetailView.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/24/25.
+//
+
+import SwiftUI
+
+struct GroupDetailView: View {
+    @Environment(\.dismiss) private var dismiss
+
+    let group: CanvasGroup
+
+    var body: some View {
+        List {
+#if DEBUG
+            Section(header: Text("Course Information")) {
+                row(label: "ID", value: group.id)
+                row(label: "Name", value: group.name)
+                row(label: "Concluded?", value: group.concluded.description)
+                row(label: "Members Count", value: group.membersCount.asString)
+                row(label: "Course ID", value: group.courseId?.asString ?? "N/A")
+                row(label: "Group Category ID", value: group.groupCategoryId?.asString ?? "N/A")
+                row(label: "Group Category Name", value: group.groupCategoryName ?? "N/A")
+                row(label: "Group Limit", value: group.groupLimit.asString)
+                row(label: "Category Allows Multiple Memberships?", value: group.allowsMultipleMemberships?.description ?? "N/A")
+                row(label: "Storage Quota (MB)", value: group.storageQuotaMb?.asString ?? "N/A")
+                row(label: "Is Public?", value: group.isPublic.description)
+                row(label: "Membership Status", value: group.currUserStatus?.rawValue ?? "No status (probs not a member)")
+            }
+
+            Section(header: Text("Description")) {
+                Text(group.groupDescription ?? "N/A")
+            }
+
+            Section(header: Text("Permissions")) {
+                row(label: "Can create discussion?", value: group.canCreateDiscussionTopic?.description ?? "N/A")
+                row(label: "Can join?", value: group.canJoin?.description ?? "N/A")
+                row(label: "Can create announcement?", value: group.canCreateAnnouncement?.description ?? "N/A")
+            }
+#endif
+
+            Section(header: Text("Users")) {
+                ForEach(group.users ?? []) {
+                    row(label: $0.id.asString, value: $0.name)
+                }
+            }
+        }
+#if os(iOS)
+        .listStyle(.insetGrouped)
+#else
+        .listStyle(.sidebar)
+#endif
+    }
+
+    func row(label: String, value: String) -> some View {
+        HStack {
+            Text(label)
+            Spacer()
+            Text(value)
+                .foregroundStyle(.secondary)
+        }
+    }
+}
+
+#Preview {
+    VStack(spacing: 0) {
+        GroupDetailView(group: .sample)
+
+        Divider()
+        HStack {
+            Spacer()
+            Button("Done") {
+            }
+            .buttonStyle(.borderedProminent)
+            .controlSize(.extraLarge)
+            .keyboardShortcut(.return)
+        }
+        .padding()
+    }
+}
diff --git a/CanvasPlusPlayground/Features/Groups/GroupRowView.swift b/CanvasPlusPlayground/Features/Groups/GroupRowView.swift
new file mode 100644
index 00000000..bf938063
--- /dev/null
+++ b/CanvasPlusPlayground/Features/Groups/GroupRowView.swift
@@ -0,0 +1,124 @@
+//
+//  GroupRowView.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/30/25.
+//
+
+import SwiftUI
+
+struct GroupRowView: View {
+    @Environment(CourseGroupsViewModel.self) private var courseGroupsVM
+
+    let group: CanvasGroup
+    @Binding var selectedGroupDetail: CanvasGroup?
+
+    @State private var error: Error?
+    private var showError: Binding<Bool> {
+        Binding {
+            error != nil
+        } set: { showErrorNew in
+            if !showErrorNew { error = nil }
+        }
+    }
+
+    var membersLimit: String {
+        group.groupLimit == .max ? "∞" : String(group.groupLimit)
+    }
+
+    var body: some View {
+        HStack {
+            Text(group.name)
+
+            numMembersLabel
+                .font(.subheadline)
+
+            moreDetailsButton
+
+            Spacer()
+
+            if group.isLoadingMembership {
+                ProgressView()
+            } else if let action = group.availableAction {
+                groupActionButton(for: action)
+                    .font(.subheadline)
+            } else {
+                lockStatusLabel
+            }
+        }
+        .font(.headline)
+        .alert("Failure", isPresented: showError) {
+            Button("Ok", role: .cancel) {
+                error = nil
+            }
+        }
+    }
+
+    var moreDetailsButton: some View {
+        Button {
+            selectedGroupDetail = group
+        } label: {
+            Image(systemName: .infoCircle)
+        }
+        .buttonStyle(.plain)
+#if os(macOS)
+        .foregroundStyle(.secondary)
+#else
+        .foregroundStyle(.blue)
+#endif
+    }
+
+    var numMembersLabel: some View {
+        Text("(\(group.membersCount)/\(membersLimit))" )
+            .foregroundStyle(.secondary)
+    }
+
+    var categoryLabel: some View {
+        Text(group.groupCategoryName ?? "Unknown Category")
+    }
+
+    var lockStatusLabel: Image {
+#if os(macOS)
+        Image(systemName: .lock)
+#else
+        Image(systemName: .lockFilled)
+#endif
+    }
+
+    func groupActionButton(for action: GroupAction) -> some View {
+        let label = if action == .join {
+            courseGroupsVM.canOnlySwitch(to: group) ? "Switch to" : action.label
+        } else { action.label }
+
+        return Button(label) {
+            Task {
+                await groupAction(action)
+            }
+        }
+    }
+
+    func groupAction(_ action: GroupAction) async {
+        do {
+            switch action {
+            case .join:
+                // TODO: must update existing groups upon success
+                try await group.joinGroup()
+                if let categoryId = group.groupCategoryId {
+                    try await courseGroupsVM.fetchAllGroupMembershipsFor(
+                        categoryId: categoryId,
+                        excluding: group.id
+                    )
+                }
+            case .leave:
+                try await group.leaveGroup()
+            case .cancelRequest:
+                // TODO: action here
+                break
+            case .accept:
+                try await group.acceptInvite()
+            }
+        } catch {
+            self.error = error
+        }
+    }
+}
diff --git a/CanvasPlusPlayground/Features/Groups/GroupsListView.swift b/CanvasPlusPlayground/Features/Groups/GroupsListView.swift
new file mode 100644
index 00000000..0a9798fa
--- /dev/null
+++ b/CanvasPlusPlayground/Features/Groups/GroupsListView.swift
@@ -0,0 +1,58 @@
+//
+//  GroupsListView.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import SwiftUI
+
+struct GroupsListView: View {
+    @Environment(CourseGroupsViewModel.self) private var courseGroupsVM
+
+    @State private var selectedGroupDetail: CanvasGroup?
+
+    var body: some View {
+        List(courseGroupsVM.groupsDisplayed) { group in
+            GroupRowView(group: group, selectedGroupDetail: $selectedGroupDetail)
+                .frame(height: 40)
+                .task {
+                    do {
+                        try await group.fetchMembershipState()
+                    } catch {
+                        // TODO: indicate unknown status in UI (perhaps triangle with !, clicking on it shows subtle popover with error message
+                    }
+                }
+        }
+        .sheet(item: $selectedGroupDetail) { group in
+            NavigationStack {
+                groupDetail(for: group)
+                    .navigationTitle("Details")
+            }
+            .frame(height: 600)
+        }
+    }
+
+    @ViewBuilder
+    func groupDetail(for group: CanvasGroup) -> some View {
+        GroupDetailView(group: group)
+            .toolbar {
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("Done") {
+                        selectedGroupDetail = nil
+                    }
+                }
+            }
+    }
+}
+
+#Preview {
+    var courseGroupVM = {
+        let vm = CourseGroupsViewModel()
+        vm.groups = [.sample, .sample]
+        return vm
+    }()
+
+    GroupsListView()
+        .environment(courseGroupVM)
+}
diff --git a/CanvasPlusPlayground/Features/Groups/Models/APIGroup.swift b/CanvasPlusPlayground/Features/Groups/Models/APIGroup.swift
new file mode 100644
index 00000000..60860e41
--- /dev/null
+++ b/CanvasPlusPlayground/Features/Groups/Models/APIGroup.swift
@@ -0,0 +1,85 @@
+//
+//  APIGroup.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import Foundation
+
+// swiftlint:disable identifier_name
+
+struct APIGroup: APIResponse {
+    typealias Model = CanvasGroup
+
+    let id: Int
+    let name: String
+    let description: String?
+    let concluded: Bool
+    let members_count: Int
+    let course_id: Int?
+    let group_category: GroupCategory?
+    let storage_quota_mb: Int?
+    let is_public: Bool
+    let users: [UserAPI]?
+    let permissions: Permissions?
+    let join_level: GroupJoinLevel?
+    let avatar_url: URL?
+    let max_membership: Int?
+
+    func createModel() -> CanvasGroup {
+        CanvasGroup(from: self)
+    }
+
+    struct GroupCategory: Codable {
+        let id: Int?
+        let name: String?
+        let group_limit: Int?
+        let allows_multiple_memberships: Bool?
+    }
+
+    struct Permissions: Codable {
+        let create_discussion_topic: Bool
+        let join: Bool
+        let create_announcement: Bool
+    }
+}
+
+enum GroupJoinLevel: String, Codable {
+    case invitationOnly = "invitation_only"
+    case parentContextRequest = "parent_context_request"
+    case parentContextAutoJoin = "parent_context_auto_join"
+}
+
+// MARK: Preview
+
+extension APIGroup {
+    static let sample1 = APIGroup(
+        id: 12345,
+        name: "iOS Development Team",
+        description: "A group for students working on the final iOS project",
+        concluded: false,
+        members_count: 6,
+        course_id: 9876,
+        group_category: APIGroup.GroupCategory(
+            id: 42,
+            name: "Project Teams",
+            group_limit: 8,
+            allows_multiple_memberships: false
+        ),
+        storage_quota_mb: 1024,
+        is_public: false,
+        users: [
+            .sample1,
+            .sample2
+        ],
+        permissions: APIGroup.Permissions(
+            create_discussion_topic: true,
+            join: false,
+            create_announcement: true
+        ),
+        join_level: .invitationOnly,
+        avatar_url: URL(string: "https://canvas.example.edu/groups/12345/avatar.png"),
+        max_membership: 8
+    )
+}
diff --git a/CanvasPlusPlayground/Features/Groups/Models/APIGroupMembership.swift b/CanvasPlusPlayground/Features/Groups/Models/APIGroupMembership.swift
new file mode 100644
index 00000000..93deb0d3
--- /dev/null
+++ b/CanvasPlusPlayground/Features/Groups/Models/APIGroupMembership.swift
@@ -0,0 +1,29 @@
+//
+//  APIGroupMembership.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import Foundation
+
+// swiftlint:disable identifier_name
+
+struct APIGroupMembership: APIResponse {
+    typealias Model = GroupMembership
+
+    let id: Int
+    let group_id: Int
+    let user_id: Int
+    let workflow_state: GroupMembershipState
+    let moderator: Bool?
+    // let just_created: Bool?
+
+    func createModel() -> GroupMembership {
+        GroupMembership(from: self)
+    }
+}
+
+enum GroupMembershipState: String, Codable {
+    case accepted, invited, requested
+}
diff --git a/CanvasPlusPlayground/Features/Groups/Models/CanvasGroup+Memberships.swift b/CanvasPlusPlayground/Features/Groups/Models/CanvasGroup+Memberships.swift
new file mode 100644
index 00000000..dc058a2d
--- /dev/null
+++ b/CanvasPlusPlayground/Features/Groups/Models/CanvasGroup+Memberships.swift
@@ -0,0 +1,99 @@
+//
+//  CanvasGroup+Memberships.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/29/25.
+//
+
+// MARK: Memberships
+extension CanvasGroup {
+    @MainActor
+    func leaveGroup() async throws {
+        isLoadingMembership = true
+        defer { isLoadingMembership = false }
+
+        let req = CanvasRequest.leaveGroup(groupId: id, via: .memberships(membershipId: "self"))
+
+        try await CanvasService.shared.fetch(req)
+
+        currUserStatus = nil // leaving should have succeeded if no error was thrown, so we assume `left` state
+
+        LoggerService.main.debug(
+            """
+            [GroupsListView] Group leave for \(self.name) succeeded:
+            \(self.currUserStatus?.rawValue ?? "nil"), \(self.availableAction?.rawValue ?? "nil")
+            """
+        )
+    }
+
+    @MainActor
+    func acceptInvite() async throws {
+        isLoadingMembership = true
+        defer { isLoadingMembership = false }
+
+        let req = CanvasRequest.updateGroupMembership(groupId: id, via: .memberships(membershipId: "self"))
+
+        let membershipRes = try await CanvasService.shared.syncWithAPI(req)
+        let membership = membershipRes[0]
+
+        self.currUserStatus = membership.workflowState
+
+        LoggerService.main.debug(
+            """
+            [GroupsListView] Group invite accept for \(self.name) succeeded:
+            \(self.currUserStatus?.rawValue ?? "nil"), \(self.availableAction?.rawValue ?? "nil")
+            """
+        )
+    }
+
+    @MainActor
+    func joinGroup() async throws {
+        isLoadingMembership = true
+        defer { isLoadingMembership = false }
+
+        let req = CanvasRequest.createGroupMembership(groupId: self.id)
+
+        let membershipRes = try await CanvasService.shared.syncWithAPI(req)
+        let membership = membershipRes[0]
+
+        membership.tag = "\(self.id)/self"
+
+        self.currUserStatus = membership.workflowState
+
+        LoggerService.main.debug(
+            """
+            [GroupsListView] Group join request for \(self.name) succeeded:
+            \(self.currUserStatus?.rawValue ?? "nil"), \(self.availableAction?.rawValue ?? "nil")
+            """
+        )
+    }
+
+    @MainActor
+    func fetchMembershipState() async throws {
+        isLoadingMembership = true
+        defer { isLoadingMembership = false }
+
+        let req = CanvasRequest.getSingleGroupMembership(groupId: self.id, via: .users(userId: "self"))
+        do {
+            let membershipRes = try await CanvasService.shared.syncWithAPI(req)
+            let membership = membershipRes[0]
+
+            self.currUserStatus = membership.workflowState
+            LoggerService.main.debug(
+                """
+                [GroupsListView] Membership state fetch for \(self.name) succeeded:
+                \(self.currUserStatus?.rawValue ?? "nil"), \(self.availableAction?.rawValue ?? "nil")
+                """
+            )
+        } catch {
+            // If 404 (not found) -> means user is not in group, so we reset status.
+            if let error = error as? HTTPStatusCode, error == .notFound {
+                self.currUserStatus = nil
+                LoggerService.main.error("[GroupsListView] Membership wansn't found. User is not part of group.")
+            } else {
+                LoggerService.main.error("[GroupsListView] Membership state fetch failed: \(error)")
+                throw error
+            }
+        }
+    }
+}
diff --git a/CanvasPlusPlayground/Features/Groups/Models/CanvasGroup.swift b/CanvasPlusPlayground/Features/Groups/Models/CanvasGroup.swift
new file mode 100644
index 00000000..ac8a8887
--- /dev/null
+++ b/CanvasPlusPlayground/Features/Groups/Models/CanvasGroup.swift
@@ -0,0 +1,124 @@
+//
+//  Group.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import Foundation
+import SwiftData
+
+@Model
+class CanvasGroup: Cacheable, Hashable {
+    var id: String
+    var name: String
+    var groupDescription: String?
+    var concluded: Bool
+    var membersCount: Int
+    var courseId: Int?
+    var groupCategoryId: Int?
+    var groupCategoryName: String?
+    var groupLimit: Int
+    var allowsMultipleMemberships: Bool?
+    var storageQuotaMb: Int?
+    var isPublic: Bool
+    var users: [UserAPI]?
+    var joinLevel: GroupJoinLevel?
+    var avatarUrl: URL?
+
+    // MARK: Permissions
+    var canCreateDiscussionTopic: Bool?
+    var canJoin: Bool?
+    var canCreateAnnouncement: Bool?
+
+    // MARK: Custom
+    var usersIsIncomplete: Bool {
+        membersCount > users?.count ?? 0
+    }
+    var availableAction: GroupAction? {
+        guard canJoin == true && !concluded else { return nil } // no action can be taken
+
+        // make sure group has space to join OR user is already in group OR user already has request in progress. otherwise lock action.
+        guard membersCount < groupLimit || currUserStatus == .accepted || currUserStatus == .requested else { return nil }
+
+        return switch currUserStatus {
+        case .accepted:
+            .leave
+        case .invited:
+            .accept
+        case .requested:
+            .cancelRequest
+        case nil:
+            .join
+        }
+        // TODO: verify action logic + handle `Switch to`
+    }
+    // TODO: store GroupMembership as relationship instead
+    var currUserStatus: GroupMembershipState? // should update by fetching GroupMembership of `self`
+    @Attribute(.ephemeral) var isLoadingMembership: Bool = false
+
+    init(from api: APIGroup) {
+        self.id = api.id.asString
+        self.name = api.name
+        self.groupDescription = api.description
+        self.concluded = api.concluded
+        self.membersCount = api.members_count
+        self.courseId = api.course_id
+        self.groupCategoryId = api.group_category?.id
+        self.groupCategoryName = api.group_category?.name
+        self.groupLimit = api.group_category?.group_limit ?? api.max_membership ?? .max
+        self.allowsMultipleMemberships = api.group_category?.allows_multiple_memberships
+        self.storageQuotaMb = api.storage_quota_mb
+        self.isPublic = api.is_public
+
+        // TODO: use user api instead
+        self.users = api.users
+
+        self.joinLevel = api.join_level
+        self.avatarUrl = api.avatar_url
+
+        self.canCreateDiscussionTopic = api.permissions?.create_discussion_topic
+        self.canJoin = api.permissions?.join
+        self.canCreateAnnouncement = api.permissions?.create_announcement
+    }
+}
+
+enum GroupAction: String {
+    case join = "Join", leave = "Leave", accept = "Accept", cancelRequest = "Cancel request"
+
+    var label: String {
+        self.rawValue
+    }
+}
+
+// MARK: Cacheable
+extension CanvasGroup {
+    func merge(with other: CanvasGroup) {
+        self.name = other.name
+        self.concluded = other.concluded
+        self.groupDescription = other.groupDescription ?? self.groupDescription
+        self.membersCount = other.membersCount
+        self.courseId = other.courseId ?? self.courseId
+        self.groupCategoryId = other.groupCategoryId ?? self.groupCategoryId
+        self.groupCategoryName = other.groupCategoryName ?? self.groupCategoryName
+        self.groupLimit = other.groupLimit
+        self.allowsMultipleMemberships = other.allowsMultipleMemberships ?? self.allowsMultipleMemberships
+        self.storageQuotaMb = other.storageQuotaMb ?? self.storageQuotaMb
+        self.isPublic = other.isPublic
+
+        self.users = other.users ?? self.users
+        self.joinLevel = other.joinLevel ?? self.joinLevel
+        self.avatarUrl = other.avatarUrl ?? self.avatarUrl
+
+        self.canCreateDiscussionTopic = other.canCreateDiscussionTopic ?? self.canCreateDiscussionTopic
+        self.canJoin = other.canJoin ?? self.canJoin
+        self.canCreateAnnouncement = other.canCreateAnnouncement ?? self.canCreateAnnouncement
+    }
+}
+
+// MARK: Preview
+extension CanvasGroup {
+    static let sample = CanvasGroup(
+        from: .sample1
+    )
+}
diff --git a/CanvasPlusPlayground/Features/Groups/Models/GroupMembership.swift b/CanvasPlusPlayground/Features/Groups/Models/GroupMembership.swift
new file mode 100644
index 00000000..0d18504b
--- /dev/null
+++ b/CanvasPlusPlayground/Features/Groups/Models/GroupMembership.swift
@@ -0,0 +1,36 @@
+//
+//  GroupMembership.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import Foundation
+import SwiftData
+
+@Model
+class GroupMembership: Cacheable, Hashable {
+    var id: String
+    var groupId: Int
+    var userId: Int
+    var workflowState: GroupMembershipState
+    var isModerator: Bool?
+
+    // MARK: Custom
+    var tag: String = "" // for fetching from cache when `self` is passed as id
+
+    init(from api: APIGroupMembership) {
+        self.id = api.id.asString
+        self.groupId = api.group_id
+        self.userId = api.user_id
+        self.workflowState = api.workflow_state
+        self.isModerator = api.moderator
+    }
+
+    func merge(with other: GroupMembership) {
+        self.groupId = other.groupId
+        self.userId = other.userId
+        self.workflowState = other.workflowState
+        self.isModerator = other.isModerator ?? self.isModerator
+    }
+}
diff --git a/CanvasPlusPlayground/Features/Navigation/CourseDetailView.swift b/CanvasPlusPlayground/Features/Navigation/CourseDetailView.swift
index 64332760..bafc72ac 100644
--- a/CanvasPlusPlayground/Features/Navigation/CourseDetailView.swift
+++ b/CanvasPlusPlayground/Features/Navigation/CourseDetailView.swift
@@ -26,6 +26,8 @@ struct CourseDetailView: View {
                 CourseGradeView(course: course)
             case .people:
                 PeopleView(courseID: course.id)
+            case .groups:
+                CourseGroupsView(course: course)
             case .tabs:
                 CourseTabsView(course: course)
             case .quizzes:
diff --git a/CanvasPlusPlayground/Features/Navigation/NavigationModel.swift b/CanvasPlusPlayground/Features/Navigation/NavigationModel.swift
index 4178bb30..346692ef 100644
--- a/CanvasPlusPlayground/Features/Navigation/NavigationModel.swift
+++ b/CanvasPlusPlayground/Features/Navigation/NavigationModel.swift
@@ -54,6 +54,7 @@ class NavigationModel {
         case grades
         case calendar
         case people
+        case groups
         case tabs
         case quizzes
         case modules
@@ -64,7 +65,7 @@ class NavigationModel {
         }
 
         static let requiredTabs: Set<CoursePage> = [
-            .tabs, .people
+            .tabs, .people, .groups
         ]
 
         var systemImageIcon: String {
@@ -83,6 +84,8 @@ class NavigationModel {
                 "graduationcap.fill"
             case .people:
                 "person.crop.circle.fill"
+            case .groups:
+                "person.3.sequence.fill"
             case .quizzes:
                 "questionmark.circle.fill"
             case .modules:
diff --git a/CanvasPlusPlayground/Features/People/Models/User.swift b/CanvasPlusPlayground/Features/People/Models/User.swift
index 43f64a58..b03b84be 100644
--- a/CanvasPlusPlayground/Features/People/Models/User.swift
+++ b/CanvasPlusPlayground/Features/People/Models/User.swift
@@ -26,12 +26,12 @@ class User: Cacheable {
     var enrollmentRoles: [EnrollmentType] {
         Array(
             Set(
-                enrollments.compactMap { EnrollmentType(rawValue: $0.type) }
+                enrollments?.compactMap { EnrollmentType(rawValue: $0.type) } ?? []
             )
         )
         .sorted { $0.rawValue < $1.rawValue }
     }
-    var enrollments: [EnrollmentAPI]
+    var enrollments: [EnrollmentAPI]?
 
     // MARK: Custom
     var courseId: String?
@@ -47,7 +47,7 @@ class User: Cacheable {
         self.email = userAPI.email
         self.pronouns = userAPI.pronouns
         self.role = userAPI.role
-        self.enrollments = userAPI.enrollments ?? []
+        self.enrollments = userAPI.enrollments
         self.tag = ""
     }
 
@@ -55,11 +55,12 @@ class User: Cacheable {
         self.name = other.name
         self.sortableName = other.sortableName
         self.shortName = other.shortName
-        self.avatarURL = other.avatarURL
-        self.email = other.email
-        self.pronouns = other.pronouns
-        self.role = other.role
-        self.enrollments = other.enrollments
+        self.avatarURL = other.avatarURL ?? self.avatarURL
+        self.email = other.email ?? self.email
+        self.pronouns = other.pronouns ?? self.pronouns
+        self.role = other.role ?? self.role
+
+        self.enrollments = other.enrollments ?? self.enrollments
     }
 
     var hasAvatar: Bool {
diff --git a/CanvasPlusPlayground/Features/People/Models/UserAPI.swift b/CanvasPlusPlayground/Features/People/Models/UserAPI.swift
index 352d8dac..fcbe4d4a 100644
--- a/CanvasPlusPlayground/Features/People/Models/UserAPI.swift
+++ b/CanvasPlusPlayground/Features/People/Models/UserAPI.swift
@@ -39,6 +39,54 @@ struct UserAPI: APIResponse, Identifiable {
     }
 }
 
+// MARK: Preview
+extension UserAPI {
+    static let sample1 = UserAPI(
+        id: 1001,
+        name: "Alex Chen",
+        sortable_name: "Chen, Alex",
+        last_name: "Chen",
+        first_name: "Alex",
+        short_name: "Alex",
+        sis_user_id: "AC1001",
+        sis_import_id: 5001,
+        integration_id: "INT-1001",
+        login_id: "achen",
+        avatar_url: URL(string: "https://canvas.example.edu/users/1001/avatar.png"),
+        avatar_state: "approved",
+        enrollments: nil,
+        email: "alex.chen@example.edu",
+        locale: "en",
+        last_login: "2025-03-20T14:30:45Z",
+        time_zone: "America/Los_Angeles",
+        bio: "Computer Science major with an interest in mobile app development.",
+        pronouns: "he/him",
+        role: "student"
+    )
+    static let sample2 = UserAPI(
+        id: 1002,
+        name: "Jamie Smith",
+        sortable_name: "Smith, Jamie",
+        last_name: "Smith",
+        first_name: "Jamie",
+        short_name: "Jamie",
+        sis_user_id: "JS1002",
+        sis_import_id: 5002,
+        integration_id: "INT-1002",
+        login_id: "jsmith",
+        avatar_url: URL(string: "https://canvas.example.edu/users/1002/avatar.png"),
+        avatar_state: "approved",
+        enrollments: nil,
+        email: "jamie.smith@example.edu",
+        locale: "en",
+        last_login: "2025-03-23T09:15:22Z",
+        time_zone: "America/Chicago",
+        bio: "Design student focusing on UI/UX for mobile applications.",
+        pronouns: "they/them",
+        role: "student"
+    )
+}
+
 /*
  // A Canvas user, e.g. a student, teacher, administrator, observer, etc.
  {


Reviews:
user: login: MJFree34
body: Some comments and such
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T21:14:27Z
user: login: azooz2003-bit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T21:55:07Z
user: login: azooz2003-bit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T21:59:03Z
user: login: azooz2003-bit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T22:04:00Z
user: login: azooz2003-bit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T22:12:44Z
user: login: azooz2003-bit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T22:14:21Z
user: login: azooz2003-bit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T22:16:45Z
user: login: azooz2003-bit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T22:17:59Z
user: login: azooz2003-bit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T22:20:24Z
user: login: azooz2003-bit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T22:22:36Z
user: login: MJFree34
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T22:39:10Z
user: login: MJFree34
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T22:41:39Z
user: login: rahulon12
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-31T02:46:10Z
user: login: azooz2003-bit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T02:48:08Z
user: login: azooz2003-bit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T02:48:22Z
user: login: azooz2003-bit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T02:51:15Z
user: login: azooz2003-bit
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T03:12:42Z

Review comments:
diff_hunk: @@ -0,0 +1,68 @@
+//
+//  GroupDetailView.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/24/25.
+//
+
+import SwiftUI
+
+struct GroupDetailView: View {
+    @Environment(\.dismiss) private var dismiss
+
+    let group: CanvasGroup
+
+    var body: some View {
+        List {
+            Section(header: Text("Course Information")) {
+                row(label: "ID", value: group.id)
+                row(label: "Name", value: group.name)
+                row(label: "Concluded?", value: group.concluded.description)
+                row(label: "Members Count", value: group.membersCount.asString)
+                row(label: "Course ID", value: group.courseId?.asString ?? "N/A")
+                row(label: "Group Category ID", value: group.groupCategoryId?.asString ?? "N/A")
+                row(label: "Group Category Name", value: group.groupCategoryName ?? "N/A")
+                row(label: "Group Limit", value: group.groupLimit.asString)
+                row(label: "Category Allows Multiple Memberships?", value: group.allowsMultipleMemberships?.description ?? "N/A")
+                row(label: "Storage Quota (MB)", value: group.storageQuotaMb?.asString ?? "N/A")
+                row(label: "Is Public?", value: group.isPublic.description)
+                row(label: "Membership Status", value: group.currUserStatus?.rawValue ?? "No status (probs not a member)")
+            }
+
+            Section(header: Text("Description")) {
+                Text(group.groupDescription ?? "N/A")
+                    .font(.body)
+            }
+
+            Section(header: Text("Permissions")) {
+                row(label: "Can create discussion?", value: group.canCreateDiscussionTopic?.description ?? "N/A")
+                row(label: "Can join?", value: group.canJoin?.description ?? "N/A")
+                row(label: "Can create announcement?", value: group.canCreateAnnouncement?.description ?? "N/A")
+            }
+
+            Section(header: Text("Users")) {
+                ForEach(group.users ?? []) {
+                    row(label: $0.id.asString, value: $0.name)
+                }
+            }
+        }
path: CanvasPlusPlayground/Features/Groups/GroupDetailView.swift
user: login: rahulon12
body: Use a `Form` with `LabeledContent` instead. Look at assignments detail (I think)
created_at: 2025-03-30T03:29:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,180 @@
+//
+//  GroupsListView.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import SwiftUI
+
+struct GroupsListView: View {
+    let groups: [CanvasGroup]
+
+    @State private var selectedGroupDetail: CanvasGroup?
+
+    var body: some View {
+        List(groups) { group in
+            GroupRowView(group: group, selectedGroupDetail: $selectedGroupDetail)
+                .frame(height: 40)
+                .task {
+                    do {
+                        try await group.fetchMembershipState()
+                    } catch {
+                        // TODO: indicate unknown status in UI
+                    }
+                }
+        }
+        .sheet(item: $selectedGroupDetail) { group in
+            NavigationStack {
+                groupDetail(for: group)
+                    .navigationTitle("Details")
+            }
+            .frame(minHeight: 600)
+        }
+    }
+
+    @ViewBuilder
+    func groupDetail(for group: CanvasGroup) -> some View {
+#if os(macOS)
path: CanvasPlusPlayground/Features/Groups/GroupsListView.swift
user: login: rahulon12
body: can you indent compiler flags at the same level of the below code?
created_at: 2025-03-30T03:29:46Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,180 @@
+//
+//  GroupsListView.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import SwiftUI
+
+struct GroupsListView: View {
+    let groups: [CanvasGroup]
+
+    @State private var selectedGroupDetail: CanvasGroup?
+
+    var body: some View {
+        List(groups) { group in
+            GroupRowView(group: group, selectedGroupDetail: $selectedGroupDetail)
+                .frame(height: 40)
+                .task {
+                    do {
+                        try await group.fetchMembershipState()
+                    } catch {
+                        // TODO: indicate unknown status in UI
+                    }
+                }
+        }
+        .sheet(item: $selectedGroupDetail) { group in
+            NavigationStack {
+                groupDetail(for: group)
+                    .navigationTitle("Details")
+            }
+            .frame(minHeight: 600)
+        }
+    }
+
+    @ViewBuilder
+    func groupDetail(for group: CanvasGroup) -> some View {
+#if os(macOS)
+        GroupDetailView(group: group)
+
+        Divider()
+
+        HStack {
+            Spacer()
+            Button("Done") {
+                selectedGroupDetail = nil
+            }
+            .buttonStyle(.borderedProminent)
+            .controlSize(.extraLarge)
+            .keyboardShortcut(.return)
+        }
+        .padding()
+#else
+        GroupDetailView(group: group)
+            .toolbar {
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("Done") {
+                        selectedGroupDetail = nil
+                    }
+                }
+            }
+#endif
+    }
+}
+
+struct GroupRowView: View {
path: CanvasPlusPlayground/Features/Groups/GroupsListView.swift
user: login: rahulon12
body: ```suggestion
private struct GroupRowView: View {
```
created_at: 2025-03-30T03:30:01Z
author_association: MEMBER
path: CanvasPlusPlayground/Common/Utilities/HTTPURLResponse+Codes.swift
user: login: rahulon12
body: Love this
created_at: 2025-03-30T15:14:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,31 @@
+//
+//  GroupsView.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import SwiftUI
+
+struct CourseGroupsView: View {
+    let course: Course
+
+    @State private var courseGroupsVM: CourseGroupsViewModel
+    @State private var isLoading: Bool = false
+
+    init(course: Course) {
+        self.course = course
+        self._courseGroupsVM = State(initialValue: CourseGroupsViewModel())
+    }
+
+    var body: some View {
+        GroupsListView(groups: courseGroupsVM.groupsDisplayed)
path: CanvasPlusPlayground/Features/Groups/CourseGroups/CourseGroupsView.swift
user: login: rahulon12
body: Since `courseGroupsVM` is being passed in through the environment, no need for this argument
created_at: 2025-03-30T15:15:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,184 @@
+//
+//  GroupsListView.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import SwiftUI
+
+struct GroupsListView: View {
+    let groups: [CanvasGroup]
+
+    @State private var selectedGroupDetail: CanvasGroup?
+
+    var body: some View {
+        List(groups) { group in
+            GroupRowView(group: group, selectedGroupDetail: $selectedGroupDetail)
+                .frame(height: 40)
+                .task {
+                    do {
+                        try await group.fetchMembershipState()
+                    } catch {
+                        // TODO: indicate unknown status in UI
+                    }
+                }
+        }
+        .sheet(item: $selectedGroupDetail) { group in
+            NavigationStack {
+                groupDetail(for: group)
+                    .navigationTitle("Details")
+            }
+            .frame(minHeight: 600)
+        }
+    }
+
+    @ViewBuilder
+    func groupDetail(for group: CanvasGroup) -> some View {
+#if os(macOS)
+        GroupDetailView(group: group)
+
+        Divider()
+
+        HStack {
+            Spacer()
+            Button("Done") {
+                selectedGroupDetail = nil
+            }
+            .buttonStyle(.borderedProminent)
+            .controlSize(.extraLarge)
+            .keyboardShortcut(.return)
+        }
path: CanvasPlusPlayground/Features/Groups/GroupsListView.swift
user: login: rahulon12
body: Use the toolbar for macOS too, you automatically get the keyboard shortcut. Any reason why this is different?
created_at: 2025-03-30T15:17:47Z
author_association: MEMBER
diff_hunk: @@ -646,6 +683,46 @@
 			path = Grades;
 			sourceTree = "<group>";
 		};
+		A372D0FD2D90BA96005E94CA /* Groups */ = {
+			isa = PBXGroup;
+			children = (
+				A372D1122D91D1B0005E94CA /* AllGroups */,
path: CanvasPlusPlayground.xcodeproj/project.pbxproj
user: login: MJFree34
body: This is showing up as empty and invalid on my pull of the branch
![CleanShot 2025-03-30 at 14 20 12@2x](https://github.com/user-attachments/assets/f1b4e60c-d077-48b7-93af-605efcc42b29)

created_at: 2025-03-30T18:20:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,11 +55,15 @@ class User: Cacheable {
         self.name = other.name
         self.sortableName = other.sortableName
         self.shortName = other.shortName
-        self.avatarURL = other.avatarURL
-        self.email = other.email
-        self.pronouns = other.pronouns
-        self.role = other.role
-        self.enrollments = other.enrollments
+        self.avatarURL = other.avatarURL ?? self.avatarURL
+        self.email = other.email ?? self.email
+        self.pronouns = other.pronouns ?? self.pronouns
+        self.role = other.role ?? self.role
+
+        // this logic is wrong, we should make `enrollments` optional and check if it's nil in `other`. for now ill keep it.
path: CanvasPlusPlayground/Features/People/Models/User.swift
user: login: MJFree34
body: I feel like changing it to `enrollments` optional isn't a tall order--perhaps something to do quickly or a new ticket to spin up?
created_at: 2025-03-30T18:24:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,124 @@
+//
+//  Group.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import Foundation
+import SwiftData
+
+@Model
+class CanvasGroup: Cacheable, Hashable {
+    var id: String
+    var name: String
+    var groupDescription: String?
+    var concluded: Bool
+    var membersCount: Int
+    var courseId: Int?
+    var groupCategoryId: Int?
+    var groupCategoryName: String?
+    var groupLimit: Int
+    var allowsMultipleMemberships: Bool?
+    var storageQuotaMb: Int?
+    var isPublic: Bool
+    var users: [UserAPI]?
+    var joinLevel: GroupJoinLevel?
+    var avatarUrl: URL?
+
+    // MARK: Permissions
+    var canCreateDiscussionTopic: Bool?
+    var canJoin: Bool?
+    var canCreateAnnouncement: Bool?
+
+    // MARK: Custom
+    var usersIsIncomplete: Bool {
+        membersCount > users?.count ?? 0
+    }
+    var availableAction: GroupAction? {
+        guard canJoin == true && !concluded else { return nil } // no action can be taken
+
+        // make sure group has space to join OR user is already in group OR user already has request in progress. otherwise lock action.
+        guard membersCount < groupLimit || currUserStatus == .accepted || currUserStatus == .requested else { return nil }
path: CanvasPlusPlayground/Features/Groups/Models/CanvasGroup.swift
user: login: MJFree34
body: strictly less than limit? not including limit?
created_at: 2025-03-30T18:25:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,81 @@
+//
+//  APIGroup.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import Foundation
+
+// swiftlint:disable identifier_name
+
+struct APIGroup: APIResponse {
+    typealias Model = CanvasGroup
+
+    let id: Int
+    let name: String
+    let description: String?
+    let concluded: Bool
+    let members_count: Int
+    let course_id: Int?
+    let group_category: GroupCategory?
+    let storage_quota_mb: Int?
+    let is_public: Bool
+    let users: [UserAPI]?
+    let permissions: Permissions?
+    let join_level: GroupJoinLevel?
+    let avatar_url: URL?
+    let max_membership: Int?
+
+    func createModel() -> CanvasGroup {
+        CanvasGroup(from: self)
+    }
+
+    struct GroupCategory: Codable {
+        let id: Int?
+        let name: String?
+        let group_limit: Int?
+        let allows_multiple_memberships: Bool?
+    }
+
+    struct Permissions: Codable {
+        let create_discussion_topic: Bool
+        let join: Bool
+        let create_announcement: Bool
+    }
+
+    static let sample1 = APIGroup(
path: CanvasPlusPlayground/Features/Groups/Models/APIGroup.swift
user: login: MJFree34
body: put in extension?
created_at: 2025-03-30T18:26:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -37,6 +37,51 @@ struct UserAPI: APIResponse, Identifiable {
     func createModel() -> User {
         User(from: self)
     }
+
+    static let sample1 = UserAPI(
path: CanvasPlusPlayground/Features/People/Models/UserAPI.swift
user: login: MJFree34
body: extension perhaps?
created_at: 2025-03-30T18:27:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -229,4 +229,32 @@ final class Course: Cacheable {
 //        self.rgbColors = other.rgbColors
 //        self.nickname = other.nickname
     }
+
+    static let sample = Course(
path: CanvasPlusPlayground/Features/Courses/Models/Course.swift
user: login: MJFree34
body: extension?
created_at: 2025-03-30T18:28:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -215,6 +215,7 @@ extension DiscussionTopic {
 
 // MARK: Read Marking
 extension DiscussionTopic {
+    @MainActor
path: CanvasPlusPlayground/Features/Announcements/Models/DiscussionTopic.swift
user: login: MJFree34
body: why marked on MainActor? Seems unrelated.
created_at: 2025-03-30T18:28:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,16 @@
+//
+//  String+SFSymbols.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/24/25.
+//
+
+import Foundation
+
+extension String {
+    static var infoCircle = "info.circle"
+    static var infoCircleFilled = "info.circle.fill"
+
+    static var lock = "lock"
+    static var lockFilled = "lock.fill"
path: CanvasPlusPlayground/Common/Utilities/String+SFSymbols.swift
user: login: MJFree34
body: why don't we create a ticket to refactor this further for all the symbols we currently use instead of just these 4?
created_at: 2025-03-30T18:29:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -28,10 +25,23 @@ class CanvasRepository {
             User.self,
             Profile.self,
             DiscussionTopic.self,
-            Page.self
+            Page.self,
+            CanvasGroup.self,
+            GroupMembership.self
             // TODO: Add cacheable models here
         )
-        self.modelContext = ModelContext(modelContainer)
+        return ModelContext(modelContainer)
+    }()
+}
+
+@MainActor
+class CanvasRepository {
+    let modelContainer: ModelContainer
+    let modelContext: ModelContext
+
+    init() {
+        self.modelContext = ModelContext.shared
+        self.modelContainer = modelContext.container
         modelContext.autosaveEnabled = true
path: CanvasPlusPlayground/Common/Storage/CanvasRepository.swift
user: login: MJFree34
body: I feel like since we are creating a singleton this line should be included in singleton initialization above
created_at: 2025-03-30T18:31:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -90,16 +90,20 @@ extension APIRequest {
         urlRequest.setValue(contentType, forHTTPHeaderField: "Content-Type")
         urlRequest.setValue(contentLength, forHTTPHeaderField: "Content-Length")
         urlRequest.setValue("Bearer \(StorageKeys.accessTokenValue)", forHTTPHeaderField: "Authorization")
-        do {
-            let (data, response) = try await URLSession.shared.data(for: urlRequest)
 
-            guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
-                LoggerService.main.error("HTTP error: $\(response)$ data $\(String(data: data, encoding: .utf8) ?? "not text")$")
-                throw URLError(.badServerResponse)
-            }
+        let (data, response) = try await URLSession.shared.data(for: urlRequest)
path: CanvasPlusPlayground/Common/Network/APIRequest+Network.swift
user: login: MJFree34
body: By doing this we don't want to handle any errors in the `data(for: urlRequest)` call itself--we good with that?
created_at: 2025-03-30T18:34:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,54 @@
+//
+//  GetCourseGroupsRequest.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import Foundation
+
+struct GetCourseGroupsRequest: CacheableArrayAPIRequest {
+    typealias Subject = APIGroup
+
+    var path: String { "courses/\(courseId)/groups" }
+    var queryParameters: [QueryParameter] {
+        [
+            ("only_own_groups", onlyOwnGroups),
+            ("per_page", perPage),
+            ("collaboration_state", collaborationState)
+        ]
+        + include.map { ("include[]", $0.rawValue) }
+    }
+
+    let courseId: String
+    let onlyOwnGroups: Bool
+    let include: [Include]
+    let collaborationState: CollaborationState
+
+    let perPage: Int
+
+    var requestId: Int? { courseId.asInt }
+    var requestIdKey: ParentKeyPath<CanvasGroup, Int?> {
+        .createWritable(\.courseId)
+    }
+    var idPredicate: Predicate<CanvasGroup> {
+        #Predicate { $0.courseId == requestId }
+    }
+    var customPredicate: Predicate<CanvasGroup> {
+        if onlyOwnGroups {
+            let accepted = GroupMembershipState.accepted as GroupMembershipState?
+            return #Predicate<CanvasGroup> { $0.currUserStatus == accepted }
+        }else { return .true }
path: CanvasPlusPlayground/Common/Network/API Requests/GetCourseGroupsRequest.swift
user: login: MJFree34
body: spacing
created_at: 2025-03-30T18:35:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,70 @@
+//
+//  GroupDetailView.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/24/25.
+//
+
+import SwiftUI
+
+struct GroupDetailView: View {
+    @Environment(\.dismiss) private var dismiss
+
+    let group: CanvasGroup
+
+    var body: some View {
+        List {
+#if DEBUG
+            Section(header: Text("Course Information")) {
+                row(label: "ID", value: group.id)
+                row(label: "Name", value: group.name)
+                row(label: "Concluded?", value: group.concluded.description)
+                row(label: "Members Count", value: group.membersCount.asString)
+                row(label: "Course ID", value: group.courseId?.asString ?? "N/A")
+                row(label: "Group Category ID", value: group.groupCategoryId?.asString ?? "N/A")
+                row(label: "Group Category Name", value: group.groupCategoryName ?? "N/A")
+                row(label: "Group Limit", value: group.groupLimit.asString)
+                row(label: "Category Allows Multiple Memberships?", value: group.allowsMultipleMemberships?.description ?? "N/A")
+                row(label: "Storage Quota (MB)", value: group.storageQuotaMb?.asString ?? "N/A")
+                row(label: "Is Public?", value: group.isPublic.description)
+                row(label: "Membership Status", value: group.currUserStatus?.rawValue ?? "No status (probs not a member)")
+            }
+
+            Section(header: Text("Description")) {
+                Text(group.groupDescription ?? "N/A")
+                    .font(.body)
path: CanvasPlusPlayground/Features/Groups/GroupDetailView.swift
user: login: MJFree34
body: This makes this font awkwardly larger than the rest
![CleanShot 2025-03-30 at 14 39 21@2x](https://github.com/user-attachments/assets/58656dd2-1872-4b62-af16-ad0c05ab4961)

created_at: 2025-03-30T18:39:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,184 @@
+//
+//  GroupsListView.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import SwiftUI
+
+struct GroupsListView: View {
+    let groups: [CanvasGroup]
+
+    @State private var selectedGroupDetail: CanvasGroup?
+
+    var body: some View {
+        List(groups) { group in
+            GroupRowView(group: group, selectedGroupDetail: $selectedGroupDetail)
+                .frame(height: 40)
+                .task {
+                    do {
+                        try await group.fetchMembershipState()
+                    } catch {
+                        // TODO: indicate unknown status in UI
path: CanvasPlusPlayground/Features/Groups/GroupsListView.swift
user: login: MJFree34
body: ticket? or todo for this PR?
created_at: 2025-03-30T18:43:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,184 @@
+//
+//  GroupsListView.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import SwiftUI
+
+struct GroupsListView: View {
+    let groups: [CanvasGroup]
+
+    @State private var selectedGroupDetail: CanvasGroup?
+
+    var body: some View {
+        List(groups) { group in
+            GroupRowView(group: group, selectedGroupDetail: $selectedGroupDetail)
+                .frame(height: 40)
+                .task {
+                    do {
+                        try await group.fetchMembershipState()
+                    } catch {
+                        // TODO: indicate unknown status in UI
+                    }
+                }
+        }
+        .sheet(item: $selectedGroupDetail) { group in
+            NavigationStack {
+                groupDetail(for: group)
+                    .navigationTitle("Details")
+            }
+            .frame(minHeight: 600)
+        }
+    }
+
+    @ViewBuilder
+    func groupDetail(for group: CanvasGroup) -> some View {
+#if os(macOS)
+        GroupDetailView(group: group)
+
+        Divider()
+
+        HStack {
+            Spacer()
+            Button("Done") {
+                selectedGroupDetail = nil
+            }
+            .buttonStyle(.borderedProminent)
+            .controlSize(.extraLarge)
+            .keyboardShortcut(.return)
+        }
+        .padding()
+#else
+        GroupDetailView(group: group)
+            .toolbar {
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("Done") {
+                        selectedGroupDetail = nil
+                    }
+                }
+            }
+#endif
+    }
+}
+
+struct GroupRowView: View {
path: CanvasPlusPlayground/Features/Groups/GroupsListView.swift
user: login: MJFree34
body: Want to make this a new file? It is pretty heavy
created_at: 2025-03-30T18:43:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,99 @@
+//
+//  CanvasGroup+Memberships.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/29/25.
+//
+
+// MARK: Memberships
+extension CanvasGroup {
+    @MainActor
+    func leaveGroup() async throws {
+        isLoadingMembership = true
+        defer { isLoadingMembership = false }
+
+        let req = CanvasRequest.leaveGroup(groupId: id, via: .memberships(membershipId: "self"))
+
+        try await CanvasService.shared.fetch(req)
+
+        currUserStatus = nil // leaving should have succeeded if no error was thrown, so we assume `left` state
+
+        LoggerService.main.debug(
+            """
+            [GroupsListView] Group leave for \(self.name) succeeded:
+            \(self.currUserStatus?.rawValue ?? "nil"), \(self.availableAction?.rawValue ?? "nil")
+            """
+        )
+    }
+
+    @MainActor
+    func acceptInvite() async throws {
+        isLoadingMembership = true
+        defer { isLoadingMembership = false }
+
+        let req = CanvasRequest.updateGroupMembership(groupId: id, via: .memberships(membershipId: "self"))
+
+        let membershipRes = try await CanvasService.shared.syncWithAPI(req)
+        let membership = membershipRes[0]
+
+        self.currUserStatus = membership.workflowState
+
+        LoggerService.main.debug(
+            """
+            [GroupsListView] Group invite accept for \(self.name) succeeded:
+            \(self.currUserStatus?.rawValue ?? "nil"), \(self.availableAction?.rawValue ?? "nil")
+            """
+        )
+    }
+
+    @MainActor
+    func joinGroup() async throws {
+        isLoadingMembership = true
+        defer { isLoadingMembership = false }
+
+        let req = CanvasRequest.createGroupMembership(groupId: self.id)
+
+        let membershipRes = try await CanvasService.shared.syncWithAPI(req)
+        let membership = membershipRes[0]
+
+        membership.tag = "\(self.id)/self"
+
+        self.currUserStatus = membership.workflowState
+
+        LoggerService.main.debug(
+            """
+            [GroupsListView] Group join request for \(self.name) succeeded:
+            \(self.currUserStatus?.rawValue ?? "nil"), \(self.availableAction?.rawValue ?? "nil")
+            """
+        )
+    }
+
+    @MainActor
+    func fetchMembershipState() async throws {
+        isLoadingMembership = true
+        defer { isLoadingMembership = false }
+
+        let req = CanvasRequest.getSingleGroupMembership(groupId: self.id, via: .users(userId: "self"))
+        do {
+            let membershipRes = try await CanvasService.shared.syncWithAPI(req)
+            let membership = membershipRes[0]
+
+            self.currUserStatus = membership.workflowState
+            LoggerService.main.debug(
+                """
+                [GroupsListView] Membership state fetch for \(self.name) succeeded:
+                \(self.currUserStatus?.rawValue ?? "nil"), \(self.availableAction?.rawValue ?? "nil")
+                """
+            )
+        } catch {
+            LoggerService.main.error("[GroupsListView] Membership state fetch failed: \(error)")
+
+            // If 404 (not found) -> means user is not in group, so we reset status.
+            if let error = error as? HTTPStatusCode, error == .notFound {
+                self.currUserStatus = nil
path: CanvasPlusPlayground/Features/Groups/Models/CanvasGroup+Memberships.swift
user: login: MJFree34
body: Logger?
created_at: 2025-03-30T18:45:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -646,6 +683,46 @@
 			path = Grades;
 			sourceTree = "<group>";
 		};
+		A372D0FD2D90BA96005E94CA /* Groups */ = {
+			isa = PBXGroup;
+			children = (
+				A372D1122D91D1B0005E94CA /* AllGroups */,
path: CanvasPlusPlayground.xcodeproj/project.pbxproj
user: login: azooz2003-bit
body: it should be empty, i didnt add anything to it. ill just remove it, i think thats why its invalid
created_at: 2025-03-30T21:55:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,124 @@
+//
+//  Group.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import Foundation
+import SwiftData
+
+@Model
+class CanvasGroup: Cacheable, Hashable {
+    var id: String
+    var name: String
+    var groupDescription: String?
+    var concluded: Bool
+    var membersCount: Int
+    var courseId: Int?
+    var groupCategoryId: Int?
+    var groupCategoryName: String?
+    var groupLimit: Int
+    var allowsMultipleMemberships: Bool?
+    var storageQuotaMb: Int?
+    var isPublic: Bool
+    var users: [UserAPI]?
+    var joinLevel: GroupJoinLevel?
+    var avatarUrl: URL?
+
+    // MARK: Permissions
+    var canCreateDiscussionTopic: Bool?
+    var canJoin: Bool?
+    var canCreateAnnouncement: Bool?
+
+    // MARK: Custom
+    var usersIsIncomplete: Bool {
+        membersCount > users?.count ?? 0
+    }
+    var availableAction: GroupAction? {
+        guard canJoin == true && !concluded else { return nil } // no action can be taken
+
+        // make sure group has space to join OR user is already in group OR user already has request in progress. otherwise lock action.
+        guard membersCount < groupLimit || currUserStatus == .accepted || currUserStatus == .requested else { return nil }
path: CanvasPlusPlayground/Features/Groups/Models/CanvasGroup.swift
user: login: azooz2003-bit
body: yup if it's == groupLimit then there's no space for the user to join.
created_at: 2025-03-30T21:59:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,184 @@
+//
+//  GroupsListView.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import SwiftUI
+
+struct GroupsListView: View {
+    let groups: [CanvasGroup]
+
+    @State private var selectedGroupDetail: CanvasGroup?
+
+    var body: some View {
+        List(groups) { group in
+            GroupRowView(group: group, selectedGroupDetail: $selectedGroupDetail)
+                .frame(height: 40)
+                .task {
+                    do {
+                        try await group.fetchMembershipState()
+                    } catch {
+                        // TODO: indicate unknown status in UI
path: CanvasPlusPlayground/Features/Groups/GroupsListView.swift
user: login: azooz2003-bit
body: yup ticket
created_at: 2025-03-30T22:03:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,184 @@
+//
+//  GroupsListView.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import SwiftUI
+
+struct GroupsListView: View {
+    let groups: [CanvasGroup]
+
+    @State private var selectedGroupDetail: CanvasGroup?
+
+    var body: some View {
+        List(groups) { group in
+            GroupRowView(group: group, selectedGroupDetail: $selectedGroupDetail)
+                .frame(height: 40)
+                .task {
+                    do {
+                        try await group.fetchMembershipState()
+                    } catch {
+                        // TODO: indicate unknown status in UI
path: CanvasPlusPlayground/Features/Groups/GroupsListView.swift
user: login: azooz2003-bit
body: https://github.com/gtiosclub/CanvasPlusPlayground/issues/295
created_at: 2025-03-30T22:12:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,16 @@
+//
+//  String+SFSymbols.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/24/25.
+//
+
+import Foundation
+
+extension String {
+    static var infoCircle = "info.circle"
+    static var infoCircleFilled = "info.circle.fill"
+
+    static var lock = "lock"
+    static var lockFilled = "lock.fill"
path: CanvasPlusPlayground/Common/Utilities/String+SFSymbols.swift
user: login: azooz2003-bit
body: https://github.com/gtiosclub/CanvasPlusPlayground/issues/296
created_at: 2025-03-30T22:14:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -90,16 +90,20 @@ extension APIRequest {
         urlRequest.setValue(contentType, forHTTPHeaderField: "Content-Type")
         urlRequest.setValue(contentLength, forHTTPHeaderField: "Content-Length")
         urlRequest.setValue("Bearer \(StorageKeys.accessTokenValue)", forHTTPHeaderField: "Authorization")
-        do {
-            let (data, response) = try await URLSession.shared.data(for: urlRequest)
 
-            guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
-                LoggerService.main.error("HTTP error: $\(response)$ data $\(String(data: data, encoding: .utf8) ?? "not text")$")
-                throw URLError(.badServerResponse)
-            }
+        let (data, response) = try await URLSession.shared.data(for: urlRequest)
path: CanvasPlusPlayground/Common/Network/APIRequest+Network.swift
user: login: azooz2003-bit
body: yeah we want to propagate all errors to the UI layer, I also realized the API throws certain errors (like 404) to indicate some state. making codes an enum helps with understanding the response.
created_at: 2025-03-30T22:16:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -90,16 +90,20 @@ extension APIRequest {
         urlRequest.setValue(contentType, forHTTPHeaderField: "Content-Type")
         urlRequest.setValue(contentLength, forHTTPHeaderField: "Content-Length")
         urlRequest.setValue("Bearer \(StorageKeys.accessTokenValue)", forHTTPHeaderField: "Authorization")
-        do {
-            let (data, response) = try await URLSession.shared.data(for: urlRequest)
 
-            guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
-                LoggerService.main.error("HTTP error: $\(response)$ data $\(String(data: data, encoding: .utf8) ?? "not text")$")
-                throw URLError(.badServerResponse)
-            }
+        let (data, response) = try await URLSession.shared.data(for: urlRequest)
path: CanvasPlusPlayground/Common/Network/APIRequest+Network.swift
user: login: azooz2003-bit
body: PS if `data(...)` fails, it's probably lack of wifi etc.
created_at: 2025-03-30T22:17:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -215,6 +215,7 @@ extension DiscussionTopic {
 
 // MARK: Read Marking
 extension DiscussionTopic {
+    @MainActor
path: CanvasPlusPlayground/Features/Announcements/Models/DiscussionTopic.swift
user: login: azooz2003-bit
body: just in case someone uses it in BG incorrectly. this method modifies the model which is bound to the main thread, so app will crash if model is modified in BG.

Btw, I think I fix the announcement markAsRead bug in line 225, can you recheck that?
created_at: 2025-03-30T22:20:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -28,10 +25,23 @@ class CanvasRepository {
             User.self,
             Profile.self,
             DiscussionTopic.self,
-            Page.self
+            Page.self,
+            CanvasGroup.self,
+            GroupMembership.self
             // TODO: Add cacheable models here
         )
-        self.modelContext = ModelContext(modelContainer)
+        return ModelContext(modelContainer)
+    }()
+}
+
+@MainActor
+class CanvasRepository {
+    let modelContainer: ModelContainer
+    let modelContext: ModelContext
+
+    init() {
+        self.modelContext = ModelContext.shared
+        self.modelContainer = modelContext.container
         modelContext.autosaveEnabled = true
path: CanvasPlusPlayground/Common/Storage/CanvasRepository.swift
user: login: azooz2003-bit
body: im confused, how would we include it? can u explain
created_at: 2025-03-30T22:22:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -90,16 +90,20 @@ extension APIRequest {
         urlRequest.setValue(contentType, forHTTPHeaderField: "Content-Type")
         urlRequest.setValue(contentLength, forHTTPHeaderField: "Content-Length")
         urlRequest.setValue("Bearer \(StorageKeys.accessTokenValue)", forHTTPHeaderField: "Authorization")
-        do {
-            let (data, response) = try await URLSession.shared.data(for: urlRequest)
 
-            guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
-                LoggerService.main.error("HTTP error: $\(response)$ data $\(String(data: data, encoding: .utf8) ?? "not text")$")
-                throw URLError(.badServerResponse)
-            }
+        let (data, response) = try await URLSession.shared.data(for: urlRequest)
path: CanvasPlusPlayground/Common/Network/APIRequest+Network.swift
user: login: MJFree34
body: Yep! Just clarifying!
created_at: 2025-03-30T22:39:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -28,10 +25,23 @@ class CanvasRepository {
             User.self,
             Profile.self,
             DiscussionTopic.self,
-            Page.self
+            Page.self,
+            CanvasGroup.self,
+            GroupMembership.self
             // TODO: Add cacheable models here
         )
-        self.modelContext = ModelContext(modelContainer)
+        return ModelContext(modelContainer)
+    }()
+}
+
+@MainActor
+class CanvasRepository {
+    let modelContainer: ModelContainer
+    let modelContext: ModelContext
+
+    init() {
+        self.modelContext = ModelContext.shared
+        self.modelContainer = modelContext.container
         modelContext.autosaveEnabled = true
path: CanvasPlusPlayground/Common/Storage/CanvasRepository.swift
user: login: MJFree34
body: Up when we create the `modelContext`, we would use the initializer for `ModelContext(modelConatiner)` like above, save it to a variable, modify the `autosaveEnabled` flag on the variable, and return the context.

This is just a thought since it is a singleton. We technically have it as a computed value, so every time it is accessed it is recomputed, so this may not be necessary.
created_at: 2025-03-30T22:41:39Z
author_association: CONTRIBUTOR
path: CanvasPlusPlayground/Common/Utilities/HTTPURLResponse+Codes.swift
user: login: azooz2003-bit
body: ;)
created_at: 2025-03-31T02:48:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,180 @@
+//
+//  GroupsListView.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import SwiftUI
+
+struct GroupsListView: View {
+    let groups: [CanvasGroup]
+
+    @State private var selectedGroupDetail: CanvasGroup?
+
+    var body: some View {
+        List(groups) { group in
+            GroupRowView(group: group, selectedGroupDetail: $selectedGroupDetail)
+                .frame(height: 40)
+                .task {
+                    do {
+                        try await group.fetchMembershipState()
+                    } catch {
+                        // TODO: indicate unknown status in UI
+                    }
+                }
+        }
+        .sheet(item: $selectedGroupDetail) { group in
+            NavigationStack {
+                groupDetail(for: group)
+                    .navigationTitle("Details")
+            }
+            .frame(minHeight: 600)
+        }
+    }
+
+    @ViewBuilder
+    func groupDetail(for group: CanvasGroup) -> some View {
+#if os(macOS)
+        GroupDetailView(group: group)
+
+        Divider()
+
+        HStack {
+            Spacer()
+            Button("Done") {
+                selectedGroupDetail = nil
+            }
+            .buttonStyle(.borderedProminent)
+            .controlSize(.extraLarge)
+            .keyboardShortcut(.return)
+        }
+        .padding()
+#else
+        GroupDetailView(group: group)
+            .toolbar {
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("Done") {
+                        selectedGroupDetail = nil
+                    }
+                }
+            }
+#endif
+    }
+}
+
+struct GroupRowView: View {
path: CanvasPlusPlayground/Features/Groups/GroupsListView.swift
user: login: azooz2003-bit
body: moved to diff file
created_at: 2025-03-31T02:48:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,180 @@
+//
+//  GroupsListView.swift
+//  CanvasPlusPlayground
+//
+//  Created by Abdulaziz Albahar on 3/23/25.
+//
+
+import SwiftUI
+
+struct GroupsListView: View {
+    let groups: [CanvasGroup]
+
+    @State private var selectedGroupDetail: CanvasGroup?
+
+    var body: some View {
+        List(groups) { group in
+            GroupRowView(group: group, selectedGroupDetail: $selectedGroupDetail)
+                .frame(height: 40)
+                .task {
+                    do {
+                        try await group.fetchMembershipState()
+                    } catch {
+                        // TODO: indicate unknown status in UI
+                    }
+                }
+        }
+        .sheet(item: $selectedGroupDetail) { group in
+            NavigationStack {
+                groupDetail(for: group)
+                    .navigationTitle("Details")
+            }
+            .frame(minHeight: 600)
+        }
+    }
+
+    @ViewBuilder
+    func groupDetail(for group: CanvasGroup) -> some View {
+#if os(macOS)
path: CanvasPlusPlayground/Features/Groups/GroupsListView.swift
user: login: azooz2003-bit
body: i prefer it like this tbh, easier to separate from actual code
created_at: 2025-03-31T02:51:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -28,10 +25,23 @@ class CanvasRepository {
             User.self,
             Profile.self,
             DiscussionTopic.self,
-            Page.self
+            Page.self,
+            CanvasGroup.self,
+            GroupMembership.self
             // TODO: Add cacheable models here
         )
-        self.modelContext = ModelContext(modelContainer)
+        return ModelContext(modelContainer)
+    }()
+}
+
+@MainActor
+class CanvasRepository {
+    let modelContainer: ModelContainer
+    let modelContext: ModelContext
+
+    init() {
+        self.modelContext = ModelContext.shared
+        self.modelContainer = modelContext.container
         modelContext.autosaveEnabled = true
path: CanvasPlusPlayground/Common/Storage/CanvasRepository.swift
user: login: azooz2003-bit
body: It's actually not a computed property, look at () at the end of the cuyrly braces. ur point on autosave is right though.
created_at: 2025-03-31T03:12:42Z
author_association: CONTRIBUTOR

PR comments:
user: login: iwatger
created_at: 2025-03-30T20:34:31Z
body: May I request a feature: Can you add the ability to hide groups that are empty? As a TA, I constantly have to go to the canvas groups tab and manually open the disclosed group members for each group. Being able to do this in canvas plus would be a big help. I could open this up as a separate ticket.

<img width="1201" alt="Screenshot 2025-03-30 at 4 33 20 PM" src="https://github.com/user-attachments/assets/c645c419-5fa9-4f23-b6f8-f973275d5c8b" />

author_association: CONTRIBUTOR
user: login: iwatger
created_at: 2025-03-30T20:37:45Z
body: <img width="515" alt="Screenshot 2025-03-30 at 4 36 53 PM" src="https://github.com/user-attachments/assets/4a637918-2460-494e-9595-7445ead0693d" />

I know not all this info will be shown in the Release version, but is the content background supposed to be different than the border around the sheet? Is the intention that general users could click on the "i" icon, or is this also just for debugging? If so, I think it would be better if the detail view was another view in the navigation stack, rather than a sheet
author_association: CONTRIBUTOR
user: login: azooz2003-bit
created_at: 2025-03-30T22:26:07Z
body: > May I request a feature: Can you add the ability to hide groups that are empty? As a TA, I constantly have to go to the canvas groups tab and manually open the disclosed group members for each group. Being able to do this in canvas plus would be a big help. I could open this up as a separate ticket.
> 
> <img alt="Screenshot 2025-03-30 at 4 33 20 PM" width="1201" src="https://private-user-images.githubusercontent.com/51765575/428426365-c645c419-5fa9-4f23-b6f8-f973275d5c8b.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDMzNzMzOTAsIm5iZiI6MTc0MzM3MzA5MCwicGF0aCI6Ii81MTc2NTU3NS80Mjg0MjYzNjUtYzY0NWM0MTktNWZhOS00ZjIzLWI2ZjgtZjk3MzI3NWQ1YzhiLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAzMzAlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMzMwVDIyMTgxMFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTM2YjIwMTk4YWFmM2Q0MzllMjkxOTA2ZDcwMjM5ZGExOTI1ZGFkMTdjN2Y1NzAwNWI4ZDJmNGI1ZjNlODdiYzcmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.1jRwUU1KrTUUoOLVLP0_X0l1qZCelhxTimjxXudXbdc">

yeah for sure! i'll make a ticket to work on it in v2, since this PR is quite large alr

https://github.com/gtiosclub/CanvasPlusPlayground/issues/297
author_association: CONTRIBUTOR
user: login: azooz2003-bit
created_at: 2025-03-30T22:34:43Z
body: > <img alt="Screenshot 2025-03-30 at 4 36 53 PM" width="515" src="https://private-user-images.githubusercontent.com/51765575/428426571-4a637918-2460-494e-9595-7445ead0693d.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDMzNzMzOTAsIm5iZiI6MTc0MzM3MzA5MCwicGF0aCI6Ii81MTc2NTU3NS80Mjg0MjY1NzEtNGE2Mzc5MTgtMjQ2MC00OTRlLTk1OTUtNzQ0NWVhZDA2OTNkLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTAzMzAlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwMzMwVDIyMTgxMFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTZiMzQwMDE0YjNlZGJkZmRkZjA5YTNiODE0ZjEyYWMwMmFiOTNlNmU2ZmQ1M2U4N2Y2MGJhNWNjZmRlZmE5MjgmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.JDSrk5hDaBTphN4_6R99U-itWPQOzUvGy__B9cEt7f8">
> I know not all this info will be shown in the Release version, but is the content background supposed to be different than the border around the sheet? Is the intention that general users could click on the "i" icon, or is this also just for debugging? If so, I think it would be better if the detail view was another view in the navigation stack, rather than a sheet

yup the (i) is meant to just show them a list of users for now, maybe we can include some other useful props like Category too. everything else is for debugging.

I'll fix the background issue, couldnt tell from dark mode. 

For this detail view I'm using a sheet because it's more lightweight, i imagine anyone who looks at the details will only take a glance.
author_association: CONTRIBUTOR