Skip to content

Commit

Permalink
FAQ: Add FAQ feature (#195)
Browse files Browse the repository at this point in the history
* Add FaqService

* Add FAQ

* Update package versions

* Add localization for Read more

* Show only accepted faqs

* Use question mark icon for no faqs available
  • Loading branch information
anian03 authored Oct 26, 2024
1 parent 9306822 commit 6abf231
Show file tree
Hide file tree
Showing 15 changed files with 458 additions and 11 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"originHash" : "8eb0e3c715c0eaa17ff6603b1eae1965a5a0666609384d9087b9c335356d3826",
"originHash" : "56012048077ac605e3e4427ea4068452adfc50949f0073b161c8b2050ca8ae9a",
"pins" : [
{
"identity" : "apollon-ios-module",
Expand All @@ -15,8 +15,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/ls1intum/artemis-ios-core-modules",
"state" : {
"revision" : "dc9ddaf88a726ed1fc454e865b21bc1b4fb0c343",
"version" : "14.5.2"
"revision" : "1c7b69b8a706ce4fa58d48664a637b63114e4800",
"version" : "14.6.0"
}
},
{
Expand Down Expand Up @@ -60,8 +60,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/NetworkImage",
"state" : {
"revision" : "7aff8d1b31148d32c5933d75557d42f6323ee3d1",
"version" : "6.0.0"
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
"version" : "6.0.1"
}
},
{
Expand Down Expand Up @@ -105,17 +105,26 @@
"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"
}
},
{
"identity" : "swift-markdown-ui",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
"state" : {
"revision" : "55441810c0f678c78ed7e2ebd46dde89228e02fc",
"version" : "2.4.0"
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
"version" : "2.4.1"
}
},
{
Expand Down
19 changes: 18 additions & 1 deletion ArtemisKit/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ 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/ls1intum/artemis-ios-core-modules", .upToNextMajor(from: "14.6.0")),
.package(url: "https://github.com/mac-cain13/R.swift.git", from: "7.7.0")
],
targets: [
Expand Down Expand Up @@ -51,6 +51,7 @@ let package = Package(
.target(
name: "CourseView",
dependencies: [
"Faq",
"Messages",
"Navigation",
.product(name: "ApollonEdit", package: "apollon-ios-module"),
Expand Down Expand Up @@ -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: [
Expand Down
14 changes: 13 additions & 1 deletion ArtemisKit/Sources/CourseView/CourseView.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import Faq
import Common
import SharedModels
import Navigation
Expand Down Expand Up @@ -44,12 +45,23 @@ public struct CourseView: View {
}
.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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"exercisesTabLabel" = "Exercises";
"lectureTabLabel" = "Lectures";
"messagesTabLabel" = "Messages";
"faqTabLabel" = "FAQ";
"exercisesUnavailable" = "No Exercises";
"lecturesUnavailable" = "No Lectures";
"exercise" = "Exercise";
Expand Down
25 changes: 25 additions & 0 deletions ArtemisKit/Sources/Faq/Navigation/PathViewModels.swift
Original file line number Diff line number Diff line change
@@ -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<FaqDTO>

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)
}
}
30 changes: 30 additions & 0 deletions ArtemisKit/Sources/Faq/Navigation/PathViews.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
}
38 changes: 38 additions & 0 deletions ArtemisKit/Sources/Faq/Navigation/Paths.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions ArtemisKit/Sources/Faq/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"faqs" = "FAQs";
"noFaqs" = "No FAQs";
"readMore" = "Read more";
18 changes: 18 additions & 0 deletions ArtemisKit/Sources/Faq/Services/FaqService/FaqService.swift
Original file line number Diff line number Diff line change
@@ -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<FaqDTO>
}

enum FaqServiceFactory {
static let shared: FaqService = FaqServiceImpl()
}
66 changes: 66 additions & 0 deletions ArtemisKit/Sources/Faq/Services/FaqService/FaqServiceImpl.swift
Original file line number Diff line number Diff line change
@@ -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<FaqDTO> {
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))
}
}
}
46 changes: 46 additions & 0 deletions ArtemisKit/Sources/Faq/ViewModels/FaqViewModel.swift
Original file line number Diff line number Diff line change
@@ -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)
} ?? []
}
}
Loading

0 comments on commit 6abf231

Please sign in to comment.