Skip to content

Commit 2e5d4d5

Browse files
authored
Merge pull request #21 from Nexters/feature/chat-list-styling
[Feature] 대화방 목록 UI 작업
2 parents 0a1457f + 0665664 commit 2e5d4d5

28 files changed

Lines changed: 541 additions & 128 deletions

‎GAMSS.xcodeproj/project.pbxproj‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,23 @@
1414
1D8DF6DFC7E7383B9B8C5850 /* DefaultSummaryRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17C099A15F7D9B1481998D0 /* DefaultSummaryRepositoryTests.swift */; };
1515
24EED49AAB4C574B02E9C077 /* EmotionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB4219C48B0B2A51FA9457C /* EmotionTests.swift */; };
1616
26A84A0255BB5BB7139414C5 /* Tokenizers in Frameworks */ = {isa = PBXBuildFile; productRef = 4F92A4F86371ED0E8E85D78F /* Tokenizers */; };
17+
37CC583C76082794EA8ED2C3 /* ConversationListDateHeaderFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279ECD6F38C25ADFF48996F2 /* ConversationListDateHeaderFormatterTests.swift */; };
1718
3D1B542CB60C3958D3F16702 /* EmotionCharacterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BB1A4112363D93ED3AD1598 /* EmotionCharacterTests.swift */; };
1819
3EF2F6DB34A2276C2C9FD746 /* diary_samples.json in Resources */ = {isa = PBXBuildFile; fileRef = ED231F0A80BD3A4AF9E50BBC /* diary_samples.json */; };
20+
49CAEB1FBF568E55C6C648B7 /* ConversationListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A26CAC1BD5FA5DF19D14615 /* ConversationListViewModelTests.swift */; };
1921
573CFD0A1638F27B889EF865 /* SendMessageUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DDF78D0118869F3D9E1E269 /* SendMessageUseCaseTests.swift */; };
2022
58652E08F4413DEA2016C082 /* MessageBubbleLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF457567BE11A68704DC4FE /* MessageBubbleLayoutTests.swift */; };
2123
5E6EF3C5D7CE4FF3EE4392EC /* ConversationMessageDTOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35175B780592B411BDD6B82 /* ConversationMessageDTOTests.swift */; };
2224
5F8A6F3F851E20BCFE3F805D /* CommentRevealPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86563A77A8A674E7F050119E /* CommentRevealPolicyTests.swift */; };
2325
66BC114B170E43E9C900F517 /* QuotedReplyHeaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B68677BF01B5A8971B85AC4 /* QuotedReplyHeaderTests.swift */; };
2426
70248D85300794C800857369 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 70248D84300794C800857369 /* FirebaseCrashlytics */; };
2527
70248DCD300D429100857369 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 70248DCC300D429100857369 /* FirebaseAuth */; };
28+
7651811B18BCC6B77AD9FB7B /* ISO8601FlexibleParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8157B3B43D20FE0CB425BCC3 /* ISO8601FlexibleParserTests.swift */; };
2629
8F4E518B79C7B76223B098BA /* Tokenizers in Frameworks */ = {isa = PBXBuildFile; productRef = 8E8487F478AF703D394C5EB6 /* Tokenizers */; };
2730
94A526BCCCD9DF730A39BF97 /* sample_sentences.json in Resources */ = {isa = PBXBuildFile; fileRef = D4BE9D664889F13369325DC9 /* sample_sentences.json */; };
31+
96AB63E38D6D2755BA3BC839 /* ConversationPreviewTextFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA0CD379D5E578049585DD1 /* ConversationPreviewTextFormatterTests.swift */; };
2832
9EA6F6B8DF51021E18342083 /* HomeViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EDC07CB1FA5BA970388DB6A /* HomeViewModelTests.swift */; };
33+
A2EE79F9D91A740A8F752761 /* ConversationSummaryDTOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ACAF83D4A9CCBB62EFD5C84 /* ConversationSummaryDTOTests.swift */; };
2934
A8948FBCA4DF4E43F2C582EA /* Pods_GAMSS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB14F9D00BB8B80EFE9A40A4 /* Pods_GAMSS.framework */; };
3035
A8FEE0AD9A4A60908DC5F5F1 /* CreateMessageRequestDTOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DAA522A6DCECC0B3666523C /* CreateMessageRequestDTOTests.swift */; };
3136
A9E97BA8DEC5EE7635B77D1F /* ChatComposerViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C5DF3BD54F67AE200949A44 /* ChatComposerViewTests.swift */; };
@@ -58,23 +63,28 @@
5863

