Repository: instructure/canvas-ios
Pull request #3311: SG Refactor #3 - Extract submission grader logic to view model
Author: login: vargaat
Created: 2025-04-14T07:44:28Z | Merged: 2025-04-17T09:52:36Z

refs: MBL-18708
affects: Teacher
release note: none

test plan:
- Test if attempt switching works correctly.
- You can change attempts with the attempt dropdown or by selecting an attempt in the comments field.
- Files should display for the given attempt.
- Comments should be filtered to the given attempt is assignment enhancements are enabled.

## Checklist

- [ ] Tested on phone
- [x] Tested on tablet


Diff:
diff --git a/Teacher/Teacher.xcodeproj/project.pbxproj b/Teacher/Teacher.xcodeproj/project.pbxproj
index 82ac36bc60..74a2791d4d 100644
--- a/Teacher/Teacher.xcodeproj/project.pbxproj
+++ b/Teacher/Teacher.xcodeproj/project.pbxproj
@@ -28,10 +28,10 @@
 		494AE0BA1F843CB5001A8F31 /* Routes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 494AE0B61F843CB4001A8F31 /* Routes.swift */; };
 		61BD85FC1EB2933C005A09A5 /* TeacherAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BD85FB1EB2933C005A09A5 /* TeacherAppDelegate.swift */; };
 		78AD913121C8180C0075FBCF /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 78AD913021C8180C0075FBCF /* GoogleService-Info.plist */; };
-		7D0A28E2251A8284004FB3B6 /* SubmissionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0A28E1251A8284004FB3B6 /* SubmissionHeader.swift */; };
+		7D0A28E2251A8284004FB3B6 /* SubmissionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0A28E1251A8284004FB3B6 /* SubmissionHeaderView.swift */; };
 		7D0D7AE2251E6E6C00550AEC /* SubmissionListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0D7AE1251E6E6C00550AEC /* SubmissionListViewController.swift */; };
 		7D0D7B36251EA41B00550AEC /* SubmissionListViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 7D0D7B35251EA41B00550AEC /* SubmissionListViewController.storyboard */; };
-		7D264DC72537A6E1007CDD90 /* SimilarityScore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D264DC62537A6E1007CDD90 /* SimilarityScore.swift */; };
+		7D264DC72537A6E1007CDD90 /* SimilarityScoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D264DC62537A6E1007CDD90 /* SimilarityScoreView.swift */; };
 		7D3DC0C0252291B400B0EBC1 /* SubmissionFilterPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D3DC0BF252291B400B0EBC1 /* SubmissionFilterPickerViewController.swift */; };
 		7D3DC0C7252432DD00B0EBC1 /* SubmissionFilterPickerViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D3DC0C6252432DD00B0EBC1 /* SubmissionFilterPickerViewControllerTests.swift */; };
 		7D3DC0CB252432ED00B0EBC1 /* SubmissionListViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D3DC0CA252432ED00B0EBC1 /* SubmissionListViewControllerTests.swift */; };
@@ -53,14 +53,14 @@
 		7D64A983236796CC004EAEDF /* AttendanceStatusControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D64A982236796CC004EAEDF /* AttendanceStatusControllerTests.swift */; };
 		7D64A98523689DD5004EAEDF /* DatePickerViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D64A98423689DD4004EAEDF /* DatePickerViewControllerTests.swift */; };
 		7D64A9872368C6C0004EAEDF /* AttendanceViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D64A9862368C6C0004EAEDF /* AttendanceViewControllerTests.swift */; };
-		7D7126B12534B980000DEDE4 /* Drawer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D7126B02534B980000DEDE4 /* Drawer.swift */; };
+		7D7126B12534B980000DEDE4 /* DrawerContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D7126B02534B980000DEDE4 /* DrawerContainer.swift */; };
 		7D7126BC25366DAB000DEDE4 /* SubmissionViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D7126BB25366DAB000DEDE4 /* SubmissionViewer.swift */; };
 		7D7126C025374446000DEDE4 /* SubmissionGrades.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D7126BF25374446000DEDE4 /* SubmissionGrades.swift */; };
-		7D7126C425374457000DEDE4 /* SubmissionCommentList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D7126C325374457000DEDE4 /* SubmissionCommentList.swift */; };
+		7D7126C425374457000DEDE4 /* SubmissionCommentListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D7126C325374457000DEDE4 /* SubmissionCommentListView.swift */; };
 		7D7126C725374469000DEDE4 /* SubmissionFileList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D7126C625374469000DEDE4 /* SubmissionFileList.swift */; };
 		7D742F382554D01400FD6CF1 /* RubricsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D742F372554D01400FD6CF1 /* RubricsView.swift */; };
-		7D742F452556566F00FD6CF1 /* CommentEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D742F442556566F00FD6CF1 /* CommentEditor.swift */; };
-		7D7D6D5225102052002B1485 /* SubmissionGrader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D7D6D5125102052002B1485 /* SubmissionGrader.swift */; };
+		7D742F452556566F00FD6CF1 /* CommentEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D742F442556566F00FD6CF1 /* CommentEditorView.swift */; };
+		7D7D6D5225102052002B1485 /* SubmissionGraderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D7D6D5125102052002B1485 /* SubmissionGraderView.swift */; };
 		7DA4DA302440EFBA009CA964 /* IBDesignables.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DA4DA2F2440EFBA009CA964 /* IBDesignables.swift */; };
 		7DBF923B2269791600710D0F /* TestsFoundation.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = B15CA26C221DDEB40014FB02 /* TestsFoundation.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		7DC7047A24ABFD4800CD1C46 /* GetRootFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC7047924ABFD4800CD1C46 /* GetRootFolders.swift */; };
@@ -105,6 +105,7 @@
 		CF0605E32D95C5A50014C2EC /* RubricCircle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF0605E22D95C5A00014C2EC /* RubricCircle.swift */; };
 		CF06060C2D96EA100014C2EC /* RubricCriterionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF06060B2D96EA100014C2EC /* RubricCriterionView.swift */; };
 		CF0606122D9A9A990014C2EC /* RubricsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF0606112D9A9A950014C2EC /* RubricsViewModel.swift */; };
+		CF0E06942DA8FFC6007AC131 /* SubmissionGraderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF0E06932DA8FFC4007AC131 /* SubmissionGraderViewModel.swift */; };
 		CF13E54C299D090500F57F69 /* QuizSubmissionListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF13E54B299D090500F57F69 /* QuizSubmissionListViewModel.swift */; };
 		CF13E550299D0E0B00F57F69 /* QuizSubmissionListInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF13E54F299D0E0B00F57F69 /* QuizSubmissionListInteractor.swift */; };
 		CF13E552299D0EE100F57F69 /* QuizSubmissionListInteractorLive.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF13E551299D0EE100F57F69 /* QuizSubmissionListInteractorLive.swift */; };
@@ -114,7 +115,7 @@
 		CF20B5C02B1F9C7F00C00B39 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = CF20B5BF2B1F9C7F00C00B39 /* LaunchScreen.storyboard */; };
 		CF28C6BD2DA4024A007CD65A /* SpeedGraderInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF28C6BC2DA40246007CD65A /* SpeedGraderInteractor.swift */; };
 		CF4C5F7C2DA02401005AE000 /* SpeedGraderInteractorLive.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF4C5F7B2DA023FB005AE000 /* SpeedGraderInteractorLive.swift */; };
-		CF56B71525F68CD3000DD32F /* SubmissionHeaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF56B71425F68CD3000DD32F /* SubmissionHeaderTests.swift */; };
+		CF56B71525F68CD3000DD32F /* SubmissionHeaderViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF56B71425F68CD3000DD32F /* SubmissionHeaderViewTests.swift */; };
 		CF67D00A2B18E9A1009D826A /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = CF67D0092B18E9A1009D826A /* InfoPlist.xcstrings */; };
 		CF67D00C2B18E9A1009D826A /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = CF67D00B2B18E9A1009D826A /* InfoPlist.xcstrings */; };
 		CF67D00E2B18E9A1009D826A /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = CF67D00D2B18E9A1009D826A /* Localizable.xcstrings */; };
