diff --git a/Artemis.xcodeproj/project.pbxproj b/Artemis.xcodeproj/project.pbxproj index 5b14c4e0..19425549 100644 --- a/Artemis.xcodeproj/project.pbxproj +++ b/Artemis.xcodeproj/project.pbxproj @@ -468,9 +468,9 @@ ENABLE_TESTING_SEARCH_PATHS = YES; FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = Artemis/Supporting/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Artemis - Learning"; + INFOPLIST_KEY_CFBundleDisplayName = Artemis; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -497,9 +497,9 @@ ENABLE_TESTING_SEARCH_PATHS = YES; FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = Artemis/Supporting/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Artemis - Learning"; + INFOPLIST_KEY_CFBundleDisplayName = Artemis; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index fb8536dc..38738485 100644 --- a/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Artemis.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "da5f81e1ea4d3ca4bb9cdba1505f382a0747884879f3fd6ab52e1aea178f1dab", "pins" : [ { "identity" : "apollon-ios-module", @@ -14,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ls1intum/artemis-ios-core-modules", "state" : { - "revision" : "dc9ddaf88a726ed1fc454e865b21bc1b4fb0c343", - "version" : "14.5.2" + "revision" : "13e3416ec64c8e8670a017d544f668be43ee3188", + "version" : "14.7.0" } }, { @@ -59,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/gonzalezreal/NetworkImage", "state" : { - "revision" : "7aff8d1b31148d32c5933d75557d42f6323ee3d1", - "version" : "6.0.0" + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" } }, { @@ -68,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mac-cain13/R.swift.git", "state" : { - "revision" : "384eab88d1a0b98ac96f4819e50a4308ecd5359f", - "version" : "7.5.0" + "revision" : "4ac2eb7e6157887c9f59dc5ccc5978d51546be6d", + "version" : "7.7.0" } }, { @@ -104,8 +105,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "46989693916f56d1186bd59ac15124caef896560", - "version" : "1.3.1" + "revision" : "41982a3656a71c768319979febd796c6fd111d5c", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53", + "version" : "0.5.0" } }, { @@ -113,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/gonzalezreal/swift-markdown-ui", "state" : { - "revision" : "55441810c0f678c78ed7e2ebd46dde89228e02fc", - "version" : "2.4.0" + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" } }, { @@ -176,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/tomlokhorst/XcodeEdit", "state" : { - "revision" : "b6b67389a0f1a6fdd9c6457a8ab5b02eaab13c5c", - "version" : "2.9.2" + "revision" : "017d23f71fa8d025989610db26d548c44cacefae", + "version" : "2.10.2" } }, { @@ -190,5 +200,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/1024 1.png b/Artemis/Assets.xcassets/AppIcon.appiconset/1024 1.png new file mode 100644 index 00000000..40f48279 Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/1024 1.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/1024 2.png b/Artemis/Assets.xcassets/AppIcon.appiconset/1024 2.png new file mode 100644 index 00000000..033b0ba6 Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/1024 2.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/114 1.png b/Artemis/Assets.xcassets/AppIcon.appiconset/114 1.png new file mode 100644 index 00000000..6da4bb42 Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/114 1.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/120 1.png b/Artemis/Assets.xcassets/AppIcon.appiconset/120 1.png new file mode 100644 index 00000000..c37712c2 Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/120 1.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/120 2.png b/Artemis/Assets.xcassets/AppIcon.appiconset/120 2.png new file mode 100644 index 00000000..9bca4b91 Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/120 2.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/120 3.png b/Artemis/Assets.xcassets/AppIcon.appiconset/120 3.png new file mode 100644 index 00000000..9bca4b91 Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/120 3.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/128 1.png b/Artemis/Assets.xcassets/AppIcon.appiconset/128 1.png new file mode 100644 index 00000000..71afe0b8 Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/128 1.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/144.png b/Artemis/Assets.xcassets/AppIcon.appiconset/144.png deleted file mode 100644 index 242a276c..00000000 Binary files a/Artemis/Assets.xcassets/AppIcon.appiconset/144.png and /dev/null differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/152 1.png b/Artemis/Assets.xcassets/AppIcon.appiconset/152 1.png new file mode 100644 index 00000000..f8e66bef Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/152 1.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/167 1.png b/Artemis/Assets.xcassets/AppIcon.appiconset/167 1.png new file mode 100644 index 00000000..7b472e19 Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/167 1.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/180 1.png b/Artemis/Assets.xcassets/AppIcon.appiconset/180 1.png new file mode 100644 index 00000000..a44243c2 Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/180 1.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/20.png b/Artemis/Assets.xcassets/AppIcon.appiconset/20.png deleted file mode 100644 index b4df6a34..00000000 Binary files a/Artemis/Assets.xcassets/AppIcon.appiconset/20.png and /dev/null differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/29.png b/Artemis/Assets.xcassets/AppIcon.appiconset/29.png deleted file mode 100644 index f50b49b8..00000000 Binary files a/Artemis/Assets.xcassets/AppIcon.appiconset/29.png and /dev/null differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/40 1.png b/Artemis/Assets.xcassets/AppIcon.appiconset/40 1.png new file mode 100644 index 00000000..1204901a Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/40 1.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/50.png b/Artemis/Assets.xcassets/AppIcon.appiconset/50.png deleted file mode 100644 index f4d9c843..00000000 Binary files a/Artemis/Assets.xcassets/AppIcon.appiconset/50.png and /dev/null differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/57.png b/Artemis/Assets.xcassets/AppIcon.appiconset/57.png deleted file mode 100644 index c86aee0b..00000000 Binary files a/Artemis/Assets.xcassets/AppIcon.appiconset/57.png and /dev/null differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/58 1.png b/Artemis/Assets.xcassets/AppIcon.appiconset/58 1.png new file mode 100644 index 00000000..60db8727 Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/58 1.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/58 2.png b/Artemis/Assets.xcassets/AppIcon.appiconset/58 2.png new file mode 100644 index 00000000..6f9c21ca Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/58 2.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/60 1.png b/Artemis/Assets.xcassets/AppIcon.appiconset/60 1.png new file mode 100644 index 00000000..f194910e Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/60 1.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/72.png b/Artemis/Assets.xcassets/AppIcon.appiconset/72.png deleted file mode 100644 index 0b5abbc7..00000000 Binary files a/Artemis/Assets.xcassets/AppIcon.appiconset/72.png and /dev/null differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/76 1.png b/Artemis/Assets.xcassets/AppIcon.appiconset/76 1.png new file mode 100644 index 00000000..fe42ee20 Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/76 1.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/80 1.png b/Artemis/Assets.xcassets/AppIcon.appiconset/80 1.png new file mode 100644 index 00000000..ead90b11 Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/80 1.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/80 2.png b/Artemis/Assets.xcassets/AppIcon.appiconset/80 2.png new file mode 100644 index 00000000..dc0e8798 Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/80 2.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/87 1.png b/Artemis/Assets.xcassets/AppIcon.appiconset/87 1.png new file mode 100644 index 00000000..2e781af7 Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/87 1.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/87 2.png b/Artemis/Assets.xcassets/AppIcon.appiconset/87 2.png new file mode 100644 index 00000000..4b827431 Binary files /dev/null and b/Artemis/Assets.xcassets/AppIcon.appiconset/87 2.png differ diff --git a/Artemis/Assets.xcassets/AppIcon.appiconset/Contents.json b/Artemis/Assets.xcassets/AppIcon.appiconset/Contents.json index 8e70699a..8149b5f6 100644 --- a/Artemis/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Artemis/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -2,152 +2,506 @@ "images" : [ { "filename" : "40.png", - "idiom" : "iphone", + "idiom" : "universal", + "platform" : "ios", "scale" : "2x", "size" : "20x20" }, { "filename" : "60.png", - "idiom" : "iphone", + "idiom" : "universal", + "platform" : "ios", "scale" : "3x", "size" : "20x20" }, { - "filename" : "29.png", - "idiom" : "iphone", - "scale" : "1x", + "filename" : "58 1.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", "size" : "29x29" }, { - "filename" : "58.png", - "idiom" : "iphone", - "scale" : "2x", + "filename" : "87 1.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", "size" : "29x29" }, { - "filename" : "87.png", - "idiom" : "iphone", + "filename" : "76.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "38x38" + }, + { + "filename" : "114.png", + "idiom" : "universal", + "platform" : "ios", "scale" : "3x", - "size" : "29x29" + "size" : "38x38" }, { - "filename" : "80.png", - "idiom" : "iphone", + "filename" : "80 1.png", + "idiom" : "universal", + "platform" : "ios", "scale" : "2x", "size" : "40x40" }, { - "filename" : "120.png", - "idiom" : "iphone", + "filename" : "120 1.png", + "idiom" : "universal", + "platform" : "ios", "scale" : "3x", "size" : "40x40" }, { - "filename" : "57.png", - "idiom" : "iphone", - "scale" : "1x", - "size" : "57x57" + "filename" : "120.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "60x60" }, { - "filename" : "114.png", - "idiom" : "iphone", + "filename" : "180.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "universal", + "platform" : "ios", "scale" : "2x", - "size" : "57x57" + "size" : "64x64" }, { - "filename" : "120.png", - "idiom" : "iphone", + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "64x64" + }, + { + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "68x68" + }, + { + "filename" : "152.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "167.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "1024 1.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "40 1.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "20x20" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "60 1.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "20x20" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "58 2.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "29x29" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "87 2.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "29x29" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "76 1.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "38x38" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "114 1.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "38x38" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "80 2.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "40x40" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "120 2.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "40x40" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "120 3.png", + "idiom" : "universal", + "platform" : "ios", "scale" : "2x", "size" : "60x60" }, { - "filename" : "180.png", - "idiom" : "iphone", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "180 1.png", + "idiom" : "universal", + "platform" : "ios", "scale" : "3x", "size" : "60x60" }, { - "filename" : "20.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "20x20" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "128 1.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "64x64" }, { - "filename" : "40.png", - "idiom" : "ipad", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "64x64" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "68x68" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "152 1.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "76x76" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "167 1.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "1024 2.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", "scale" : "2x", "size" : "20x20" }, { - "filename" : "29.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "29x29" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "20x20" }, { - "filename" : "58.png", - "idiom" : "ipad", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", "scale" : "2x", "size" : "29x29" }, { - "filename" : "40.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "40x40" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "29x29" }, { - "filename" : "80.png", - "idiom" : "ipad", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "38x38" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "38x38" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", "scale" : "2x", "size" : "40x40" }, { - "filename" : "50.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "50x50" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "40x40" }, { - "filename" : "100.png", - "idiom" : "ipad", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", "scale" : "2x", - "size" : "50x50" + "size" : "60x60" }, { - "filename" : "72.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "72x72" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "60x60" }, { - "filename" : "144.png", - "idiom" : "ipad", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", "scale" : "2x", - "size" : "72x72" + "size" : "64x64" }, { - "filename" : "76.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "76x76" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "64x64" }, { - "filename" : "152.png", - "idiom" : "ipad", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "68x68" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", "scale" : "2x", "size" : "76x76" }, { - "filename" : "167.png", - "idiom" : "ipad", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", "scale" : "2x", "size" : "83.5x83.5" }, { - "filename" : "1024.png", - "idiom" : "ios-marketing", - "scale" : "1x", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", "size" : "1024x1024" }, { diff --git a/Artemis/Supporting/Info.plist b/Artemis/Supporting/Info.plist index be536268..c5dad143 100644 --- a/Artemis/Supporting/Info.plist +++ b/Artemis/Supporting/Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.3.2 + 1.4.0 CFBundleVersion 1 ITSAppUsesNonExemptEncryption diff --git a/ArtemisKit/Package.swift b/ArtemisKit/Package.swift index 5f4503d2..4be84dde 100644 --- a/ArtemisKit/Package.swift +++ b/ArtemisKit/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -7,7 +7,7 @@ let package = Package( name: "ArtemisKit", defaultLocalization: "en", platforms: [ - .iOS(.v17) + .iOS(.v18) ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. @@ -20,8 +20,8 @@ let package = Package( dependencies: [ .package(url: "https://github.com/Kelvas09/EmojiPicker.git", from: "1.0.0"), .package(url: "https://github.com/ls1intum/apollon-ios-module", .upToNextMajor(from: "1.0.2")), - .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "14.5.2")), - .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.0.0") + .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "14.7.0")), + .package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.7.0") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -51,6 +51,7 @@ let package = Package( .target( name: "CourseView", dependencies: [ + "Faq", "Messages", "Navigation", .product(name: "ApollonEdit", package: "apollon-ios-module"), @@ -89,6 +90,22 @@ let package = Package( dependencies: [ .product(name: "Common", package: "artemis-ios-core-modules") ]), + .target( + name: "Faq", + dependencies: [ + "Extensions", + "Navigation", + .product(name: "APIClient", package: "artemis-ios-core-modules"), + .product(name: "ArtemisMarkdown", package: "artemis-ios-core-modules"), + .product(name: "DesignLibrary", package: "artemis-ios-core-modules"), + .product(name: "SharedModels", package: "artemis-ios-core-modules"), + .product(name: "SharedServices", package: "artemis-ios-core-modules"), + .product(name: "UserStore", package: "artemis-ios-core-modules"), + .product(name: "RswiftLibrary", package: "R.swift") + ], + plugins: [ + .plugin(name: "RswiftGeneratePublicResources", package: "R.swift") + ]), .target( name: "Messages", dependencies: [ @@ -137,5 +154,7 @@ let package = Package( dependencies: [ "Messages" ]) - ] + ], + // TODO: Eventually upgrade + swiftLanguageModes: [.v5] ) diff --git a/ArtemisKit/Sources/CourseView/CourseView.swift b/ArtemisKit/Sources/CourseView/CourseView.swift index 35455699..7ac55e93 100644 --- a/ArtemisKit/Sources/CourseView/CourseView.swift +++ b/ArtemisKit/Sources/CourseView/CourseView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Faq import Common import SharedModels import Navigation @@ -18,7 +19,7 @@ public struct CourseView: View { public var body: some View { TabView(selection: $navigationController.courseTab) { - FixBlankScreenView { + TabBarIpad { ExerciseListView(viewModel: viewModel, searchText: $searchText) } .tabItem { @@ -26,7 +27,7 @@ public struct CourseView: View { } .tag(TabIdentifier.exercise) - FixBlankScreenView { + TabBarIpad { LectureListView(viewModel: viewModel, searchText: $searchText) } .tabItem { @@ -35,19 +36,32 @@ public struct CourseView: View { .tag(TabIdentifier.lecture) if viewModel.isMessagesVisible { - MessagesTabView(course: viewModel.course, searchText: $searchText) - .environmentObject(messagesPreferences) - .tabItem { - Label(R.string.localizable.messagesTabLabel(), systemImage: "bubble.right.fill") - } - .tag(TabIdentifier.communication) + TabBarIpad { + MessagesTabView(course: viewModel.course, searchText: $searchText) + .environmentObject(messagesPreferences) + } + .tabItem { + Label(R.string.localizable.messagesTabLabel(), systemImage: "bubble.right.fill") + } + .tag(TabIdentifier.communication) + } + + if viewModel.course.faqEnabled ?? false { + TabBarIpad { + FaqListView(course: viewModel.course) + } + .tabItem { + Label(R.string.localizable.faqTabLabel(), systemImage: "questionmark.circle") + } + .tag(TabIdentifier.faq) } } .navigationTitle(viewModel.course.title ?? R.string.localizable.loading()) .navigationBarTitleDisplayMode(.inline) .modifier( + // TODO: Move search into each tab, why is this even here? SearchableIf( - condition: navigationController.courseTab != .communication || messagesPreferences.isSearchable, + condition: (navigationController.courseTab != .communication || messagesPreferences.isSearchable) && navigationController.courseTab != .faq, text: $searchText) ) .onChange(of: navigationController.courseTab) { @@ -59,15 +73,6 @@ public struct CourseView: View { navigationController.selectedPath = nil } } - .onAppear { - // On iPad, always make Tab Bar opaque - // This prevents an issue where the tab bar has content behind it but is transparent - if UIDevice.current.userInterfaceIdiom == .pad { - let appearance = UITabBarAppearance() - appearance.configureWithDefaultBackground() - UITabBar.appearance().scrollEdgeAppearance = appearance - } - } } } diff --git a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift index 4781aac3..dfcab404 100644 --- a/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift +++ b/ArtemisKit/Sources/CourseView/ExerciseTab/ExerciseListView.swift @@ -8,6 +8,7 @@ import DesignLibrary struct ExerciseListView: View { @EnvironmentObject var navController: NavigationController + @Environment(\.horizontalSizeClass) var sizeClass @ObservedObject var viewModel: CourseViewModel @State private var columnVisibilty: NavigationSplitViewVisibility = .doubleColumn @@ -61,7 +62,7 @@ struct ExerciseListView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { - BackToRootButton() + BackToRootButton(placement: .navBar, sizeClass: sizeClass) } } } detail: { @@ -92,7 +93,6 @@ struct ExerciseListView: View { } } } - .toolbar(.hidden, for: .navigationBar) } } @@ -171,7 +171,7 @@ struct ExerciseListSection: View { var body: some View { DisclosureGroup( - "\(exerciseGroup.type.description) (^[\(exerciseGroup.exercises.count) Exercise](inflect:true))", + "\(exerciseGroup.type.description) (^[\(exerciseGroup.exercises.count) \(R.string.localizable.exercise())](inflect:true))", isExpanded: $isExpanded ) { ForEach(exerciseGroup.weeklyExercises) { exercise in diff --git a/ArtemisKit/Sources/CourseView/FixBlankScreenView.swift b/ArtemisKit/Sources/CourseView/FixBlankScreenView.swift deleted file mode 100644 index 35bcce76..00000000 --- a/ArtemisKit/Sources/CourseView/FixBlankScreenView.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// FixBlankScreenView.swift -// -// -// Created by Anian Schleyer on 08.09.24. -// - -import SwiftUI - -/// **Workaround for a SwiftUI Bug.** -/// SwiftUI renders a blank screen in some cases when opening the -/// Course TabView on Exercises or Lectures. Introducing a small -/// delay before showing the view fixes the issue for some reason. -struct FixBlankScreenView: View { - @ViewBuilder var content: () -> Content - @State private var displayContent = false - - var body: some View { - Group { - if displayContent { - content() - } else { - Spacer() - .task { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - displayContent = true - } - } - } - } - } -} diff --git a/ArtemisKit/Sources/CourseView/LectureTab/LectureListView.swift b/ArtemisKit/Sources/CourseView/LectureTab/LectureListView.swift index 4a8d27dc..743fbb09 100644 --- a/ArtemisKit/Sources/CourseView/LectureTab/LectureListView.swift +++ b/ArtemisKit/Sources/CourseView/LectureTab/LectureListView.swift @@ -12,6 +12,7 @@ import SwiftUI import Messages struct LectureListView: View { + @Environment(\.horizontalSizeClass) var sizeClass @EnvironmentObject var navController: NavigationController @ObservedObject var viewModel: CourseViewModel @State private var columnVisibilty: NavigationSplitViewVisibility = .doubleColumn @@ -67,7 +68,7 @@ struct LectureListView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { - BackToRootButton() + BackToRootButton(placement: .navBar, sizeClass: sizeClass) } } } detail: { @@ -96,7 +97,6 @@ struct LectureListView: View { } } } - .toolbar(.hidden, for: .navigationBar) } } @@ -172,7 +172,7 @@ private struct LectureListSectionView: View { var body: some View { DisclosureGroup( - R.string.localizable.lecturesGroupTitle(lectureGroup.type.description, lectureGroup.lectures.count), + "\(lectureGroup.type.description) (^[\(lectureGroup.lectures.count) \(R.string.localizable.lecture())](inflect:true))", isExpanded: $isExpanded ) { ForEach(lectureGroup.weeklyLectures, id: \.id) { weeklyLecture in diff --git a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings index cbca5dc8..102bf853 100644 --- a/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/CourseView/Resources/en.lproj/Localizable.strings @@ -6,8 +6,11 @@ "exercisesTabLabel" = "Exercises"; "lectureTabLabel" = "Lectures"; "messagesTabLabel" = "Messages"; +"faqTabLabel" = "FAQ"; "exercisesUnavailable" = "No Exercises"; "lecturesUnavailable" = "No Lectures"; +"exercise" = "Exercise"; +"lecture" = "Lecture"; // MARK: SubmissionResultStatusView "userNotAssignedToTeam" = "You have not been assigned to a team yet."; @@ -98,6 +101,5 @@ "dueSoon" = "Due soon"; "past" = "Past"; "lectureUnits" = "Lecture Units"; -"lecturesGroupTitle" = "%s (Lectures: %i)"; "attachments" = "Attachments"; "communication" = "Communication"; diff --git a/ArtemisKit/Sources/CourseView/TabBarIpad.swift b/ArtemisKit/Sources/CourseView/TabBarIpad.swift new file mode 100644 index 00000000..d9a1a4a5 --- /dev/null +++ b/ArtemisKit/Sources/CourseView/TabBarIpad.swift @@ -0,0 +1,37 @@ +// +// TabBarIpad.swift +// +// +// Created by Anian Schleyer on 08.09.24. +// + +import Navigation +import SwiftUI + +/// Tab Bar Background for iPadOS 18 +/// Since the Tab Bar is at the top, we can use this space for our back button +struct TabBarIpad: View { + @ViewBuilder var content: () -> Content + @Environment(\.horizontalSizeClass) var sizeClass + + var body: some View { + if sizeClass == .compact || UIDevice.current.userInterfaceIdiom != .pad { + // Old Tab Bar is shown + content() + } else { + // Floating Tab Bar is shown + VStack(spacing: 0) { + HStack(alignment: .center) { + BackToRootButton(placement: .tabBar, sizeClass: sizeClass) + Spacer() + } + .padding(.horizontal) + .frame(height: 50) + .frame(maxWidth: .infinity) + .background(.thinMaterial) + + content() + } + } + } +} diff --git a/ArtemisKit/Sources/Faq/Navigation/PathViewModels.swift b/ArtemisKit/Sources/Faq/Navigation/PathViewModels.swift new file mode 100644 index 00000000..22d138ab --- /dev/null +++ b/ArtemisKit/Sources/Faq/Navigation/PathViewModels.swift @@ -0,0 +1,25 @@ +// +// PathViewModels.swift +// ArtemisKit +// +// Created by Anian Schleyer on 24.10.24. +// + +import Common +import Foundation +import SharedModels + +@Observable +final class FaqPathViewModel { + let path: FaqPath + var faq: DataState + + init(path: FaqPath) { + self.path = path + self.faq = path.faq.map(DataState.done) ?? .loading + } + + func loadFaq() async { + faq = await FaqServiceFactory.shared.getFaq(with: path.id, for: path.courseId ?? 0) + } +} diff --git a/ArtemisKit/Sources/Faq/Navigation/PathViews.swift b/ArtemisKit/Sources/Faq/Navigation/PathViews.swift new file mode 100644 index 00000000..0d4673d6 --- /dev/null +++ b/ArtemisKit/Sources/Faq/Navigation/PathViews.swift @@ -0,0 +1,30 @@ +// +// PathViews.swift +// ArtemisKit +// +// Created by Anian Schleyer on 24.10.24. +// + +import DesignLibrary +import SwiftUI + +public struct FaqPathView: View { + @State private var viewModel: FaqPathViewModel + + public init(path: FaqPath) { + self._viewModel = State(initialValue: FaqPathViewModel(path: path)) + } + + public var body: some View { + DataStateView(data: $viewModel.faq) { + await viewModel.loadFaq() + } content: { faq in + FaqDetailView(faq: faq, namespace: viewModel.path.namespace) + } + .task { + if case .loading = viewModel.faq { + await viewModel.loadFaq() + } + } + } +} diff --git a/ArtemisKit/Sources/Faq/Navigation/Paths.swift b/ArtemisKit/Sources/Faq/Navigation/Paths.swift new file mode 100644 index 00000000..c8f1199f --- /dev/null +++ b/ArtemisKit/Sources/Faq/Navigation/Paths.swift @@ -0,0 +1,38 @@ +// +// Paths.swift +// ArtemisKit +// +// Created by Anian Schleyer on 24.10.24. +// + +import SharedModels +import SwiftUI + +public struct FaqPath: Hashable { + public static func == (lhs: FaqPath, rhs: FaqPath) -> Bool { + lhs.hashValue == rhs.hashValue + } + + public let id: Int64 + public let courseId: Int? + public let faq: FaqDTO? + public let namespace: Namespace.ID? + + public init(faq: FaqDTO, namespace: Namespace.ID?) { + self.faq = faq + self.id = faq.id + self.namespace = namespace + self.courseId = nil + } + + public init(id: Int64, courseId: Int, namespace: Namespace.ID? = nil) { + self.id = id + self.courseId = courseId + self.faq = nil + self.namespace = namespace + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/ArtemisKit/Sources/Faq/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Faq/Resources/en.lproj/Localizable.strings new file mode 100644 index 00000000..5284b856 --- /dev/null +++ b/ArtemisKit/Sources/Faq/Resources/en.lproj/Localizable.strings @@ -0,0 +1,3 @@ +"faqs" = "FAQs"; +"noFaqs" = "No FAQs"; +"readMore" = "Read more"; diff --git a/ArtemisKit/Sources/Faq/Services/FaqService/FaqService.swift b/ArtemisKit/Sources/Faq/Services/FaqService/FaqService.swift new file mode 100644 index 00000000..bf7abc0b --- /dev/null +++ b/ArtemisKit/Sources/Faq/Services/FaqService/FaqService.swift @@ -0,0 +1,18 @@ +// +// FaqService.swift +// ArtemisKit +// +// Created by Anian Schleyer on 24.10.24. +// + +import Common +import SharedModels + +protocol FaqService { + func getFaqs(for courseId: Int) async -> DataState<[FaqDTO]> + func getFaq(with faqId: Int64, for courseId: Int) async -> DataState +} + +enum FaqServiceFactory { + static let shared: FaqService = FaqServiceImpl() +} diff --git a/ArtemisKit/Sources/Faq/Services/FaqService/FaqServiceImpl.swift b/ArtemisKit/Sources/Faq/Services/FaqService/FaqServiceImpl.swift new file mode 100644 index 00000000..0b61bac9 --- /dev/null +++ b/ArtemisKit/Sources/Faq/Services/FaqService/FaqServiceImpl.swift @@ -0,0 +1,66 @@ +// +// FaqServiceImpl.swift +// ArtemisKit +// +// Created by Anian Schleyer on 24.10.24. +// + +import APIClient +import Common +import SharedModels + +struct FaqServiceImpl: FaqService { + + let client = APIClient() + + struct GetFaqsRequest: APIRequest { + typealias Response = [FaqDTO] + + let courseId: Int + + var method: HTTPMethod { + return .get + } + + var resourceName: String { + return "api/courses/\(courseId)/faqs" + } + } + + func getFaqs(for courseId: Int) async -> DataState<[FaqDTO]> { + let result = await client.sendRequest(GetFaqsRequest(courseId: courseId)) + + switch result { + case .success((let response, _)): + return .done(response: response) + case .failure(let error): + return .failure(error: UserFacingError(error: error)) + } + } + + struct GetFaqRequest: APIRequest { + typealias Response = FaqDTO + + let courseId: Int + let faqId: Int64 + + var method: HTTPMethod { + return .get + } + + var resourceName: String { + return "api/courses/\(courseId)/faqs/\(faqId)" + } + } + + func getFaq(with faqId: Int64, for courseId: Int) async -> DataState { + let result = await client.sendRequest(GetFaqRequest(courseId: courseId, faqId: faqId)) + + switch result { + case .success((let response, _)): + return .done(response: response) + case .failure(let error): + return .failure(error: UserFacingError(error: error)) + } + } +} diff --git a/ArtemisKit/Sources/Faq/ViewModels/FaqViewModel.swift b/ArtemisKit/Sources/Faq/ViewModels/FaqViewModel.swift new file mode 100644 index 00000000..5d134303 --- /dev/null +++ b/ArtemisKit/Sources/Faq/ViewModels/FaqViewModel.swift @@ -0,0 +1,46 @@ +// +// FaqViewModel.swift +// ArtemisKit +// +// Created by Anian Schleyer on 24.10.24. +// + +import Common +import Foundation +import SharedModels + +@Observable +class FaqViewModel { + let course: Course + + private let faqService = FaqServiceFactory.shared + var faqs: DataState<[FaqDTO]> = .loading + + var searchText = "" + + init(course: Course) { + self.course = course + } + + func loadFaq() async { + let allFaqs = await faqService.getFaqs(for: course.id) + switch allFaqs { + case .loading: + faqs = .loading + case .failure(let error): + faqs = .failure(error: error) + case .done(let response): + faqs = .done(response: response.filter { $0.faqState == .accepted }) + } + } +} + +// MARK: FAQ+Search +extension FaqViewModel { + var searchResults: [FaqDTO] { + faqs.value?.filter { + $0.questionTitle.localizedStandardContains(searchText) || + $0.questionAnswer.localizedStandardContains(searchText) + } ?? [] + } +} diff --git a/ArtemisKit/Sources/Faq/Views/FaqDetailView.swift b/ArtemisKit/Sources/Faq/Views/FaqDetailView.swift new file mode 100644 index 00000000..55c0f132 --- /dev/null +++ b/ArtemisKit/Sources/Faq/Views/FaqDetailView.swift @@ -0,0 +1,40 @@ +// +// FaqDetailView.swift +// ArtemisKit +// +// Created by Anian Schleyer on 24.10.24. +// + +import ArtemisMarkdown +import SharedModels +import SwiftUI + +struct FaqDetailView: View { + let faq: FaqDTO + let namespace: Namespace.ID? + + var body: some View { + ScrollView { + ArtemisMarkdownView(string: faq.questionAnswer) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, .l) + } + .navigationTitle(faq.questionTitle) + .navigationBarTitleDisplayMode(.large) + .modifier(TransitionIfAvailable(id: faq.id, namespace: namespace)) + } +} + +private struct TransitionIfAvailable: ViewModifier { + let id: Int64 + let namespace: Namespace.ID? + + func body(content: Content) -> some View { + if let namespace { + content + .navigationTransition(.zoom(sourceID: id, in: namespace)) + } else { + content + } + } +} diff --git a/ArtemisKit/Sources/Faq/Views/FaqListView.swift b/ArtemisKit/Sources/Faq/Views/FaqListView.swift new file mode 100644 index 00000000..da838a3a --- /dev/null +++ b/ArtemisKit/Sources/Faq/Views/FaqListView.swift @@ -0,0 +1,139 @@ +// +// FaqListView.swift +// ArtemisKit +// +// Created by Anian Schleyer on 24.10.24. +// + +import ArtemisMarkdown +import Common +import DesignLibrary +import Navigation +import SharedModels +import SwiftUI + +public struct FaqListView: View { + @Namespace var namespace + @Environment(\.horizontalSizeClass) var sizeClass + @EnvironmentObject var navController: NavigationController + @State private var columnVisibilty: NavigationSplitViewVisibility = .doubleColumn + @State var viewModel: FaqViewModel + + private var selectedFaq: Binding { + navController.selectedPathBinding($navController.selectedPath) + } + + public init(course: Course) { + self._viewModel = State(initialValue: FaqViewModel(course: course)) + } + + public var body: some View { + NavigationSplitView(columnVisibility: $columnVisibilty) { + DataStateView(data: $viewModel.faqs) { + await viewModel.loadFaq() + } content: { faqs in + List(selection: selectedFaq) { + if viewModel.searchText.isEmpty { + if faqs.isEmpty { + ContentUnavailableView(R.string.localizable.noFaqs(), systemImage: "questionmark.circle.dashed") + .listRowSeparator(.hidden) + } else { + ForEach(faqs) { faq in + FaqListCell(faq: faq, namespace: namespace) + } + } + } else { + if viewModel.searchResults.isEmpty { + ContentUnavailableView.search + .listRowSeparator(.hidden) + } else { + ForEach(viewModel.searchResults) { faq in + FaqListCell(faq: faq, namespace: namespace) + } + } + } + } + .listRowSpacing(.m) + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .searchable(text: $viewModel.searchText) + .refreshable { + await viewModel.loadFaq() + } + } + .navigationTitle(viewModel.course.title ?? R.string.localizable.faqs()) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + BackToRootButton(placement: .navBar, sizeClass: sizeClass) + } + } + } detail: { + NavigationStack(path: $navController.tabPath) { + Group { + if let path = navController.selectedPath as? FaqPath { + FaqPathView(path: path) + .id(path.id) + } else { + SelectDetailView() + } + } + .navigationDestination(for: FaqPath.self) { path in + FaqPathView(path: path) + } + } + } + .task { + await viewModel.loadFaq() + } + } +} + +private struct FaqListCell: View { + let faq: FaqDTO + let namespace: Namespace.ID + @EnvironmentObject var navController: NavigationController + + var body: some View { + ZStack { + VStack(alignment: .leading) { + Text(faq.questionTitle) + .font(.title2.bold()) + .lineLimit(2) + ArtemisMarkdownView(string: faq.questionAnswer) + .frame(minHeight: 70, maxHeight: 150, alignment: .top) + } + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + .padding(12) + + Button { + navController.selectedPath = FaqPath(faq: faq, namespace: namespace) + } label: { + Text("\(R.string.localizable.readMore()) \(Image(systemName: "chevron.forward"))") + .padding(.m) + } + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.top, 30) + .background(.ultraThinMaterial) + .mask( + LinearGradient( + gradient: Gradient( + colors: [ + .black.opacity(0), + .black.opacity(0.8), + .black, + .black] + ), + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(maxHeight: .infinity, alignment: .bottom) + } + .listRowBackground(Color.Artemis.exerciseCardBackgroundColor) + .listRowInsets(EdgeInsets()) + .id(FaqPath(faq: faq, namespace: namespace)) + .matchedTransitionSource(id: faq.id, in: namespace) + } +} diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index eb53ab7a..c2b53483 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -140,8 +140,8 @@ "editDescriptionLabel" = "Edit Description"; "newDescriptionLabel" = "New Description"; "moreInfoLabel" = "More info"; -"createdByLabel" = "Created by: %s"; -"createdOnLabel" = "Created on: %s"; +"createdByLabel" = "Created by"; +"createdOnLabel" = "Created on"; "addFavorite" = "Add to Favorites"; "removeFavorite" = "Remove from Favorites"; "sendMessage" = "Send Message"; diff --git a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift index fad5f768..5f637994 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/ConversationViewModels/ConversationViewModel.swift @@ -29,7 +29,6 @@ class ConversationViewModel: BaseViewModel { @Published var isConversationInfoSheetPresented = false @Published var selectedMessageId: Int64? - var isPerformingMessageAction = false var isAllowedToPost: Bool { guard let channel = conversation.baseConversation as? Channel else { diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ProfileViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ProfileViewModel.swift index 592d08aa..1b6a5a42 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ProfileViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ProfileViewModel.swift @@ -22,11 +22,13 @@ class ProfileViewModel { let role: UserRole? private var login: String? let course: Course + var actions: [ProfileInfoSheetAction] - init(course: Course, user: ConversationUser, role: UserRole?) { + init(course: Course, user: ConversationUser, role: UserRole?, actions: [ProfileInfoSheetAction]) { self.course = course self.user = user self.role = role + self.actions = actions } // We can only create a conversation if we have the user's login diff --git a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift index bb8a9ba6..ed1262fd 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/MessageDetailViewModels/ReactionsViewModel.swift @@ -67,6 +67,7 @@ class ReactionsViewModel { self.messageBinding.wrappedValue = .done(response: response) } } + conversationViewModel.selectedMessageId = nil } func isMyReaction(_ emoji: String) -> Bool { diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift index 47999e67..25b9c933 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift @@ -9,6 +9,7 @@ import APIClient import Common import Foundation import SharedModels +import SwiftUI import UserStore extension SendMessageViewModel { @@ -44,6 +45,7 @@ final class SendMessageViewModel { // MARK: Text var text = "" + var selection: TextSelection? var isEditing: Bool { switch configuration { @@ -158,35 +160,31 @@ extension SendMessageViewModel { // MARK: Toolbar func didTapBoldButton() { - text.append("****") + appendToSelection(before: "**", after: "**", placeholder: "bold") } func didTapItalicButton() { - text.append("**") + appendToSelection(before: "*", after: "*", placeholder: "italic") } func didTapUnderlineButton() { - text.append("") + appendToSelection(before: "", after: "", placeholder: "underlined") } func didTapBlockquoteButton() { - text.append("> Reference") + appendToSelection(before: "> ", after: "", placeholder: "Reference") } func didTapCodeButton() { - text.append("``") + appendToSelection(before: "`", after: "`", placeholder: "Code") } func didTapCodeBlockButton() { - text.append(""" - ```java - Source Code - ``` - """) + appendToSelection(before: "```java\n", after: "\n```", placeholder: "Source Code") } func didTapLinkButton() { - text.append("[](http://)") + appendToSelection(before: "[", after: "](https://)", placeholder: "Display Text") } func didTapAtButton() { @@ -194,7 +192,7 @@ extension SendMessageViewModel { isMemberPickerSuppressed = true } else { isMemberPickerSuppressed = false - text += "@" + appendToSelection(before: "@", after: " ", placeholder: " ") } } @@ -203,7 +201,43 @@ extension SendMessageViewModel { isChannelPickerSuppressed = true } else { isChannelPickerSuppressed = false - text += "#" + appendToSelection(before: "#", after: " ", placeholder: " ") + } + } + + /// Prepends/Appends the given snippets to text the user has selected. + private func appendToSelection(before: String, after: String, placeholder: String) { + let placeholderText = "\(before)\(placeholder)\(after)" + var shouldSelectPlaceholder = false + + if let selection { + switch selection.indices { + case .selection(let range): + let newText: String + if text[range].isEmpty { + newText = placeholderText + shouldSelectPlaceholder = true + } else { + newText = "\(before)\(text[range])\(after)" + } + text.replaceSubrange(range, with: newText) + if !shouldSelectPlaceholder, let endIndex = text.range(of: newText)?.upperBound { + self.selection = TextSelection(insertionPoint: endIndex) + } + default: + break + } + } else { + text.append(placeholderText) + shouldSelectPlaceholder = true + } + + if shouldSelectPlaceholder { + for range in text.ranges(of: placeholderText) { + if let placeholderRange = text[range].range(of: placeholder) { + selection = TextSelection(range: range.clamped(to: placeholderRange)) + } + } } } @@ -239,6 +273,7 @@ extension SendMessageViewModel { } switch result { case .success: + selection = nil text = "" default: return diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift index 65ca88d7..2c495bf9 100644 --- a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationInfoSheetView.swift @@ -134,27 +134,32 @@ private extension ConversationInfoSheetView { } content: { members in ForEach(members, id: \.id) { member in if let name = member.name { - Menu { - if let login = member.login, - !(conversation.baseConversation is OneToOneChat) { - Button(R.string.localizable.sendMessage(), systemImage: "bubble.left.fill") { - viewModel.sendMessageToUser(with: login, navigationController: navigationController) { - dismiss() - } - } - } - Divider() - removeUserButton(member: member) - } label: { - HStack { - Text(name) - Spacer() - if UserSessionFactory.shared.user?.login == member.login { - Chip(text: R.string.localizable.youLabel(), backgroundColor: .Artemis.artemisBlue) - } + HStack { + ProfilePictureView( + user: member, + role: nil, + course: viewModel.course, + size: 25, + actions: [ + ProfileInfoSheetAction( + title: R.string.localizable.removeUserButtonLabel(), + iconName: "person.badge.minus", + isDestructive: true, + isEnabled: UserSessionFactory.shared.user?.login != member.login && viewModel.canRemoveUsers, + action: { + viewModel.isLoading = true + Task { + await viewModel.removeMemberFromConversation(member: member) + viewModel.isLoading = false + } + }) + ]) + Text(name) + Spacer() + if UserSessionFactory.shared.user?.login == member.login { + Chip(text: R.string.localizable.youLabel(), backgroundColor: .Artemis.artemisBlue) } } - .buttonStyle(.plain) .swipeActions(edge: .trailing) { removeUserButton(member: member) } @@ -180,6 +185,7 @@ private extension ConversationInfoSheetView { viewModel.isLoading = false } } + .foregroundStyle(.red) } } @@ -256,10 +262,12 @@ private struct InfoSection: View { if conversation.baseConversation.creator?.name != nil || conversation.baseConversation.creationDate != nil { Section(R.string.localizable.moreInfoLabel()) { if let creator = conversation.baseConversation.creator?.name { - Text(R.string.localizable.createdByLabel(creator)) + Text(R.string.localizable.createdByLabel()) + .badge(creator) } if let creationDate = conversation.baseConversation.creationDate { - Text(R.string.localizable.createdOnLabel(creationDate.mediumDateShortTime)) + Text(R.string.localizable.createdOnLabel()) + .badge(creationDate.mediumDateShortTime) } } } diff --git a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift index 95b28939..bd378345 100644 --- a/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift +++ b/ArtemisKit/Sources/Messages/Views/ConversationView/ConversationView.swift @@ -102,11 +102,22 @@ public struct ConversationView: View { .resizable() .scaledToFit() .frame(height: 20) - Text(viewModel.conversation.baseConversation.conversationName) - .fontWeight(.semibold) - Image(systemName: "chevron.forward") - .font(.caption2) - .offset(x: -5, y: 1) + VStack(alignment: .leading, spacing: .xxs) { + HStack(alignment: .center, spacing: .m) { + Text(viewModel.conversation.baseConversation.conversationName) + .lineLimit(1) + .fontWeight(.semibold) + .frame(maxWidth: 200) + Image(systemName: "chevron.forward") + .font(.caption2) + .offset(x: -4, y: 1) + } + if let memberCount = viewModel.conversation.baseConversation.numberOfMembers, + !(viewModel.conversation.baseConversation is OneToOneChat) { + Text(R.string.localizable.numberOfMembers(memberCount)) + .font(.caption2) + } + } } .padding(.horizontal, .m) .foregroundStyle(Color.Artemis.primaryLabel) diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActions.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActions.swift index 3cf74782..591cc4dc 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActions.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageActions.swift @@ -21,7 +21,7 @@ struct MessageActions: View { var body: some View { Group { ReplyInThreadButton(viewModel: viewModel, message: $message, conversationPath: conversationPath) - CopyTextButton(message: $message) + CopyTextButton(viewModel: viewModel, message: $message) PinButton(viewModel: viewModel, message: $message) MarkResolvingButton(viewModel: viewModel, message: $message) EditDeleteSection(viewModel: viewModel, message: $message) @@ -46,6 +46,7 @@ struct MessageActions: View { conversationViewModel: viewModel ) { navigationController.tabPath.append(messagePath) + viewModel.selectedMessageId = nil } else { viewModel.presentError(userFacingError: UserFacingError(title: R.string.localizable.detailViewCantBeOpened())) } @@ -56,6 +57,7 @@ struct MessageActions: View { struct CopyTextButton: View { @EnvironmentObject var navController: NavigationController + @ObservedObject var viewModel: ConversationViewModel @Binding var message: DataState @State private var showSuccess = false @@ -65,6 +67,7 @@ struct MessageActions: View { if !navController.tabPath.isEmpty && message.value is Message { showSuccess = true } + viewModel.selectedMessageId = nil } .opacity(showSuccess ? 0 : 1) .overlay { @@ -117,11 +120,9 @@ struct MessageActions: View { Divider() Button(R.string.localizable.editMessage(), systemImage: "pencil") { - viewModel.isPerformingMessageAction = true showEditSheet = true } .sheet(isPresented: $showEditSheet) { - viewModel.isPerformingMessageAction = false viewModel.selectedMessageId = nil } content: { editMessage @@ -129,7 +130,6 @@ struct MessageActions: View { } Button(R.string.localizable.deleteMessage(), systemImage: "trash", role: .destructive) { - viewModel.isPerformingMessageAction = true showDeleteAlert = true } .alert(R.string.localizable.confirmDeletionTitle(), isPresented: $showDeleteAlert) { @@ -144,18 +144,16 @@ struct MessageActions: View { success = await viewModel.deleteMessage(messageId: message.value?.id) } viewModel.isLoading = false - viewModel.isPerformingMessageAction = false viewModel.selectedMessageId = nil if success { // if we deleted a Message and are in the MessageDetailView we pop it - if navigationController.outerPath.count == 3 && tempMessage is Message { - navigationController.outerPath.removeLast() + if !navigationController.tabPath.isEmpty && tempMessage is Message { + navigationController.tabPath.removeLast() } } } } Button(R.string.localizable.cancel(), role: .cancel) { - viewModel.isPerformingMessageAction = false viewModel.selectedMessageId = nil } } @@ -244,14 +242,12 @@ struct MessageActions: View { func togglePinned() { guard let message = message.value as? Message else { return } - viewModel.isPerformingMessageAction = true Task { let result = await viewModel.togglePinned(for: message) let oldRole = message.authorRole if var newMessageResult = result.value as? Message { newMessageResult.authorRole = oldRole self.$message.wrappedValue = .done(response: newMessageResult) - viewModel.isPerformingMessageAction = false viewModel.selectedMessageId = nil } } @@ -297,10 +293,8 @@ struct MessageActions: View { func toggleResolved() { guard let message = message.value as? AnswerMessage else { return } - viewModel.isPerformingMessageAction = true Task { if await viewModel.toggleResolving(for: message) { - viewModel.isPerformingMessageAction = false viewModel.selectedMessageId = nil } } @@ -320,18 +314,29 @@ struct MessageReactionsPopover: View { } var body: some View { - HStack(spacing: .m) { - EmojiTextButton(viewModel: reactionsViewModel, emoji: "😂") - EmojiTextButton(viewModel: reactionsViewModel, emoji: "👍") - EmojiTextButton(viewModel: reactionsViewModel, emoji: "➕") - EmojiTextButton(viewModel: reactionsViewModel, emoji: "🚀") - EmojiPickerButton(viewModel: reactionsViewModel) + HStack(alignment: .center) { + HStack(spacing: .m) { + EmojiTextButton(viewModel: reactionsViewModel, emoji: "😂") + EmojiTextButton(viewModel: reactionsViewModel, emoji: "👍") + EmojiTextButton(viewModel: reactionsViewModel, emoji: "➕") + EmojiTextButton(viewModel: reactionsViewModel, emoji: "🚀") + EmojiPickerButton(viewModel: reactionsViewModel) + } + .padding(.m) + .buttonStyle(.plain) + .font(.headline) + .symbolVariant(.fill) + .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) + .background(.bar, in: .rect(cornerRadius: 10)) + + Button { + viewModel.selectedMessageId = nil + } label: { + Image(systemName: "xmark.circle") + } + .font(.title2) + .frame(maxWidth: .infinity, alignment: .center) } - .padding(.l) - .buttonStyle(.plain) - .font(.headline) - .symbolVariant(.fill) - .alert(isPresented: $viewModel.showError, error: viewModel.error, actions: {}) } } @@ -376,8 +381,6 @@ private struct ContextMenuLabelStyle: LabelStyle { private struct EmojiTextButton: View { - @Environment(\.dismiss) var dismiss - var viewModel: ReactionsViewModel let emoji: String @@ -386,7 +389,7 @@ private struct EmojiTextButton: View { Text("\(emoji)") .font(.title3) .foregroundColor(Color.Artemis.primaryLabel) - .frame(width: .mediumImage, height: .mediumImage) + .frame(width: .mediumImage * 0.75, height: .mediumImage * 0.75) .padding(.s) .background( Capsule().fill(Color.Artemis.reactionCapsuleColor) @@ -395,7 +398,6 @@ private struct EmojiTextButton: View { if let emojiId = Smile.alias(emoji: emoji) { Task { await viewModel.addReaction(emojiId: emojiId) - dismiss() } } } @@ -404,8 +406,6 @@ private struct EmojiTextButton: View { private struct EmojiPickerButton: View { - @Environment(\.dismiss) var dismiss - var viewModel: ReactionsViewModel @State private var showEmojiPicker = false @@ -420,8 +420,8 @@ private struct EmojiPickerButton: View { .resizable() .scaledToFit() .foregroundColor(Color.Artemis.secondaryLabel) - .frame(width: .smallImage, height: .smallImage) - .padding(.l) + .frame(width: .smallImage * 0.75, height: .smallImage * 0.75) + .padding(.m * 1.5) .background(Capsule().fill(Color.Artemis.reactionCapsuleColor)) } .sheet(isPresented: $showEmojiPicker) { @@ -437,7 +437,6 @@ private struct EmojiPickerButton: View { Task { await viewModel.addReaction(emojiId: emojiId) selectedEmoji = nil - dismiss() } } } diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift index 20c1cc04..1ecbedbb 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageCell.swift @@ -25,6 +25,8 @@ struct MessageCell: View { var body: some View { VStack(alignment: .leading, spacing: .s) { + reactionMenuIfAvailable + HStack { VStack(alignment: .leading, spacing: .s) { pinnedIndicator @@ -57,15 +59,9 @@ struct MessageCell: View { .background(backgroundOnPress, in: .rect(cornerRadius: .m)) .background(messageBackground, in: .rect(cornerRadii: viewModel.roundedCorners(isSelected: isSelected))) - .modifier(ReactionsPopoverModifier(isSelected: isSelected, - viewModel: viewModel, - conversationViewModel: conversationViewModel, - message: $message)) .padding(.top, viewModel.isHeaderVisible ? .m : 0) .padding(.horizontal, useFullWidth ? 0 : (.m + .l) / 2) .opacity(opacity) - /// Ensure the message is fully visible when selected, space for reactions popover - .padding(.top, isSelected ? 100 : 0) .id(message.value?.id.description) } } @@ -256,7 +252,7 @@ private extension MessageCell { } @ViewBuilder var actionsMenuIfAvailable: some View { - if isSelected { + if isSelected && !useFullWidth { MessageActionsMenu(viewModel: conversationViewModel, message: $message, conversationPath: viewModel.conversationPath) @@ -266,6 +262,17 @@ private extension MessageCell { } } + @ViewBuilder var reactionMenuIfAvailable: some View { + if isSelected { + MessageReactionsPopover(viewModel: conversationViewModel, + message: $message, + conversationPath: viewModel.conversationPath) + .frame(maxWidth: .infinity) + .padding(.bottom, 5) + .transition(.scale(0, anchor: .bottom).combined(with: .opacity)) + } + } + func openThread(showErrorOnFailure: Bool = true, presentKeyboard: Bool = false) { // We cannot navigate to details if conversation path is nil, e.g. in the message detail view. if let conversationPath = viewModel.conversationPath, @@ -284,8 +291,10 @@ private extension MessageCell { // MARK: Gestures func onTapPresentMessage() { - guard conversationViewModel.selectedMessageId == nil else { return } - openThread(showErrorOnFailure: false) + if conversationViewModel.selectedMessageId == nil || isSelected { + openThread(showErrorOnFailure: false) + } + conversationViewModel.selectedMessageId = nil } func onSwipePresentMessage() { @@ -299,14 +308,8 @@ private extension MessageCell { let feedback = UIImpactFeedbackGenerator(style: .heavy) feedback.impactOccurred() - if useFullWidth { - viewModel.showReactionsPopover = true - } else { - withAnimation { - conversationViewModel.selectedMessageId = message.value?.id - } completion: { - viewModel.showReactionsPopover = true - } + withAnimation { + conversationViewModel.selectedMessageId = message.value?.id } viewModel.isDetectingLongPress = false } @@ -368,49 +371,6 @@ private extension MessageCell { } } -// MARK: - ReactionsPopover -struct ReactionsPopoverModifier: ViewModifier { - @Environment(\.messageUseFullWidth) var useFullWidth - let isSelected: Bool - let viewModel: MessageCellModel - let conversationViewModel: ConversationViewModel - @Binding var message: DataState - - func body(content: Content) -> some View { - content - .popover( - isPresented: Binding(get: { - (isSelected || useFullWidth) - && viewModel.showReactionsPopover - }, set: { value, _ in - if !value { - if !conversationViewModel.isPerformingMessageAction { - conversationViewModel.selectedMessageId = nil - } - viewModel.showReactionsPopover = false - } - }), - attachmentAnchor: .point(useFullWidth ? .bottom : .top), - arrowEdge: useFullWidth ? .top : .bottom - ) { - MessageReactionsPopover( - viewModel: conversationViewModel, - message: $message, - conversationPath: viewModel.conversationPath - ) - .presentationCompactAdaptation(.popover) - .presentationBackgroundInteraction(.enabled) - .presentationBackground(.bar) - } - .onChange(of: conversationViewModel.selectedMessageId) { - // Reset recations popover presentation when message is no longer selected - if conversationViewModel.selectedMessageId == nil && viewModel.showReactionsPopover { - viewModel.showReactionsPopover = false - } - } - } -} - // MARK: - Environment+IsMessageOffline private enum IsMessageOfflineEnvironmentKey: EnvironmentKey { diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift index 9f8838dc..6890b4ec 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/MessageDetailView.swift @@ -39,7 +39,6 @@ struct MessageDetailView: View { top(message: message) answers(of: message, proxy: proxy) } - .defaultScrollAnchor(.bottom) } if !((viewModel.conversation.baseConversation as? Channel)?.isArchived ?? false), let message = message as? Message { @@ -92,6 +91,7 @@ private extension MessageDetailView { ) .environment(\.isEmojiPickerButtonVisible, true) .environment(\.messageUseFullWidth, true) + .animation(.default, value: viewModel.selectedMessageId) } @ViewBuilder var divider: some View { diff --git a/ArtemisKit/Sources/Messages/Views/MessageDetailView/ProfilePictureView.swift b/ArtemisKit/Sources/Messages/Views/MessageDetailView/ProfilePictureView.swift index cfb30035..0ae716ab 100644 --- a/ArtemisKit/Sources/Messages/Views/MessageDetailView/ProfilePictureView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessageDetailView/ProfilePictureView.swift @@ -12,9 +12,11 @@ import SwiftUI struct ProfilePictureView: View { @State private var viewModel: ProfileViewModel + let size: CGFloat - init(user: ConversationUser, role: UserRole?, course: Course) { - self._viewModel = State(initialValue: ProfileViewModel(course: course, user: user, role: role)) + init(user: ConversationUser, role: UserRole?, course: Course, size: CGFloat = 44, actions: [ProfileInfoSheetAction] = []) { + self._viewModel = State(initialValue: ProfileViewModel(course: course, user: user, role: role, actions: actions)) + self.size = size } var body: some View { @@ -26,10 +28,10 @@ struct ProfilePictureView: View { DefaultProfilePictureView(viewModel: viewModel) } } else { - DefaultProfilePictureView(viewModel: viewModel) + DefaultProfilePictureView(viewModel: viewModel, font: size < 35 ? .caption.bold() : .headline.bold()) } } - .frame(width: 44, height: 44) + .frame(width: size, height: size) .clipShape(.rect(cornerRadius: .m)) .sheet(isPresented: $viewModel.showProfileSheet) { ProfileInfoSheet(viewModel: viewModel) @@ -121,6 +123,15 @@ struct ProfileInfoSheet: View { dismiss() } } + ForEach(viewModel.actions, id: \.hashValue) { action in + if action.isEnabled { + Button(action.title, systemImage: action.iconName) { + action.action() + dismiss() + } + .foregroundStyle(action.isDestructive ? Color.red : Color.blue) + } + } } } } @@ -165,3 +176,20 @@ struct ProfileInfoSheet: View { } } } + +struct ProfileInfoSheetAction: Hashable, Equatable { + static func == (lhs: ProfileInfoSheetAction, rhs: ProfileInfoSheetAction) -> Bool { + lhs.hashValue == rhs.hashValue + } + + let title: String + let iconName: String + let isDestructive: Bool + let isEnabled: Bool + let action: () -> Void + + func hash(into hasher: inout Hasher) { + hasher.combine(title) + hasher.combine(iconName) + } +} diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift index a5a01139..aa053c04 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift @@ -13,6 +13,7 @@ import SwiftUI public struct MessagesAvailableView: View { + @Environment(\.horizontalSizeClass) var sizeClass @EnvironmentObject var navController: NavigationController @StateObject private var viewModel: MessagesAvailableViewModel @@ -174,7 +175,7 @@ public struct MessagesAvailableView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { - BackToRootButton() + BackToRootButton(placement: .navBar, sizeClass: sizeClass) } } } detail: { diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift index fb125743..9c457709 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageMentionContentView.swift @@ -15,7 +15,16 @@ struct SendMessageMentionContentView: View { var body: some View { NavigationStack { let delegate = SendMessageMentionContentDelegate { [weak viewModel] mention in - viewModel?.text.append(mention) + if let selection = viewModel?.selection { + switch selection.indices { + case .selection(let range): + viewModel?.text.insert(contentsOf: mention, at: range.upperBound) + default: + viewModel?.text.append(mention) + } + } else { + viewModel?.text.append(mention) + } viewModel?.wantsToAddMessageMentionContentType = nil } Group { diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift index 61a91ec4..9d4732a7 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift @@ -96,6 +96,7 @@ private extension SendMessageView { TextField( R.string.localizable.messageAction(viewModel.conversation.baseConversation.conversationName), text: $viewModel.text, + selection: $viewModel.selection, axis: .vertical ) .textFieldStyle(.roundedBorder) @@ -184,6 +185,7 @@ private extension SendMessageView { .labelStyle(.iconOnly) } } + .font(.title3) } Spacer() sendButton diff --git a/ArtemisKit/Sources/Navigation/SplitViewSupporting/BackToRootButton.swift b/ArtemisKit/Sources/Navigation/SplitViewSupporting/BackToRootButton.swift index 7c9d97f8..f741e916 100644 --- a/ArtemisKit/Sources/Navigation/SplitViewSupporting/BackToRootButton.swift +++ b/ArtemisKit/Sources/Navigation/SplitViewSupporting/BackToRootButton.swift @@ -8,20 +8,38 @@ import SwiftUI public struct BackToRootButton: View { + public enum Placement { + case navBar, tabBar + } + @EnvironmentObject var navController: NavigationController - public init() {} + private let placement: Placement + private let sizeClass: UserInterfaceSizeClass? + + public init(placement: Placement, sizeClass: UserInterfaceSizeClass?) { + self.placement = placement + self.sizeClass = sizeClass + } public var body: some View { - Button { - navController.popToRoot() - } label: { - HStack(spacing: .s) { - Image(systemName: "chevron.backward") - .fontWeight(.semibold) - Text("Back") + // Only show this button in the NavBar if we are on compact width, + // Otherwise we have a separate bar with a back button + if placement != .navBar || sizeClass == .compact || !iPad { + Button { + navController.popToRoot() + } label: { + HStack(spacing: .s) { + Image(systemName: "chevron.backward") + .fontWeight(.semibold) + Text("Back") + } + .offset(x: placement == .navBar ? -8 : 0) } - .offset(x: -8) } } + + private var iPad: Bool { + UIDevice.current.userInterfaceIdiom == .pad + } } diff --git a/ArtemisKit/Sources/Navigation/SplitViewSupporting/SelectDetailView.swift b/ArtemisKit/Sources/Navigation/SplitViewSupporting/SelectDetailView.swift index 1c7969f3..80a380fe 100644 --- a/ArtemisKit/Sources/Navigation/SplitViewSupporting/SelectDetailView.swift +++ b/ArtemisKit/Sources/Navigation/SplitViewSupporting/SelectDetailView.swift @@ -47,6 +47,8 @@ public struct SelectDetailView: View { R.string.localizable.selectLecture() case .communication: R.string.localizable.selectConversation() + case .faq: + "Select faq" // TODO } } } diff --git a/ArtemisKit/Sources/Navigation/TabIdentifier.swift b/ArtemisKit/Sources/Navigation/TabIdentifier.swift index d98e0ea2..f5d80915 100644 --- a/ArtemisKit/Sources/Navigation/TabIdentifier.swift +++ b/ArtemisKit/Sources/Navigation/TabIdentifier.swift @@ -9,4 +9,5 @@ public enum TabIdentifier { case exercise case lecture case communication + case faq }