5964
/* Begin PBXFileReference section */
6065
032B0D990023EAA1E72E1E82 /* DefaultConversationSummaryStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DefaultConversationSummaryStoreTests.swift; sourceTree = "<group>"; };
66+
0ACAF83D4A9CCBB62EFD5C84 /* ConversationSummaryDTOTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConversationSummaryDTOTests.swift; sourceTree = "<group>"; };
6167
162D28B313B7D3B6D79FE039 /* SummaryErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SummaryErrorTests.swift; sourceTree = "<group>"; };
6268
1A8F630A15A80F5FAB52F2C1 /* Pods-GAMSSTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GAMSSTests.debug.xcconfig"; path = "Target Support Files/Pods-GAMSSTests/Pods-GAMSSTests.debug.xcconfig"; sourceTree = "<group>"; };
6369
1B68677BF01B5A8971B85AC4 /* QuotedReplyHeaderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = QuotedReplyHeaderTests.swift; sourceTree = "<group>"; };
70+
279ECD6F38C25ADFF48996F2 /* ConversationListDateHeaderFormatterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConversationListDateHeaderFormatterTests.swift; sourceTree = "<group>"; };
6471
316EA002FAC5D3328796EF90 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
72+
3A26CAC1BD5FA5DF19D14615 /* ConversationListViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConversationListViewModelTests.swift; sourceTree = "<group>"; };
6573
3C15D652D0472A1EBD024674 /* MainTabTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTabTests.swift; sourceTree = "<group>"; };
6674
3C5DF3BD54F67AE200949A44 /* ChatComposerViewTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChatComposerViewTests.swift; sourceTree = "<group>"; };
6775
3EDC07CB1FA5BA970388DB6A /* HomeViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HomeViewModelTests.swift; sourceTree = "<group>"; };
6876
41CCBD8A019CAA37319E7984 /* ConversationSummaryPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConversationSummaryPolicyTests.swift; sourceTree = "<group>"; };
6977
4DDF78D0118869F3D9E1E269 /* SendMessageUseCaseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SendMessageUseCaseTests.swift; sourceTree = "<group>"; };
7078
596EE0F239D37460A7BA389D /* Color_GAMSSTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Color_GAMSSTests.swift; sourceTree = "<group>"; };
7179
5B4F594F8D1059D3DF30DE92 /* GAMSSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GAMSSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
80+
5EA0CD379D5E578049585DD1 /* ConversationPreviewTextFormatterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConversationPreviewTextFormatterTests.swift; sourceTree = "<group>"; };
7281
6EF457567BE11A68704DC4FE /* MessageBubbleLayoutTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MessageBubbleLayoutTests.swift; sourceTree = "<group>"; };
7382
708F57F33003C651008DF433 /* GAMSS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GAMSS.app; sourceTree = BUILT_PRODUCTS_DIR; };
7483
7674D757C326ABEDB6302AA6 /* GAMSSTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GAMSSTests.swift; sourceTree = "<group>"; };
7584
79F056320618BF2744EAC909 /* EmotionTestViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EmotionTestViewModelTests.swift; sourceTree = "<group>"; };
7685
7BB1A4112363D93ED3AD1598 /* EmotionCharacterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EmotionCharacterTests.swift; sourceTree = "<group>"; };
7786
7DAA522A6DCECC0B3666523C /* CreateMessageRequestDTOTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreateMessageRequestDTOTests.swift; sourceTree = "<group>"; };
87+
8157B3B43D20FE0CB425BCC3 /* ISO8601FlexibleParserTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ISO8601FlexibleParserTests.swift; sourceTree = "<group>"; };
7888
83A67D7BECCB85A88C969AD7 /* GetMessagesUseCaseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GetMessagesUseCaseTests.swift; sourceTree = "<group>"; };
7989
86563A77A8A674E7F050119E /* CommentRevealPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CommentRevealPolicyTests.swift; sourceTree = "<group>"; };
8090
A17C099A15F7D9B1481998D0 /* DefaultSummaryRepositoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DefaultSummaryRepositoryTests.swift; sourceTree = "<group>"; };
@@ -219,6 +229,8 @@
219229
children = (
220230
7DAA522A6DCECC0B3666523C /* CreateMessageRequestDTOTests.swift */,
221231
B35175B780592B411BDD6B82 /* ConversationMessageDTOTests.swift */,
232+
8157B3B43D20FE0CB425BCC3 /* ISO8601FlexibleParserTests.swift */,
233+
0ACAF83D4A9CCBB62EFD5C84 /* ConversationSummaryDTOTests.swift */,
222234
);
223235
path = DTO;
224236
sourceTree = "<group>";
@@ -292,6 +304,9 @@
292304
1B68677BF01B5A8971B85AC4 /* QuotedReplyHeaderTests.swift */,
293305
6EF457567BE11A68704DC4FE /* MessageBubbleLayoutTests.swift */,
294306
3C5DF3BD54F67AE200949A44 /* ChatComposerViewTests.swift */,
307+
5EA0CD379D5E578049585DD1 /* ConversationPreviewTextFormatterTests.swift */,
308+
279ECD6F38C25ADFF48996F2 /* ConversationListDateHeaderFormatterTests.swift */,
309+
3A26CAC1BD5FA5DF19D14615 /* ConversationListViewModelTests.swift */,
295310
);
296311
path = Presentation;
297312
sourceTree = "<group>";
@@ -604,6 +619,11 @@
604619
66BC114B170E43E9C900F517 /* QuotedReplyHeaderTests.swift in Sources */,
605620
58652E08F4413DEA2016C082 /* MessageBubbleLayoutTests.swift in Sources */,
606621
A9E97BA8DEC5EE7635B77D1F /* ChatComposerViewTests.swift in Sources */,
622+
7651811B18BCC6B77AD9FB7B /* ISO8601FlexibleParserTests.swift in Sources */,
623+
A2EE79F9D91A740A8F752761 /* ConversationSummaryDTOTests.swift in Sources */,
624+
96AB63E38D6D2755BA3BC839 /* ConversationPreviewTextFormatterTests.swift in Sources */,
625+
37CC583C76082794EA8ED2C3 /* ConversationListDateHeaderFormatterTests.swift in Sources */,
626+
49CAEB1FBF568E55C6C648B7 /* ConversationListViewModelTests.swift in Sources */,
607627
);
608628
runOnlyForDeploymentPostprocessing = 0;
609629
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
//
2+
// ISO8601FlexibleParser.swift
3+
// GAMSS
4+
//
5+
// Created by cchanmi on 8/13/26.
6+
//
7+
8+
import Foundation
9+
10+
/// 서버가 소수초를 포함해서 보낼 수도 있는 ISO8601 타임스탬프 문자열(예: "2026-08-07T00:00:00.123Z")을
11+
/// 파싱한다. 화면에는 어차피 분 단위까지만 표시하므로 소수초 정밀도는 필요 없어, 포매터를 두 개 두고
12+
/// 두 번 시도하는 대신 소수초 부분만 잘라내고 포매터 하나로 파싱한다. ISO8601DateFormatter는 동시 접근
13+
/// 안전성이 문서화되어 있지 않아 인스턴스를 static으로 공유하지 않고 호출마다 새로 만든다 — 여러 DTO가
14+
/// 동시에 디코딩될 수 있기 때문(예: 대화방 목록 로드와 메시지 히스토리 로드).
15+
enum ISO8601FlexibleParser {
16+
static func date(from value: String) -> Date? {
17+
ISO8601DateFormatter().date(from: stripFractionalSeconds(value))
18+
}
19+
20+
/// 소수초 부분(".123" 등)만 제거한다. 뒤에 타임존 지정자(Z/+/-)가 없어도 소수초 자체는
21+
/// 잘라내야 하므로, 그 존재를 전제하지 않고 "."부터 이어지는 숫자만 건너뛴다.
22+
private static func stripFractionalSeconds(_ value: String) -> String {
23+
guard let dotIndex = value.firstIndex(of: ".") else { return value }
24+
let afterDot = value[value.index(after: dotIndex)...]
25+
let suffixStart = afterDot.firstIndex(where: { !$0.isNumber }) ?? afterDot.endIndex
26+
return String(value[..<dotIndex]) + String(afterDot[suffixStart...])
27+
}
28+
}