@@ -239,10 +240,10 @@
 		497CA7321F82ACF400501613 /* CanvasCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = CanvasCore.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		61BD85FB1EB2933C005A09A5 /* TeacherAppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TeacherAppDelegate.swift; sourceTree = "<group>"; };
 		78AD913021C8180C0075FBCF /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
-		7D0A28E1251A8284004FB3B6 /* SubmissionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmissionHeader.swift; sourceTree = "<group>"; };
+		7D0A28E1251A8284004FB3B6 /* SubmissionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmissionHeaderView.swift; sourceTree = "<group>"; };
 		7D0D7AE1251E6E6C00550AEC /* SubmissionListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmissionListViewController.swift; sourceTree = "<group>"; };
 		7D0D7B35251EA41B00550AEC /* SubmissionListViewController.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = SubmissionListViewController.storyboard; sourceTree = "<group>"; };
-		7D264DC62537A6E1007CDD90 /* SimilarityScore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimilarityScore.swift; sourceTree = "<group>"; };
+		7D264DC62537A6E1007CDD90 /* SimilarityScoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimilarityScoreView.swift; sourceTree = "<group>"; };
 		7D3DA83821876142000D828F /* Core.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		7D3DC0BF252291B400B0EBC1 /* SubmissionFilterPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmissionFilterPickerViewController.swift; sourceTree = "<group>"; };
 		7D3DC0C6252432DD00B0EBC1 /* SubmissionFilterPickerViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmissionFilterPickerViewControllerTests.swift; sourceTree = "<group>"; };
@@ -268,14 +269,14 @@
 		7D64A982236796CC004EAEDF /* AttendanceStatusControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttendanceStatusControllerTests.swift; sourceTree = "<group>"; };
 		7D64A98423689DD4004EAEDF /* DatePickerViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePickerViewControllerTests.swift; sourceTree = "<group>"; };
 		7D64A9862368C6C0004EAEDF /* AttendanceViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttendanceViewControllerTests.swift; sourceTree = "<group>"; };
-		7D7126B02534B980000DEDE4 /* Drawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Drawer.swift; sourceTree = "<group>"; };
+		7D7126B02534B980000DEDE4 /* DrawerContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrawerContainer.swift; sourceTree = "<group>"; };
 		7D7126BB25366DAB000DEDE4 /* SubmissionViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmissionViewer.swift; sourceTree = "<group>"; };
 		7D7126BF25374446000DEDE4 /* SubmissionGrades.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmissionGrades.swift; sourceTree = "<group>"; };
-		7D7126C325374457000DEDE4 /* SubmissionCommentList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmissionCommentList.swift; sourceTree = "<group>"; };
+		7D7126C325374457000DEDE4 /* SubmissionCommentListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmissionCommentListView.swift; sourceTree = "<group>"; };
 		7D7126C625374469000DEDE4 /* SubmissionFileList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmissionFileList.swift; sourceTree = "<group>"; };
 		7D742F372554D01400FD6CF1 /* RubricsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RubricsView.swift; sourceTree = "<group>"; };
-		7D742F442556566F00FD6CF1 /* CommentEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentEditor.swift; sourceTree = "<group>"; };
-		7D7D6D5125102052002B1485 /* SubmissionGrader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmissionGrader.swift; sourceTree = "<group>"; };
+		7D742F442556566F00FD6CF1 /* CommentEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentEditorView.swift; sourceTree = "<group>"; };
+		7D7D6D5125102052002B1485 /* SubmissionGraderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmissionGraderView.swift; sourceTree = "<group>"; };
 		7DA4DA2F2440EFBA009CA964 /* IBDesignables.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IBDesignables.swift; sourceTree = "<group>"; };
 		7DC7047924ABFD4800CD1C46 /* GetRootFolders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetRootFolders.swift; sourceTree = "<group>"; };
 		7DCA320B2541D38F00458651 /* URLSubmissionViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSubmissionViewer.swift; sourceTree = "<group>"; };
@@ -316,6 +317,7 @@
 		CF0605E22D95C5A00014C2EC /* RubricCircle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RubricCircle.swift; sourceTree = "<group>"; };
 		CF06060B2D96EA100014C2EC /* RubricCriterionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RubricCriterionView.swift; sourceTree = "<group>"; };
 		CF0606112D9A9A950014C2EC /* RubricsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RubricsViewModel.swift; sourceTree = "<group>"; };
+		CF0E06932DA8FFC4007AC131 /* SubmissionGraderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmissionGraderViewModel.swift; sourceTree = "<group>"; };
 		CF13E54B299D090500F57F69 /* QuizSubmissionListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizSubmissionListViewModel.swift; sourceTree = "<group>"; };
 		CF13E54F299D0E0B00F57F69 /* QuizSubmissionListInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizSubmissionListInteractor.swift; sourceTree = "<group>"; };
 		CF13E551299D0EE100F57F69 /* QuizSubmissionListInteractorLive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizSubmissionListInteractorLive.swift; sourceTree = "<group>"; };
@@ -325,7 +327,7 @@
 		CF20B5BF2B1F9C7F00C00B39 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
 		CF28C6BC2DA40246007CD65A /* SpeedGraderInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeedGraderInteractor.swift; sourceTree = "<group>"; };
 		CF4C5F7B2DA023FB005AE000 /* SpeedGraderInteractorLive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeedGraderInteractorLive.swift; sourceTree = "<group>"; };
-		CF56B71425F68CD3000DD32F /* SubmissionHeaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmissionHeaderTests.swift; sourceTree = "<group>"; };
+		CF56B71425F68CD3000DD32F /* SubmissionHeaderViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmissionHeaderViewTests.swift; sourceTree = "<group>"; };
 		CF67D0092B18E9A1009D826A /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
 		CF67D00B2B18E9A1009D826A /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
 		CF67D00D2B18E9A1009D826A /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
@@ -799,8 +801,8 @@
 		CF0605CE2D95A5790014C2EC /* View */ = {
 			isa = PBXGroup;
 			children = (
-				7D742F442556566F00FD6CF1 /* CommentEditor.swift */,
-				7D7126C325374457000DEDE4 /* SubmissionCommentList.swift */,
+				7D742F442556566F00FD6CF1 /* CommentEditorView.swift */,
+				7D7126C325374457000DEDE4 /* SubmissionCommentListView.swift */,
 				7D4B2747253F2D1600A98DE3 /* SubmissionCommentListCell.swift */,
 			);
 			path = View;
@@ -890,6 +892,7 @@
 		CF0605D82D95A9340014C2EC /* MainLayout */ = {
 			isa = PBXGroup;
 			children = (
+				CF0E06922DA8FEF9007AC131 /* ViewModel */,
 				CF0605D92D95A93D0014C2EC /* View */,
 			);
 			path = MainLayout;
@@ -898,10 +901,10 @@
 		CF0605D92D95A93D0014C2EC /* View */ = {
 			isa = PBXGroup;
 			children = (
-				7D7126B02534B980000DEDE4 /* Drawer.swift */,
-				7D0A28E1251A8284004FB3B6 /* SubmissionHeader.swift */,
-				7D7D6D5125102052002B1485 /* SubmissionGrader.swift */,
-				7D264DC62537A6E1007CDD90 /* SimilarityScore.swift */,
+				7D7126B02534B980000DEDE4 /* DrawerContainer.swift */,
+				7D0A28E1251A8284004FB3B6 /* SubmissionHeaderView.swift */,
+				7D7D6D5125102052002B1485 /* SubmissionGraderView.swift */,
+				7D264DC62537A6E1007CDD90 /* SimilarityScoreView.swift */,
 			);
 			path = View;
 			sourceTree = "<group>";
@@ -990,7 +993,7 @@
 		CF0606012D96E47E0014C2EC /* View */ = {
 			isa = PBXGroup;
 			children = (
-				CF56B71425F68CD3000DD32F /* SubmissionHeaderTests.swift */,
+				CF56B71425F68CD3000DD32F /* SubmissionHeaderViewTests.swift */,
 			);
 			path = View;
 			sourceTree = "<group>";
@@ -1037,6 +1040,14 @@
 			path = ViewModel;
 			sourceTree = "<group>";
 		};
+		CF0E06922DA8FEF9007AC131 /* ViewModel */ = {
+			isa = PBXGroup;
+			children = (
+				CF0E06932DA8FFC4007AC131 /* SubmissionGraderViewModel.swift */,
+			);
+			path = ViewModel;
+			sourceTree = "<group>";
+		};
 		CF13E549299D08E000F57F69 /* QuizSubmissionList */ = {
 			isa = PBXGroup;
 			children = (
@@ -1470,20 +1481,20 @@
 				CF4C5F7C2DA02401005AE000 /* SpeedGraderInteractorLive.swift in Sources */,
 				CF93FCB82D5A5AF600C4538F /* SpeedGraderAssembly.swift in Sources */,
 				7D3DC0C0252291B400B0EBC1 /* SubmissionFilterPickerViewController.swift in Sources */,
-				7D7126C425374457000DEDE4 /* SubmissionCommentList.swift in Sources */,
+				7D7126C425374457000DEDE4 /* SubmissionCommentListView.swift in Sources */,
 				7DCA320C2541D38F00458651 /* URLSubmissionViewer.swift in Sources */,
 				CFCADAF02D9D6E1D003796E7 /* RubricRatingViewModel.swift in Sources */,
 				7D742F382554D01400FD6CF1 /* RubricsView.swift in Sources */,
 				CF13E550299D0E0B00F57F69 /* QuizSubmissionListInteractor.swift in Sources */,
 				494AE0BA1F843CB5001A8F31 /* Routes.swift in Sources */,
 				CF13E559299D1AEC00F57F69 /* QuizSubmissionListView.swift in Sources */,
-				7D7126B12534B980000DEDE4 /* Drawer.swift in Sources */,
+				7D7126B12534B980000DEDE4 /* DrawerContainer.swift in Sources */,
 				3B55FED522FDE70F00FCB7B2 /* HideGradesViewController.swift in Sources */,
-				7D0A28E2251A8284004FB3B6 /* SubmissionHeader.swift in Sources */,
+				7D0A28E2251A8284004FB3B6 /* SubmissionHeaderView.swift in Sources */,
 				7DCA321A25423CDF00458651 /* SpeedGraderViewController.swift in Sources */,
 				CF0605E32D95C5A50014C2EC /* RubricCircle.swift in Sources */,
 				CFCADAF42D9D871D003796E7 /* RubricRatingView.swift in Sources */,
-				7D742F452556566F00FD6CF1 /* CommentEditor.swift in Sources */,
+				7D742F452556566F00FD6CF1 /* CommentEditorView.swift in Sources */,
 				CF28C6BD2DA4024A007CD65A /* SpeedGraderInteractor.swift in Sources */,
 				CFCADAFB2D9D9AE0003796E7 /* RubricGradingInteractor.swift in Sources */,
 				EBAF32F627AACFC9000ACD32 /* SubmissionCommentLibraryViewModel.swift in Sources */,
@@ -1502,8 +1513,9 @@
 				7D64A95E23620A27004EAEDF /* AttendanceViewController.swift in Sources */,
 				D91C051F29AE107B0032BFB1 /* QuizSubmissionListItemViewModel.swift in Sources */,
 				B4707B052B1DDF7900F67CA8 /* SubmissionCommentListViewModel.swift in Sources */,
-				7D264DC72537A6E1007CDD90 /* SimilarityScore.swift in Sources */,
+				7D264DC72537A6E1007CDD90 /* SimilarityScoreView.swift in Sources */,
 				CFCADAF22D9D6FE4003796E7 /* RubricCustomRatingViewModel.swift in Sources */,
+				CF0E06942DA8FFC6007AC131 /* SubmissionGraderViewModel.swift in Sources */,
 				D9624A5229B0E90800F5303F /* QuizSubmissionListFilter.swift in Sources */,
 				D9624A5029AF94D000F5303F /* QuizSubmissionListInteractorPreview.swift in Sources */,
 				CF13E54C299D090500F57F69 /* QuizSubmissionListViewModel.swift in Sources */,
@@ -1523,7 +1535,7 @@
 				EBE802AF27B41C5F0003A663 /* CommentLibrarySheet.swift in Sources */,
 				494AE0B91F843CB5001A8F31 /* TeacherTabBarController.swift in Sources */,
 				CFCADAEA2D9BE21F003796E7 /* RubricCriterionViewModel.swift in Sources */,
-				7D7D6D5225102052002B1485 /* SubmissionGrader.swift in Sources */,
+				7D7D6D5225102052002B1485 /* SubmissionGraderView.swift in Sources */,
 				D9624A6829B8A05600F5303F /* QuizSubmissionListItemArrayFilter.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
@@ -1546,7 +1558,7 @@
 				7D3DC0C7252432DD00B0EBC1 /* SubmissionFilterPickerViewControllerTests.swift in Sources */,
 				7D64A98123676E36004EAEDF /* RollCallSessionTests.swift in Sources */,
 				7D64A96D23626C22004EAEDF /* StatusCellTests.swift in Sources */,
-				CF56B71525F68CD3000DD32F /* SubmissionHeaderTests.swift in Sources */,
+				CF56B71525F68CD3000DD32F /* SubmissionHeaderViewTests.swift in Sources */,
 				D9624A7E29C4AEF000F5303F /* QuizSubmissionListItemTests.swift in Sources */,
 				D9B4850029CCA8090004EBB5 /* QuizSubmissionListInteractorLiveTests.swift in Sources */,
 				7D64A983236796CC004EAEDF /* AttendanceStatusControllerTests.swift in Sources */,
diff --git a/Teacher/Teacher/SpeedGrader/CommentLibrary/View/CommentLibrarySheet.swift b/Teacher/Teacher/SpeedGrader/CommentLibrary/View/CommentLibrarySheet.swift
index 4ceb7e7638..919e50976d 100644
--- a/Teacher/Teacher/SpeedGrader/CommentLibrary/View/CommentLibrarySheet.swift
+++ b/Teacher/Teacher/SpeedGrader/CommentLibrary/View/CommentLibrarySheet.swift
@@ -32,7 +32,7 @@ struct CommentLibrarySheet: View {
                 CommentLibraryList(viewModel: viewModel, comment: $comment) {
                     presentationMode.wrappedValue.dismiss()
                 }
-                CommentEditor(
+                CommentEditorView(
                     text: $comment,
                     shouldShowCommentLibrary: false,
                     showCommentLibrary: .constant(false),
diff --git a/Teacher/Teacher/SpeedGrader/Comments/View/CommentEditor.swift b/Teacher/Teacher/SpeedGrader/Comments/View/CommentEditorView.swift
similarity index 97%
rename from Teacher/Teacher/SpeedGrader/Comments/View/CommentEditor.swift
rename to Teacher/Teacher/SpeedGrader/Comments/View/CommentEditorView.swift
index b8c426672d..fa215d2fb2 100644
--- a/Teacher/Teacher/SpeedGrader/Comments/View/CommentEditor.swift
+++ b/Teacher/Teacher/SpeedGrader/Comments/View/CommentEditorView.swift
@@ -19,7 +19,7 @@
 import SwiftUI
 import Core
 
-struct CommentEditor: View {
+struct CommentEditorView: View {
     @Environment(\.viewController) var controller
 
     @Binding var text: String
@@ -77,7 +77,7 @@ struct CommentEditor: View {
 struct CommentEditor_Previews: PreviewProvider {
     static var previews: some View {
         @State var showCommentLibrary = false
-        CommentEditor(text: .constant("Sample Text"),
+        CommentEditorView(text: .constant("Sample Text"),
                       shouldShowCommentLibrary: true,
                       showCommentLibrary: $showCommentLibrary,
                       action: {},
diff --git a/Teacher/Teacher/SpeedGrader/Comments/View/SubmissionCommentListCell.swift b/Teacher/Teacher/SpeedGrader/Comments/View/SubmissionCommentListCell.swift
index 43144973e5..9b2f766153 100644
--- a/Teacher/Teacher/SpeedGrader/Comments/View/SubmissionCommentListCell.swift
+++ b/Teacher/Teacher/SpeedGrader/Comments/View/SubmissionCommentListCell.swift
@@ -24,7 +24,7 @@ struct SubmissionCommentListCell: View {
     let submission: Submission
     let comment: SubmissionComment
 
-    @Binding var attempt: Int?
+    @Binding var attempt: Int
     @Binding var fileID: String?
 
     @Environment(\.appEnvironment.currentSession?.userID) var currentUserID
diff --git a/Teacher/Teacher/SpeedGrader/Comments/View/SubmissionCommentList.swift b/Teacher/Teacher/SpeedGrader/Comments/View/SubmissionCommentListView.swift
similarity index 96%
rename from Teacher/Teacher/SpeedGrader/Comments/View/SubmissionCommentList.swift
rename to Teacher/Teacher/SpeedGrader/Comments/View/SubmissionCommentListView.swift
index 2fa36a2d9c..604bbcb298 100644
--- a/Teacher/Teacher/SpeedGrader/Comments/View/SubmissionCommentList.swift
+++ b/Teacher/Teacher/SpeedGrader/Comments/View/SubmissionCommentListView.swift
@@ -20,11 +20,11 @@ import SwiftUI
 import Core
 import Combine
 
-struct SubmissionCommentList: View {
+struct SubmissionCommentListView: View {
     let assignment: Assignment
     let submission: Submission
     let filePicker = FilePicker(env: .shared)
-    @Binding var attempt: Int?
+    @Binding var attempt: Int
     @Binding var fileID: String?
     @Binding var showRecorder: MediaCommentType?
     @Binding var comment: String
@@ -32,7 +32,7 @@ struct SubmissionCommentList: View {
     @Environment(\.appEnvironment) var env
     @Environment(\.viewController) var controller
 
-    @ObservedObject var attempts: Store<LocalUseCase<Submission>>
+    var attempts: [Submission]
     @ObservedObject var commentLibrary: SubmissionCommentLibraryViewModel
 
     @StateObject private var viewModel: SubmissionCommentListViewModel
@@ -40,18 +40,18 @@ struct SubmissionCommentList: View {
     @State var showMediaOptions = false
     @State var showCommentLibrary = false
 
-    @AccessibilityFocusState private var focusedTab: SubmissionGrader.GraderTab?
+    @AccessibilityFocusState private var focusedTab: SubmissionGraderView.GraderTab?
 
     init(
         assignment: Assignment,
         submission: Submission,
-        attempts: Store<LocalUseCase<Submission>>,
-        attempt: Binding<Int?>,
+        attempts: [Submission],
+        attempt: Binding<Int>,
         fileID: Binding<String?>,
         showRecorder: Binding<MediaCommentType?>,
         enteredComment: Binding<String>,
         commentLibrary: SubmissionCommentLibraryViewModel,
-        focusedTab: AccessibilityFocusState<SubmissionGrader.GraderTab?>
+        focusedTab: AccessibilityFocusState<SubmissionGraderView.GraderTab?>
     ) {
         self.assignment = assignment
         self.submission = submission
@@ -159,7 +159,7 @@ struct SubmissionCommentList: View {
                         .cancel()
                     ])
                 }
-            CommentEditor(
+            CommentEditorView(
                 text: $comment,
                 shouldShowCommentLibrary: commentLibrary.shouldShow,
                 showCommentLibrary: $showCommentLibrary,
diff --git a/Teacher/Teacher/SpeedGrader/Grading/Points/View/SubmissionGrades.swift b/Teacher/Teacher/SpeedGrader/Grading/Points/View/SubmissionGrades.swift
index 7323066fa8..ef3027900f 100644
--- a/Teacher/Teacher/SpeedGrader/Grading/Points/View/SubmissionGrades.swift
+++ b/Teacher/Teacher/SpeedGrader/Grading/Points/View/SubmissionGrades.swift
@@ -126,7 +126,7 @@ struct SubmissionGrades: View {
     }
 
     private func commentEditor() -> some View {
-        CommentEditor(
+        CommentEditorView(
             text: $rubricsViewModel.criterionComment,
             shouldShowCommentLibrary: false,
             showCommentLibrary: .constant(false),
diff --git a/Teacher/Teacher/SpeedGrader/MainLayout/View/Drawer.swift b/Teacher/Teacher/SpeedGrader/MainLayout/View/DrawerContainer.swift
similarity index 98%
rename from Teacher/Teacher/SpeedGrader/MainLayout/View/Drawer.swift
rename to Teacher/Teacher/SpeedGrader/MainLayout/View/DrawerContainer.swift
index 560a841052..138f98b505 100644
--- a/Teacher/Teacher/SpeedGrader/MainLayout/View/Drawer.swift
+++ b/Teacher/Teacher/SpeedGrader/MainLayout/View/DrawerContainer.swift
@@ -25,7 +25,7 @@ enum DrawerState {
 }
 
 // Place after the main content in a ZStack(alignment: .bottom)
-struct Drawer<Content: View>: View {
+struct DrawerContainer<Content: View>: View {
     let content: Content
     let minHeight: CGFloat
     let maxHeight: CGFloat
diff --git a/Teacher/Teacher/SpeedGrader/MainLayout/View/SimilarityScore.swift b/Teacher/Teacher/SpeedGrader/MainLayout/View/SimilarityScoreView.swift
similarity index 98%
rename from Teacher/Teacher/SpeedGrader/MainLayout/View/SimilarityScore.swift
rename to Teacher/Teacher/SpeedGrader/MainLayout/View/SimilarityScoreView.swift
index a143ef8c03..079ba750d7 100644
--- a/Teacher/Teacher/SpeedGrader/MainLayout/View/SimilarityScore.swift
+++ b/Teacher/Teacher/SpeedGrader/MainLayout/View/SimilarityScoreView.swift
@@ -19,7 +19,7 @@
 import SwiftUI
 import Core
 
-struct SimilarityScore: View {
+struct SimilarityScoreView: View {
     let status: String
     let score: Double
     let url: URL?
diff --git a/Teacher/Teacher/SpeedGrader/MainLayout/View/SubmissionGrader.swift b/Teacher/Teacher/SpeedGrader/MainLayout/View/SubmissionGrader.swift
deleted file mode 100644
index 2a861e6cf4..0000000000
--- a/Teacher/Teacher/SpeedGrader/MainLayout/View/SubmissionGrader.swift
+++ /dev/null
@@ -1,411 +0,0 @@
-//
-// This file is part of Canvas.
-// Copyright (C) 2020-present  Instructure, Inc.
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as
-// published by the Free Software Foundation, either version 3 of the
-// License, or (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program.  If not, see <https://www.gnu.org/licenses/>.
-//
-
-import Core
-import SwiftUI
-
-struct SubmissionGrader: View {
-    private enum Layout {
-        case portrait
-        case landscape // only on iPads no matter the iPhone screen size
-    }
-
-    let index: Int
-    private let assignment: Assignment
-    private let submission: Submission
-    private var handleRefresh: (() -> Void)?
-
-    @Environment(\.viewController) var controller
-
-    @ObservedObject var attempts: Store<LocalUseCase<Submission>>
-    @ObservedObject var commentLibrary = SubmissionCommentLibraryViewModel()
-
-    @State var attempt: Int? {
-        willSet {
-            let attemptChanged = (selected.attempt != newValue)
-
-            if attemptChanged {
-                let newAttempt = attempts.first { newValue == $0.attempt } ?? submission
-                studentAnnotationViewModel = StudentAnnotationSubmissionViewerViewModel(submission: newAttempt)
-            }
-        }
-    }
-
-    @State var drawerState: DrawerState = .min
-    @State var fileID: String?
-    @State var showAttempts = false
-    @State var tab: GraderTab = .grades
-    @State var showRecorder: MediaCommentType?
-    /** This is mainly used by `SubmissionCommentList` but since it's re-created on rotation and app backgrounding the entered text is lost. */
-    @State var enteredComment: String = ""
-    /** Used to work around an issue which caused the page to re-load after putting the app into background. See `layoutForWidth()` method for more. */
-    @State private var lastPresentedLayout: Layout = .portrait
-    @State private var studentAnnotationViewModel: StudentAnnotationSubmissionViewerViewModel
-    @State private var selectedIndex = 0
-    @StateObject private var rubricsViewModel: RubricsViewModel
-
-    @AccessibilityFocusState private var focusedTab: GraderTab?
-
-    private var selected: Submission { attempts.first { attempt == $0.attempt } ?? submission }
-    private var file: File? {
-        selected.attachments?.first { fileID == $0.id } ??
-            selected.attachments?.sorted(by: File.idCompare).first
-    }
-
-    init(
-        env: AppEnvironment,
-        index: Int,
-        assignment: Assignment,
-        submission: Submission,
-        handleRefresh: (() -> Void)?
-    ) {
-        self.index = index
-        self.assignment = assignment
-        self.submission = submission
-        attempts = env.subscribe(scope: Scope(
-            predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
-                NSPredicate(key: #keyPath(Submission.assignmentID), equals: assignment.id),
-                NSPredicate(key: #keyPath(Submission.userID), equals: submission.userID),
-                NSPredicate(format: "%K != nil", #keyPath(Submission.submittedAt))
-            ]),
-            orderBy: #keyPath(Submission.attempt)
-        ))
-        attempt = submission.attempt
-        self.handleRefresh = handleRefresh
-        studentAnnotationViewModel = StudentAnnotationSubmissionViewerViewModel(submission: submission)
-        _rubricsViewModel = StateObject(wrappedValue:
-            RubricsViewModel(
-                assignment: assignment,
-                submission: submission,
-                interactor: RubricGradingInteractorLive(assignment: assignment, submission: submission)
-            )
-        )
-    }
-
-    var body: some View {
-        GeometryReader { geometry in
-            let bottomInset = geometry.safeAreaInsets.bottom
-            let minHeight = bottomInset + 58
-            let maxHeight = bottomInset + geometry.size.height - 64
-            // At 1/4 of a screen offset, scale to 90% and round corners to 20
-            let delta = abs(geometry.frame(in: .global).minX / max(1, geometry.size.width))
-            let scale = interpolate(value: delta, fromMin: 0, fromMax: 0.25, toMin: 1, toMax: 0.9)
-            let cornerRadius = interpolate(value: delta, fromMin: 0, fromMax: 0.25, toMin: 0, toMax: 20)
-
-            switch layoutForWidth(geometry.size.width) {
-            case .landscape:
-                VStack(spacing: 0) {
-                    SubmissionHeader(assignment: assignment, submission: submission)
-                        .accessibility(sortPriority: 2)
-                    Divider()
-                    HStack(spacing: 0) {
-                        VStack(alignment: .leading, spacing: 0) {
-                            attemptToggle
-                            Divider()
-                            ZStack(alignment: .top) {
-                                VStack(spacing: 0) {
-                                    SimilarityScore(selected, file: file)
-                                    SubmissionViewer(
-                                        assignment: assignment,
-                                        submission: selected,
-                                        fileID: fileID,
-                                        studentAnnotationViewModel: studentAnnotationViewModel,
-                                        handleRefresh: handleRefresh
-                                    )
-                                }
-                                // Disable submission content interaction in case attempt picker is above it
-                                .accessibilityElement(children: showAttempts ? .ignore : .contain)
-                                .accessibility(hidden: showAttempts)
-                                attemptPicker
-                            }
-                            Spacer().frame(height: bottomInset)
-                        }
-                        .zIndex(1)
-                        .accessibility(sortPriority: 1)
-                        Divider()
-                        VStack(spacing: 0) {
-                            tools(bottomInset: bottomInset, isDrawer: false)
-                        }
-                        .padding(.top, 16)
-                        .frame(width: 375)
-                    }
-                }
-                .background(Color.backgroundLightest)
-                .cornerRadius(cornerRadius)
-                .scaleEffect(scale)
-                .edgesIgnoringSafeArea(.bottom)
-                .onAppear { didChangeLayout(to: .landscape) }
-            case .portrait:
-                ZStack(alignment: .bottom) {
-                    VStack(alignment: .leading, spacing: 0) {
-                        SubmissionHeader(assignment: assignment, submission: submission)
-                        attemptToggle
-                            .accessibility(hidden: drawerState == .max)
-                        Divider()
-                        let isSubmissionContentHiddenFromA11y = (drawerState != .min || showAttempts)
-                        ZStack(alignment: .top) {
-                            VStack(spacing: 0) {
-                                SimilarityScore(selected, file: file)
-                                SubmissionViewer(
-                                    assignment: assignment,
-                                    submission: selected,
-                                    fileID: fileID,
-                                    studentAnnotationViewModel: studentAnnotationViewModel,
-                                    handleRefresh: handleRefresh
-                                )
-                            }
-                            .accessibilityElement(children: isSubmissionContentHiddenFromA11y ? .ignore : .contain)
-                            .accessibility(hidden: isSubmissionContentHiddenFromA11y)
-                            attemptPicker
-                        }
-                        Spacer().frame(height: drawerState == .min ? minHeight : (minHeight + maxHeight) / 2)
-                    }
-                    Drawer(state: $drawerState, minHeight: minHeight, maxHeight: maxHeight) {
-                        tools(bottomInset: bottomInset, isDrawer: true)
-                    }
-                }
-                .background(Color.backgroundLightest)
-                .cornerRadius(cornerRadius)
-                .scaleEffect(scale)
-                .edgesIgnoringSafeArea(.bottom)
-                .onAppear { didChangeLayout(to: .portrait) }
-            }
-        }
-        .avoidKeyboardArea()
-    }
-
-    @ViewBuilder
-    var attemptToggle: some View {
-        if let first = attempts.first, attempts.count == 1 {
-            HStack {
-                Text("Attempt \(attempt ?? 1)", bundle: .teacher).font(.regular14)
-                Spacer()
-                Text(first.submittedAt?.dateTimeString ?? "")
-                    .font(.regular14)
-                    .frame(minHeight: 24)
-            }
-            .foregroundColor(.textDark)
-            .padding(EdgeInsets(top: 8, leading: 16, bottom: 4, trailing: 16))
-
-        } else if let selected = attempts.first(where: { attempt == $0.attempt }) ?? attempts.last {
-            Button(action: {
-                showAttempts.toggle()
-            }, label: {
-                HStack {
-                    Text("Attempt \(attempt ?? 1)", bundle: .teacher).font(.regular14)
-                    Spacer()
-                    Text(selected.submittedAt?.dateTimeString ?? "")
-                        .font(.regular14)
-                        .frame(minHeight: 24)
-                    Image.arrowOpenDownLine
-                        .resizable()
-                        .frame(width: 14, height: 14)
-                        .rotationEffect(.degrees(showAttempts ? 180 : 0))
-                }
-                .foregroundColor(.textDark)
-                .padding(EdgeInsets(top: 8, leading: 16, bottom: 4, trailing: 16))
-            })
-        }
-    }
-
-    @ViewBuilder
-    var attemptPicker: some View {
-        if showAttempts {
-            VStack(spacing: 0) {
-                Picker(selection: Binding(get: { selected.attempt }, set: { newValue in
-                    withTransaction(.exclusive()) {
-                        NotificationCenter.default.post(name: .SpeedGraderAttemptPickerChanged, object: newValue)
-                        attempt = newValue
-                        fileID = nil
-                    }
-                    showAttempts = false
-                }), label: Text(verbatim: "")) {
-                    ForEach(attempts.all, id: \.attempt) { attempt in
-                        Text(attempt.submittedAt?.dateTimeString ?? "")
-                            .tag(Optional(attempt.attempt))
-                    }
-                }
-                .labelsHidden()
-                .pickerStyle(WheelPickerStyle())
-                Divider()
-            }
-            .background(Color.backgroundLightest)
-        }
-    }
-
-    enum GraderTab: Int, CaseIterable { case grades, comments, files }
-
-    private func segmentedTitles() -> [String] {
-        let filesString: String!
-        if selected.type == .online_upload, let count = selected.attachments?.count, count > 0 {
-            filesString = String(localized: "Files (\(count))", bundle: .teacher)
-        } else {
-            filesString = String(localized: "Files", bundle: .teacher)
-        }
-
-        return [
-            String(localized: "Grades", bundle: .teacher),
-            String(localized: "Comments", bundle: .teacher),
-            filesString
-        ]
-    }
-
-    @ViewBuilder
-    func tools(bottomInset: CGFloat, isDrawer: Bool) -> some View {
-        SegmentedPicker(
-            segmentedTitles(),
-            selectedIndex: Binding(
-                get: { selectedIndex },
-                set: { newValue in
-                    selectedIndex = newValue ?? 0
-                    if drawerState == .min {
-                        snapDrawerTo(.mid)
-                    }
-                    let newTab = SubmissionGrader.GraderTab(rawValue: newValue ?? 0)!
-                    withAnimation(.default) {
-                        tab = newTab
-                    }
-                    controller.view.endEditing(true)
-                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
-                        focusedTab = tab
-                    }
-                }
-            ),
-            selectionAlignment: .bottom,
-            content: { item, _ in
-                Text(item)
-                    .font(.regular14)
-                    .foregroundColor(.textDark)
-                    .padding(.horizontal, 16)
-                    .padding(.vertical, 8)
-                    .frame(maxWidth: .infinity)
-                    .contentShape(Rectangle())
-            }
-        )
-        .onAppear {
-            selectedIndex = 0
-        }
-        .identifier("SpeedGrader.toolPicker")
-        Divider()
-        GeometryReader { geometry in
-            HStack(spacing: 0) {
-                let drawerAttempt = Binding(get: { attempt }, set: {
-                    attempt = $0
-                    fileID = nil
-                    snapDrawerTo(.min)
-                })
-                let drawerFileID = Binding(get: { fileID }, set: {
-                    fileID = $0
-                    snapDrawerTo(.min)
-                })
-
-                let isGradesOnScreen = isGraderTabOnScreen(.grades, isDrawer: isDrawer)
-                VStack(spacing: 0) {
-                    SubmissionGrades(assignment: assignment, containerHeight: geometry.size.height, submission: submission, rubricsViewModel: rubricsViewModel)
-                        .clipped()
-                    Spacer().frame(height: bottomInset)
-                }
-                .frame(width: geometry.size.width, height: geometry.size.height)
-                .accessibilityElement(children: isGradesOnScreen ? .contain : .ignore)
-                .accessibility(hidden: !isGradesOnScreen)
-                .accessibilityFocused($focusedTab, equals: .grades)
-
-                let isCommentsOnScreen = isGraderTabOnScreen(.comments, isDrawer: isDrawer)
-                VStack(spacing: 0) {
-                    SubmissionCommentList(
-                        assignment: assignment,
-                        submission: submission,
-                        attempts: attempts,
-                        attempt: drawerAttempt,
-                        fileID: drawerFileID,
-                        showRecorder: $showRecorder,
-                        enteredComment: $enteredComment,
-                        commentLibrary: commentLibrary,
-                        focusedTab: _focusedTab
-                    )
-                    .clipped()
-                    if showRecorder != .video || drawerState == .min {
-                        Spacer().frame(height: bottomInset)
-                    }
-                }
-                .frame(width: geometry.size.width, height: geometry.size.height)
-                .background(Color.backgroundLight)
-                .accessibilityElement(children: isCommentsOnScreen ? .contain : .ignore)
-                .accessibility(hidden: !isCommentsOnScreen)
-
-                let isFilesOnScreen = isGraderTabOnScreen(.files, isDrawer: isDrawer)
-                VStack(spacing: 0) {
-                    SubmissionFileList(submission: selected, fileID: drawerFileID)
-                        .clipped()
-                    Spacer().frame(height: bottomInset)
-                }
-                .frame(width: geometry.size.width, height: geometry.size.height)
-                .accessibilityElement(children: isFilesOnScreen ? .contain : .ignore)
-                .accessibility(hidden: !isFilesOnScreen)
-                .accessibilityFocused($focusedTab, equals: .files)
-            }
-            .frame(width: geometry.size.width, alignment: .leading)
-            .background(Color.backgroundLightest)
-            .offset(x: -CGFloat(tab.rawValue) * geometry.size.width)
-        }
-        .clipped()
-    }
-
-    private func snapDrawerTo(_ state: DrawerState) {
-        withTransaction(DrawerState.transaction) {
-            drawerState = state
-        }
-    }
-
-    private func isGraderTabOnScreen(_ tab: GraderTab, isDrawer: Bool) -> Bool {
-        let isTabSelected = (self.tab == tab)
-
-        if isDrawer {
-            return (drawerState != .min && isTabSelected)
-        } else {
-            return isTabSelected
-        }
-    }
-
-    private func layoutForWidth(_ width: CGFloat) -> Layout {
-        // On iPads if the app is backgrounded then it changes the device orientation back and forth causing the UI to re-render and the submission to re-load.
-        // To overcome this we force the last presented layout in case the app is in the background.
-        guard UIApplication.shared.applicationState != .background else {
-            return lastPresentedLayout
-        }
-        return width > 834 ? .landscape : .portrait
-    }
-
-    private func didChangeLayout(to layout: Layout) {
-        if lastPresentedLayout != layout {
-            // When the layout changes the keyboard disappears without any system notifications
-            // on iPads so we simulate one to allow .avoidKeyboardArea() to work correctly.
-            NotificationCenter.default.post(name: UIApplication.keyboardWillHideNotification, object: nil, userInfo: [:])
-        }
-        lastPresentedLayout = layout
-    }
-}
-
-private func interpolate(value: CGFloat, fromMin: CGFloat, fromMax: CGFloat, toMin: CGFloat, toMax: CGFloat) -> CGFloat {
-    let bounded = max(fromMin, min(value, fromMax))
-    return (((toMax - toMin) / (fromMax - fromMin)) * (bounded - fromMin)) + toMin
-}
-
-extension NSNotification.Name {
-    public static var SpeedGraderAttemptPickerChanged = NSNotification.Name("com.instructure.core.speedgrader-attempt-changed")
-}
diff --git a/Teacher/Teacher/SpeedGrader/MainLayout/View/SubmissionGraderView.swift b/Teacher/Teacher/SpeedGrader/MainLayout/View/SubmissionGraderView.swift
new file mode 100644
index 0000000000..b906d98e7b
--- /dev/null
+++ b/Teacher/Teacher/SpeedGrader/MainLayout/View/SubmissionGraderView.swift
@@ -0,0 +1,431 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2020-present  Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+//
+
+import Core
+import SwiftUI
+import Combine
+
+struct SubmissionGraderView: View {
+    private enum Layout {
+        case portrait
+        case landscape // only on iPads no matter the iPhone screen size
+    }
+
+    let userIndexInSubmissionList: Int
+
+    @Environment(\.viewController) private var controller
+
+    @State private var selectedDrawerTabIndex = 0
+    @State private var drawerState: DrawerState = .min
+    @State private var showAttempts = false
+    @State private var tab: GraderTab = .grades
+    @State private var showRecorder: MediaCommentType?
+    /** Used to work around an issue which caused the page to re-load after putting the app into background. See `layoutForWidth()` method for more. */
+    @State private var lastPresentedLayout: Layout = .portrait
+    @AccessibilityFocusState private var focusedTab: GraderTab?
+
+    @StateObject private var commentLibrary = SubmissionCommentLibraryViewModel()
+    @StateObject private var rubricsViewModel: RubricsViewModel
+    @StateObject private var viewModel: SubmissionGraderViewModel
+
+    private var handleRefresh: (() -> Void)?
+
+    init(
+        env: AppEnvironment,
+        userIndexInSubmissionList: Int,
+        viewModel: SubmissionGraderViewModel,
+        handleRefresh: (() -> Void)?
+    ) {
+        self.userIndexInSubmissionList = userIndexInSubmissionList
+        self._viewModel = StateObject(wrappedValue: viewModel)
+        self.handleRefresh = handleRefresh
+        _rubricsViewModel = StateObject(wrappedValue:
+            RubricsViewModel(
+                assignment: viewModel.assignment,
+                submission: viewModel.submission,
+                interactor: RubricGradingInteractorLive(assignment: viewModel.assignment, submission: viewModel.submission)
+            )
+        )
+    }
+
+    var body: some View {
+        GeometryReader { geometry in
+            let bottomInset = geometry.safeAreaInsets.bottom
+            let minHeight = bottomInset + 58
+            let maxHeight = bottomInset + geometry.size.height - 64
+            // At 1/4 of a screen offset, scale to 90% and round corners to 20
+            let delta = abs(geometry.frame(in: .global).minX / max(1, geometry.size.width))
+            let scale = interpolate(value: delta, fromMin: 0, fromMax: 0.25, toMin: 1, toMax: 0.9)
+            let cornerRadius = interpolate(value: delta, fromMin: 0, fromMax: 0.25, toMin: 0, toMax: 20)
+
+            mainLayout(
+                geometry: geometry,
+                bottomInset: bottomInset,
+                minHeight: minHeight,
+                maxHeight: maxHeight
+            )
+            .background(Color.backgroundLightest)
+            .cornerRadius(cornerRadius)
+            .scaleEffect(scale)
+            .edgesIgnoringSafeArea(.bottom)
+        }
+        .avoidKeyboardArea()
+    }
+
+    @ViewBuilder
+    private func mainLayout(
+        geometry: GeometryProxy,
+        bottomInset: CGFloat,
+        minHeight: CGFloat,
+        maxHeight: CGFloat
+    ) -> some View {
+        switch layoutForWidth(geometry.size.width) {
+        case .landscape:
+            landscapeLayout(bottomInset: bottomInset)
+        case .portrait:
+            portraitLayout(minHeight: minHeight, maxHeight: maxHeight, bottomInset: bottomInset)
+        }
+    }
+
+    private func landscapeLayout(
+        bottomInset: CGFloat
+    ) -> some View {
+        VStack(spacing: 0) {
+            SubmissionHeaderView(assignment: viewModel.assignment, submission: viewModel.submission)
+                .accessibility(sortPriority: 2)
+            Divider()
+            HStack(spacing: 0) {
+                VStack(alignment: .leading, spacing: 0) {
+                    attemptToggle
+                    Divider()
+                    ZStack(alignment: .top) {
+                        VStack(spacing: 0) {
+                            SimilarityScoreView(viewModel.selectedAttempt, file: viewModel.file)
+                            SubmissionViewer(
+                                assignment: viewModel.assignment,
+                                submission: viewModel.selectedAttempt,
+                                fileID: viewModel.fileID,
+                                studentAnnotationViewModel: viewModel.studentAnnotationViewModel,
+                                handleRefresh: handleRefresh
+                            )
+                        }
+                        // Disable submission content interaction in case attempt picker is above it
+                        .accessibilityElement(children: showAttempts ? .ignore : .contain)
+                        .accessibility(hidden: showAttempts)
+                        attemptPicker
+                    }
+                    Spacer().frame(height: bottomInset)
+                }
+                .zIndex(1)
+                .accessibility(sortPriority: 1)
+                Divider()
+                VStack(spacing: 0) {
+                    tools(bottomInset: bottomInset, isDrawer: false)
+                }
+                .padding(.top, 16)
+                .frame(width: 375)
+            }
+        }
+        .onAppear { didChangeLayout(to: .landscape) }
+    }
+
+    private func portraitLayout(
+        minHeight: CGFloat,
+        maxHeight: CGFloat,
+        bottomInset: CGFloat
+    ) -> some View {
+        ZStack(alignment: .bottom) {
+            VStack(alignment: .leading, spacing: 0) {
+                SubmissionHeaderView(assignment: viewModel.assignment, submission: viewModel.submission)
+                attemptToggle
+                    .accessibility(hidden: drawerState == .max)
+                Divider()
+                let isSubmissionContentHiddenFromA11y = (drawerState != .min || showAttempts)
+                ZStack(alignment: .top) {
+                    VStack(spacing: 0) {
+                        SimilarityScoreView(viewModel.selectedAttempt, file: viewModel.file)
+                        SubmissionViewer(
+                            assignment: viewModel.assignment,
+                            submission: viewModel.selectedAttempt,
+                            fileID: viewModel.fileID,
+                            studentAnnotationViewModel: viewModel.studentAnnotationViewModel,
+                            handleRefresh: handleRefresh
+                        )
+                    }
+                    .accessibilityElement(children: isSubmissionContentHiddenFromA11y ? .ignore : .contain)
+                    .accessibility(hidden: isSubmissionContentHiddenFromA11y)
+                    attemptPicker
+                }
+                Spacer().frame(height: drawerState == .min ? minHeight : (minHeight + maxHeight) / 2)
+            }
+            DrawerContainer(state: $drawerState, minHeight: minHeight, maxHeight: maxHeight) {
+                tools(bottomInset: bottomInset, isDrawer: true)
+            }
+        }
+        .onAppear { didChangeLayout(to: .portrait) }
+    }
+
+    @ViewBuilder
+    private var attemptToggle: some View {
+        if viewModel.hasSubmissions {
+            Button {
+                showAttempts.toggle()
+            } label: {
+                HStack {
+                    Text("Attempt \(viewModel.selectedAttemptIndex)", bundle: .teacher)
+                    Spacer()
+                    Text(viewModel.selectedAttempt.submittedAt?.dateTimeString ?? "")
+                        .frame(minHeight: 24)
+
+                    if !viewModel.isSingleSubmission {
+                        Image.arrowOpenDownLine
+                            .resizable()
+                            .frame(width: 14, height: 14)
+                            .rotationEffect(.degrees(showAttempts ? 180 : 0))
+                    }
+                }
+                .font(.regular14)
+                .foregroundColor(.textDark)
+                .padding(EdgeInsets(top: 8, leading: 16, bottom: 4, trailing: 16))
+            }
+            .disabled(viewModel.isSingleSubmission)
+        }
+    }
+
+    @ViewBuilder
+    private var attemptPicker: some View {
+        if showAttempts {
+            VStack(spacing: 0) {
+                let binding = Binding(
+                    get: {
+                        viewModel.selectedAttemptIndex
+                    },
+                    set: { newAttemptIndex in
+                        withTransaction(.exclusive()) {
+                            viewModel.didSelectNewAttempt(attemptIndex: newAttemptIndex)
+                        }
+                        showAttempts = false
+                    }
+                )
+                Picker(selection: binding, label: Text(verbatim: "")) {
+                    ForEach(viewModel.attempts, id: \.attempt) { attempt in
+                        Text(attempt.submittedAt?.dateTimeString ?? "")
+                            .tag(Optional(attempt.attempt))
+                    }
+                }
+                .labelsHidden()
+                .pickerStyle(WheelPickerStyle())
+                Divider()
+            }
+            .background(Color.backgroundLightest)
+        }
+    }
+
+    // MARK: - Drawer
+
+    enum GraderTab: Int, CaseIterable { case grades, comments, files }
+
+    private var segmentedTitles: [String] {
+        [
+            String(localized: "Grades", bundle: .teacher),
+            String(localized: "Comments", bundle: .teacher),
+            viewModel.fileTabTitle
+        ]
+    }
+
+    @ViewBuilder
+    private func tools(bottomInset: CGFloat, isDrawer: Bool) -> some View {
+        SegmentedPicker(
+            segmentedTitles,
+            selectedIndex: Binding(
+                get: { selectedDrawerTabIndex },
+                set: { newValue in
+                    selectedDrawerTabIndex = newValue ?? 0
+                    if drawerState == .min {
+                        snapDrawerTo(.mid)
+                    }
+                    let newTab = SubmissionGraderView.GraderTab(rawValue: newValue ?? 0)!
+                    withAnimation(.default) {
+                        tab = newTab
+                    }
+                    controller.view.endEditing(true)
+                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+                        focusedTab = tab
+                    }
+                }
+            ),
+            selectionAlignment: .bottom,
+            content: { item, _ in
+                Text(item)
+                    .font(.regular14)
+                    .foregroundColor(.textDark)
+                    .padding(.horizontal, 16)
+                    .padding(.vertical, 8)
+                    .frame(maxWidth: .infinity)
+                    .contentShape(Rectangle())
+            }
+        )
+        .identifier("SpeedGrader.toolPicker")
+        Divider()
+        GeometryReader { geometry in
+            HStack(spacing: 0) {
+                let drawerFileID = Binding<String?>(
+                    get: {
+                        viewModel.fileID
+                    },
+                    set: {
+                        viewModel.didSelectFile(fileID: $0)
+                        snapDrawerTo(.min)
+                    }
+                )
+
+                gradesTab(bottomInset: bottomInset, isDrawer: isDrawer, geometry: geometry)
+                commentsTab(bottomInset: bottomInset, isDrawer: isDrawer, fileID: drawerFileID, geometry: geometry)
+                filesTab(bottomInset: bottomInset, isDrawer: isDrawer, fileID: drawerFileID, geometry: geometry)
+            }
+            .frame(width: geometry.size.width, alignment: .leading)
+            .background(Color.backgroundLightest)
+            .offset(x: -CGFloat(tab.rawValue) * geometry.size.width)
+        }
+        .clipped()
+    }
+
+    private func snapDrawerTo(_ state: DrawerState) {
+        withTransaction(DrawerState.transaction) {
+            drawerState = state
+        }
+    }
+
+    private func isGraderTabOnScreen(_ tab: GraderTab, isDrawer: Bool) -> Bool {
+        let isTabSelected = (self.tab == tab)
+
+        if isDrawer {
+            return (drawerState != .min && isTabSelected)
+        } else {
+            return isTabSelected
+        }
+    }
+
+    // MARK: - Tab Contents
+
+    @ViewBuilder
+    private func gradesTab(
+        bottomInset: CGFloat,
+        isDrawer: Bool,
+        geometry: GeometryProxy
+    ) -> some View {
+        let isGradesOnScreen = isGraderTabOnScreen(.grades, isDrawer: isDrawer)
+        VStack(spacing: 0) {
+            SubmissionGrades(
+                assignment: viewModel.assignment,
+                containerHeight: geometry.size.height,
+                submission: viewModel.submission,
+                rubricsViewModel: rubricsViewModel
+            )
+            .clipped()
+            Spacer().frame(height: bottomInset)
+        }
+        .frame(width: geometry.size.width, height: geometry.size.height)
+        .accessibilityElement(children: isGradesOnScreen ? .contain : .ignore)
+        .accessibility(hidden: !isGradesOnScreen)
+        .accessibilityFocused($focusedTab, equals: .grades)
+    }
+
+    @ViewBuilder
+    private func commentsTab(
+        bottomInset: CGFloat,
+        isDrawer: Bool,
+        fileID: Binding<String?>,
+        geometry: GeometryProxy
+    ) -> some View {
+        let drawerAttempt = Binding(
+            get: {
+                viewModel.selectedAttemptIndex
+            }, set: {
+                viewModel.didSelectNewAttempt(attemptIndex: $0)
+                snapDrawerTo(.min)
+            }
+        )
+        let isCommentsOnScreen = isGraderTabOnScreen(.comments, isDrawer: isDrawer)
+        VStack(spacing: 0) {
+            SubmissionCommentListView(
+                assignment: viewModel.assignment,
+                submission: viewModel.submission,
+                attempts: viewModel.attempts,
+                attempt: drawerAttempt,
+                fileID: fileID,
+                showRecorder: $showRecorder,
+                enteredComment: $viewModel.enteredComment,
+                commentLibrary: commentLibrary,
+                focusedTab: _focusedTab
+            )
+            .clipped()
+            if showRecorder != .video || drawerState == .min {
+                Spacer().frame(height: bottomInset)
+            }
+        }
+        .frame(width: geometry.size.width, height: geometry.size.height)
+        .background(Color.backgroundLight)
+        .accessibilityElement(children: isCommentsOnScreen ? .contain : .ignore)
+        .accessibility(hidden: !isCommentsOnScreen)
+    }
+
+    @ViewBuilder
+    private func filesTab(
+        bottomInset: CGFloat,
+        isDrawer: Bool,
+        fileID: Binding<String?>,
+        geometry: GeometryProxy
+    ) -> some View {
+        let isFilesOnScreen = isGraderTabOnScreen(.files, isDrawer: isDrawer)
+        VStack(spacing: 0) {
+            SubmissionFileList(submission: viewModel.selectedAttempt, fileID: fileID)
+                .clipped()
+            Spacer().frame(height: bottomInset)
+        }
+        .frame(width: geometry.size.width, height: geometry.size.height)
+        .accessibilityElement(children: isFilesOnScreen ? .contain : .ignore)
+        .accessibility(hidden: !isFilesOnScreen)
+        .accessibilityFocused($focusedTab, equals: .files)
+    }
+
+    // MARK: - Rotation
+
+    private func layoutForWidth(_ width: CGFloat) -> Layout {
+        // On iPads if the app is backgrounded then it changes the device orientation back and forth causing the UI to re-render and the submission to re-load.
+        // To overcome this we force the last presented layout in case the app is in the background.
+        guard UIApplication.shared.applicationState != .background else {
+            return lastPresentedLayout
+        }
+        return width > 834 ? .landscape : .portrait
+    }
+
+    private func didChangeLayout(to layout: Layout) {
+        if lastPresentedLayout != layout {
+            // When the layout changes the keyboard disappears without any system notifications
+            // on iPads so we simulate one to allow .avoidKeyboardArea() to work correctly.
+            NotificationCenter.default.post(name: UIApplication.keyboardWillHideNotification, object: nil, userInfo: [:])
+        }
+        lastPresentedLayout = layout
+    }
+}
+
+private func interpolate(value: CGFloat, fromMin: CGFloat, fromMax: CGFloat, toMin: CGFloat, toMax: CGFloat) -> CGFloat {
+    let bounded = max(fromMin, min(value, fromMax))
+    return (((toMax - toMin) / (fromMax - fromMin)) * (bounded - fromMin)) + toMin
+}
diff --git a/Teacher/Teacher/SpeedGrader/MainLayout/View/SubmissionHeader.swift b/Teacher/Teacher/SpeedGrader/MainLayout/View/SubmissionHeaderView.swift
similarity index 99%
rename from Teacher/Teacher/SpeedGrader/MainLayout/View/SubmissionHeader.swift
rename to Teacher/Teacher/SpeedGrader/MainLayout/View/SubmissionHeaderView.swift
index e98a6a1851..5c7e662f37 100644
--- a/Teacher/Teacher/SpeedGrader/MainLayout/View/SubmissionHeader.swift
+++ b/Teacher/Teacher/SpeedGrader/MainLayout/View/SubmissionHeaderView.swift
@@ -19,7 +19,7 @@
 import SwiftUI
 import Core
 
-struct SubmissionHeader: View {
+struct SubmissionHeaderView: View {
     let assignment: Assignment
     let submission: Submission
 
diff --git a/Teacher/Teacher/SpeedGrader/MainLayout/ViewModel/SubmissionGraderViewModel.swift b/Teacher/Teacher/SpeedGrader/MainLayout/ViewModel/SubmissionGraderViewModel.swift
new file mode 100644
index 0000000000..8b842e9652
--- /dev/null
+++ b/Teacher/Teacher/SpeedGrader/MainLayout/ViewModel/SubmissionGraderViewModel.swift
@@ -0,0 +1,114 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2025-present  Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+//
+
+import Combine
+import Core
+import CoreData
+
+class SubmissionGraderViewModel: ObservableObject {
+
+    // MARK: - Outputs
+
+    @Published private(set) var selectedAttemptIndex: Int
+    @Published private(set) var selectedAttempt: Submission
+    @Published private(set) var studentAnnotationViewModel: StudentAnnotationSubmissionViewerViewModel
+    @Published private(set) var attempts: [Submission] = []
+    @Published private(set) var hasSubmissions = false
+    @Published private(set) var isSingleSubmission = false
+    @Published private(set) var file: File?
+    @Published private(set) var fileID: String?
+    @Published private(set) var fileTabTitle: String = ""
+    let assignment: Assignment
+    let submission: Submission
+
+    // MARK: - Inputs
+
+    /** This is mainly used by `SubmissionCommentList` but since it's re-created on rotation and app backgrounding the entered text is lost. */
+    @Published var enteredComment: String = ""
+
+    private var subscriptions = Set<AnyCancellable>()
+
+    init(assignment: Assignment, submission: Submission) {
+        self.assignment = assignment
+        self.submission = submission
+        selectedAttempt = submission
+        selectedAttemptIndex = submission.attempt
+        studentAnnotationViewModel = StudentAnnotationSubmissionViewerViewModel(submission: submission)
+        observeAttemptChangesInDatabase()
+        didSelectNewAttempt(attemptIndex: submission.attempt)
+    }
+
+    func didSelectNewAttempt(attemptIndex: Int) {
+        NotificationCenter.default.post(name: .SpeedGraderAttemptPickerChanged, object: attemptIndex)
+        selectedAttemptIndex = attemptIndex
+        selectedAttempt = attempts.first { selectedAttemptIndex == $0.attempt } ?? submission
+        fileTabTitle = {
+            if selectedAttempt.type == .online_upload, let count = selectedAttempt.attachments?.count, count > 0 {
+                return String(localized: "Files (\(count))", bundle: .teacher)
+            } else {
+                return String(localized: "Files", bundle: .teacher)
+            }
+        }()
+        studentAnnotationViewModel = StudentAnnotationSubmissionViewerViewModel(submission: selectedAttempt)
+        didSelectFile(fileID: nil)
+    }
+
+    func didSelectFile(fileID: String?) {
+        self.fileID = fileID
+        updateSelectedFile()
+    }
+
+    private func updateSelectedFile() {
+        file = selectedAttempt.attachments?.first { fileID == $0.id } ??
+                selectedAttempt.attachments?.sorted(by: File.idCompare).first
+    }
+
+    private func observeAttemptChangesInDatabase() {
+        let scope = Scope(
+            predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
+                NSPredicate(key: #keyPath(Submission.assignmentID), equals: assignment.id),
+                NSPredicate(key: #keyPath(Submission.userID), equals: submission.userID),
+                NSPredicate(format: "%K != nil", #keyPath(Submission.submittedAt))
+            ]),
+            orderBy: #keyPath(Submission.attempt)
+        )
+        let useCase = LocalUseCase<Submission>(scope: scope)
+        ReactiveStore(useCase: useCase)
+            .getEntities(keepObservingDatabaseChanges: true)
+            .replaceError(with: [])
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] attempts in
+                guard let self else { return }
+                self.attempts = attempts
+                hasSubmissions = attempts.lastAttemptIndex > 0
+                isSingleSubmission = attempts.lastAttemptIndex == 1
+            }
+            .store(in: &subscriptions)
+    }
+}
+
+extension [Submission] {
+
+    fileprivate var lastAttemptIndex: Int {
+        last?.attempt ?? 0
+    }
+}
+
+extension NSNotification.Name {
+    public static var SpeedGraderAttemptPickerChanged = NSNotification.Name("com.instructure.core.speedgrader-attempt-changed")
+}
diff --git a/Teacher/Teacher/SpeedGrader/StudentPager/View/SpeedGraderViewController.swift b/Teacher/Teacher/SpeedGrader/StudentPager/View/SpeedGraderViewController.swift
index d62fe0d2bf..7aa160aa42 100644
--- a/Teacher/Teacher/SpeedGrader/StudentPager/View/SpeedGraderViewController.swift
+++ b/Teacher/Teacher/SpeedGrader/StudentPager/View/SpeedGraderViewController.swift
@@ -22,7 +22,7 @@ import UIKit
 import Core
 
 class SpeedGraderViewController: ScreenViewTrackableViewController, PagesViewControllerDataSource {
-    typealias Page = CoreHostingController<SubmissionGrader>
+    typealias Page = CoreHostingController<SubmissionGraderView>
 
     var env: AppEnvironment = .defaultValue
 
@@ -138,7 +138,7 @@ class SpeedGraderViewController: ScreenViewTrackableViewController, PagesViewCon
 
     private func updatePages() {
         for page in pages.children.compactMap({ $0 as? Page }) {
-            if let grader = grader(for: page.rootView.content.index) {
+            if let grader = grader(for: page.rootView.content.userIndexInSubmissionList) {
                 page.rootView.content = grader
             }
         }
@@ -147,11 +147,11 @@ class SpeedGraderViewController: ScreenViewTrackableViewController, PagesViewCon
     // MARK: - PagesViewControllerDataSource
 
     func pagesViewController(_ pages: PagesViewController, pageBefore page: UIViewController) -> UIViewController? {
-        (page as? Page).flatMap { controller(for: $0.rootView.content.index - 1) }
+        (page as? Page).flatMap { controller(for: $0.rootView.content.userIndexInSubmissionList - 1) }
     }
 
     func pagesViewController(_ pages: PagesViewController, pageAfter page: UIViewController) -> UIViewController? {
-        (page as? Page).flatMap { controller(for: $0.rootView.content.index + 1) }
+        (page as? Page).flatMap { controller(for: $0.rootView.content.userIndexInSubmissionList + 1) }
     }
 
     func controller(for index: Int) -> UIViewController? {
@@ -160,18 +160,20 @@ class SpeedGraderViewController: ScreenViewTrackableViewController, PagesViewCon
         return controller
     }
 
-    func grader(for index: Int) -> SubmissionGrader? {
+    func grader(for index: Int) -> SubmissionGraderView? {
         guard
             let data = interactor.data,
             index >= 0,
             index < data.submissions.count
         else { return nil }
 
-        return SubmissionGrader(
+        return SubmissionGraderView(
             env: env,
-            index: index,
-            assignment: data.assignment,
-            submission: data.submissions[index],
+            userIndexInSubmissionList: index,
+            viewModel: SubmissionGraderViewModel(
+                assignment: data.assignment,
+                submission: data.submissions[index]
+            ),
             handleRefresh: { [weak self] in
                 self?.interactor.refreshSubmission(forUserId: data.submissions[index].userID)
             }
diff --git a/Teacher/Teacher/SpeedGrader/SubmissionRenderer/ViewModel/StudentAnnotationSubmissionViewerViewModel.swift b/Teacher/Teacher/SpeedGrader/SubmissionRenderer/ViewModel/StudentAnnotationSubmissionViewerViewModel.swift
index 7dca3024d7..f34d74c946 100644
--- a/Teacher/Teacher/SpeedGrader/SubmissionRenderer/ViewModel/StudentAnnotationSubmissionViewerViewModel.swift
+++ b/Teacher/Teacher/SpeedGrader/SubmissionRenderer/ViewModel/StudentAnnotationSubmissionViewerViewModel.swift
@@ -21,12 +21,14 @@ import SwiftUI
 
 class StudentAnnotationSubmissionViewerViewModel: ObservableObject {
     @Published public private(set) var session: Result<URL, Error>?
+    public let submissionId: String
 
     private var isInitialLoadStarted = false
     private let request: CanvaDocsSessionRequest
 
     public init(submission: Submission) {
-        self.request = CanvaDocsSessionRequest(submissionId: submission.id, attempt: "\(submission.attempt)")
+        submissionId = submission.id
+        request = CanvaDocsSessionRequest(submissionId: submission.id, attempt: "\(submission.attempt)")
     }
 
     public func viewDidAppear() {
diff --git a/Teacher/TeacherTests/SpeedGrader/MainLayout/View/SubmissionHeaderTests.swift b/Teacher/TeacherTests/SpeedGrader/MainLayout/View/SubmissionHeaderViewTests.swift
similarity index 86%
rename from Teacher/TeacherTests/SpeedGrader/MainLayout/View/SubmissionHeaderTests.swift
rename to Teacher/TeacherTests/SpeedGrader/MainLayout/View/SubmissionHeaderViewTests.swift
index c0df1c8e0d..f6ef6f5c0e 100644
--- a/Teacher/TeacherTests/SpeedGrader/MainLayout/View/SubmissionHeaderTests.swift
+++ b/Teacher/TeacherTests/SpeedGrader/MainLayout/View/SubmissionHeaderViewTests.swift
@@ -25,7 +25,7 @@ class SubmissionHeaderTests: TeacherTestCase {
     func testGroupSubmissionCheck() {
         let submission = Submission(context: databaseClient)
         let assignment = Assignment(context: databaseClient)
-        let testee = SubmissionHeader(assignment: assignment, submission: submission)
+        let testee = SubmissionHeaderView(assignment: assignment, submission: submission)
 
         assignment.gradedIndividually = false
         submission.groupID = "TestGroupID"
@@ -36,7 +36,7 @@ class SubmissionHeaderTests: TeacherTestCase {
     func testGroupName() {
         let submission = Submission(context: databaseClient)
         let assignment = Assignment(context: databaseClient)
-        let testee = SubmissionHeader(assignment: assignment, submission: submission)
+        let testee = SubmissionHeaderView(assignment: assignment, submission: submission)
 
         assignment.gradedIndividually = false
         submission.groupName = "TestGroup Name"
@@ -49,7 +49,7 @@ class SubmissionHeaderTests: TeacherTestCase {
     func testRouteToGroupSubmitter() {
         let submission = Submission(context: databaseClient)
         let assignment = Assignment(context: databaseClient)
-        let testee = SubmissionHeader(assignment: assignment, submission: submission)
+        let testee = SubmissionHeaderView(assignment: assignment, submission: submission)
 
         assignment.gradedIndividually = false
         assignment.courseID = "testCourseID"
@@ -61,7 +61,7 @@ class SubmissionHeaderTests: TeacherTestCase {
     func testRouteToIndividialInGroupSubmission() {
         let submission = Submission(context: databaseClient)
         let assignment = Assignment(context: databaseClient)
-        let testee = SubmissionHeader(assignment: assignment, submission: submission)
+        let testee = SubmissionHeaderView(assignment: assignment, submission: submission)
 
         assignment.gradedIndividually = true
         assignment.courseID = "testCourseID"


Reviews:
user: login: rh12
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T11:14:40Z
user: login: vargaat
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T14:54:50Z
user: login: vargaat
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T15:00:51Z
user: login: rh12
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T08:38:42Z
user: login: rh12
body: QA + 1 (for this PR's changes)

The single attempt view IMHO shouldn't be a disabled button, just static text A11Y-wise (as in Student app), but since that will be changed anyway soon I think it's okay like this now.
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T10:02:14Z
user: login: ndrsszsz
body: QA +1
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T16:01:33Z
user: login: rh12
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:03:27Z

Review comments:
diff_hunk: @@ -0,0 +1,429 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2020-present  Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+//
+
+import Core
+import SwiftUI
+import Combine
+
+struct SubmissionGraderView: View {
+    private enum Layout {
+        case portrait
+        case landscape // only on iPads no matter the iPhone screen size
+    }
+
+    let userIndexInSubmissionList: Int
+
+    @Environment(\.viewController) private var controller
+
+    @State private var selectedDrawerTabIndex = 0
+    @State private var drawerState: DrawerState = .min
+    @State private var showAttempts = false
+    @State private var tab: GraderTab = .grades
+    @State private var showRecorder: MediaCommentType?
+    /** Used to work around an issue which caused the page to re-load after putting the app into background. See `layoutForWidth()` method for more. */
+    @State private var lastPresentedLayout: Layout = .portrait
+    @AccessibilityFocusState private var focusedTab: GraderTab?
+
+    @StateObject private var commentLibrary = SubmissionCommentLibraryViewModel()
+    @StateObject private var rubricsViewModel: RubricsViewModel
+    @StateObject private var viewModel: SubmissionGraderViewModel
+
+    private var handleRefresh: (() -> Void)?
+
+    init(
+        env: AppEnvironment,
+        userIndexInSubmissionList: Int,
+        viewModel: SubmissionGraderViewModel,
+        handleRefresh: (() -> Void)?
+    ) {
+        self.userIndexInSubmissionList = userIndexInSubmissionList
+        self._viewModel = StateObject(wrappedValue: viewModel)
+        self.handleRefresh = handleRefresh
+        _rubricsViewModel = StateObject(wrappedValue:
+            RubricsViewModel(
+                assignment: viewModel.assignment,
+                submission: viewModel.submission,
+                interactor: RubricGradingInteractorLive(assignment: viewModel.assignment, submission: viewModel.submission)
+            )
+        )
+    }
+
+    var body: some View {
+        GeometryReader { geometry in
+            let bottomInset = geometry.safeAreaInsets.bottom
+            let minHeight = bottomInset + 58
+            let maxHeight = bottomInset + geometry.size.height - 64
+            // At 1/4 of a screen offset, scale to 90% and round corners to 20
+            let delta = abs(geometry.frame(in: .global).minX / max(1, geometry.size.width))
+            let scale = interpolate(value: delta, fromMin: 0, fromMax: 0.25, toMin: 1, toMax: 0.9)
+            let cornerRadius = interpolate(value: delta, fromMin: 0, fromMax: 0.25, toMin: 0, toMax: 20)
+
+            mainLayout(
+                geometry: geometry,
+                bottomInset: bottomInset,
+                minHeight: minHeight,
+                maxHeight: maxHeight
+            )
+            .background(Color.backgroundLightest)
+            .cornerRadius(cornerRadius)
+            .scaleEffect(scale)
+            .edgesIgnoringSafeArea(.bottom)
+        }
+        .avoidKeyboardArea()
+    }
+
+    @ViewBuilder
+    private func mainLayout(
+        geometry: GeometryProxy,
+        bottomInset: CGFloat,
+        minHeight: CGFloat,
+        maxHeight: CGFloat
+    ) -> some View {
+        switch layoutForWidth(geometry.size.width) {
+        case .landscape:
+            landscapeLayout(bottomInset: bottomInset)
+        case .portrait:
+            portraitLayout(minHeight: minHeight, maxHeight: maxHeight, bottomInset: bottomInset)
+        }
+    }
+
+    private func landscapeLayout(
+        bottomInset: CGFloat
+    ) -> some View {
+        VStack(spacing: 0) {
+            SubmissionHeaderView(assignment: viewModel.assignment, submission: viewModel.submission)
+                .accessibility(sortPriority: 2)
+            Divider()
+            HStack(spacing: 0) {
+                VStack(alignment: .leading, spacing: 0) {
+                    attemptToggle
+                    Divider()
+                    ZStack(alignment: .top) {
+                        VStack(spacing: 0) {
+                            SimilarityScoreView(viewModel.selectedAttempt, file: viewModel.file)
+                            SubmissionViewer(
+                                assignment: viewModel.assignment,
+                                submission: viewModel.selectedAttempt,
+                                fileID: viewModel.fileID,
+                                studentAnnotationViewModel: viewModel.studentAnnotationViewModel,
+                                handleRefresh: handleRefresh
+                            )
+                        }
+                        // Disable submission content interaction in case attempt picker is above it
+                        .accessibilityElement(children: showAttempts ? .ignore : .contain)
+                        .accessibility(hidden: showAttempts)
+                        attemptPicker
+                    }
+                    Spacer().frame(height: bottomInset)
+                }
+                .zIndex(1)
+                .accessibility(sortPriority: 1)
+                Divider()
+                VStack(spacing: 0) {
+                    tools(bottomInset: bottomInset, isDrawer: false)
+                }
+                .padding(.top, 16)
+                .frame(width: 375)
+            }
+        }
+        .onAppear { didChangeLayout(to: .landscape) }
+    }
+
+    private func portraitLayout(
+        minHeight: CGFloat,
+        maxHeight: CGFloat,
+        bottomInset: CGFloat
+    ) -> some View {
+        ZStack(alignment: .bottom) {
+            VStack(alignment: .leading, spacing: 0) {
+                SubmissionHeaderView(assignment: viewModel.assignment, submission: viewModel.submission)
+                attemptToggle
+                    .accessibility(hidden: drawerState == .max)
+                Divider()
+                let isSubmissionContentHiddenFromA11y = (drawerState != .min || showAttempts)
+                ZStack(alignment: .top) {
+                    VStack(spacing: 0) {
+                        SimilarityScoreView(viewModel.selectedAttempt, file: viewModel.file)
+                        SubmissionViewer(
+                            assignment: viewModel.assignment,
+                            submission: viewModel.selectedAttempt,
+                            fileID: viewModel.fileID,
+                            studentAnnotationViewModel: viewModel.studentAnnotationViewModel,
+                            handleRefresh: handleRefresh
+                        )
+                    }
+                    .accessibilityElement(children: isSubmissionContentHiddenFromA11y ? .ignore : .contain)
+                    .accessibility(hidden: isSubmissionContentHiddenFromA11y)
+                    attemptPicker
+                }
+                Spacer().frame(height: drawerState == .min ? minHeight : (minHeight + maxHeight) / 2)
+            }
+            DrawerContainer(state: $drawerState, minHeight: minHeight, maxHeight: maxHeight) {
+                tools(bottomInset: bottomInset, isDrawer: true)
+            }
+        }
+        .onAppear { didChangeLayout(to: .portrait) }
+    }
+
+    @ViewBuilder
+    private var attemptToggle: some View {
+        if viewModel.hasSubmissions {
+            Button {
+                showAttempts.toggle()
+            } label: {
+                HStack {
+                    Text("Attempt \(viewModel.selectedAttemptIndex)", bundle: .teacher)
+                    Spacer()
+                    Text(viewModel.selectedAttempt.submittedAt?.dateTimeString ?? "")
+                        .frame(minHeight: 24)
+
+                    if !viewModel.isSingleSubmission {
+                        Image.arrowOpenDownLine
+                            .resizable()
+                            .frame(width: 14, height: 14)
+                            .rotationEffect(.degrees(showAttempts ? 180 : 0))
+                    }
+                }
+                .font(.regular14)
+                .foregroundColor(.textDark)
+                .padding(EdgeInsets(top: 8, leading: 16, bottom: 4, trailing: 16))
+            }
+            .disabled(viewModel.isSingleSubmission)
+        }
+    }
+
+    @ViewBuilder
+    private var attemptPicker: some View {
+        if showAttempts {
+            VStack(spacing: 0) {
+                let binding = Binding(
+                    get: {
+                        viewModel.selectedAttemptIndex
+                    },
+                    set: { newAttemptIndex in
+                        withTransaction(.exclusive()) {
+                            viewModel.didSelectNewAttempt(attemptIndex: newAttemptIndex)
+                        }
+                        showAttempts = false
+                    }
+                )
+                Picker(selection: binding, label: Text(verbatim: "")) {
+                    ForEach(viewModel.attempts, id: \.attempt) { attempt in
+                        Text(attempt.submittedAt?.dateTimeString ?? "")
+                            .tag(Optional(attempt.attempt))
+                    }
+                }
+                .labelsHidden()
+                .pickerStyle(WheelPickerStyle())
+                Divider()
+            }
+            .background(Color.backgroundLightest)
+        }
+    }
+
+    enum GraderTab: Int, CaseIterable { case grades, comments, files }
+
+    private var segmentedTitles: [String] {
+        [
+            String(localized: "Grades", bundle: .teacher),
+            String(localized: "Comments", bundle: .teacher),
+            viewModel.fileTabTitle
+        ]
+    }
+
+    @ViewBuilder
+    private func tools(bottomInset: CGFloat, isDrawer: Bool) -> some View {
+        SegmentedPicker(
+            segmentedTitles,
+            selectedIndex: Binding(
+                get: { selectedDrawerTabIndex },
+                set: { newValue in
+                    selectedDrawerTabIndex = newValue ?? 0
+                    if drawerState == .min {
+                        snapDrawerTo(.mid)
+                    }
+                    let newTab = SubmissionGraderView.GraderTab(rawValue: newValue ?? 0)!
+                    withAnimation(.default) {
+                        tab = newTab
+                    }
+                    controller.view.endEditing(true)
+                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+                        focusedTab = tab
+                    }
+                }
+            ),
+            selectionAlignment: .bottom,
+            content: { item, _ in
+                Text(item)
+                    .font(.regular14)
+                    .foregroundColor(.textDark)
+                    .padding(.horizontal, 16)
+                    .padding(.vertical, 8)
+                    .frame(maxWidth: .infinity)
+                    .contentShape(Rectangle())
+            }
+        )
+        .identifier("SpeedGrader.toolPicker")
+        Divider()
+        GeometryReader { geometry in
+            HStack(spacing: 0) {
+                let drawerFileID = Binding<String?>(
+                    get: {
+                        viewModel.fileID
+                    },
+                    set: {
+                        viewModel.didSelectFile(fileID: $0)
+                        snapDrawerTo(.min)
+                    }
+                )
+
+                gradesTab(bottomInset: bottomInset, isDrawer: isDrawer, geometry: geometry)
+                commentsTab(bottomInset: bottomInset, isDrawer: isDrawer, fileID: drawerFileID, geometry: geometry)
+                filesTab(bottomInset: bottomInset, isDrawer: isDrawer, fileID: drawerFileID, geometry: geometry)
+            }
+            .frame(width: geometry.size.width, alignment: .leading)
+            .background(Color.backgroundLightest)
+            .offset(x: -CGFloat(tab.rawValue) * geometry.size.width)
+        }
+        .clipped()
+    }
+
+    @ViewBuilder
+    private func filesTab(
+        bottomInset: CGFloat,
+        isDrawer: Bool,
+        fileID: Binding<String?>,
+        geometry: GeometryProxy
+    ) -> some View {
+        let isFilesOnScreen = isGraderTabOnScreen(.files, isDrawer: isDrawer)
+        VStack(spacing: 0) {
+            SubmissionFileList(submission: viewModel.selectedAttempt, fileID: fileID)
+                .clipped()
+            Spacer().frame(height: bottomInset)
+        }
+        .frame(width: geometry.size.width, height: geometry.size.height)
+        .accessibilityElement(children: isFilesOnScreen ? .contain : .ignore)
+        .accessibility(hidden: !isFilesOnScreen)
+        .accessibilityFocused($focusedTab, equals: .files)
+    }
+
+    @ViewBuilder
+    private func commentsTab(
+        bottomInset: CGFloat,
+        isDrawer: Bool,
+        fileID: Binding<String?>,
+        geometry: GeometryProxy
+    ) -> some View {
+        let drawerAttempt = Binding(
+            get: {
+                viewModel.selectedAttemptIndex
+            }, set: {
+                viewModel.didSelectNewAttempt(attemptIndex: $0)
+                snapDrawerTo(.min)
+            }
+        )
+        let isCommentsOnScreen = isGraderTabOnScreen(.comments, isDrawer: isDrawer)
+        VStack(spacing: 0) {
+            SubmissionCommentListView(
+                assignment: viewModel.assignment,
+                submission: viewModel.submission,
+                attempts: viewModel.attempts,
+                attempt: drawerAttempt,
+                fileID: fileID,
+                showRecorder: $showRecorder,
+                enteredComment: $viewModel.enteredComment,
+                commentLibrary: commentLibrary,
+                focusedTab: _focusedTab
+            )
+            .clipped()
+            if showRecorder != .video || drawerState == .min {
+                Spacer().frame(height: bottomInset)
+            }
+        }
+        .frame(width: geometry.size.width, height: geometry.size.height)
+        .background(Color.backgroundLight)
+        .accessibilityElement(children: isCommentsOnScreen ? .contain : .ignore)
+        .accessibility(hidden: !isCommentsOnScreen)
+    }
+
+    @ViewBuilder
+    private func gradesTab(
+        bottomInset: CGFloat,
+        isDrawer: Bool,
+        geometry: GeometryProxy
+    ) -> some View {
+        let isGradesOnScreen = isGraderTabOnScreen(.grades, isDrawer: isDrawer)
+        VStack(spacing: 0) {
+            SubmissionGrades(
+                assignment: viewModel.assignment,
+                containerHeight: geometry.size.height,
+                submission: viewModel.submission,
+                rubricsViewModel: rubricsViewModel
+            )
+            .clipped()
+            Spacer().frame(height: bottomInset)
+        }
+        .frame(width: geometry.size.width, height: geometry.size.height)
+        .accessibilityElement(children: isGradesOnScreen ? .contain : .ignore)
+        .accessibility(hidden: !isGradesOnScreen)
+        .accessibilityFocused($focusedTab, equals: .grades)
+    }
+
+    private func snapDrawerTo(_ state: DrawerState) {
+        withTransaction(DrawerState.transaction) {
+            drawerState = state
+        }
+    }
+
+    private func isGraderTabOnScreen(_ tab: GraderTab, isDrawer: Bool) -> Bool {
+        let isTabSelected = (self.tab == tab)
+
+        if isDrawer {
+            return (drawerState != .min && isTabSelected)
+        } else {
+            return isTabSelected
+        }
+    }
+
+    private func layoutForWidth(_ width: CGFloat) -> Layout {
+        // On iPads if the app is backgrounded then it changes the device orientation back and forth causing the UI to re-render and the submission to re-load.
+        // To overcome this we force the last presented layout in case the app is in the background.
+        guard UIApplication.shared.applicationState != .background else {
+            return lastPresentedLayout
+        }
+        return width > 834 ? .landscape : .portrait
+    }
+
+    private func didChangeLayout(to layout: Layout) {
+        if lastPresentedLayout != layout {
+            // When the layout changes the keyboard disappears without any system notifications
+            // on iPads so we simulate one to allow .avoidKeyboardArea() to work correctly.
+            NotificationCenter.default.post(name: UIApplication.keyboardWillHideNotification, object: nil, userInfo: [:])
+        }
+        lastPresentedLayout = layout
+    }
+}
+
+private func interpolate(value: CGFloat, fromMin: CGFloat, fromMax: CGFloat, toMin: CGFloat, toMax: CGFloat) -> CGFloat {
+    let bounded = max(fromMin, min(value, fromMax))
+    return (((toMax - toMin) / (fromMax - fromMin)) * (bounded - fromMin)) + toMin
+}
+
+extension NSNotification.Name {
+    public static var SpeedGraderAttemptPickerChanged = NSNotification.Name("com.instructure.core.speedgrader-attempt-changed")
path: Teacher/Teacher/SpeedGrader/MainLayout/View/SubmissionGraderView.swift
user: login: rh12
body: This could maybe move to the viewmodel, as it is posted from there and received in another viewmodel
created_at: 2025-04-15T11:10:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,429 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2020-present  Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+//
+
+import Core
+import SwiftUI
+import Combine
+
+struct SubmissionGraderView: View {
+    private enum Layout {
+        case portrait
+        case landscape // only on iPads no matter the iPhone screen size
+    }
+
+    let userIndexInSubmissionList: Int
+
+    @Environment(\.viewController) private var controller
+
+    @State private var selectedDrawerTabIndex = 0
+    @State private var drawerState: DrawerState = .min
+    @State private var showAttempts = false
+    @State private var tab: GraderTab = .grades
+    @State private var showRecorder: MediaCommentType?
+    /** Used to work around an issue which caused the page to re-load after putting the app into background. See `layoutForWidth()` method for more. */
+    @State private var lastPresentedLayout: Layout = .portrait
+    @AccessibilityFocusState private var focusedTab: GraderTab?
+
+    @StateObject private var commentLibrary = SubmissionCommentLibraryViewModel()
+    @StateObject private var rubricsViewModel: RubricsViewModel
+    @StateObject private var viewModel: SubmissionGraderViewModel
+
+    private var handleRefresh: (() -> Void)?
+
+    init(
+        env: AppEnvironment,
+        userIndexInSubmissionList: Int,
+        viewModel: SubmissionGraderViewModel,
+        handleRefresh: (() -> Void)?
+    ) {
+        self.userIndexInSubmissionList = userIndexInSubmissionList
+        self._viewModel = StateObject(wrappedValue: viewModel)
+        self.handleRefresh = handleRefresh
+        _rubricsViewModel = StateObject(wrappedValue:
+            RubricsViewModel(
+                assignment: viewModel.assignment,
+                submission: viewModel.submission,
+                interactor: RubricGradingInteractorLive(assignment: viewModel.assignment, submission: viewModel.submission)
+            )
+        )
+    }
+
+    var body: some View {
+        GeometryReader { geometry in
+            let bottomInset = geometry.safeAreaInsets.bottom
+            let minHeight = bottomInset + 58
+            let maxHeight = bottomInset + geometry.size.height - 64
+            // At 1/4 of a screen offset, scale to 90% and round corners to 20
+            let delta = abs(geometry.frame(in: .global).minX / max(1, geometry.size.width))
+            let scale = interpolate(value: delta, fromMin: 0, fromMax: 0.25, toMin: 1, toMax: 0.9)
+            let cornerRadius = interpolate(value: delta, fromMin: 0, fromMax: 0.25, toMin: 0, toMax: 20)
+
+            mainLayout(
+                geometry: geometry,
+                bottomInset: bottomInset,
+                minHeight: minHeight,
+                maxHeight: maxHeight
+            )
+            .background(Color.backgroundLightest)
+            .cornerRadius(cornerRadius)
+            .scaleEffect(scale)
+            .edgesIgnoringSafeArea(.bottom)
+        }
+        .avoidKeyboardArea()
+    }
+
+    @ViewBuilder
+    private func mainLayout(
+        geometry: GeometryProxy,
+        bottomInset: CGFloat,
+        minHeight: CGFloat,
+        maxHeight: CGFloat
+    ) -> some View {
+        switch layoutForWidth(geometry.size.width) {
+        case .landscape:
+            landscapeLayout(bottomInset: bottomInset)
+        case .portrait:
+            portraitLayout(minHeight: minHeight, maxHeight: maxHeight, bottomInset: bottomInset)
+        }
+    }
+
+    private func landscapeLayout(
+        bottomInset: CGFloat
+    ) -> some View {
+        VStack(spacing: 0) {
+            SubmissionHeaderView(assignment: viewModel.assignment, submission: viewModel.submission)
+                .accessibility(sortPriority: 2)
+            Divider()
+            HStack(spacing: 0) {
+                VStack(alignment: .leading, spacing: 0) {
+                    attemptToggle
+                    Divider()
+                    ZStack(alignment: .top) {
+                        VStack(spacing: 0) {
+                            SimilarityScoreView(viewModel.selectedAttempt, file: viewModel.file)
+                            SubmissionViewer(
+                                assignment: viewModel.assignment,
+                                submission: viewModel.selectedAttempt,
+                                fileID: viewModel.fileID,
+                                studentAnnotationViewModel: viewModel.studentAnnotationViewModel,
+                                handleRefresh: handleRefresh
+                            )
+                        }
+                        // Disable submission content interaction in case attempt picker is above it
+                        .accessibilityElement(children: showAttempts ? .ignore : .contain)
+                        .accessibility(hidden: showAttempts)
+                        attemptPicker
+                    }
+                    Spacer().frame(height: bottomInset)
+                }
+                .zIndex(1)
+                .accessibility(sortPriority: 1)
+                Divider()
+                VStack(spacing: 0) {
+                    tools(bottomInset: bottomInset, isDrawer: false)
+                }
+                .padding(.top, 16)
+                .frame(width: 375)
+            }
+        }
+        .onAppear { didChangeLayout(to: .landscape) }
+    }
+
+    private func portraitLayout(
+        minHeight: CGFloat,
+        maxHeight: CGFloat,
+        bottomInset: CGFloat
+    ) -> some View {
+        ZStack(alignment: .bottom) {
+            VStack(alignment: .leading, spacing: 0) {
+                SubmissionHeaderView(assignment: viewModel.assignment, submission: viewModel.submission)
+                attemptToggle
+                    .accessibility(hidden: drawerState == .max)
+                Divider()
+                let isSubmissionContentHiddenFromA11y = (drawerState != .min || showAttempts)
+                ZStack(alignment: .top) {
+                    VStack(spacing: 0) {
+                        SimilarityScoreView(viewModel.selectedAttempt, file: viewModel.file)
+                        SubmissionViewer(
+                            assignment: viewModel.assignment,
+                            submission: viewModel.selectedAttempt,
+                            fileID: viewModel.fileID,
+                            studentAnnotationViewModel: viewModel.studentAnnotationViewModel,
+                            handleRefresh: handleRefresh
+                        )
+                    }
+                    .accessibilityElement(children: isSubmissionContentHiddenFromA11y ? .ignore : .contain)
+                    .accessibility(hidden: isSubmissionContentHiddenFromA11y)
+                    attemptPicker
+                }
+                Spacer().frame(height: drawerState == .min ? minHeight : (minHeight + maxHeight) / 2)
+            }
+            DrawerContainer(state: $drawerState, minHeight: minHeight, maxHeight: maxHeight) {
+                tools(bottomInset: bottomInset, isDrawer: true)
+            }
+        }
+        .onAppear { didChangeLayout(to: .portrait) }
+    }
+
+    @ViewBuilder
+    private var attemptToggle: some View {
+        if viewModel.hasSubmissions {
+            Button {
+                showAttempts.toggle()
+            } label: {
+                HStack {
+                    Text("Attempt \(viewModel.selectedAttemptIndex)", bundle: .teacher)
+                    Spacer()
+                    Text(viewModel.selectedAttempt.submittedAt?.dateTimeString ?? "")
+                        .frame(minHeight: 24)
+
+                    if !viewModel.isSingleSubmission {
+                        Image.arrowOpenDownLine
+                            .resizable()
+                            .frame(width: 14, height: 14)
+                            .rotationEffect(.degrees(showAttempts ? 180 : 0))
+                    }
+                }
+                .font(.regular14)
+                .foregroundColor(.textDark)
+                .padding(EdgeInsets(top: 8, leading: 16, bottom: 4, trailing: 16))
+            }
+            .disabled(viewModel.isSingleSubmission)
+        }
+    }
+
+    @ViewBuilder
+    private var attemptPicker: some View {
+        if showAttempts {
+            VStack(spacing: 0) {
+                let binding = Binding(
+                    get: {
+                        viewModel.selectedAttemptIndex
+                    },
+                    set: { newAttemptIndex in
+                        withTransaction(.exclusive()) {
+                            viewModel.didSelectNewAttempt(attemptIndex: newAttemptIndex)
+                        }
+                        showAttempts = false
+                    }
+                )
+                Picker(selection: binding, label: Text(verbatim: "")) {
+                    ForEach(viewModel.attempts, id: \.attempt) { attempt in
+                        Text(attempt.submittedAt?.dateTimeString ?? "")
+                            .tag(Optional(attempt.attempt))
+                    }
+                }
+                .labelsHidden()
+                .pickerStyle(WheelPickerStyle())
+                Divider()
+            }
+            .background(Color.backgroundLightest)
+        }
+    }
+
+    enum GraderTab: Int, CaseIterable { case grades, comments, files }
+
+    private var segmentedTitles: [String] {
+        [
+            String(localized: "Grades", bundle: .teacher),
+            String(localized: "Comments", bundle: .teacher),
+            viewModel.fileTabTitle
+        ]
+    }
+
+    @ViewBuilder
+    private func tools(bottomInset: CGFloat, isDrawer: Bool) -> some View {
+        SegmentedPicker(
+            segmentedTitles,
+            selectedIndex: Binding(
+                get: { selectedDrawerTabIndex },
+                set: { newValue in
+                    selectedDrawerTabIndex = newValue ?? 0
+                    if drawerState == .min {
+                        snapDrawerTo(.mid)
+                    }
+                    let newTab = SubmissionGraderView.GraderTab(rawValue: newValue ?? 0)!
+                    withAnimation(.default) {
+                        tab = newTab
+                    }
+                    controller.view.endEditing(true)
+                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+                        focusedTab = tab
+                    }
+                }
+            ),
+            selectionAlignment: .bottom,
+            content: { item, _ in
+                Text(item)
+                    .font(.regular14)
+                    .foregroundColor(.textDark)
+                    .padding(.horizontal, 16)
+                    .padding(.vertical, 8)
+                    .frame(maxWidth: .infinity)
+                    .contentShape(Rectangle())
+            }
+        )
+        .identifier("SpeedGrader.toolPicker")
+        Divider()
+        GeometryReader { geometry in
+            HStack(spacing: 0) {
+                let drawerFileID = Binding<String?>(
+                    get: {
+                        viewModel.fileID
+                    },
+                    set: {
+                        viewModel.didSelectFile(fileID: $0)
+                        snapDrawerTo(.min)
+                    }
+                )
+
+                gradesTab(bottomInset: bottomInset, isDrawer: isDrawer, geometry: geometry)
+                commentsTab(bottomInset: bottomInset, isDrawer: isDrawer, fileID: drawerFileID, geometry: geometry)
+                filesTab(bottomInset: bottomInset, isDrawer: isDrawer, fileID: drawerFileID, geometry: geometry)
+            }
+            .frame(width: geometry.size.width, alignment: .leading)
+            .background(Color.backgroundLightest)
+            .offset(x: -CGFloat(tab.rawValue) * geometry.size.width)
+        }
+        .clipped()
+    }
+
+    @ViewBuilder
+    private func filesTab(
+        bottomInset: CGFloat,
+        isDrawer: Bool,
+        fileID: Binding<String?>,
+        geometry: GeometryProxy
+    ) -> some View {
+        let isFilesOnScreen = isGraderTabOnScreen(.files, isDrawer: isDrawer)
+        VStack(spacing: 0) {
+            SubmissionFileList(submission: viewModel.selectedAttempt, fileID: fileID)
+                .clipped()
+            Spacer().frame(height: bottomInset)
+        }
+        .frame(width: geometry.size.width, height: geometry.size.height)
+        .accessibilityElement(children: isFilesOnScreen ? .contain : .ignore)
+        .accessibility(hidden: !isFilesOnScreen)
+        .accessibilityFocused($focusedTab, equals: .files)
+    }
+
+    @ViewBuilder
+    private func commentsTab(
+        bottomInset: CGFloat,
+        isDrawer: Bool,
+        fileID: Binding<String?>,
+        geometry: GeometryProxy
+    ) -> some View {
+        let drawerAttempt = Binding(
+            get: {
+                viewModel.selectedAttemptIndex
+            }, set: {
+                viewModel.didSelectNewAttempt(attemptIndex: $0)
+                snapDrawerTo(.min)
+            }
+        )
+        let isCommentsOnScreen = isGraderTabOnScreen(.comments, isDrawer: isDrawer)
+        VStack(spacing: 0) {
+            SubmissionCommentListView(
+                assignment: viewModel.assignment,
+                submission: viewModel.submission,
+                attempts: viewModel.attempts,
+                attempt: drawerAttempt,
+                fileID: fileID,
+                showRecorder: $showRecorder,
+                enteredComment: $viewModel.enteredComment,
+                commentLibrary: commentLibrary,
+                focusedTab: _focusedTab
+            )
+            .clipped()
+            if showRecorder != .video || drawerState == .min {
+                Spacer().frame(height: bottomInset)
+            }
+        }
+        .frame(width: geometry.size.width, height: geometry.size.height)
+        .background(Color.backgroundLight)
+        .accessibilityElement(children: isCommentsOnScreen ? .contain : .ignore)
+        .accessibility(hidden: !isCommentsOnScreen)
+    }
+
+    @ViewBuilder
+    private func gradesTab(
path: Teacher/Teacher/SpeedGrader/MainLayout/View/SubmissionGraderView.swift
user: login: rh12
body: If you touch the PR, could you please move this above the other tabs to keep the visible order?
created_at: 2025-04-15T11:11:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,429 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2020-present  Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+//
+
+import Core
+import SwiftUI
+import Combine
+
+struct SubmissionGraderView: View {
+    private enum Layout {
+        case portrait
+        case landscape // only on iPads no matter the iPhone screen size
+    }
+
+    let userIndexInSubmissionList: Int
+
+    @Environment(\.viewController) private var controller
+
+    @State private var selectedDrawerTabIndex = 0
+    @State private var drawerState: DrawerState = .min
+    @State private var showAttempts = false
+    @State private var tab: GraderTab = .grades
+    @State private var showRecorder: MediaCommentType?
+    /** Used to work around an issue which caused the page to re-load after putting the app into background. See `layoutForWidth()` method for more. */
+    @State private var lastPresentedLayout: Layout = .portrait
+    @AccessibilityFocusState private var focusedTab: GraderTab?
+
+    @StateObject private var commentLibrary = SubmissionCommentLibraryViewModel()
+    @StateObject private var rubricsViewModel: RubricsViewModel
+    @StateObject private var viewModel: SubmissionGraderViewModel
+
+    private var handleRefresh: (() -> Void)?
+
+    init(
+        env: AppEnvironment,
+        userIndexInSubmissionList: Int,
+        viewModel: SubmissionGraderViewModel,
+        handleRefresh: (() -> Void)?
+    ) {
+        self.userIndexInSubmissionList = userIndexInSubmissionList
+        self._viewModel = StateObject(wrappedValue: viewModel)
+        self.handleRefresh = handleRefresh
+        _rubricsViewModel = StateObject(wrappedValue:
+            RubricsViewModel(
+                assignment: viewModel.assignment,
+                submission: viewModel.submission,
+                interactor: RubricGradingInteractorLive(assignment: viewModel.assignment, submission: viewModel.submission)
+            )
+        )
+    }
+
+    var body: some View {
+        GeometryReader { geometry in
+            let bottomInset = geometry.safeAreaInsets.bottom
+            let minHeight = bottomInset + 58
+            let maxHeight = bottomInset + geometry.size.height - 64
+            // At 1/4 of a screen offset, scale to 90% and round corners to 20
+            let delta = abs(geometry.frame(in: .global).minX / max(1, geometry.size.width))
+            let scale = interpolate(value: delta, fromMin: 0, fromMax: 0.25, toMin: 1, toMax: 0.9)
+            let cornerRadius = interpolate(value: delta, fromMin: 0, fromMax: 0.25, toMin: 0, toMax: 20)
+
+            mainLayout(
+                geometry: geometry,
+                bottomInset: bottomInset,
+                minHeight: minHeight,
+                maxHeight: maxHeight
+            )
+            .background(Color.backgroundLightest)
+            .cornerRadius(cornerRadius)
+            .scaleEffect(scale)
+            .edgesIgnoringSafeArea(.bottom)
+        }
+        .avoidKeyboardArea()
+    }
+
+    @ViewBuilder
+    private func mainLayout(
+        geometry: GeometryProxy,
+        bottomInset: CGFloat,
+        minHeight: CGFloat,
+        maxHeight: CGFloat
+    ) -> some View {
+        switch layoutForWidth(geometry.size.width) {
+        case .landscape:
+            landscapeLayout(bottomInset: bottomInset)
+        case .portrait:
+            portraitLayout(minHeight: minHeight, maxHeight: maxHeight, bottomInset: bottomInset)
+        }
+    }
+
+    private func landscapeLayout(
+        bottomInset: CGFloat
+    ) -> some View {
+        VStack(spacing: 0) {
+            SubmissionHeaderView(assignment: viewModel.assignment, submission: viewModel.submission)
+                .accessibility(sortPriority: 2)
+            Divider()
+            HStack(spacing: 0) {
+                VStack(alignment: .leading, spacing: 0) {
+                    attemptToggle
+                    Divider()
+                    ZStack(alignment: .top) {
+                        VStack(spacing: 0) {
+                            SimilarityScoreView(viewModel.selectedAttempt, file: viewModel.file)
+                            SubmissionViewer(
+                                assignment: viewModel.assignment,
+                                submission: viewModel.selectedAttempt,
+                                fileID: viewModel.fileID,
+                                studentAnnotationViewModel: viewModel.studentAnnotationViewModel,
+                                handleRefresh: handleRefresh
+                            )
+                        }
+                        // Disable submission content interaction in case attempt picker is above it
+                        .accessibilityElement(children: showAttempts ? .ignore : .contain)
+                        .accessibility(hidden: showAttempts)
+                        attemptPicker
+                    }
+                    Spacer().frame(height: bottomInset)
+                }
+                .zIndex(1)
+                .accessibility(sortPriority: 1)
+                Divider()
+                VStack(spacing: 0) {
+                    tools(bottomInset: bottomInset, isDrawer: false)
+                }
+                .padding(.top, 16)
+                .frame(width: 375)
+            }
+        }
+        .onAppear { didChangeLayout(to: .landscape) }
+    }
+
+    private func portraitLayout(
+        minHeight: CGFloat,
+        maxHeight: CGFloat,
+        bottomInset: CGFloat
+    ) -> some View {
+        ZStack(alignment: .bottom) {
+            VStack(alignment: .leading, spacing: 0) {
+                SubmissionHeaderView(assignment: viewModel.assignment, submission: viewModel.submission)
+                attemptToggle
+                    .accessibility(hidden: drawerState == .max)
+                Divider()
+                let isSubmissionContentHiddenFromA11y = (drawerState != .min || showAttempts)
+                ZStack(alignment: .top) {
+                    VStack(spacing: 0) {
+                        SimilarityScoreView(viewModel.selectedAttempt, file: viewModel.file)
+                        SubmissionViewer(
+                            assignment: viewModel.assignment,
+                            submission: viewModel.selectedAttempt,
+                            fileID: viewModel.fileID,
+                            studentAnnotationViewModel: viewModel.studentAnnotationViewModel,
+                            handleRefresh: handleRefresh
+                        )
+                    }
+                    .accessibilityElement(children: isSubmissionContentHiddenFromA11y ? .ignore : .contain)
+                    .accessibility(hidden: isSubmissionContentHiddenFromA11y)
+                    attemptPicker
+                }
+                Spacer().frame(height: drawerState == .min ? minHeight : (minHeight + maxHeight) / 2)
+            }
+            DrawerContainer(state: $drawerState, minHeight: minHeight, maxHeight: maxHeight) {
+                tools(bottomInset: bottomInset, isDrawer: true)
+            }
+        }
+        .onAppear { didChangeLayout(to: .portrait) }
+    }
+
+    @ViewBuilder
+    private var attemptToggle: some View {
+        if viewModel.hasSubmissions {
+            Button {
+                showAttempts.toggle()
+            } label: {
+                HStack {
+                    Text("Attempt \(viewModel.selectedAttemptIndex)", bundle: .teacher)
+                    Spacer()
+                    Text(viewModel.selectedAttempt.submittedAt?.dateTimeString ?? "")
+                        .frame(minHeight: 24)
+
+                    if !viewModel.isSingleSubmission {
+                        Image.arrowOpenDownLine
+                            .resizable()
+                            .frame(width: 14, height: 14)
+                            .rotationEffect(.degrees(showAttempts ? 180 : 0))
+                    }
+                }
+                .font(.regular14)
+                .foregroundColor(.textDark)
+                .padding(EdgeInsets(top: 8, leading: 16, bottom: 4, trailing: 16))
+            }
+            .disabled(viewModel.isSingleSubmission)
+        }
+    }
+
+    @ViewBuilder
+    private var attemptPicker: some View {
+        if showAttempts {
+            VStack(spacing: 0) {
+                let binding = Binding(
+                    get: {
+                        viewModel.selectedAttemptIndex
+                    },
+                    set: { newAttemptIndex in
+                        withTransaction(.exclusive()) {
+                            viewModel.didSelectNewAttempt(attemptIndex: newAttemptIndex)
+                        }
+                        showAttempts = false
+                    }
+                )
+                Picker(selection: binding, label: Text(verbatim: "")) {
+                    ForEach(viewModel.attempts, id: \.attempt) { attempt in
+                        Text(attempt.submittedAt?.dateTimeString ?? "")
+                            .tag(Optional(attempt.attempt))
+                    }
+                }
+                .labelsHidden()
+                .pickerStyle(WheelPickerStyle())
+                Divider()
+            }
+            .background(Color.backgroundLightest)
+        }
+    }
+
+    enum GraderTab: Int, CaseIterable { case grades, comments, files }
+
+    private var segmentedTitles: [String] {
+        [
+            String(localized: "Grades", bundle: .teacher),
+            String(localized: "Comments", bundle: .teacher),
+            viewModel.fileTabTitle
+        ]
+    }
+
+    @ViewBuilder
+    private func tools(bottomInset: CGFloat, isDrawer: Bool) -> some View {
+        SegmentedPicker(
+            segmentedTitles,
+            selectedIndex: Binding(
+                get: { selectedDrawerTabIndex },
+                set: { newValue in
+                    selectedDrawerTabIndex = newValue ?? 0
+                    if drawerState == .min {
+                        snapDrawerTo(.mid)
+                    }
+                    let newTab = SubmissionGraderView.GraderTab(rawValue: newValue ?? 0)!
+                    withAnimation(.default) {
+                        tab = newTab
+                    }
+                    controller.view.endEditing(true)
+                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+                        focusedTab = tab
+                    }
+                }
+            ),
+            selectionAlignment: .bottom,
+            content: { item, _ in
+                Text(item)
+                    .font(.regular14)
+                    .foregroundColor(.textDark)
+                    .padding(.horizontal, 16)
+                    .padding(.vertical, 8)
+                    .frame(maxWidth: .infinity)
+                    .contentShape(Rectangle())
+            }
+        )
+        .identifier("SpeedGrader.toolPicker")
+        Divider()
+        GeometryReader { geometry in
+            HStack(spacing: 0) {
+                let drawerFileID = Binding<String?>(
+                    get: {
+                        viewModel.fileID
+                    },
+                    set: {
+                        viewModel.didSelectFile(fileID: $0)
+                        snapDrawerTo(.min)
+                    }
+                )
+
+                gradesTab(bottomInset: bottomInset, isDrawer: isDrawer, geometry: geometry)
+                commentsTab(bottomInset: bottomInset, isDrawer: isDrawer, fileID: drawerFileID, geometry: geometry)
+                filesTab(bottomInset: bottomInset, isDrawer: isDrawer, fileID: drawerFileID, geometry: geometry)
+            }
+            .frame(width: geometry.size.width, alignment: .leading)
+            .background(Color.backgroundLightest)
+            .offset(x: -CGFloat(tab.rawValue) * geometry.size.width)
+        }
+        .clipped()
+    }
+
+    @ViewBuilder
+    private func filesTab(
+        bottomInset: CGFloat,
+        isDrawer: Bool,
+        fileID: Binding<String?>,
+        geometry: GeometryProxy
+    ) -> some View {
+        let isFilesOnScreen = isGraderTabOnScreen(.files, isDrawer: isDrawer)
+        VStack(spacing: 0) {
+            SubmissionFileList(submission: viewModel.selectedAttempt, fileID: fileID)
+                .clipped()
+            Spacer().frame(height: bottomInset)
+        }
+        .frame(width: geometry.size.width, height: geometry.size.height)
+        .accessibilityElement(children: isFilesOnScreen ? .contain : .ignore)
+        .accessibility(hidden: !isFilesOnScreen)
+        .accessibilityFocused($focusedTab, equals: .files)
+    }
+
+    @ViewBuilder
+    private func commentsTab(
+        bottomInset: CGFloat,
+        isDrawer: Bool,
+        fileID: Binding<String?>,
+        geometry: GeometryProxy
+    ) -> some View {
+        let drawerAttempt = Binding(
+            get: {
+                viewModel.selectedAttemptIndex
+            }, set: {
+                viewModel.didSelectNewAttempt(attemptIndex: $0)
+                snapDrawerTo(.min)
+            }
+        )
+        let isCommentsOnScreen = isGraderTabOnScreen(.comments, isDrawer: isDrawer)
+        VStack(spacing: 0) {
+            SubmissionCommentListView(
+                assignment: viewModel.assignment,
+                submission: viewModel.submission,
+                attempts: viewModel.attempts,
+                attempt: drawerAttempt,
+                fileID: fileID,
+                showRecorder: $showRecorder,
+                enteredComment: $viewModel.enteredComment,
+                commentLibrary: commentLibrary,
+                focusedTab: _focusedTab
+            )
+            .clipped()
+            if showRecorder != .video || drawerState == .min {
+                Spacer().frame(height: bottomInset)
+            }
+        }
+        .frame(width: geometry.size.width, height: geometry.size.height)
+        .background(Color.backgroundLight)
+        .accessibilityElement(children: isCommentsOnScreen ? .contain : .ignore)
+        .accessibility(hidden: !isCommentsOnScreen)
+    }
+
+    @ViewBuilder
+    private func gradesTab(
+        bottomInset: CGFloat,
+        isDrawer: Bool,
+        geometry: GeometryProxy
+    ) -> some View {
+        let isGradesOnScreen = isGraderTabOnScreen(.grades, isDrawer: isDrawer)
+        VStack(spacing: 0) {
+            SubmissionGrades(
+                assignment: viewModel.assignment,
+                containerHeight: geometry.size.height,
+                submission: viewModel.submission,
+                rubricsViewModel: rubricsViewModel
+            )
+            .clipped()
+            Spacer().frame(height: bottomInset)
+        }
+        .frame(width: geometry.size.width, height: geometry.size.height)
+        .accessibilityElement(children: isGradesOnScreen ? .contain : .ignore)
+        .accessibility(hidden: !isGradesOnScreen)
+        .accessibilityFocused($focusedTab, equals: .grades)
+    }
+
+    private func snapDrawerTo(_ state: DrawerState) {
+        withTransaction(DrawerState.transaction) {
+            drawerState = state
+        }
+    }
+
+    private func isGraderTabOnScreen(_ tab: GraderTab, isDrawer: Bool) -> Bool {
+        let isTabSelected = (self.tab == tab)
+
+        if isDrawer {
+            return (drawerState != .min && isTabSelected)
+        } else {
+            return isTabSelected
+        }
+    }
+
+    private func layoutForWidth(_ width: CGFloat) -> Layout {
+        // On iPads if the app is backgrounded then it changes the device orientation back and forth causing the UI to re-render and the submission to re-load.
+        // To overcome this we force the last presented layout in case the app is in the background.
+        guard UIApplication.shared.applicationState != .background else {
+            return lastPresentedLayout
+        }
+        return width > 834 ? .landscape : .portrait
+    }
+
+    private func didChangeLayout(to layout: Layout) {
+        if lastPresentedLayout != layout {
+            // When the layout changes the keyboard disappears without any system notifications
+            // on iPads so we simulate one to allow .avoidKeyboardArea() to work correctly.
+            NotificationCenter.default.post(name: UIApplication.keyboardWillHideNotification, object: nil, userInfo: [:])
+        }
+        lastPresentedLayout = layout
+    }
+}
+
+private func interpolate(value: CGFloat, fromMin: CGFloat, fromMax: CGFloat, toMin: CGFloat, toMax: CGFloat) -> CGFloat {
+    let bounded = max(fromMin, min(value, fromMax))
+    return (((toMax - toMin) / (fromMax - fromMin)) * (bounded - fromMin)) + toMin
+}
+
+extension NSNotification.Name {
+    public static var SpeedGraderAttemptPickerChanged = NSNotification.Name("com.instructure.core.speedgrader-attempt-changed")
path: Teacher/Teacher/SpeedGrader/MainLayout/View/SubmissionGraderView.swift
user: login: vargaat
body: Moved but to be honest this should be also refactored at a later stage.
created_at: 2025-04-15T14:54:50Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,429 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2020-present  Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+//
+
+import Core
+import SwiftUI
+import Combine
+
+struct SubmissionGraderView: View {
+    private enum Layout {
+        case portrait
+        case landscape // only on iPads no matter the iPhone screen size
+    }
+
+    let userIndexInSubmissionList: Int
+
+    @Environment(\.viewController) private var controller
+
+    @State private var selectedDrawerTabIndex = 0
+    @State private var drawerState: DrawerState = .min
+    @State private var showAttempts = false
+    @State private var tab: GraderTab = .grades
+    @State private var showRecorder: MediaCommentType?
+    /** Used to work around an issue which caused the page to re-load after putting the app into background. See `layoutForWidth()` method for more. */
+    @State private var lastPresentedLayout: Layout = .portrait
+    @AccessibilityFocusState private var focusedTab: GraderTab?
+
+    @StateObject private var commentLibrary = SubmissionCommentLibraryViewModel()
+    @StateObject private var rubricsViewModel: RubricsViewModel
+    @StateObject private var viewModel: SubmissionGraderViewModel
+
+    private var handleRefresh: (() -> Void)?
+
+    init(
+        env: AppEnvironment,
+        userIndexInSubmissionList: Int,
+        viewModel: SubmissionGraderViewModel,
+        handleRefresh: (() -> Void)?
+    ) {
+        self.userIndexInSubmissionList = userIndexInSubmissionList
+        self._viewModel = StateObject(wrappedValue: viewModel)
+        self.handleRefresh = handleRefresh
+        _rubricsViewModel = StateObject(wrappedValue:
+            RubricsViewModel(
+                assignment: viewModel.assignment,
+                submission: viewModel.submission,
+                interactor: RubricGradingInteractorLive(assignment: viewModel.assignment, submission: viewModel.submission)
+            )
+        )
+    }
+
+    var body: some View {
+        GeometryReader { geometry in
+            let bottomInset = geometry.safeAreaInsets.bottom
+            let minHeight = bottomInset + 58
+            let maxHeight = bottomInset + geometry.size.height - 64
+            // At 1/4 of a screen offset, scale to 90% and round corners to 20
+            let delta = abs(geometry.frame(in: .global).minX / max(1, geometry.size.width))
+            let scale = interpolate(value: delta, fromMin: 0, fromMax: 0.25, toMin: 1, toMax: 0.9)
+            let cornerRadius = interpolate(value: delta, fromMin: 0, fromMax: 0.25, toMin: 0, toMax: 20)
+
+            mainLayout(
+                geometry: geometry,
+                bottomInset: bottomInset,
+                minHeight: minHeight,
+                maxHeight: maxHeight
+            )
+            .background(Color.backgroundLightest)
+            .cornerRadius(cornerRadius)
+            .scaleEffect(scale)
+            .edgesIgnoringSafeArea(.bottom)
+        }
+        .avoidKeyboardArea()
+    }
+
+    @ViewBuilder
+    private func mainLayout(
+        geometry: GeometryProxy,
+        bottomInset: CGFloat,
+        minHeight: CGFloat,
+        maxHeight: CGFloat
+    ) -> some View {
+        switch layoutForWidth(geometry.size.width) {
+        case .landscape:
+            landscapeLayout(bottomInset: bottomInset)
+        case .portrait:
+            portraitLayout(minHeight: minHeight, maxHeight: maxHeight, bottomInset: bottomInset)
+        }
+    }
+
+    private func landscapeLayout(
+        bottomInset: CGFloat
+    ) -> some View {
+        VStack(spacing: 0) {
+            SubmissionHeaderView(assignment: viewModel.assignment, submission: viewModel.submission)
+                .accessibility(sortPriority: 2)
+            Divider()
+            HStack(spacing: 0) {
+                VStack(alignment: .leading, spacing: 0) {
+                    attemptToggle
+                    Divider()
+                    ZStack(alignment: .top) {
+                        VStack(spacing: 0) {
+                            SimilarityScoreView(viewModel.selectedAttempt, file: viewModel.file)
+                            SubmissionViewer(
+                                assignment: viewModel.assignment,
+                                submission: viewModel.selectedAttempt,
+                                fileID: viewModel.fileID,
+                                studentAnnotationViewModel: viewModel.studentAnnotationViewModel,
+                                handleRefresh: handleRefresh
+                            )
+                        }
+                        // Disable submission content interaction in case attempt picker is above it
+                        .accessibilityElement(children: showAttempts ? .ignore : .contain)
+                        .accessibility(hidden: showAttempts)
+                        attemptPicker
+                    }
+                    Spacer().frame(height: bottomInset)
+                }
+                .zIndex(1)
+                .accessibility(sortPriority: 1)
+                Divider()
+                VStack(spacing: 0) {
+                    tools(bottomInset: bottomInset, isDrawer: false)
+                }
+                .padding(.top, 16)
+                .frame(width: 375)
+            }
+        }
+        .onAppear { didChangeLayout(to: .landscape) }
+    }
+
+    private func portraitLayout(
+        minHeight: CGFloat,
+        maxHeight: CGFloat,
+        bottomInset: CGFloat
+    ) -> some View {
+        ZStack(alignment: .bottom) {
+            VStack(alignment: .leading, spacing: 0) {
+                SubmissionHeaderView(assignment: viewModel.assignment, submission: viewModel.submission)
+                attemptToggle
+                    .accessibility(hidden: drawerState == .max)
+                Divider()
+                let isSubmissionContentHiddenFromA11y = (drawerState != .min || showAttempts)
+                ZStack(alignment: .top) {
+                    VStack(spacing: 0) {
+                        SimilarityScoreView(viewModel.selectedAttempt, file: viewModel.file)
+                        SubmissionViewer(
+                            assignment: viewModel.assignment,
+                            submission: viewModel.selectedAttempt,
+                            fileID: viewModel.fileID,
+                            studentAnnotationViewModel: viewModel.studentAnnotationViewModel,
+                            handleRefresh: handleRefresh
+                        )
+                    }
+                    .accessibilityElement(children: isSubmissionContentHiddenFromA11y ? .ignore : .contain)
+                    .accessibility(hidden: isSubmissionContentHiddenFromA11y)
+                    attemptPicker
+                }
+                Spacer().frame(height: drawerState == .min ? minHeight : (minHeight + maxHeight) / 2)
+            }
+            DrawerContainer(state: $drawerState, minHeight: minHeight, maxHeight: maxHeight) {
+                tools(bottomInset: bottomInset, isDrawer: true)
+            }
+        }
+        .onAppear { didChangeLayout(to: .portrait) }
+    }
+
+    @ViewBuilder
+    private var attemptToggle: some View {
+        if viewModel.hasSubmissions {
+            Button {
+                showAttempts.toggle()
+            } label: {
+                HStack {
+                    Text("Attempt \(viewModel.selectedAttemptIndex)", bundle: .teacher)
+                    Spacer()
+                    Text(viewModel.selectedAttempt.submittedAt?.dateTimeString ?? "")
+                        .frame(minHeight: 24)
+
+                    if !viewModel.isSingleSubmission {
+                        Image.arrowOpenDownLine
+                            .resizable()
+                            .frame(width: 14, height: 14)
+                            .rotationEffect(.degrees(showAttempts ? 180 : 0))
+                    }
+                }
+                .font(.regular14)
+                .foregroundColor(.textDark)
+                .padding(EdgeInsets(top: 8, leading: 16, bottom: 4, trailing: 16))
+            }
+            .disabled(viewModel.isSingleSubmission)
+        }
+    }
+
+    @ViewBuilder
+    private var attemptPicker: some View {
+        if showAttempts {
+            VStack(spacing: 0) {
+                let binding = Binding(
+                    get: {
+                        viewModel.selectedAttemptIndex
+                    },
+                    set: { newAttemptIndex in
+                        withTransaction(.exclusive()) {
+                            viewModel.didSelectNewAttempt(attemptIndex: newAttemptIndex)
+                        }
+                        showAttempts = false
+                    }
+                )
+                Picker(selection: binding, label: Text(verbatim: "")) {
+                    ForEach(viewModel.attempts, id: \.attempt) { attempt in
+                        Text(attempt.submittedAt?.dateTimeString ?? "")
+                            .tag(Optional(attempt.attempt))
+                    }
+                }
+                .labelsHidden()
+                .pickerStyle(WheelPickerStyle())
+                Divider()
+            }
+            .background(Color.backgroundLightest)
+        }
+    }
+
+    enum GraderTab: Int, CaseIterable { case grades, comments, files }
+
+    private var segmentedTitles: [String] {
+        [
+            String(localized: "Grades", bundle: .teacher),
+            String(localized: "Comments", bundle: .teacher),
+            viewModel.fileTabTitle
+        ]
+    }
+
+    @ViewBuilder
+    private func tools(bottomInset: CGFloat, isDrawer: Bool) -> some View {
+        SegmentedPicker(
+            segmentedTitles,
+            selectedIndex: Binding(
+                get: { selectedDrawerTabIndex },
+                set: { newValue in
+                    selectedDrawerTabIndex = newValue ?? 0
+                    if drawerState == .min {
+                        snapDrawerTo(.mid)
+                    }
+                    let newTab = SubmissionGraderView.GraderTab(rawValue: newValue ?? 0)!
+                    withAnimation(.default) {
+                        tab = newTab
+                    }
+                    controller.view.endEditing(true)
+                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+                        focusedTab = tab
+                    }
+                }
+            ),
+            selectionAlignment: .bottom,
+            content: { item, _ in
+                Text(item)
+                    .font(.regular14)
+                    .foregroundColor(.textDark)
+                    .padding(.horizontal, 16)
+                    .padding(.vertical, 8)
+                    .frame(maxWidth: .infinity)
+                    .contentShape(Rectangle())
+            }
+        )
+        .identifier("SpeedGrader.toolPicker")
+        Divider()
+        GeometryReader { geometry in
+            HStack(spacing: 0) {
+                let drawerFileID = Binding<String?>(
+                    get: {
+                        viewModel.fileID
+                    },
+                    set: {
+                        viewModel.didSelectFile(fileID: $0)
+                        snapDrawerTo(.min)
+                    }
+                )
+
+                gradesTab(bottomInset: bottomInset, isDrawer: isDrawer, geometry: geometry)
+                commentsTab(bottomInset: bottomInset, isDrawer: isDrawer, fileID: drawerFileID, geometry: geometry)
+                filesTab(bottomInset: bottomInset, isDrawer: isDrawer, fileID: drawerFileID, geometry: geometry)
+            }
+            .frame(width: geometry.size.width, alignment: .leading)
+            .background(Color.backgroundLightest)
+            .offset(x: -CGFloat(tab.rawValue) * geometry.size.width)
+        }
+        .clipped()
+    }
+
+    @ViewBuilder
+    private func filesTab(
+        bottomInset: CGFloat,
+        isDrawer: Bool,
+        fileID: Binding<String?>,
+        geometry: GeometryProxy
+    ) -> some View {
+        let isFilesOnScreen = isGraderTabOnScreen(.files, isDrawer: isDrawer)
+        VStack(spacing: 0) {
+            SubmissionFileList(submission: viewModel.selectedAttempt, fileID: fileID)
+                .clipped()
+            Spacer().frame(height: bottomInset)
+        }
+        .frame(width: geometry.size.width, height: geometry.size.height)
+        .accessibilityElement(children: isFilesOnScreen ? .contain : .ignore)
+        .accessibility(hidden: !isFilesOnScreen)
+        .accessibilityFocused($focusedTab, equals: .files)
+    }
+
+    @ViewBuilder
+    private func commentsTab(
+        bottomInset: CGFloat,
+        isDrawer: Bool,
+        fileID: Binding<String?>,
+        geometry: GeometryProxy
+    ) -> some View {
+        let drawerAttempt = Binding(
+            get: {
+                viewModel.selectedAttemptIndex
+            }, set: {
+                viewModel.didSelectNewAttempt(attemptIndex: $0)
+                snapDrawerTo(.min)
+            }
+        )
+        let isCommentsOnScreen = isGraderTabOnScreen(.comments, isDrawer: isDrawer)
+        VStack(spacing: 0) {
+            SubmissionCommentListView(
+                assignment: viewModel.assignment,
+                submission: viewModel.submission,
+                attempts: viewModel.attempts,
+                attempt: drawerAttempt,
+                fileID: fileID,
+                showRecorder: $showRecorder,
+                enteredComment: $viewModel.enteredComment,
+                commentLibrary: commentLibrary,
+                focusedTab: _focusedTab
+            )
+            .clipped()
+            if showRecorder != .video || drawerState == .min {
+                Spacer().frame(height: bottomInset)
+            }
+        }
+        .frame(width: geometry.size.width, height: geometry.size.height)
+        .background(Color.backgroundLight)
+        .accessibilityElement(children: isCommentsOnScreen ? .contain : .ignore)
+        .accessibility(hidden: !isCommentsOnScreen)
+    }
+
+    @ViewBuilder
+    private func gradesTab(
path: Teacher/Teacher/SpeedGrader/MainLayout/View/SubmissionGraderView.swift
user: login: vargaat
body: Done.
created_at: 2025-04-15T15:00:51Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,429 @@
+//
+// This file is part of Canvas.
+// Copyright (C) 2020-present  Instructure, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+//
+
+import Core
+import SwiftUI
+import Combine
+
+struct SubmissionGraderView: View {
+    private enum Layout {
+        case portrait
+        case landscape // only on iPads no matter the iPhone screen size
+    }
+
+    let userIndexInSubmissionList: Int
+
+    @Environment(\.viewController) private var controller
+
+    @State private var selectedDrawerTabIndex = 0
+    @State private var drawerState: DrawerState = .min
+    @State private var showAttempts = false
+    @State private var tab: GraderTab = .grades
+    @State private var showRecorder: MediaCommentType?
+    /** Used to work around an issue which caused the page to re-load after putting the app into background. See `layoutForWidth()` method for more. */
+    @State private var lastPresentedLayout: Layout = .portrait
+    @AccessibilityFocusState private var focusedTab: GraderTab?
+
+    @StateObject private var commentLibrary = SubmissionCommentLibraryViewModel()
+    @StateObject private var rubricsViewModel: RubricsViewModel
+    @StateObject private var viewModel: SubmissionGraderViewModel
+
+    private var handleRefresh: (() -> Void)?
+
+    init(
+        env: AppEnvironment,
+        userIndexInSubmissionList: Int,
+        viewModel: SubmissionGraderViewModel,
+        handleRefresh: (() -> Void)?
+    ) {
+        self.userIndexInSubmissionList = userIndexInSubmissionList
+        self._viewModel = StateObject(wrappedValue: viewModel)
+        self.handleRefresh = handleRefresh
+        _rubricsViewModel = StateObject(wrappedValue:
+            RubricsViewModel(
+                assignment: viewModel.assignment,
+                submission: viewModel.submission,
+                interactor: RubricGradingInteractorLive(assignment: viewModel.assignment, submission: viewModel.submission)
+            )
+        )
+    }
+
+    var body: some View {
+        GeometryReader { geometry in
+            let bottomInset = geometry.safeAreaInsets.bottom
+            let minHeight = bottomInset + 58
+            let maxHeight = bottomInset + geometry.size.height - 64
+            // At 1/4 of a screen offset, scale to 90% and round corners to 20
+            let delta = abs(geometry.frame(in: .global).minX / max(1, geometry.size.width))
+            let scale = interpolate(value: delta, fromMin: 0, fromMax: 0.25, toMin: 1, toMax: 0.9)
+            let cornerRadius = interpolate(value: delta, fromMin: 0, fromMax: 0.25, toMin: 0, toMax: 20)
+
+            mainLayout(
+                geometry: geometry,
+                bottomInset: bottomInset,
+                minHeight: minHeight,
+                maxHeight: maxHeight
+            )
+            .background(Color.backgroundLightest)
+            .cornerRadius(cornerRadius)
+            .scaleEffect(scale)
+            .edgesIgnoringSafeArea(.bottom)
+        }
+        .avoidKeyboardArea()
+    }
+
+    @ViewBuilder
+    private func mainLayout(
+        geometry: GeometryProxy,
+        bottomInset: CGFloat,
+        minHeight: CGFloat,
+        maxHeight: CGFloat
+    ) -> some View {
+        switch layoutForWidth(geometry.size.width) {
+        case .landscape:
+            landscapeLayout(bottomInset: bottomInset)
+        case .portrait:
+            portraitLayout(minHeight: minHeight, maxHeight: maxHeight, bottomInset: bottomInset)
+        }
+    }
+
+    private func landscapeLayout(
+        bottomInset: CGFloat
+    ) -> some View {
+        VStack(spacing: 0) {
+            SubmissionHeaderView(assignment: viewModel.assignment, submission: viewModel.submission)
+                .accessibility(sortPriority: 2)
+            Divider()
+            HStack(spacing: 0) {
+                VStack(alignment: .leading, spacing: 0) {
+                    attemptToggle
+                    Divider()
+                    ZStack(alignment: .top) {
+                        VStack(spacing: 0) {
+                            SimilarityScoreView(viewModel.selectedAttempt, file: viewModel.file)
+                            SubmissionViewer(
+                                assignment: viewModel.assignment,
+                                submission: viewModel.selectedAttempt,
+                                fileID: viewModel.fileID,
+                                studentAnnotationViewModel: viewModel.studentAnnotationViewModel,
+                                handleRefresh: handleRefresh
+                            )
+                        }
+                        // Disable submission content interaction in case attempt picker is above it
+                        .accessibilityElement(children: showAttempts ? .ignore : .contain)
+                        .accessibility(hidden: showAttempts)
+                        attemptPicker
+                    }
+                    Spacer().frame(height: bottomInset)
+                }
+                .zIndex(1)
+                .accessibility(sortPriority: 1)
+                Divider()
+                VStack(spacing: 0) {
+                    tools(bottomInset: bottomInset, isDrawer: false)
+                }
+                .padding(.top, 16)
+                .frame(width: 375)
+            }
+        }
+        .onAppear { didChangeLayout(to: .landscape) }
+    }
+
+    private func portraitLayout(
+        minHeight: CGFloat,
+        maxHeight: CGFloat,
+        bottomInset: CGFloat
+    ) -> some View {
+        ZStack(alignment: .bottom) {
+            VStack(alignment: .leading, spacing: 0) {
+                SubmissionHeaderView(assignment: viewModel.assignment, submission: viewModel.submission)
+                attemptToggle
+                    .accessibility(hidden: drawerState == .max)
+                Divider()
+                let isSubmissionContentHiddenFromA11y = (drawerState != .min || showAttempts)
+                ZStack(alignment: .top) {
+                    VStack(spacing: 0) {
+                        SimilarityScoreView(viewModel.selectedAttempt, file: viewModel.file)
+                        SubmissionViewer(
+                            assignment: viewModel.assignment,
+                            submission: viewModel.selectedAttempt,
+                            fileID: viewModel.fileID,
+                            studentAnnotationViewModel: viewModel.studentAnnotationViewModel,
+                            handleRefresh: handleRefresh
+                        )
+                    }
+                    .accessibilityElement(children: isSubmissionContentHiddenFromA11y ? .ignore : .contain)
+                    .accessibility(hidden: isSubmissionContentHiddenFromA11y)
+                    attemptPicker
+                }
+                Spacer().frame(height: drawerState == .min ? minHeight : (minHeight + maxHeight) / 2)
+            }
+            DrawerContainer(state: $drawerState, minHeight: minHeight, maxHeight: maxHeight) {
+                tools(bottomInset: bottomInset, isDrawer: true)
+            }
+        }
+        .onAppear { didChangeLayout(to: .portrait) }
+    }
+
+    @ViewBuilder
+    private var attemptToggle: some View {
+        if viewModel.hasSubmissions {
+            Button {
+                showAttempts.toggle()
+            } label: {
+                HStack {
+                    Text("Attempt \(viewModel.selectedAttemptIndex)", bundle: .teacher)
+                    Spacer()
+                    Text(viewModel.selectedAttempt.submittedAt?.dateTimeString ?? "")
+                        .frame(minHeight: 24)
+
+                    if !viewModel.isSingleSubmission {
+                        Image.arrowOpenDownLine
+                            .resizable()
+                            .frame(width: 14, height: 14)
+                            .rotationEffect(.degrees(showAttempts ? 180 : 0))
+                    }
+                }
+                .font(.regular14)
+                .foregroundColor(.textDark)
+                .padding(EdgeInsets(top: 8, leading: 16, bottom: 4, trailing: 16))
+            }
+            .disabled(viewModel.isSingleSubmission)
+        }
+    }
+
+    @ViewBuilder
+    private var attemptPicker: some View {
+        if showAttempts {
+            VStack(spacing: 0) {
+                let binding = Binding(
+                    get: {
+                        viewModel.selectedAttemptIndex
+                    },
+                    set: { newAttemptIndex in
+                        withTransaction(.exclusive()) {
+                            viewModel.didSelectNewAttempt(attemptIndex: newAttemptIndex)
+                        }
+                        showAttempts = false
+                    }
+                )
+                Picker(selection: binding, label: Text(verbatim: "")) {
+                    ForEach(viewModel.attempts, id: \.attempt) { attempt in
+                        Text(attempt.submittedAt?.dateTimeString ?? "")
+                            .tag(Optional(attempt.attempt))
+                    }
+                }
+                .labelsHidden()
+                .pickerStyle(WheelPickerStyle())
+                Divider()
+            }
+            .background(Color.backgroundLightest)
+        }
+    }
+
+    enum GraderTab: Int, CaseIterable { case grades, comments, files }
+
+    private var segmentedTitles: [String] {
+        [
+            String(localized: "Grades", bundle: .teacher),
+            String(localized: "Comments", bundle: .teacher),
+            viewModel.fileTabTitle
+        ]
+    }
+
+    @ViewBuilder
+    private func tools(bottomInset: CGFloat, isDrawer: Bool) -> some View {
+        SegmentedPicker(
+            segmentedTitles,
+            selectedIndex: Binding(
+                get: { selectedDrawerTabIndex },
+                set: { newValue in
+                    selectedDrawerTabIndex = newValue ?? 0
+                    if drawerState == .min {
+                        snapDrawerTo(.mid)
+                    }
+                    let newTab = SubmissionGraderView.GraderTab(rawValue: newValue ?? 0)!
+                    withAnimation(.default) {
+                        tab = newTab
+                    }
+                    controller.view.endEditing(true)
+                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+                        focusedTab = tab
+                    }
+                }
+            ),
+            selectionAlignment: .bottom,
+            content: { item, _ in
+                Text(item)
+                    .font(.regular14)
+                    .foregroundColor(.textDark)
+                    .padding(.horizontal, 16)
+                    .padding(.vertical, 8)
+                    .frame(maxWidth: .infinity)
+                    .contentShape(Rectangle())
+            }
+        )
+        .identifier("SpeedGrader.toolPicker")
+        Divider()
+        GeometryReader { geometry in
+            HStack(spacing: 0) {
+                let drawerFileID = Binding<String?>(
+                    get: {
+                        viewModel.fileID
+                    },
+                    set: {
+                        viewModel.didSelectFile(fileID: $0)
+                        snapDrawerTo(.min)
+                    }
+                )
+
+                gradesTab(bottomInset: bottomInset, isDrawer: isDrawer, geometry: geometry)
+                commentsTab(bottomInset: bottomInset, isDrawer: isDrawer, fileID: drawerFileID, geometry: geometry)
+                filesTab(bottomInset: bottomInset, isDrawer: isDrawer, fileID: drawerFileID, geometry: geometry)
+            }
+            .frame(width: geometry.size.width, alignment: .leading)
+            .background(Color.backgroundLightest)
+            .offset(x: -CGFloat(tab.rawValue) * geometry.size.width)
+        }
+        .clipped()
+    }
+
+    @ViewBuilder
+    private func filesTab(
+        bottomInset: CGFloat,
+        isDrawer: Bool,
+        fileID: Binding<String?>,
+        geometry: GeometryProxy
+    ) -> some View {
+        let isFilesOnScreen = isGraderTabOnScreen(.files, isDrawer: isDrawer)
+        VStack(spacing: 0) {
+            SubmissionFileList(submission: viewModel.selectedAttempt, fileID: fileID)
+                .clipped()
+            Spacer().frame(height: bottomInset)
+        }
+        .frame(width: geometry.size.width, height: geometry.size.height)
+        .accessibilityElement(children: isFilesOnScreen ? .contain : .ignore)
+        .accessibility(hidden: !isFilesOnScreen)
+        .accessibilityFocused($focusedTab, equals: .files)
+    }
+
+    @ViewBuilder
+    private func commentsTab(
+        bottomInset: CGFloat,
+        isDrawer: Bool,
+        fileID: Binding<String?>,
+        geometry: GeometryProxy
+    ) -> some View {
+        let drawerAttempt = Binding(
+            get: {
+                viewModel.selectedAttemptIndex
+            }, set: {
+                viewModel.didSelectNewAttempt(attemptIndex: $0)
+                snapDrawerTo(.min)
+            }
+        )
+        let isCommentsOnScreen = isGraderTabOnScreen(.comments, isDrawer: isDrawer)
+        VStack(spacing: 0) {
+            SubmissionCommentListView(
+                assignment: viewModel.assignment,
+                submission: viewModel.submission,
+                attempts: viewModel.attempts,
+                attempt: drawerAttempt,
+                fileID: fileID,
+                showRecorder: $showRecorder,
+                enteredComment: $viewModel.enteredComment,
+                commentLibrary: commentLibrary,
+                focusedTab: _focusedTab
+            )
+            .clipped()
+            if showRecorder != .video || drawerState == .min {
+                Spacer().frame(height: bottomInset)
+            }
+        }
+        .frame(width: geometry.size.width, height: geometry.size.height)
+        .background(Color.backgroundLight)
+        .accessibilityElement(children: isCommentsOnScreen ? .contain : .ignore)
+        .accessibility(hidden: !isCommentsOnScreen)
+    }
+
+    @ViewBuilder
+    private func gradesTab(
+        bottomInset: CGFloat,
+        isDrawer: Bool,
+        geometry: GeometryProxy
+    ) -> some View {
+        let isGradesOnScreen = isGraderTabOnScreen(.grades, isDrawer: isDrawer)
+        VStack(spacing: 0) {
+            SubmissionGrades(
+                assignment: viewModel.assignment,
+                containerHeight: geometry.size.height,
+                submission: viewModel.submission,
+                rubricsViewModel: rubricsViewModel
+            )
+            .clipped()
+            Spacer().frame(height: bottomInset)
+        }
+        .frame(width: geometry.size.width, height: geometry.size.height)
+        .accessibilityElement(children: isGradesOnScreen ? .contain : .ignore)
+        .accessibility(hidden: !isGradesOnScreen)
+        .accessibilityFocused($focusedTab, equals: .grades)
+    }
+
+    private func snapDrawerTo(_ state: DrawerState) {
+        withTransaction(DrawerState.transaction) {
+            drawerState = state
+        }
+    }
+
+    private func isGraderTabOnScreen(_ tab: GraderTab, isDrawer: Bool) -> Bool {
+        let isTabSelected = (self.tab == tab)
+
+        if isDrawer {
+            return (drawerState != .min && isTabSelected)
+        } else {
+            return isTabSelected
+        }
+    }
+
+    private func layoutForWidth(_ width: CGFloat) -> Layout {
+        // On iPads if the app is backgrounded then it changes the device orientation back and forth causing the UI to re-render and the submission to re-load.
+        // To overcome this we force the last presented layout in case the app is in the background.
+        guard UIApplication.shared.applicationState != .background else {
+            return lastPresentedLayout
+        }
+        return width > 834 ? .landscape : .portrait
+    }
+
+    private func didChangeLayout(to layout: Layout) {
+        if lastPresentedLayout != layout {
+            // When the layout changes the keyboard disappears without any system notifications
+            // on iPads so we simulate one to allow .avoidKeyboardArea() to work correctly.
+            NotificationCenter.default.post(name: UIApplication.keyboardWillHideNotification, object: nil, userInfo: [:])
+        }
+        lastPresentedLayout = layout
+    }
+}
+
+private func interpolate(value: CGFloat, fromMin: CGFloat, fromMax: CGFloat, toMin: CGFloat, toMax: CGFloat) -> CGFloat {
+    let bounded = max(fromMin, min(value, fromMax))
+    return (((toMax - toMin) / (fromMax - fromMin)) * (bounded - fromMin)) + toMin
+}
+
+extension NSNotification.Name {
+    public static var SpeedGraderAttemptPickerChanged = NSNotification.Name("com.instructure.core.speedgrader-attempt-changed")
path: Teacher/Teacher/SpeedGrader/MainLayout/View/SubmissionGraderView.swift
user: login: rh12
body: Yes, definitely, it just seemed less likely to be forgotten there
created_at: 2025-04-16T08:38:42Z
author_association: CONTRIBUTOR

PR comments:
user: login: inst-danger
created_at: 2025-04-14T07:53:35Z
body: <details><summary>Teacher Build QR Code:</summary>
<img src="https://api.qrserver.com/v1/create-qr-code/?data=https%3A%2F%2Fapp.bitrise.io%2Fapp%2F88a3641e29e4745c%2Finstallable-artifacts%2F5aeeb4beb39082c6%2Fpublic-install-page%2F939541f4e72c045c6761ca09c0563d70&size=150x150" />
</details>


<!-- TeacherBuild -->
author_association: CONTRIBUTOR
user: login: inst-danger
created_at: 2025-04-14T07:57:52Z
body: 
<!--
  0 failure: 
  1 warning:  This pull request...
  
  3 markdown notices
  DangerID: danger-id-Danger;
-->


<table>
  <thead>
    <tr>
      <th width="50"></th>
      <th width="100%" data-danger-table="true">Warnings</th>
    </tr>
  </thead>
  <tbody><tr>
      <td>:warning:</td>
      <td>This pull request will not generate a release note.</td>
    </tr>
  </tbody>
</table>


#### Affected Apps: Teacher

[MBL-18708](https://instructure.atlassian.net/browse/MBL-18708)

Coverage | New % | Master % | Delta
-------- | ----- | -------- | -----
**Canvas iOS** | 91.62% | 91.62% | 0%
<p align="right">
  Generated by :no_entry_sign: <a href="https://danger.systems/js">dangerJS</a> against 7edd0e10e346a3b18cc4cff1206b6831b36b572a
</p>


[MBL-18708]: https://instructure.atlassian.net/browse/MBL-18708?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
author_association: CONTRIBUTOR