diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a3ae172..ba3033d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,8 +9,7 @@ on: jobs: build: - - runs-on: macos-latest + runs-on: macos-15 environment: develop env: # app archive 및 export 에 쓰일 환경 변수 설정 diff --git a/.github/workflows/unitTest.yml b/.github/workflows/unitTest.yml index 53bb63d..d25a2cf 100644 --- a/.github/workflows/unitTest.yml +++ b/.github/workflows/unitTest.yml @@ -6,7 +6,7 @@ on: jobs: run-unitTest: - runs-on: macos-latest + runs-on: macos-15 steps: - uses: actions/checkout@v4 @@ -27,7 +27,7 @@ jobs: run: | set -o pipefail && xcodebuild test \ -scheme three-days-UnitTest \ - -destination 'platform=iOS Simulator,name=iPhone 15,OS=latest' \ + -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \ | tee result.log if grep -q "** TEST FAILED **" result.log; then exit 1 diff --git a/OpenApiGenerator/Sources/OpenapiGenerated/Client.swift b/OpenApiGenerator/Sources/OpenapiGenerated/Client.swift index 7046fe9..fcd73aa 100644 --- a/OpenApiGenerator/Sources/OpenapiGenerated/Client.swift +++ b/OpenApiGenerator/Sources/OpenapiGenerated/Client.swift @@ -127,7 +127,10 @@ public struct Client: APIProtocol { default: return .undocumented( statusCode: response.status.code, - .init() + .init( + headerFields: response.headerFields, + body: responseBody + ) ) } } @@ -263,7 +266,10 @@ public struct Client: APIProtocol { default: return .undocumented( statusCode: response.status.code, - .init() + .init( + headerFields: response.headerFields, + body: responseBody + ) ) } } @@ -399,7 +405,10 @@ public struct Client: APIProtocol { default: return .undocumented( statusCode: response.status.code, - .init() + .init( + headerFields: response.headerFields, + body: responseBody + ) ) } } @@ -546,7 +555,10 @@ public struct Client: APIProtocol { default: return .undocumented( statusCode: response.status.code, - .init() + .init( + headerFields: response.headerFields, + body: responseBody + ) ) } } @@ -649,7 +661,394 @@ public struct Client: APIProtocol { default: return .undocumented( statusCode: response.status.code, - .init() + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// 내 프로필 수정 + /// + /// 현재 로그인한 사용자의 프로필 정보를 수정합니다. (이름, 직군, 직장, 활동 지역) + /// + /// - Remark: HTTP `PATCH /users/my`. + /// - Remark: Generated from `#/paths//users/my/patch(updateMyUserInfo)`. + public func updateMyUserInfo(_ input: Operations.updateMyUserInfo.Input) async throws -> Operations.updateMyUserInfo.Output { + try await client.send( + input: input, + forOperation: Operations.updateMyUserInfo.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/users/my", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .patch + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .json(value): + body = try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.updateMyUserInfo.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.UpdateMyUserInfoResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.BadRequest.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .badRequest(.init(body: body)) + case 401: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Unauthorized.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .unauthorized(.init(body: body)) + case 500: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.InternalServerError.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .internalServerError(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// 프로필 위젯 추가 및 수정 + /// + /// 현재 사용자의 프로필 위젯을 추가 및 수정합니다. + /// + /// - Remark: HTTP `PUT /users/profileWidgets`. + /// - Remark: Generated from `#/paths//users/profileWidgets/put(putProfileWidget)`. + public func putProfileWidget(_ input: Operations.putProfileWidget.Input) async throws -> Operations.putProfileWidget.Output { + try await client.send( + input: input, + forOperation: Operations.putProfileWidget.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/users/profileWidgets", + parameters: [] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .put + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .json(value): + body = try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.putProfileWidget.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.PutProfileWidgetResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.BadRequest.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .badRequest(.init(body: body)) + case 401: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Unauthorized.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .unauthorized(.init(body: body)) + case 500: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.InternalServerError.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .internalServerError(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// 프로필 위젯 삭제 + /// + /// 현재 사용자의 프로필 위젯을 삭제합니다. + /// + /// - Remark: HTTP `DELETE /users/profileWidgets/{type}`. + /// - Remark: Generated from `#/paths//users/profileWidgets/{type}/delete(deleteProfileWidget)`. + public func deleteProfileWidget(_ input: Operations.deleteProfileWidget.Input) async throws -> Operations.deleteProfileWidget.Output { + try await client.send( + input: input, + forOperation: Operations.deleteProfileWidget.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/users/profileWidgets/{}", + parameters: [ + input.path._type + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .delete + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + return (request, nil) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 204: + return .noContent(.init()) + case 400: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.BadRequest.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .badRequest(.init(body: body)) + case 401: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Unauthorized.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .unauthorized(.init(body: body)) + case 500: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.InternalServerError.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .internalServerError(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) ) } } @@ -783,7 +1182,10 @@ public struct Client: APIProtocol { default: return .undocumented( statusCode: response.status.code, - .init() + .init( + headerFields: response.headerFields, + body: responseBody + ) ) } } @@ -864,7 +1266,10 @@ public struct Client: APIProtocol { default: return .undocumented( statusCode: response.status.code, - .init() + .init( + headerFields: response.headerFields, + body: responseBody + ) ) } } @@ -969,7 +1374,10 @@ public struct Client: APIProtocol { default: return .undocumented( statusCode: response.status.code, - .init() + .init( + headerFields: response.headerFields, + body: responseBody + ) ) } } @@ -1071,7 +1479,10 @@ public struct Client: APIProtocol { default: return .undocumented( statusCode: response.status.code, - .init() + .init( + headerFields: response.headerFields, + body: responseBody + ) ) } } @@ -1176,7 +1587,10 @@ public struct Client: APIProtocol { default: return .undocumented( statusCode: response.status.code, - .init() + .init( + headerFields: response.headerFields, + body: responseBody + ) ) } } diff --git a/OpenApiGenerator/Sources/OpenapiGenerated/Types.swift b/OpenApiGenerator/Sources/OpenapiGenerated/Types.swift index 08622f3..5312f55 100644 --- a/OpenApiGenerator/Sources/OpenapiGenerated/Types.swift +++ b/OpenApiGenerator/Sources/OpenapiGenerated/Types.swift @@ -46,6 +46,27 @@ public protocol APIProtocol: Sendable { /// - Remark: HTTP `GET /users/my`. /// - Remark: Generated from `#/paths//users/my/get(getMyUserInfo)`. func getMyUserInfo(_ input: Operations.getMyUserInfo.Input) async throws -> Operations.getMyUserInfo.Output + /// 내 프로필 수정 + /// + /// 현재 로그인한 사용자의 프로필 정보를 수정합니다. (이름, 직군, 직장, 활동 지역) + /// + /// - Remark: HTTP `PATCH /users/my`. + /// - Remark: Generated from `#/paths//users/my/patch(updateMyUserInfo)`. + func updateMyUserInfo(_ input: Operations.updateMyUserInfo.Input) async throws -> Operations.updateMyUserInfo.Output + /// 프로필 위젯 추가 및 수정 + /// + /// 현재 사용자의 프로필 위젯을 추가 및 수정합니다. + /// + /// - Remark: HTTP `PUT /users/profileWidgets`. + /// - Remark: Generated from `#/paths//users/profileWidgets/put(putProfileWidget)`. + func putProfileWidget(_ input: Operations.putProfileWidget.Input) async throws -> Operations.putProfileWidget.Output + /// 프로필 위젯 삭제 + /// + /// 현재 사용자의 프로필 위젯을 삭제합니다. + /// + /// - Remark: HTTP `DELETE /users/profileWidgets/{type}`. + /// - Remark: Generated from `#/paths//users/profileWidgets/{type}/delete(deleteProfileWidget)`. + func deleteProfileWidget(_ input: Operations.deleteProfileWidget.Input) async throws -> Operations.deleteProfileWidget.Output /// 액세스 토큰 갱신 /// /// 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다. @@ -158,6 +179,51 @@ extension APIProtocol { public func getMyUserInfo(headers: Operations.getMyUserInfo.Input.Headers = .init()) async throws -> Operations.getMyUserInfo.Output { try await getMyUserInfo(Operations.getMyUserInfo.Input(headers: headers)) } + /// 내 프로필 수정 + /// + /// 현재 로그인한 사용자의 프로필 정보를 수정합니다. (이름, 직군, 직장, 활동 지역) + /// + /// - Remark: HTTP `PATCH /users/my`. + /// - Remark: Generated from `#/paths//users/my/patch(updateMyUserInfo)`. + public func updateMyUserInfo( + headers: Operations.updateMyUserInfo.Input.Headers = .init(), + body: Operations.updateMyUserInfo.Input.Body + ) async throws -> Operations.updateMyUserInfo.Output { + try await updateMyUserInfo(Operations.updateMyUserInfo.Input( + headers: headers, + body: body + )) + } + /// 프로필 위젯 추가 및 수정 + /// + /// 현재 사용자의 프로필 위젯을 추가 및 수정합니다. + /// + /// - Remark: HTTP `PUT /users/profileWidgets`. + /// - Remark: Generated from `#/paths//users/profileWidgets/put(putProfileWidget)`. + public func putProfileWidget( + headers: Operations.putProfileWidget.Input.Headers = .init(), + body: Operations.putProfileWidget.Input.Body + ) async throws -> Operations.putProfileWidget.Output { + try await putProfileWidget(Operations.putProfileWidget.Input( + headers: headers, + body: body + )) + } + /// 프로필 위젯 삭제 + /// + /// 현재 사용자의 프로필 위젯을 삭제합니다. + /// + /// - Remark: HTTP `DELETE /users/profileWidgets/{type}`. + /// - Remark: Generated from `#/paths//users/profileWidgets/{type}/delete(deleteProfileWidget)`. + public func deleteProfileWidget( + path: Operations.deleteProfileWidget.Input.Path, + headers: Operations.deleteProfileWidget.Input.Headers = .init() + ) async throws -> Operations.deleteProfileWidget.Output { + try await deleteProfileWidget(Operations.deleteProfileWidget.Input( + path: path, + headers: headers + )) + } /// 액세스 토큰 갱신 /// /// 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다. @@ -305,7 +371,7 @@ public enum Components { /// 사용자 상태 (신규 사용자 또는 기존 사용자) /// /// - Remark: Generated from `#/components/schemas/SendAuthCodeResponse/userStatus`. - @frozen public enum userStatusPayload: String, Codable, Hashable, Sendable { + @frozen public enum userStatusPayload: String, Codable, Hashable, Sendable, CaseIterable { case NEW = "NEW" case EXISTING = "EXISTING" } @@ -480,6 +546,8 @@ public enum Components { public var profile: Components.Schemas.UserProfile /// - Remark: Generated from `#/components/schemas/GetMyUserInfoResponse/desiredPartner`. public var desiredPartner: Components.Schemas.UserDesiredPartner + /// - Remark: Generated from `#/components/schemas/GetMyUserInfoResponse/profileWidgets`. + public var profileWidgets: [Components.Schemas.ProfileWidget] /// Creates a new `GetMyUserInfoResponse`. /// /// - Parameters: @@ -488,6 +556,100 @@ public enum Components { /// - phoneNumber: 사용자의 전화번호 (한국 휴대폰 번호 형식) /// - profile: /// - desiredPartner: + /// - profileWidgets: + public init( + id: Swift.String? = nil, + name: Swift.String, + phoneNumber: Swift.String, + profile: Components.Schemas.UserProfile, + desiredPartner: Components.Schemas.UserDesiredPartner, + profileWidgets: [Components.Schemas.ProfileWidget] + ) { + self.id = id + self.name = name + self.phoneNumber = phoneNumber + self.profile = profile + self.desiredPartner = desiredPartner + self.profileWidgets = profileWidgets + } + public enum CodingKeys: String, CodingKey { + case id + case name + case phoneNumber + case profile + case desiredPartner + case profileWidgets + } + } + /// 현재 사용자 프로필 수정 요청 (이름, 직군, 직장, 활동 지역) + /// + /// - Remark: Generated from `#/components/schemas/UpdateMyUserInfoRequest`. + public struct UpdateMyUserInfoRequest: Codable, Hashable, Sendable { + /// 사용자 이름 + /// + /// - Remark: Generated from `#/components/schemas/UpdateMyUserInfoRequest/name`. + public var name: Swift.String? + /// - Remark: Generated from `#/components/schemas/UpdateMyUserInfoRequest/jobOccupation`. + public var jobOccupation: Components.Schemas.JobOccupation? + /// 사용자의 회사 ID + /// + /// - Remark: Generated from `#/components/schemas/UpdateMyUserInfoRequest/companyId`. + public var companyId: Swift.String? + /// 사용자의 활동 지역 목록 ID 리스트 + /// + /// - Remark: Generated from `#/components/schemas/UpdateMyUserInfoRequest/locationIds`. + public var locationIds: [Swift.String]? + /// Creates a new `UpdateMyUserInfoRequest`. + /// + /// - Parameters: + /// - name: 사용자 이름 + /// - jobOccupation: + /// - companyId: 사용자의 회사 ID + /// - locationIds: 사용자의 활동 지역 목록 ID 리스트 + public init( + name: Swift.String? = nil, + jobOccupation: Components.Schemas.JobOccupation? = nil, + companyId: Swift.String? = nil, + locationIds: [Swift.String]? = nil + ) { + self.name = name + self.jobOccupation = jobOccupation + self.companyId = companyId + self.locationIds = locationIds + } + public enum CodingKeys: String, CodingKey { + case name + case jobOccupation + case companyId + case locationIds + } + } + /// - Remark: Generated from `#/components/schemas/UpdateMyUserInfoResponse`. + public struct UpdateMyUserInfoResponse: Codable, Hashable, Sendable { + /// 사용자 식별자 + /// + /// - Remark: Generated from `#/components/schemas/UpdateMyUserInfoResponse/id`. + public var id: Swift.String? + /// 사용자 이름 + /// + /// - Remark: Generated from `#/components/schemas/UpdateMyUserInfoResponse/name`. + public var name: Swift.String + /// 사용자의 전화번호 (한국 휴대폰 번호 형식) + /// + /// - Remark: Generated from `#/components/schemas/UpdateMyUserInfoResponse/phoneNumber`. + public var phoneNumber: Swift.String + /// - Remark: Generated from `#/components/schemas/UpdateMyUserInfoResponse/profile`. + public var profile: Components.Schemas.UserProfile + /// - Remark: Generated from `#/components/schemas/UpdateMyUserInfoResponse/desiredPartner`. + public var desiredPartner: Components.Schemas.UserDesiredPartner + /// Creates a new `UpdateMyUserInfoResponse`. + /// + /// - Parameters: + /// - id: 사용자 식별자 + /// - name: 사용자 이름 + /// - phoneNumber: 사용자의 전화번호 (한국 휴대폰 번호 형식) + /// - profile: + /// - desiredPartner: public init( id: Swift.String? = nil, name: Swift.String, @@ -783,21 +945,21 @@ public enum Components { /// 사용자의 운영 체제 유형 /// /// - Remark: Generated from `#/components/schemas/OSType`. - @frozen public enum OSType: String, Codable, Hashable, Sendable { + @frozen public enum OSType: String, Codable, Hashable, Sendable, CaseIterable { case IOS = "IOS" case AOS = "AOS" } /// 사용자의 성별 /// /// - Remark: Generated from `#/components/schemas/Gender`. - @frozen public enum Gender: String, Codable, Hashable, Sendable { + @frozen public enum Gender: String, Codable, Hashable, Sendable, CaseIterable { case MALE = "MALE" case FEMALE = "FEMALE" } /// 직업군 분류 /// /// - Remark: Generated from `#/components/schemas/JobOccupation`. - @frozen public enum JobOccupation: String, Codable, Hashable, Sendable { + @frozen public enum JobOccupation: String, Codable, Hashable, Sendable, CaseIterable { case BUSINESS_ADMIN = "BUSINESS_ADMIN" case SALES_MARKETING = "SALES_MARKETING" case RESEARCH_DEVELOPMENT = "RESEARCH_DEVELOPMENT" @@ -822,11 +984,59 @@ public enum Components { /// 선호하는 거리 (내 지역만, 주변 지역 포함, 어디든) /// /// - Remark: Generated from `#/components/schemas/PreferDistance`. - @frozen public enum PreferDistance: String, Codable, Hashable, Sendable { + @frozen public enum PreferDistance: String, Codable, Hashable, Sendable, CaseIterable { case ONLY_MY_AREA = "ONLY_MY_AREA" case INCLUDE_SURROUNDING_REGIONS = "INCLUDE_SURROUNDING_REGIONS" case ANYWHERE = "ANYWHERE" } + /// - Remark: Generated from `#/components/schemas/PutProfileWidgetRequest`. + public typealias PutProfileWidgetRequest = Components.Schemas.ProfileWidget + /// - Remark: Generated from `#/components/schemas/PutProfileWidgetResponse`. + public typealias PutProfileWidgetResponse = Components.Schemas.ProfileWidget + /// - Remark: Generated from `#/components/schemas/ProfileWidget`. + public struct ProfileWidget: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ProfileWidget/type`. + public var _type: Components.Schemas.ProfileWidgetType + /// 위젯 내용 + /// + /// - Remark: Generated from `#/components/schemas/ProfileWidget/content`. + public var content: Swift.String + /// Creates a new `ProfileWidget`. + /// + /// - Parameters: + /// - _type: + /// - content: 위젯 내용 + public init( + _type: Components.Schemas.ProfileWidgetType, + content: Swift.String + ) { + self._type = _type + self.content = content + } + public enum CodingKeys: String, CodingKey { + case _type = "type" + case content + } + } + /// 프로필 위젯 타입 + /// + /// - Remark: Generated from `#/components/schemas/ProfileWidgetType`. + @frozen public enum ProfileWidgetType: String, Codable, Hashable, Sendable, CaseIterable { + case HOBBY = "HOBBY" + case STYLE = "STYLE" + case MBTI = "MBTI" + case MUSIC = "MUSIC" + case BODY_TYPE = "BODY_TYPE" + case FOOD = "FOOD" + case MOVIE = "MOVIE" + case DRAMA = "DRAMA" + case BOOK = "BOOK" + case TRAVEL = "TRAVEL" + case DRINKING = "DRINKING" + case MARRIAGE = "MARRIAGE" + case RELIGION = "RELIGION" + case SMOKING = "SMOKING" + } } /// Types generated from the `#/components/parameters` section of the OpenAPI document. public enum Parameters { @@ -2021,6 +2231,567 @@ public enum Operations { } } } + /// 내 프로필 수정 + /// + /// 현재 로그인한 사용자의 프로필 정보를 수정합니다. (이름, 직군, 직장, 활동 지역) + /// + /// - Remark: HTTP `PATCH /users/my`. + /// - Remark: Generated from `#/paths//users/my/patch(updateMyUserInfo)`. + public enum updateMyUserInfo { + public static let id: Swift.String = "updateMyUserInfo" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/users/my/PATCH/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.updateMyUserInfo.Input.Headers + /// - Remark: Generated from `#/paths/users/my/PATCH/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/users/my/PATCH/requestBody/content/application\/json`. + case json(Components.Schemas.UpdateMyUserInfoRequest) + } + public var body: Operations.updateMyUserInfo.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - headers: + /// - body: + public init( + headers: Operations.updateMyUserInfo.Input.Headers = .init(), + body: Operations.updateMyUserInfo.Input.Body + ) { + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/users/my/PATCH/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/users/my/PATCH/responses/200/content/application\/json`. + case json(Components.Schemas.UpdateMyUserInfoResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.UpdateMyUserInfoResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.updateMyUserInfo.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.updateMyUserInfo.Output.Ok.Body) { + self.body = body + } + } + /// 수정 성공 + /// + /// - Remark: Generated from `#/paths//users/my/patch(updateMyUserInfo)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.updateMyUserInfo.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.updateMyUserInfo.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// 잘못된 요청 + /// + /// - Remark: Generated from `#/paths//users/my/patch(updateMyUserInfo)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.BadRequest) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.BadRequest { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// 인증 실패 (토큰 만료 또는 유효하지 않은 토큰) + /// + /// - Remark: Generated from `#/paths//users/my/patch(updateMyUserInfo)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Unauthorized) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Unauthorized { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// 서버 오류 + /// + /// - Remark: Generated from `#/paths//users/my/patch(updateMyUserInfo)/responses/500`. + /// + /// HTTP response code: `500 internalServerError`. + case internalServerError(Components.Responses.InternalServerError) + /// The associated value of the enum case if `self` is `.internalServerError`. + /// + /// - Throws: An error if `self` is not `.internalServerError`. + /// - SeeAlso: `.internalServerError`. + public var internalServerError: Components.Responses.InternalServerError { + get throws { + switch self { + case let .internalServerError(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "internalServerError", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// 프로필 위젯 추가 및 수정 + /// + /// 현재 사용자의 프로필 위젯을 추가 및 수정합니다. + /// + /// - Remark: HTTP `PUT /users/profileWidgets`. + /// - Remark: Generated from `#/paths//users/profileWidgets/put(putProfileWidget)`. + public enum putProfileWidget { + public static let id: Swift.String = "putProfileWidget" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/users/profileWidgets/PUT/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.putProfileWidget.Input.Headers + /// - Remark: Generated from `#/paths/users/profileWidgets/PUT/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/users/profileWidgets/PUT/requestBody/content/application\/json`. + case json(Components.Schemas.PutProfileWidgetRequest) + } + public var body: Operations.putProfileWidget.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - headers: + /// - body: + public init( + headers: Operations.putProfileWidget.Input.Headers = .init(), + body: Operations.putProfileWidget.Input.Body + ) { + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/users/profileWidgets/PUT/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/users/profileWidgets/PUT/responses/200/content/application\/json`. + case json(Components.Schemas.PutProfileWidgetResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.PutProfileWidgetResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.putProfileWidget.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.putProfileWidget.Output.Ok.Body) { + self.body = body + } + } + /// 프로필 위젯 추가/수정 성공 + /// + /// - Remark: Generated from `#/paths//users/profileWidgets/put(putProfileWidget)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.putProfileWidget.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.putProfileWidget.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// 잘못된 요청 + /// + /// - Remark: Generated from `#/paths//users/profileWidgets/put(putProfileWidget)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.BadRequest) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.BadRequest { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// 인증 실패 (토큰 만료 또는 유효하지 않은 토큰) + /// + /// - Remark: Generated from `#/paths//users/profileWidgets/put(putProfileWidget)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Unauthorized) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Unauthorized { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// 서버 오류 + /// + /// - Remark: Generated from `#/paths//users/profileWidgets/put(putProfileWidget)/responses/500`. + /// + /// HTTP response code: `500 internalServerError`. + case internalServerError(Components.Responses.InternalServerError) + /// The associated value of the enum case if `self` is `.internalServerError`. + /// + /// - Throws: An error if `self` is not `.internalServerError`. + /// - SeeAlso: `.internalServerError`. + public var internalServerError: Components.Responses.InternalServerError { + get throws { + switch self { + case let .internalServerError(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "internalServerError", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// 프로필 위젯 삭제 + /// + /// 현재 사용자의 프로필 위젯을 삭제합니다. + /// + /// - Remark: HTTP `DELETE /users/profileWidgets/{type}`. + /// - Remark: Generated from `#/paths//users/profileWidgets/{type}/delete(deleteProfileWidget)`. + public enum deleteProfileWidget { + public static let id: Swift.String = "deleteProfileWidget" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/users/profileWidgets/{type}/DELETE/path`. + public struct Path: Sendable, Hashable { + /// 삭제할 프로필 위젯 타입 + /// + /// - Remark: Generated from `#/paths/users/profileWidgets/{type}/DELETE/path/type`. + public var _type: Components.Schemas.ProfileWidgetType + /// Creates a new `Path`. + /// + /// - Parameters: + /// - _type: 삭제할 프로필 위젯 타입 + public init(_type: Components.Schemas.ProfileWidgetType) { + self._type = _type + } + } + public var path: Operations.deleteProfileWidget.Input.Path + /// - Remark: Generated from `#/paths/users/profileWidgets/{type}/DELETE/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.deleteProfileWidget.Input.Headers + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + public init( + path: Operations.deleteProfileWidget.Input.Path, + headers: Operations.deleteProfileWidget.Input.Headers = .init() + ) { + self.path = path + self.headers = headers + } + } + @frozen public enum Output: Sendable, Hashable { + public struct NoContent: Sendable, Hashable { + /// Creates a new `NoContent`. + public init() {} + } + /// 프로필 위젯 삭제 성공 + /// + /// - Remark: Generated from `#/paths//users/profileWidgets/{type}/delete(deleteProfileWidget)/responses/204`. + /// + /// HTTP response code: `204 noContent`. + case noContent(Operations.deleteProfileWidget.Output.NoContent) + /// The associated value of the enum case if `self` is `.noContent`. + /// + /// - Throws: An error if `self` is not `.noContent`. + /// - SeeAlso: `.noContent`. + public var noContent: Operations.deleteProfileWidget.Output.NoContent { + get throws { + switch self { + case let .noContent(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "noContent", + response: self + ) + } + } + } + /// 잘못된 요청 + /// + /// - Remark: Generated from `#/paths//users/profileWidgets/{type}/delete(deleteProfileWidget)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.BadRequest) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.BadRequest { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// 인증 실패 (토큰 만료 또는 유효하지 않은 토큰) + /// + /// - Remark: Generated from `#/paths//users/profileWidgets/{type}/delete(deleteProfileWidget)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Unauthorized) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Unauthorized { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// 서버 오류 + /// + /// - Remark: Generated from `#/paths//users/profileWidgets/{type}/delete(deleteProfileWidget)/responses/500`. + /// + /// HTTP response code: `500 internalServerError`. + case internalServerError(Components.Responses.InternalServerError) + /// The associated value of the enum case if `self` is `.internalServerError`. + /// + /// - Throws: An error if `self` is not `.internalServerError`. + /// - SeeAlso: `.internalServerError`. + public var internalServerError: Components.Responses.InternalServerError { + get throws { + switch self { + case let .internalServerError(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "internalServerError", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } /// 액세스 토큰 갱신 /// /// 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다. diff --git a/OpenApiGenerator/Sources/openapi-generator-cli/3days-oas b/OpenApiGenerator/Sources/openapi-generator-cli/3days-oas index f81da79..aba707b 160000 --- a/OpenApiGenerator/Sources/openapi-generator-cli/3days-oas +++ b/OpenApiGenerator/Sources/openapi-generator-cli/3days-oas @@ -1 +1 @@ -Subproject commit f81da79dc8537bde60d3ec02da999d4369f74a1e +Subproject commit aba707baf59df237f4492bdb9ebbcf58af41b351 diff --git a/Projects/Core/CommonKit/Sources/AppCoordinator.swift b/Projects/Core/CommonKit/Sources/AppCoordinator.swift index bf18f85..d349216 100644 --- a/Projects/Core/CommonKit/Sources/AppCoordinator.swift +++ b/Projects/Core/CommonKit/Sources/AppCoordinator.swift @@ -43,6 +43,7 @@ public final class AppCoordinator: ObservableObject { } } } + startRefreshMyUserInfo() } @MainActor @@ -100,4 +101,25 @@ public final class AppCoordinator: ObservableObject { } } } + + public func refreshMyUserInfo() async throws { + if TokenManager.accessToken == nil || TokenManager.accessToken == "" { + return + } + let userInfo = try await authService.requestMyUserInfo() + await MainActor.run { + self.userInfo = userInfo + AuthState.change(.login) + } + } + + // 20초마다 한번씩 refreshMyUserInfo() 를 호출 + private func startRefreshMyUserInfo() { + Task { + while true { + try? await Task.sleep(for: .seconds(20)) + try? await refreshMyUserInfo() + } + } + } } diff --git a/Projects/DesignSystem/DesignCore/Sources/Color+Ext.swift b/Projects/Core/CoreKit/Sources/Color+Ext.swift similarity index 75% rename from Projects/DesignSystem/DesignCore/Sources/Color+Ext.swift rename to Projects/Core/CoreKit/Sources/Color+Ext.swift index 45f0459..c33158b 100644 --- a/Projects/DesignSystem/DesignCore/Sources/Color+Ext.swift +++ b/Projects/Core/CoreKit/Sources/Color+Ext.swift @@ -1,9 +1,9 @@ // // Color+Ext.swift -// DesignCore +// CoreKit // -// Created by 김지수 on 9/14/24. -// Copyright © 2024 com.studentcenter. All rights reserved. +// Created by 김지수 on 11/11/24. +// Copyright © 2024 com.weave. All rights reserved. // import SwiftUI diff --git a/Projects/Core/CoreKit/Sources/String+Ext.swift b/Projects/Core/CoreKit/Sources/String+Ext.swift index 5bd0b62..8334599 100644 --- a/Projects/Core/CoreKit/Sources/String+Ext.swift +++ b/Projects/Core/CoreKit/Sources/String+Ext.swift @@ -34,4 +34,13 @@ extension String { return "\(firstPart)-\(secondPart)-\(thirdPart)" } } + + public func clipMaxCount(_ maxCount: Int) -> String { + if self.count > maxCount { + let index = self.index(self.startIndex, offsetBy: maxCount) + let formattedText = String(self[.. some View { + content + .keyboardType(keyboardType) + .interactiveDismissDisabled() + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .speechAnnouncementsQueued(false) + .speechSpellsOutCharacters(false) + } +} + +extension View { + public func flatTextFieldOption(keyboardType: UIKeyboardType = .default) -> some View { + modifier(FlatTextFieldOptionModifier(keyboardType: keyboardType)) + } +} diff --git a/Projects/DesignSystem/DesignCore/Sources/ProfileWidget/ProfileWidgetView.swift b/Projects/DesignSystem/DesignCore/Sources/ProfileWidget/ProfileWidgetView.swift new file mode 100644 index 0000000..ffe31e0 --- /dev/null +++ b/Projects/DesignSystem/DesignCore/Sources/ProfileWidget/ProfileWidgetView.swift @@ -0,0 +1,82 @@ +// +// ProfileWidgetView.swift +// DesignCore +// +// Created by 김지수 on 11/7/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import SwiftUI +import CoreKit + +public enum ProfileWidgetIconType { + case add + case edit + + var image: Image { + switch self { + case .add: DesignCore.Images.plusCircleFilled.image + case .edit: DesignCore.Images.pencil1.image + } + } +} + +public struct ProfileWidgetView: View { + + public let title: String + public let bodyText: String + public let titleColor: Color + public let bodyColor: Color + public let gradientColors: [Color] + public var iconType: ProfileWidgetIconType? + + public init( + title: String, + bodyText: String, + titleColor: Color, + bodyColor: Color, + gradientColors: [Color], + iconType: ProfileWidgetIconType? = nil + ) { + self.title = title + self.bodyText = bodyText + self.titleColor = titleColor + self.bodyColor = bodyColor + self.gradientColors = gradientColors + self.iconType = iconType + } + + public var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 24) + .fill( + LinearGradient( + colors: gradientColors, + startPoint: .top, + endPoint: .bottom + ) + ) + VStack { + HStack { + Text(title) + .pretendard(weight: ._600, size: 22) + .foregroundStyle(titleColor) + Spacer() + if let iconType { + iconType.image + .resizable() + .frame(width: 22, height: 22) + } + } + Spacer() + ScrollView { + LeftAlignText(bodyText) + .typography(.regular_14) + .foregroundStyle(bodyColor) + } + } + .scrollIndicators(.hidden) + .padding(.all, 20) + } + } +} diff --git a/Projects/DesignSystem/DesignCore/Sources/ProfileWidget/WritableProfileWidgetView.swift b/Projects/DesignSystem/DesignCore/Sources/ProfileWidget/WritableProfileWidgetView.swift new file mode 100644 index 0000000..fee45bc --- /dev/null +++ b/Projects/DesignSystem/DesignCore/Sources/ProfileWidget/WritableProfileWidgetView.swift @@ -0,0 +1,102 @@ +// +// WritableProfileWidgetView.swift +// DesignCore +// +// Created by 김지수 on 11/12/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import SwiftUI + +public struct WritableProfileWidgetView: View { + @FocusState private var isfocused: Bool + @Binding public var bodyText: String + public let title: String + public let placeholder: String + public let titleColor: Color + public let bodyColor: Color + public let gradientColors: [Color] + + public init( + title: String, + placeholder: String, + bodyText: Binding, + titleColor: Color, + bodyColor: Color, + gradientColors: [Color], + focusState: FocusState = .init() + ) { + self.title = title + self.placeholder = placeholder + self._bodyText = bodyText + self.titleColor = titleColor + self.bodyColor = bodyColor + self.gradientColors = gradientColors + self._isfocused = focusState + } + + public var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 24) + .fill( + LinearGradient( + colors: gradientColors, + startPoint: .top, + endPoint: .bottom + ) + ) + VStack { + HStack { + Text(title) + .pretendard(weight: ._600, size: 22) + .foregroundStyle(titleColor) + Spacer() + } + Spacer() + ZStack(alignment: .topLeading) { + if bodyText.isEmpty { + Text(placeholder) + .typography(.regular_14) + .foregroundStyle( + Color(hex: 0x15394B4D).opacity(0.3) + ) + .padding(.all, 8) + } + + TextEditor( + text: $bodyText + ) + .textEditorStyle( + PlainTextEditorStyle() + ) + .focused($isfocused) + .flatTextFieldOption(keyboardType: .namePhonePad) + .typography(.regular_14) + .foregroundStyle(bodyColor) + } + } + .scrollIndicators(.hidden) + .padding(.all, 28) + } + } +} + +struct PreviewView: View { + @State var text = "" + + var body: some View { + WritableProfileWidgetView( + title: "Title", + placeholder: "this is Placeholder hahaha dhdhdh 바래보아요", + bodyText: $text, + titleColor: .black, + bodyColor: .red, + gradientColors: [.yellow, .green] + ) + .frame(width: 300, height: 300) + } +} + +#Preview { + PreviewView() +} diff --git a/Projects/Features/Home/Sources/HomeMain/HomeMainView.swift b/Projects/Features/Home/Sources/HomeMain/HomeMainView.swift index f0643da..24937f7 100644 --- a/Projects/Features/Home/Sources/HomeMain/HomeMainView.swift +++ b/Projects/Features/Home/Sources/HomeMain/HomeMainView.swift @@ -52,9 +52,9 @@ public struct HomeMainView: View { Text(tab.title) .typography(.en_medium_20) .padding(.vertical, 12) - .foregroundColor(isSelected ? DesignCore.Colors.grey500 : DesignCore.Colors.grey500.opacity(0.2)) } + .padding(.top, 8) } } } @@ -82,7 +82,7 @@ public struct HomeMainView: View { } .ignoresSafeArea(.keyboard) .textureBackground() - .setNavigation(showLeftBackButton: false) {} + .toolbar(.hidden, for: .navigationBar) .setLoading(state.isLoading) } } diff --git a/Projects/Features/Home/Sources/Profile/ProfileIntent.swift b/Projects/Features/Home/Sources/Profile/ProfileIntent.swift index dd0501a..6d4cb3a 100644 --- a/Projects/Features/Home/Sources/Profile/ProfileIntent.swift +++ b/Projects/Features/Home/Sources/Profile/ProfileIntent.swift @@ -10,19 +10,23 @@ import Foundation import CommonKit import CoreKit import Model +import NetworkKit //MARK: - Intent class ProfileIntent { private weak var model: ProfileModelActionable? private let input: DataModel + private let profileService: ProfileServiceProtocol // MARK: Life cycle init( model: ProfileModelActionable, - input: DataModel + input: DataModel, + service: ProfileServiceProtocol = ProfileService.shared ) { self.input = input self.model = model + self.profileService = service } } @@ -30,7 +34,13 @@ class ProfileIntent { extension ProfileIntent { protocol Intentable { // content + func onTapModifyWidget(_ widget: ProfileWidget) + func onTapDeleteWidget(_ widget: ProfileWidget) + func onTapAddWidget() + func deleteWidget(_ widget: ProfileWidget) async + func onTapNextButton() + func fetchUserInfo(_ userInfo: UserInfo) // default func onAppear() @@ -45,12 +55,46 @@ extension ProfileIntent { //MARK: - Intentable extension ProfileIntent: ProfileIntent.Intentable { // default + func onTapAddWidget() { + model?.setAddWidgetModalPresented(true) + } + + func onTapDeleteWidget(_ widget: ProfileWidget) { + model?.setSelectedWidget(widget) + model?.setDeleteConfirmSheetPresented(true) + } + + func onTapModifyWidget(_ widget: ProfileWidget) { + model?.setSelectedWidget(widget) + model?.setModifyWidgetViewPresented(true) + } + + func deleteWidget(_ widget: ProfileWidget) async { + do { + model?.setLoading(status: true) + try await requestDeleteWidget(widget) + try await AppCoordinator.shared.refreshMyUserInfo() + model?.setLoading(status: false) + } catch { + print(error) + model?.setLoading(status: false) + } + } + func onAppear() { - model?.setUserInfo(input.userInfo) + fetchUserInfo(input.userInfo) + } + + func fetchUserInfo(_ userInfo: UserInfo) { + model?.setUserInfo(userInfo) } func task() async {} // content func onTapNextButton() {} + + func requestDeleteWidget(_ widget: ProfileWidget) async throws { + try await profileService.requestDeleteProfileWidget(widgetType: widget.widgetType.toDto) + } } diff --git a/Projects/Features/Home/Sources/Profile/ProfileModel.swift b/Projects/Features/Home/Sources/Profile/ProfileModel.swift index 51a88cf..c6c8133 100644 --- a/Projects/Features/Home/Sources/Profile/ProfileModel.swift +++ b/Projects/Features/Home/Sources/Profile/ProfileModel.swift @@ -16,6 +16,11 @@ final class ProfileModel: ObservableObject { //MARK: Stateful protocol Stateful { // content + var isPresentedAddWidgetModal: Bool { get set } + var isPresentedModifyWidgetView: Bool { get set } + var isPresentedDeleteConfirmSheet: Bool { get set } + var selectedWidgetType: ProfileWidget? { get } + var userInfoModel: UserInfo? { get } var isValidated: Bool { get } @@ -30,6 +35,11 @@ final class ProfileModel: ObservableObject { //MARK: State Properties // content @Published var userInfoModel: UserInfo? + @Published var isPresentedAddWidgetModal: Bool = false + @Published var isPresentedModifyWidgetView: Bool = false + @Published var isPresentedDeleteConfirmSheet: Bool = false + var selectedWidgetType: ProfileWidget? + @Published var isValidated: Bool = false // default @@ -45,8 +55,12 @@ extension ProfileModel: ProfileModel.Stateful {} //MARK: - Actionable protocol ProfileModelActionable: AnyObject { // content + func setAddWidgetModalPresented(_ isPresented: Bool) + func setModifyWidgetViewPresented(_ isPresented: Bool) + func setDeleteConfirmSheetPresented(_ isPresented: Bool) func setUserInfo(_ userInfo: UserInfo) func setValidation(value: Bool) + func setSelectedWidget(_ widget: ProfileWidget) // default func setLoading(status: Bool) @@ -59,12 +73,24 @@ protocol ProfileModelActionable: AnyObject { extension ProfileModel: ProfileModelActionable { // content + func setAddWidgetModalPresented(_ isPresented: Bool) { + isPresentedAddWidgetModal = isPresented + } + func setModifyWidgetViewPresented(_ isPresented: Bool) { + isPresentedModifyWidgetView = isPresented + } + func setDeleteConfirmSheetPresented(_ isPresented: Bool) { + isPresentedDeleteConfirmSheet = isPresented + } func setUserInfo(_ userInfo: UserInfo) { userInfoModel = userInfo } func setValidation(value: Bool) { isValidated = value } + func setSelectedWidget(_ widget: ProfileWidget) { + selectedWidgetType = widget + } // default func setLoading(status: Bool) { diff --git a/Projects/Features/Home/Sources/Profile/ProfileView.swift b/Projects/Features/Home/Sources/Profile/ProfileView.swift index 3a5bf94..7ca4ac1 100644 --- a/Projects/Features/Home/Sources/Profile/ProfileView.swift +++ b/Projects/Features/Home/Sources/Profile/ProfileView.swift @@ -15,6 +15,7 @@ import Model public struct ProfileView: View { @StateObject var container: MVIContainer +// @State var isPresentWidgetSelectionView = false private var intent: ProfileIntent.Intentable { container.intent } private var state: ProfileModel.Stateful { container.model } @@ -23,6 +24,11 @@ public struct ProfileView: View { (Device.width - 36 - 12) / 2 } + private let columns = [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16) + ] + public init(userInfo: UserInfo) { let model = ProfileModel() let intent = ProfileIntent( @@ -55,56 +61,197 @@ public struct ProfileView: View { LeftAlignText("Introductions") .typography(.en_medium_16) .padding(.bottom, 14) + .padding(.bottom, 16) + .foregroundStyle(Color(hex: 0x5E5E5E)) - ZStack { - Capsule() - .inset(by: 1) - .stroke(DesignCore.Colors.blue300, lineWidth: 1) - .fill(Color(hex: 0xF2F9FF)) - LeftAlignText("프로필 위젯을 추가해 나를 더 소개해보세요!🙌") - .padding(.leading, 26) - .typography(.semibold_14) - .foregroundStyle(DesignCore.Colors.blue300) - } - .frame(height: 57) - .shadow(.default) - .padding(.bottom, 14) - - ZStack { - RoundedRectangle(cornerRadius: 24) - .fill(.white) + // 비어있을 때의 뷰 + if userInfo.profileWidgets.isEmpty { + ZStack { + Capsule() + .inset(by: 1) + .stroke(DesignCore.Colors.blue300, lineWidth: 1) + .fill(Color(hex: 0xF2F9FF)) + LeftAlignText("프로필 위젯을 추가해 나를 더 소개해보세요!🙌") + .padding(.leading, 26) + .typography(.semibold_14) + .foregroundStyle(DesignCore.Colors.blue300) + } + .frame(height: 57) + .shadow(.default) + .padding(.bottom, 14) - RoundedRectangle(cornerRadius: 10) - .fill(DesignCore.Colors.grey50) - .strokeBorder( - style: StrokeStyle( - lineWidth: 3, - dash: [8, 8] + ZStack { + RoundedRectangle(cornerRadius: 24) + .fill(.white) + + RoundedRectangle(cornerRadius: 10) + .fill(DesignCore.Colors.grey50) + .strokeBorder( + style: StrokeStyle( + lineWidth: 3, + dash: [8, 8] + ) ) - ) - .foregroundStyle(Color(hex: 0xE0DEDD)) - .padding(.all, 8) - - VStack { - Image(systemName: "plus") - .resizable() - .frame(width: 24, height: 24) - Text("프로필 위젯 추가하기") - .typography(.semibold_14) + .foregroundStyle(Color(hex: 0xE0DEDD)) + .padding(.all, 8) + + VStack { + Image(systemName: "plus") + .resizable() + .frame(width: 24, height: 24) + Text("프로필 위젯 추가하기") + .typography(.semibold_14) + } + .foregroundStyle(DesignCore.Colors.grey200) + } + .frame(height: widgetSize) + .shadow(.default) + .padding(.bottom, 36) + .onTapGesture { + intent.onTapAddWidget() + } + } else { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(userInfo.profileWidgets, id: \.self) { widget in + ZStack { + RoundedRectangle(cornerRadius: 24) + .fill(.white) + ProfileWidgetView( + title: widget.widgetType.title + widget.widgetType.emoji, + bodyText: widget.content, + titleColor: widget.widgetType.titleColor, + bodyColor: widget.widgetType.bodyColor, + gradientColors: widget.widgetType.gradationColors, + iconType: .edit + ) + .padding(.all, 4) + } + .frame(minHeight: widgetSize) + .shadow(.default) + .contextMenu { + Button(action: { + intent.onTapModifyWidget(widget) + }) { + Text("수정하기") + } + + Button( + role: .destructive, + action: { + intent.onTapDeleteWidget(widget) + }) { + Text("삭제하기") + } + } + } + let isEveryWidgetAdded = WidgetType.allCases.count == userInfo.profileWidgets.count + if !isEveryWidgetAdded { + ZStack { + RoundedRectangle(cornerRadius: 24) + .fill(.white) + + RoundedRectangle(cornerRadius: 10) + .fill(DesignCore.Colors.grey50) + .strokeBorder( + style: StrokeStyle( + lineWidth: 3, + dash: [8, 8] + ) + ) + .foregroundStyle(Color(hex: 0xE0DEDD)) + .padding(.all, 8) + + VStack { + Image(systemName: "plus") + .resizable() + .frame(width: 24, height: 24) + Text("프로필 위젯\n추가하기") + .typography(.semibold_14) + } + .foregroundStyle(DesignCore.Colors.grey200) + .frame(minHeight: widgetSize) + } + .shadow(.default) + .onTapGesture { + intent.onTapAddWidget() + } + } } - .foregroundStyle(DesignCore.Colors.grey200) } - .frame(height: widgetSize) - .shadow(.default) - .padding(.bottom, 36) } .padding(.horizontal, 18) .padding(.top, 36) + .padding(.bottom, 20) } } else { ProgressView() } } + .onChange(of: state.isPresentedAddWidgetModal) { + if !state.isPresentedAddWidgetModal { + if let userInfo = AppCoordinator.shared.userInfo { + intent.fetchUserInfo(userInfo) + } + } + } + .onChange(of: state.isPresentedModifyWidgetView) { + if !state.isPresentedModifyWidgetView { + if let userInfo = AppCoordinator.shared.userInfo { + intent.fetchUserInfo(userInfo) + } + } + } + .onChange(of: state.isPresentedDeleteConfirmSheet) { + if !state.isPresentedDeleteConfirmSheet { + if let userInfo = AppCoordinator.shared.userInfo { + intent.fetchUserInfo(userInfo) + } + } + } + .sheet( + isPresented: $container.model.isPresentedAddWidgetModal, + content: { + NavigationStack { + WidgetSelectionView( + isPresented: $container.model.isPresentedAddWidgetModal + ) + } + }) + .sheet( + isPresented: $container.model.isPresentedModifyWidgetView, + content: { + if let widget = state.selectedWidgetType { + NavigationStack { + WidgetWritingView( + widgetType: widget.widgetType, + isModalPresented: $container.model.isPresentedModifyWidgetView, + isPushed: .constant(false), + isEditing: true, + contentString: widget.content + ) + } + } + } + ) + .sheet( + isPresented: $container.model.isPresentedDeleteConfirmSheet, + content: { + if let widget = state.selectedWidgetType { + DeleteWidgetConfirmView { + Task { + await intent.deleteWidget(widget) + await MainActor.run { + container.model.isPresentedDeleteConfirmSheet = false + } + } + } cancelHandler: { + container.model.isPresentedDeleteConfirmSheet = false + } + .presentationDetents([.height(280)]) + .presentationCornerRadius(20) + } + } + ) .task { await intent.task() } @@ -113,15 +260,57 @@ public struct ProfileView: View { } .ignoresSafeArea(.all) .textureBackground() - .setPopNavigation { - AppCoordinator.shared.pop() - } .setLoading(state.isLoading) } } +fileprivate struct DeleteWidgetConfirmView: View { + let confirmHandler: () -> Void + let cancelHandler: () -> Void + + var body: some View { + VStack(spacing: 12) { + VStack(spacing: 0) { + LeftAlignText("위젯을 삭제하시겠어요?") + .typography(.semibold_20) + .foregroundStyle(Color(hex: 0x454545)) + LeftAlignText("삭제된 위젯은 복구할 수 없어요.") + .typography(.regular_14) + .foregroundStyle(DesignCore.Colors.grey200) + } + + Spacer() + + VStack(spacing: 8) { + CTAButton( + title: "네, 삭제할게요", + titleColor: .white, + backgroundStyle: DesignCore.Colors.red300 + ) { + confirmHandler() + } + CTAButton( + title: "아니요", + titleColor: DesignCore.Colors.grey400, + backgroundStyle: Color(hex: 0xF2F1F1) + ) { + cancelHandler() + } + } + } + .padding(.horizontal, 28) + .padding(.vertical, 30) + } +} + #Preview { - NavigationView { - HomeMainView(userInfo: .mock) + DeleteWidgetConfirmView { + + } cancelHandler: { + } + +// NavigationView { +// HomeMainView(userInfo: .mock) +// } } diff --git a/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionIntent.swift b/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionIntent.swift new file mode 100644 index 0000000..9308815 --- /dev/null +++ b/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionIntent.swift @@ -0,0 +1,57 @@ +// +// WidgetSelectionIntent.swift +// Home +// +// Created by 김지수 on 11/10/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import Foundation +import CommonKit +import CoreKit +import Model + +//MARK: - Intent +class WidgetSelectionIntent { + private weak var model: WidgetSelectionModelActionable? + private let input: DataModel + + // MARK: Life cycle + init( + model: WidgetSelectionModelActionable, + input: DataModel + ) { + self.input = input + self.model = model + } +} + +//MARK: - Intentable +extension WidgetSelectionIntent { + protocol Intentable { + // content + func onTapWidget(_ widget: WidgetType) + func onTapNextButton() + + // default + func onAppear() + func task() async + } + + struct DataModel {} +} + +//MARK: - Intentable +extension WidgetSelectionIntent: WidgetSelectionIntent.Intentable { + // default + func onTapWidget(_ widget: WidgetType) { + model?.setSelectedWidget(widget: widget) + model?.setPushWriteContentView(status: true) + } + func onAppear() {} + + func task() async {} + + // content + func onTapNextButton() {} +} diff --git a/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionModel.swift b/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionModel.swift new file mode 100644 index 0000000..4742cf2 --- /dev/null +++ b/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionModel.swift @@ -0,0 +1,97 @@ +// +// WidgetSelectionModel.swift +// Home +// +// Created by 김지수 on 11/10/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import Foundation +import CommonKit +import CoreKit +import Model + +final class WidgetSelectionModel: ObservableObject { + + //MARK: Stateful + protocol Stateful { + // content + var isModalPresented: Bool { get set } + var isPushWriteContentView: Bool { get set } + var isValidated: Bool { get } + var selectedWidget: WidgetType? { get } + + // default + var isLoading: Bool { get } + + // error + var showErrorView: ErrorModel? { get } + var showErrorAlert: ErrorModel? { get } + } + + //MARK: State Properties + // content + @Published var isModalPresented: Bool = false + @Published var isPushWriteContentView: Bool = false + @Published var isValidated: Bool = false + @Published var selectedWidget: WidgetType? + + // default + @Published var isLoading: Bool = false + + // error + @Published var showErrorView: ErrorModel? + @Published var showErrorAlert: ErrorModel? +} + +extension WidgetSelectionModel: WidgetSelectionModel.Stateful {} + +//MARK: - Actionable +protocol WidgetSelectionModelActionable: AnyObject { + // content + func setModalPresented(status: Bool) + func setPushWriteContentView(status: Bool) + func setValidation(value: Bool) + func setSelectedWidget(widget: WidgetType) + + // default + func setLoading(status: Bool) + + // error + func showErrorView(error: ErrorModel) + func showErrorAlert(error: ErrorModel) + func resetError() +} + +extension WidgetSelectionModel: WidgetSelectionModelActionable { + // content + func setModalPresented(status: Bool) { + isModalPresented = status + } + func setPushWriteContentView(status: Bool) { + isPushWriteContentView = status + } + func setSelectedWidget(widget: WidgetType) { + selectedWidget = widget + } + func setValidation(value: Bool) { + isValidated = value + } + + // default + func setLoading(status: Bool) { + isLoading = status + } + + // error + func showErrorView(error: ErrorModel) { + showErrorView = error + } + func showErrorAlert(error: ErrorModel) { + showErrorAlert = error + } + func resetError() { + showErrorView = nil + showErrorAlert = nil + } +} diff --git a/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionView.swift b/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionView.swift new file mode 100644 index 0000000..59ed089 --- /dev/null +++ b/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionView.swift @@ -0,0 +1,116 @@ +// +// WidgetSelectionView.swift +// Home +// +// Created by 김지수 on 11/10/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import SwiftUI +import CoreKit +import DesignCore +import CommonKit +import Model + +public struct WidgetSelectionView: View { + +// @State private var isPushedWriteView: Bool = false + + @Binding var isPresentedSelectionView: Bool + @StateObject var container: MVIContainer + + private var intent: WidgetSelectionIntent.Intentable { container.intent } + private var state: WidgetSelectionModel.Stateful { container.model } + + let columns = [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16) + ] + + public init(isPresented: Binding) { + let model = WidgetSelectionModel() + let intent = WidgetSelectionIntent( + model: model, + input: .init() + ) + let container = MVIContainer( + intent: intent as WidgetSelectionIntent.Intentable, + model: model as WidgetSelectionModel.Stateful, + modelChangePublisher: model.objectWillChange + ) + self._container = StateObject(wrappedValue: container) + self._isPresentedSelectionView = isPresented + model.setModalPresented(status: isPresented.wrappedValue) + } + + public var body: some View { + VStack { + Text("추가하고 싶은 프로필 위젯을 눌러 소개를 작성해요!") + .typography(.regular_14) + .padding(.top, 20) + ScrollView { + LazyVGrid(columns: columns, spacing: 16) { + let totalWidgets = WidgetType.allCases + let userWidgets = AppCoordinator.shared.userInfo?.profileWidgets + .map { $0.widgetType } ?? [] + let availableWidgets = totalWidgets + .filter { !userWidgets.contains($0) } + + ForEach(availableWidgets, id: \.self) { widget in + ProfileWidgetView( + title: widget.title + widget.emoji, + bodyText: widget.exampleText, + titleColor: widget.titleColor, + bodyColor: widget.bodyColor, + gradientColors: widget.gradationColors, + iconType: .add + ) + .onTapGesture { + intent.onTapWidget(widget) + } + } + } + .padding() + } + } + .navigationDestination( + isPresented: $container.model.isPushWriteContentView, + destination: { + if let widget = state.selectedWidget { + WidgetWritingView( + widgetType: widget, + isModalPresented: $container.model.isModalPresented, + isPushed: $container.model.isPushWriteContentView + ) + } + } + ) + .onChange(of: state.isModalPresented) { + isPresentedSelectionView = state.isModalPresented + } + .task { + await intent.task() + } + .onAppear { + intent.onAppear() + } + .ignoresSafeArea(.keyboard) + .navigationTitle("프로필 위젯") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem { + Button("닫기") { + isPresentedSelectionView = false + } + .typography(.medium_16) + } + } + .setLoading(state.isLoading) + } +} + +#Preview { + NavigationStack { + WidgetSelectionView(isPresented: .constant(true)) + } +} diff --git a/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingIntent.swift b/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingIntent.swift new file mode 100644 index 0000000..d5d17e9 --- /dev/null +++ b/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingIntent.swift @@ -0,0 +1,103 @@ +// +// WidgetWritingIntent.swift +// Home +// +// Created by 김지수 on 11/12/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import Foundation +import CommonKit +import CoreKit +import Model +import NetworkKit + +//MARK: - Intent +class WidgetWritingIntent { + private weak var model: WidgetWritingModelActionable? + private let input: DataModel + private let service: ProfileServiceProtocol + + // MARK: Life cycle + init( + model: WidgetWritingModelActionable, + input: DataModel, + service: ProfileServiceProtocol = ProfileService.shared + ) { + self.input = input + self.model = model + self.service = service + model.setWidgetType(input.widgetType) + if let contentString = input.content { + model.setContentString(contentString) + } + } +} + +//MARK: - Intentable +extension WidgetWritingIntent { + protocol Intentable { + // content + func onChangedBodyText(_ text: String, maxCount: Int) + func onTapNextButton(state: WidgetWritingModel.Stateful) + func onTapBackButton() + func onTapDismissButton() + + // default + func onAppear() + func task() async + } + + struct DataModel { + let widgetType: WidgetType + let content: String? + } +} + +//MARK: - Intentable +extension WidgetWritingIntent: WidgetWritingIntent.Intentable { + // default + func onChangedBodyText(_ text: String, maxCount: Int) { + let formattedText = text.clipMaxCount(maxCount) + model?.setBodyText(formattedText) + } + func onTapBackButton() { + model?.navigationPop() + } + func onTapDismissButton() { + model?.modalDismiss() + } + func onAppear() { + model?.setFocusState(true) + } + + func task() async {} + + // content + func onTapNextButton(state: any WidgetWritingModel.Stateful) { + guard let selectedWidget = state.selectedWidgetType else { return } + // 창닫기 + Task { + model?.setLoading(status: true) + do { + try await requestPutProfileWidget( + widget: selectedWidget, + content: state.widgetBodyText + ) + try await AppCoordinator.shared.refreshMyUserInfo() + model?.setLoading(status: false) + model?.modalDismiss() + } catch { + // TODO: 에러처리 + model?.setLoading(status: false) + } + } + } + + func requestPutProfileWidget(widget: WidgetType, content: String) async throws { + try await service.requestPutProfileWidget( + widgetType: widget.toDto, + content: content + ) + } +} diff --git a/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingModel.swift b/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingModel.swift new file mode 100644 index 0000000..063a4f9 --- /dev/null +++ b/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingModel.swift @@ -0,0 +1,122 @@ +// +// WidgetWritingModel.swift +// Home +// +// Created by 김지수 on 11/12/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import Foundation +import CommonKit +import CoreKit +import Model + +final class WidgetWritingModel: ObservableObject { + + //MARK: Stateful + protocol Stateful { + // content + var selectedWidgetType: WidgetType? { get } + var widgetBodyText: String { get set } + var isValidated: Bool { get } + var textMaxCount: Int { get } + + var isPushedWriteContentView: Bool { get set } + var isModalPresented: Bool { get } + var isFocused: Bool { get } + var isCTAButtonEnabled: Bool { get } + + // default + var isLoading: Bool { get } + + // error + var showErrorView: ErrorModel? { get } + var showErrorAlert: ErrorModel? { get } + } + + //MARK: State Properties + // content + @Published var selectedWidgetType: WidgetType? + @Published var widgetBodyText = String() + @Published var isPushedWriteContentView: Bool = true + @Published var isModalPresented: Bool = true + @Published var isFocused: Bool = false + var textMaxCount: Int = 40 + + var isCTAButtonEnabled: Bool { + widgetBodyText.count > 4 + } + + @Published var isValidated: Bool = false + + // default + @Published var isLoading: Bool = false + + // error + @Published var showErrorView: ErrorModel? + @Published var showErrorAlert: ErrorModel? +} + +extension WidgetWritingModel: WidgetWritingModel.Stateful {} + +//MARK: - Actionable +protocol WidgetWritingModelActionable: AnyObject { + // content + func setFocusState(_ isFocused: Bool) + func setBodyText(_ text: String) + func setValidation(value: Bool) + func setWidgetType(_ widget: WidgetType) + func setContentString(_ content: String) + func navigationPop() + func modalDismiss() + + // default + func setLoading(status: Bool) + + // error + func showErrorView(error: ErrorModel) + func showErrorAlert(error: ErrorModel) + func resetError() +} + +extension WidgetWritingModel: WidgetWritingModelActionable { + // content + func setFocusState(_ isFocused: Bool) { + self.isFocused = isFocused + } + func setBodyText(_ text: String) { + widgetBodyText = text + } + func setValidation(value: Bool) { + isValidated = value + } + func setWidgetType(_ widget: WidgetType) { + selectedWidgetType = widget + } + func navigationPop() { + isPushedWriteContentView = false + } + func modalDismiss() { + isModalPresented = false + } + func setContentString(_ content: String) { + widgetBodyText = content + } + + // default + func setLoading(status: Bool) { + isLoading = status + } + + // error + func showErrorView(error: ErrorModel) { + showErrorView = error + } + func showErrorAlert(error: ErrorModel) { + showErrorAlert = error + } + func resetError() { + showErrorView = nil + showErrorAlert = nil + } +} diff --git a/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingView.swift b/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingView.swift new file mode 100644 index 0000000..f271219 --- /dev/null +++ b/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingView.swift @@ -0,0 +1,150 @@ +// +// WidgetWritingView.swift +// Home +// +// Created by 김지수 on 11/12/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import SwiftUI +import CoreKit +import DesignCore +import CommonKit +import Model + +public struct WidgetWritingView: View { + + @Binding var isPushed: Bool + @Binding var isModalPresented: Bool + + @FocusState var isFocused: Bool + let isEditingMode: Bool + let contentString: String? + + @StateObject var container: MVIContainer + + private var intent: WidgetWritingIntent.Intentable { container.intent } + private var state: WidgetWritingModel.Stateful { container.model } + + private var size: CGFloat { + Device.height * 0.25 + } + + private var navigationTitle: String { + if isEditingMode { + return "프로필 위젯 수정" + } + return state.selectedWidgetType?.title ?? "" + } + + public init( + widgetType: WidgetType, + isModalPresented: Binding, + isPushed: Binding, + isEditing: Bool = false, + contentString: String? = nil + ) { + let model = WidgetWritingModel() + let intent = WidgetWritingIntent( + model: model, + input: .init(widgetType: widgetType, content: contentString) + ) + let container = MVIContainer( + intent: intent as WidgetWritingIntent.Intentable, + model: model as WidgetWritingModel.Stateful, + modelChangePublisher: model.objectWillChange + ) + self._container = StateObject(wrappedValue: container) + self._isModalPresented = isModalPresented + self._isPushed = isPushed + self.isEditingMode = isEditing + self.contentString = contentString + } + + public var body: some View { + VStack { + if let widget = state.selectedWidgetType { + Text("최대 40자 이내로 자유롭게 작성해주세요!") + .typography(.regular_14) + .padding(.vertical, 20) + + WritableProfileWidgetView( + title: widget.title + widget.emoji, + placeholder: widget.exampleText, + bodyText: $container.model.widgetBodyText, + titleColor: widget.titleColor, + bodyColor: widget.bodyColor, + gradientColors: widget.gradationColors, + focusState: _isFocused + ) + .frame(width: size, height: size) + .onChange(of: state.widgetBodyText) { + intent.onChangedBodyText( + state.widgetBodyText, + maxCount: state.textMaxCount + ) + } + .onChange(of: state.isFocused) { + self.isFocused = state.isFocused + } + } + + HStack(spacing: 0) { + Text(String(state.widgetBodyText.count)) + .foregroundStyle(DesignCore.Colors.blue300) + Text("/\(state.textMaxCount)") + .foregroundStyle(DesignCore.Colors.grey300) + } + .typography(.regular_15) + Spacer() + + CTABottomButton( + title: "다 썻어요", + isActive: state.isCTAButtonEnabled + ) { + intent.onTapNextButton(state: state) + } + } + .onChange(of: state.isModalPresented) { + isModalPresented = state.isModalPresented + } + .onChange(of: state.isPushedWriteContentView) { + isPushed = state.isPushedWriteContentView + } + .task { + await intent.task() + } + .onAppear { + intent.onAppear() + } + .ignoresSafeArea(.keyboard) + .navigationTitle(navigationTitle) + .navigationBarTitleDisplayMode(.inline) + .setNavigation( + showLeftBackButton: isEditingMode ? false : true, + handler: { + intent.onTapBackButton() + }) + .toolbar { + if isEditingMode { + ToolbarItem { + Button("닫기") { + intent.onTapDismissButton() + } + .typography(.medium_16) + } + } + } + .setLoading(state.isLoading) + } +} + +#Preview { + NavigationView { + WidgetWritingView( + widgetType: .body, + isModalPresented: .constant(false), + isPushed: .constant(false) + ) + } +} diff --git a/Projects/Features/Home/UnitTest/ProfileUnitTest.swift b/Projects/Features/Home/UnitTest/ProfileUnitTest.swift new file mode 100644 index 0000000..5c3d10e --- /dev/null +++ b/Projects/Features/Home/UnitTest/ProfileUnitTest.swift @@ -0,0 +1,56 @@ +// +// ProfileUnitTest.swift +// Home-UnitTest +// +// Created by 김지수 on 11/15/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import Testing +import CommonKit +import NetworkKit +import Model +@testable import Home + +struct ProfileUnitTest { + + let appCoordinator = AppCoordinator.shared + let state: ProfileModel + let intent: ProfileIntent + + init() { + self.state = ProfileModel() + self.intent = ProfileIntent( + model: state, + input: .init(userInfo: .mock), + service: ProfileServiceMock() + ) + appCoordinator.userInfo = .mock + intent.onAppear() + } + + @Test func onTapAddWidget() async throws { + intent.onTapAddWidget() + #expect(state.isPresentedAddWidgetModal == true) + } + + @Test func modifyWidget() async throws { + let widget = ProfileWidget( + widgetType: .body, + content: "이것은 콘텐츠으" + ) + intent.onTapModifyWidget(widget) + #expect(state.selectedWidgetType == widget) + #expect(state.isPresentedModifyWidgetView == true) + } + + @Test func onTapDelete() async throws { + let widget = ProfileWidget( + widgetType: .body, + content: "이것은 콘텐츠으" + ) + intent.onTapDeleteWidget(widget) + #expect(state.selectedWidgetType == widget) + #expect(state.isPresentedDeleteConfirmSheet == true) + } +} diff --git a/Projects/Features/Home/UnitTest/WidgetUnitTest.swift b/Projects/Features/Home/UnitTest/WidgetUnitTest.swift new file mode 100644 index 0000000..0f2e4fc --- /dev/null +++ b/Projects/Features/Home/UnitTest/WidgetUnitTest.swift @@ -0,0 +1,78 @@ +// +// WidgetUnitTest.swift +// Home-UnitTest +// +// Created by 김지수 on 11/15/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import Testing +@testable import Home +import NetworkKit + +struct WidgetUnitTest { + + let selectionState: WidgetSelectionModel + let selectionIntent: WidgetSelectionIntent + + let writeState: WidgetWritingModel + let writeIntent: WidgetWritingIntent + + init () { + self.selectionState = WidgetSelectionModel() + self.selectionIntent = WidgetSelectionIntent( + model: selectionState, + input: .init() + ) + + self.writeState = WidgetWritingModel() + self.writeIntent = WidgetWritingIntent( + model: writeState, + input: .init( + widgetType: .body, + content: nil + ), + service: ProfileServiceMock() + ) + + selectionIntent.onAppear() + writeIntent.onAppear() + } + + @Test func selectWidget() async throws { + selectionIntent.onTapWidget(.body) + #expect(selectionState.selectedWidget == .body) + #expect(selectionState.isPushWriteContentView == true) + } + + @Test func writeWidgetContent() async throws { + let content: String = "Hello, World!" + writeIntent.onChangedBodyText(content, maxCount: 15) + #expect(writeState.widgetBodyText == content) + let longContent: String = "Hello World!! This is Hipster Text! Oh YEAH!!" + writeIntent.onChangedBodyText(longContent, maxCount: 15) + #expect(writeState.widgetBodyText == "Hello World!! T") + #expect(writeState.widgetBodyText.count == 15) + } + + @Test func onTapBackButton() async throws { + writeIntent.onTapBackButton() + writeState.isPushedWriteContentView = false + } + + @Test func onTapDismissButton() async throws { + writeIntent.onTapDismissButton() + writeState.isPushedWriteContentView = false + } + + @Test func keyboardFocusState() async throws { + #expect(writeState.isFocused == true) + } + + @Test func onTapNextButton() async throws { + writeIntent.onTapNextButton(state: writeState) + // 3초 후 실행 + try await Task.sleep(for: .seconds(1)) + #expect(writeState.isModalPresented == false) + } +} diff --git a/Projects/Features/Project.swift b/Projects/Features/Project.swift index b029799..d587e5f 100644 --- a/Projects/Features/Project.swift +++ b/Projects/Features/Project.swift @@ -30,6 +30,12 @@ let project: Project = .make( .project(target: .commonKit), .project(target: .designCore) ] + ), + .makeUnitTest( + target: .home, + dependencies: [ + .project(target: .home) + ] ) ] ) diff --git a/Projects/Features/SignUp/Sources/ProfileInput/AuthName/AuthNameInputView.swift b/Projects/Features/SignUp/Sources/ProfileInput/AuthName/AuthNameInputView.swift index fddf345..2aa4a6a 100644 --- a/Projects/Features/SignUp/Sources/ProfileInput/AuthName/AuthNameInputView.swift +++ b/Projects/Features/SignUp/Sources/ProfileInput/AuthName/AuthNameInputView.swift @@ -66,12 +66,7 @@ public struct AuthNameInputView: View { "김위브", text: $inputText ) - .keyboardType(.namePhonePad) - .interactiveDismissDisabled() - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .speechAnnouncementsQueued(false) - .speechSpellsOutCharacters(false) + .flatTextFieldOption(keyboardType: .namePhonePad) .multilineTextAlignment(.center) .pretendard(weight: ._400, size: 28) .foregroundStyle(DesignCore.Colors.grey500) diff --git a/Projects/Model/Model/Sources/Auth/Domain/UserInfo.swift b/Projects/Model/Model/Sources/Auth/Domain/UserInfo.swift index f173fae..ee60a59 100644 --- a/Projects/Model/Model/Sources/Auth/Domain/UserInfo.swift +++ b/Projects/Model/Model/Sources/Auth/Domain/UserInfo.swift @@ -15,19 +15,22 @@ public struct UserInfo { public let phone: String public let profile: UserInfoProfile public let dreamPartner: DreamPartnerInfo + public let profileWidgets: [ProfileWidget] public init( id: String, name: String, phone: String, profile: UserInfoProfile, - dreamPartner: DreamPartnerInfo + dreamPartner: DreamPartnerInfo, + profileWidgets: [ProfileWidget] ) { self.id = id self.name = name self.phone = phone self.profile = profile self.dreamPartner = dreamPartner + self.profileWidgets = profileWidgets } public init(from dto: Components.Schemas.GetMyUserInfoResponse) { @@ -36,6 +39,7 @@ public struct UserInfo { self.phone = dto.phoneNumber self.profile = .init(from: dto.profile) self.dreamPartner = .init(from: dto.desiredPartner) + self.profileWidgets = dto.profileWidgets.map { .init(from: $0) } } public static var mock: UserInfo { @@ -44,7 +48,8 @@ public struct UserInfo { name: "김지수", phone: "01012341234", profile: .mock, - dreamPartner: .mock + dreamPartner: .mock, + profileWidgets: ProfileWidget.mock ) } } @@ -136,3 +141,28 @@ public struct DreamPartnerInfo { ) } } + +public struct ProfileWidget: Hashable { + public let widgetType: WidgetType + public let content: String + + static var mock: [ProfileWidget] { + [ + .init(widgetType: .body, content: "GOOD BODY"), + .init(widgetType: .smoking, content: "Heavy Smoker !!") + ] + } + + public init( + widgetType: WidgetType, + content: String + ) { + self.widgetType = widgetType + self.content = content + } + + public init(from dto: Components.Schemas.ProfileWidget) { + self.widgetType = WidgetType(from: dto._type) + self.content = dto.content + } +} diff --git a/Projects/Model/Model/Sources/Profile/Widget/WidgetType.swift b/Projects/Model/Model/Sources/Profile/Widget/WidgetType.swift new file mode 100644 index 0000000..486ff6d --- /dev/null +++ b/Projects/Model/Model/Sources/Profile/Widget/WidgetType.swift @@ -0,0 +1,230 @@ +// +// WidgetType.swift +// Model +// +// Created by 김지수 on 11/10/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import SwiftUI +import CoreKit +import OpenapiGenerated + +public enum WidgetType: CaseIterable { + case hobby + case style + case mbti + case music + case body + case food + case movie + case drama + case book + case travel + case alcohol + case marriage + case religion + case smoking +} + +extension WidgetType { + public var title: String { + switch self { + case .hobby: return "취미" + case .style: return "스타일" + case .mbti: return "MBTI" + case .music: return "음악" + case .body: return "키·체형" + case .food: return "음식" + case .movie: return "영화" + case .drama: return "드라마" + case .book: return "책" + case .travel: return "여행" + case .alcohol: return "술" + case .marriage: return "결혼관" + case .religion: return "종교" + case .smoking: return "흡연" + } + } + + public var emoji: String { + switch self { + case .hobby: return "🏃" + case .style: return "👖" + case .mbti: return "💭" + case .music: return "🎧" + case .body: return "💪" + case .food: return "🍔" + case .movie: return "🎬" + case .drama: return "📺" + case .book: return "📚" + case .travel: return "✈️" + case .alcohol: return "🍷" + case .marriage: return "💍" + case .religion: return "⛪" + case .smoking: return "🚬" + } + } + + public var exampleText: String { + switch self { + case .hobby: + return "ex.\n테니스랑 헬스 즐겨해요! 같이 하실 분?" + case .style: + return "ex.\n옷은 깔끔하게 흰 티에 청바지만 입는 게 진리입니다" + case .mbti: + return "ex.\n저는 INTP지만 연애할 때는 F 100%가 된답니다" + case .music: + return "ex.\nFly to me the moon이 제 인생곡이에요!" + case .body: + return "ex.\n키는 180이구 헬스 하면서 어깨 키우고 있어요☺️" + case .food: + return "ex.\n음식 가리는 거 없이 거의 다 잘 먹어요!" + case .movie: + return "ex.\n제 인생 영화는 비긴 어게인이에용" + case .drama: + return "ex.\n하츠코이, 언내추럴 같은 일드 취향👀" + case .book: + return "ex.\nIT 관련 서적이나 자기계발서 위주로 봐요" + case .travel: + return "ex.\n아이슬란드처럼 대자연의 낭만이 있는 곳으로 가보고 싶어요.." + case .alcohol: + return "ex.\n화이트 와인, 하이볼, 칵테일을 좋아하는 술찌입니다😇" + case .marriage: + return "ex.\n마음만 맞으면 결혼 자금이나 시기는 조율할 수 있다고 생각해요!" + case .religion: + return "ex.\n저와 우리 집안 모두 무교입니다!" + case .smoking: + return "ex.\n전자담배만 피워요 ! 담배냄새는 저도 싫어합니다 ㅜ" + } + } +} + +struct WidgetColorSet { + let gradientColors: [Color] + let titleColor: Color + let bodyColor: Color + + init( + gradientColors: [Color], + titleColor: Color + ) { + self.gradientColors = gradientColors + self.titleColor = titleColor + self.bodyColor = titleColor.opacity(0.6) + } + + static var allColorSets: [WidgetColorSet] { + [skyBlueColorSet, brownColorSet, pinkColorSet, greyColorSet, greenColorSet] + } + + static var skyBlueColorSet: WidgetColorSet { + .init( + gradientColors: [ + .init(hex: 0xEDF7FF), + .init(hex: 0xCDE8FF), + ], + titleColor: .init(hex: 0x15394B) + ) + } + + static var brownColorSet: WidgetColorSet { + .init( + gradientColors: [ + .init(hex: 0xFAF3E5), + .init(hex: 0xEEDCB9), + ], + titleColor: .init(hex: 0x4C3B1C) + ) + } + + static var pinkColorSet: WidgetColorSet { + .init( + gradientColors: [ + .init(hex: 0xFEF0F4), + .init(hex: 0xEFD6E1), + ], + titleColor: .init(hex: 0x6C324A) + ) + } + + static var greyColorSet: WidgetColorSet { + .init( + gradientColors: [ + .init(hex: 0xF9F9F9), + .init(hex: 0xE7E7E7), + ], + titleColor: .init(hex: 0x454545) + ) + } + + static var greenColorSet: WidgetColorSet { + .init( + gradientColors: [ + .init(hex: 0xF2FCEB), + .init(hex: 0xD7E9C8), + ], + titleColor: .init(hex: 0x1D5018) + ) + } +} + +extension WidgetType { + private var colorSet: WidgetColorSet { + let index = WidgetType.allCases.firstIndex(of: self) ?? 0 + let allColors = WidgetColorSet.allColorSets + return allColors[index % allColors.count] + } + + public var titleColor: Color { + colorSet.titleColor + } + + public var bodyColor: Color { + colorSet.bodyColor + } + + public var gradationColors: [Color] { + colorSet.gradientColors + } +} + +extension WidgetType { + public var toDto: Components.Schemas.ProfileWidgetType { + switch self { + case .hobby: .HOBBY + case .style: .STYLE + case .mbti: .MBTI + case .music: .MUSIC + case .body: .BODY_TYPE + case .food: .FOOD + case .movie: .MOVIE + case .drama: .DRAMA + case .book: .BOOK + case .travel: .TRAVEL + case .alcohol: .DRINKING + case .marriage: .MARRIAGE + case .religion: .RELIGION + case .smoking: .SMOKING + } + } + + init(from dto: Components.Schemas.ProfileWidgetType) { + switch dto { + case .HOBBY: self = .hobby + case .STYLE: self = .style + case .MBTI: self = .mbti + case .MUSIC: self = .music + case .BODY_TYPE: self = .body + case .FOOD: self = .food + case .MOVIE: self = .movie + case .DRAMA: self = .drama + case .BOOK: self = .book + case .TRAVEL: self = .travel + case .DRINKING: self = .alcohol + case .MARRIAGE: self = .marriage + case .RELIGION: self = .religion + case .SMOKING: self = .smoking + } + } +} diff --git a/Workspace.swift b/Workspace.swift index 353a5c6..bd3660b 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -46,7 +46,8 @@ let workspace = Workspace( [ .target(.coreKit), .target(.designCore), - .target(.signUp) + .target(.signUp), + .target(.home) ] ) ),