‎GAMSS/Sources/Data/DTO/ConversationMessageDTO.swift‎

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// ConversationMessageDTO.swift
33
// GAMSS
44
//
5-
// Created by cchanmi on 8/7/26.
5+
// Created by 이건준 on 8/7/26.
66
//
77

88
import Foundation
@@ -26,25 +26,6 @@ struct ConversationMessageDTO: Decodable {
2626
"GRUMPY": .prickly, "WARM": .sadness, "QUIRKY": .quirky,
2727
]
2828

29-
/// 화면에는 어차피 분 단위까지만 표시하므로(MessageTimestampFormatter), 소수초 정밀도는
30-
/// 필요 없다. 서버가 소수초 포함 타임스탬프(예: "2026-08-07T00:00:00.123Z")를 보내더라도
31-
/// 포매터를 두 개 두고 두 번 시도하는 대신, 소수초 부분만 잘라내고 포매터 하나로 파싱한다.
32-
/// ISO8601DateFormatter는 동시 접근 안전성이 문서화되어 있지 않아 인스턴스를 static으로
33-
/// 공유하지 않고 호출마다 새로 만든다 — 히스토리 로드와 메시지 전송 응답이 동시에 디코딩될
34-
/// 수 있기 때문.
35-
private static func parseCreatedAt(_ value: String) -> Date? {
36-
ISO8601DateFormatter().date(from: stripFractionalSeconds(value))
37-
}
38-
39-
/// 소수초 부분(".123" 등)만 제거한다. 뒤에 타임존 지정자(Z/+/-)가 없어도 소수초 자체는
40-
/// 잘라내야 하므로, 그 존재를 전제하지 않고 "."부터 이어지는 숫자만 건너뛴다.
41-
private static func stripFractionalSeconds(_ value: String) -> String {
42-
guard let dotIndex = value.firstIndex(of: ".") else { return value }
43-
let afterDot = value[value.index(after: dotIndex)...]
44-
let suffixStart = afterDot.firstIndex(where: { !$0.isNumber }) ?? afterDot.endIndex
45-
return String(value[..<dotIndex]) + String(afterDot[suffixStart...])
46-
}
47-
4829
func toDomain() -> Message? {
4930
let sender: MessageSender
5031
switch senderType {
@@ -56,7 +37,7 @@ struct ConversationMessageDTO: Decodable {
5637
default:
5738
return nil
5839
}
59-
guard let createdAtDate = Self.parseCreatedAt(createdAt) else { return nil }
40+
guard let createdAtDate = ISO8601FlexibleParser.date(from: createdAt) else { return nil }
6041
return Message(id: id, conversationId: conversationId, sender: sender, content: content, repliesToMessageId: repliesToMessageId, createdAt: createdAtDate)
6142
}
6243

@@ -70,7 +51,7 @@ struct ConversationMessageDTO: Decodable {
7051
sender: .user,
7152
content: content,
7253
repliesToMessageId: repliesToMessageId,
73-
createdAt: Self.parseCreatedAt(createdAt) ?? Date()
54+
createdAt: ISO8601FlexibleParser.date(from: createdAt) ?? Date()
7455
)
7556
}
7657
}

