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
}