‎GAMSS/Sources/Data/DTO/ConversationSummaryDTO.swift‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ struct ConversationSummaryDTO: Decodable {
1414
let createdAt: String
1515
let updatedAt: String
1616

17-
func toDomain() -> ConversationSummary {
18-
ConversationSummary(id: id, title: title, status: status, createdAt: createdAt)
17+
/// createdAt 파싱에 실패하면 (다른 DTO들과 동일한 정책으로) 이 대화방을 목록에서 제외한다 —
18+
/// 잘못된 시간으로 표시하는 것보다 안전한 선택.
19+
func toDomain() -> ConversationSummary? {
20+
guard let createdAtDate = ISO8601FlexibleParser.date(from: createdAt) else { return nil }
21+
return ConversationSummary(id: id, title: title, status: status, createdAt: createdAtDate)
1922
}
2023
}

‎GAMSS/Sources/Data/Repository/DefaultConversationRepository.swift‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ final class DefaultConversationRepository: ConversationRepository {
3535
return response.data.compactMap { $0.toDomain() }
3636
}
3737

38-
func getConversations(date: String) async throws -> [ConversationSummary] {
38+
func getIncompleteConversations() async throws -> [ConversationSummary] {
3939
let response = try await networkManager.request(
40-
ChatEndpoint.fetchChats(date: date),
40+
ChatEndpoint.fetchIncompleteChats,
4141
responseType: APIResponse<[ConversationSummaryDTO]>.self
4242
)
43-
return response.data.map { $0.toDomain() }
43+
return response.data.compactMap { $0.toDomain() }
4444
}
4545
}

‎GAMSS/Sources/Domain/Conversation/ConversationRepository.swift‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ protocol ConversationRepository {
1313
/// 작성순으로 온다. 화면이 이 순서에 의존한다.
1414
func getMessages(conversationId: Int) async throws -> [Message]
1515

16-
/// 지정한 날짜(yyyy-MM-dd, KST 00:00~24:00)에 생성된 채팅방 목록을 반환한다.
17-
func getConversations(date: String) async throws -> [ConversationSummary]
16+
/// 아직 끝나지 않은(미완료) 채팅방 목록을 반환한다.
17+
func getIncompleteConversations() async throws -> [ConversationSummary]
1818
}

‎GAMSS/Sources/Domain/Conversation/ConversationSummary.swift‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
// Created by cchanmi on 8/8/26.
66
//
77

8+
import Foundation
9+
810
struct ConversationSummary: Identifiable, Hashable {
911
let id: Int
1012
let title: String?
1113
let status: String
12-
let createdAt: String
14+
let createdAt: Date
1315
}

‎GAMSS/Sources/Domain/UseCase/Conversation/GetConversationsUseCase.swift‎

Lines changed: 0 additions & 18 deletions
This file was deleted.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
//
2+
// GetIncompleteConversationsUseCase.swift
3+
// GAMSS
4+
//
5+
// Created by cchanmi on 8/13/26.
6+
//
7+
8+
struct GetIncompleteConversationsUseCase {
9+
private let conversationRepository: ConversationRepository
10+
11+
init(conversationRepository: ConversationRepository) {
12+
self.conversationRepository = conversationRepository
13+
}
14+
15+
func execute() async throws -> [ConversationSummary] {
16+
try await conversationRepository.getIncompleteConversations()
17+
}
18+
}

0 commit comments

Comments
 (0)