Skip to content

Commit 6b37a8a

Browse files
authored
[#398] 앱 전역 위젯 동기화 이벤트를 정리한다 (#403)
* refactor: shared 상수 정리 * feat: WidgetSyncEvent 추가 * feat: SyncWidgetUseCase 구현 * feat: WidgetSnapshotUpdater 구현 * feat: 위젯 관련 DI 등록 * refactor: 위젯 스냅샷 갱신 경계 정리 * test: Heatmap 위젯 스냅샷 테스트 헬퍼 수정 * refactor: WidgetSyncEvent를 변경 원인 이벤트로 정리 * feat: 위젯과 데이터를 연동하는 이벤트 버스 구현 * feat: 위젯 동기화 이벤트 핸들러 추가 * feat: 핸들러, 이벤트 버스 DI 등록 * refactor: 리포지토리 단에서 싱크 책임 코드 제거 * refactor: 싱크 책임을 Presentation 레이어에서 Data 레이어로 이동 * chore: 불필요 코디네이터 제거 * refactor: startOfQuater을 Calendar의 Extension으로 정리 * refactor: 위젯 동기화 트리거를 앱이 백그라운드로 내려갔을때로 축소
1 parent 748d569 commit 6b37a8a

30 files changed

Lines changed: 862 additions & 223 deletions

DevLog/App/Assembler/AppLayerAssembler.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@
77

88
final class AppLayerAssembler: Assembler {
99
func assemble(_ container: any DIContainer) {
10+
container.register(WidgetSyncEventBus.self) {
11+
WidgetSyncEventBusImpl()
12+
}
13+
container.register(WidgetSyncEventHandler.self) {
14+
WidgetSyncEventHandler(
15+
eventBus: container.resolve(WidgetSyncEventBus.self),
16+
repository: container.resolve(TodoRepository.self),
17+
snapshotUpdater: container.resolve(WidgetSnapshotUpdater.self)
18+
)
19+
}
1020
container.register(FCMTokenSyncHandler.self) {
1121
FCMTokenSyncHandler(
1222
userService: container.resolve(UserService.self)

DevLog/App/Assembler/DataAssembler.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ final class DataAssembler: Assembler {
9696
container.register(UserPreferencesRepository.self) {
9797
UserPreferencesRepositoryImpl(
9898
store: container.resolve(UserDefaultsStore.self),
99-
themeStore: container.resolve(ThemeStore.self)
99+
themeStore: container.resolve(ThemeStore.self),
100+
widgetSnapshotPreferenceStore: container.resolve(WidgetSnapshotPreferenceStore.self)
100101
)
101102
}
102103
}

DevLog/App/Assembler/PersistenceAssembler.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,26 @@ final class PersistenceAssembler: Assembler {
1818
container.register(WebPageImageStore.self) {
1919
WebPageImageStore()
2020
}
21+
22+
container.register(WidgetSharedDefaultsStore.self) {
23+
WidgetSharedDefaultsStore()
24+
}
25+
26+
container.register(WidgetSnapshotStore.self) {
27+
WidgetSnapshotStore(
28+
store: container.resolve(WidgetSharedDefaultsStore.self)
29+
)
30+
}
31+
32+
container.register(WidgetSnapshotPreferenceStore.self) {
33+
WidgetSnapshotPreferenceStore()
34+
}
35+
36+
container.register(WidgetSnapshotUpdater.self) {
37+
WidgetSnapshotUpdater(
38+
snapshotStore: container.resolve(WidgetSnapshotStore.self),
39+
preferenceStore: container.resolve(WidgetSnapshotPreferenceStore.self)
40+
)
41+
}
2142
}
2243
}

DevLog/App/Delegate/AppDelegate.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {
2828
FirebaseApp.configure()
2929
_ = container.resolve(FCMTokenSyncHandler.self)
3030
_ = container.resolve(UserTimeZoneSyncHandler.self)
31+
_ = container.resolve(WidgetSyncEventHandler.self)
3132

3233
// 알림 권한 요청
3334
UNUserNotificationCenter.current().delegate = self

DevLog/App/DevLogApp.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import SwiftUI
1111
struct DevLogApp: App {
1212
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
1313
@Environment(\.diContainer) var container: DIContainer
14+
@Environment(\.scenePhase) var scenePhase
1415

1516
init() {
1617
AppAssembler().assemble(AppDIContainer.shared)
@@ -24,6 +25,10 @@ struct DevLogApp: App {
2425
systemThemeUseCase: container.resolve(ObserveSystemThemeUseCase.self)
2526
))
2627
.autocorrectionDisabled()
28+
.onChange(of: scenePhase) { _, phase in
29+
guard phase == .background else { return }
30+
container.resolve(WidgetSyncEventBus.self).publish(.syncRequested)
31+
}
2732
}
2833
}
2934
}

DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,20 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository {
1515
static let pushSortOrder = "PushNotification.sortOption"
1616
static let pushTimeFilter = "PushNotification.timeFilter"
1717
static let pushUnreadOnly = "PushNotification.showUnreadOnly"
18-
static let heatmapActivityTypes = "Profile.heatmap.activityTypes"
19-
static let todayDueDateVisibility = "Today.dueDateVisibility"
20-
static let todayFocusVisibility = "Today.focusVisibility"
2118
}
2219

2320
private let store: UserDefaultsStore
2421
private let themeStore: ThemeStore
22+
private let widgetSnapshotPreferenceStore: WidgetSnapshotPreferenceStore
2523

2624
init(
2725
store: UserDefaultsStore,
28-
themeStore: ThemeStore
26+
themeStore: ThemeStore,
27+
widgetSnapshotPreferenceStore: WidgetSnapshotPreferenceStore
2928
) {
3029
self.store = store
3130
self.themeStore = themeStore
31+
self.widgetSnapshotPreferenceStore = widgetSnapshotPreferenceStore
3232
themeStore.send(systemTheme())
3333
}
3434

@@ -85,29 +85,18 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository {
8585
}
8686

8787
func heatmapActivityTypes() -> [String] {
88-
store.stringArray(forKey: Key.heatmapActivityTypes)
88+
widgetSnapshotPreferenceStore.heatmapActivityTypes()
8989
}
9090

9191
func setHeatmapActivityTypes(_ activityTypes: [String]) {
92-
store.setStringArray(activityTypes, forKey: Key.heatmapActivityTypes)
92+
widgetSnapshotPreferenceStore.setHeatmapActivityTypes(activityTypes)
9393
}
9494

9595
func todayDisplayOptions() -> TodayDisplayOptions {
96-
let dueDateVisibilityRawValue = store.string(forKey: Key.todayDueDateVisibility)
97-
let focusVisibilityRawValue = store.string(forKey: Key.todayFocusVisibility)
98-
99-
return TodayDisplayOptions(
100-
dueDateVisibility: TodayDisplayOptions.DueDateVisibility(
101-
rawValue: dueDateVisibilityRawValue ?? ""
102-
) ?? .all,
103-
focusVisibility: TodayDisplayOptions.FocusVisibility(
104-
rawValue: focusVisibilityRawValue ?? ""
105-
) ?? .all
106-
)
96+
widgetSnapshotPreferenceStore.todayDisplayOptions()
10797
}
10898

10999
func setTodayDisplayOptions(_ options: TodayDisplayOptions) {
110-
store.setString(options.dueDateVisibility.rawValue, forKey: Key.todayDueDateVisibility)
111-
store.setString(options.focusVisibility.rawValue, forKey: Key.todayFocusVisibility)
100+
widgetSnapshotPreferenceStore.setTodayDisplayOptions(options)
112101
}
113102
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//
2+
// Calendar.swift
3+
// DevLog
4+
//
5+
// Created by opfic on 4/30/26.
6+
//
7+
8+
import Foundation
9+
10+
extension Calendar {
11+
func startOfQuarter(for date: Date) -> Date {
12+
let month = component(.month, from: date)
13+
let startMonth = ((month - 1) / 3) * 3 + 1
14+
var components = dateComponents([.year], from: date)
15+
components.month = startMonth
16+
components.day = 1
17+
return self.date(from: components) ?? startOfDay(for: date)
18+
}
19+
}

DevLog/Presentation/ViewModel/ProfileViewModel.swift

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ final class ProfileViewModel: Store {
6161
case fetchActivityQuarter(Date)
6262
case updateStatusMessage(String)
6363
case updateHeatmapActivityKinds(Set<ActivityKind>)
64-
case syncHeatmapWidget
6564
}
6665

6766
private(set) var state = State()
@@ -71,10 +70,8 @@ final class ProfileViewModel: Store {
7170
private let networkConnectivityUseCase: ObserveNetworkConnectivityUseCase
7271
private let fetchHeatmapActivityTypesUseCase: FetchHeatmapActivityTypesUseCase
7372
private let updateHeatmapActivityTypesUseCase: UpdateHeatmapActivityTypesUseCase
74-
private let widgetCoordinator: HeatmapWidgetSyncCoordinator
7573
private let calendar = Calendar.current
7674
private let loadingState = LoadingState()
77-
private var syncHeatmapWidgetTask: Task<Void, Never>?
7875
private var cancellables = Set<AnyCancellable>()
7976

8077
init(
@@ -91,9 +88,6 @@ final class ProfileViewModel: Store {
9188
self.networkConnectivityUseCase = networkConnectivityUseCase
9289
self.fetchHeatmapActivityTypesUseCase = fetchHeatmapActivityTypesUseCase
9390
self.updateHeatmapActivityTypesUseCase = updateHeatmapActivityTypesUseCase
94-
self.widgetCoordinator = HeatmapWidgetSyncCoordinator(
95-
fetchTodosUseCase: fetchTodosUseCase
96-
)
9791
setupNetworkObserving()
9892
}
9993

@@ -107,7 +101,7 @@ final class ProfileViewModel: Store {
107101
guard let quarterStart = quarterStart(for: Date()) else { break }
108102
state.selectedQuarterStart = quarterStart
109103
}
110-
effects = [.fetchUserData, .syncHeatmapWidget]
104+
effects = [.fetchUserData]
111105
let rawValues = fetchHeatmapActivityTypesUseCase.execute()
112106
let settings = normalizeActivityKinds(rawValues)
113107
if !settings.isEmpty {
@@ -180,7 +174,7 @@ final class ProfileViewModel: Store {
180174
} else {
181175
state.selectedActivityKinds.insert(activityKind)
182176
}
183-
effects = [.updateHeatmapActivityKinds(state.selectedActivityKinds), .syncHeatmapWidget]
177+
effects = [.updateHeatmapActivityKinds(state.selectedActivityKinds)]
184178
case .willUpdateStatusMessage:
185179
if !state.isNetworkConnected { break }
186180
let message = self.state.statusMessage
@@ -241,13 +235,6 @@ final class ProfileViewModel: Store {
241235
return activityKinds.contains(activityKind)
242236
}
243237
updateHeatmapActivityTypesUseCase.execute(rawValues)
244-
case .syncHeatmapWidget:
245-
syncHeatmapWidgetTask?.cancel()
246-
syncHeatmapWidgetTask = Task { [selectedActivityKinds = state.selectedActivityKinds] in
247-
await widgetCoordinator.sync(
248-
selectedActivityKinds: selectedActivityKinds
249-
)
250-
}
251238
}
252239
}
253240
}

DevLog/Presentation/ViewModel/TodayViewModel.swift

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@ final class TodayViewModel: Store {
7171
case fetchTodos
7272
case completeTodo(TodayTodoItem)
7373
case togglePinned(TodayTodoItem)
74-
case syncTodayWidget
7574
}
7675

7776
private(set) var state = State()
@@ -83,7 +82,6 @@ final class TodayViewModel: Store {
8382
private let upsertTodoUseCase: UpsertTodoUseCase
8483
private let updateTodayDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase
8584
private let loadingState = LoadingState()
86-
private let widgetCoordinator = TodayWidgetSyncCoordinator()
8785

8886
init(
8987
fetchTodosUseCase: FetchTodosUseCase,
@@ -258,11 +256,6 @@ final class TodayViewModel: Store {
258256
send(.setAlert(true))
259257
}
260258
}
261-
case .syncTodayWidget:
262-
widgetCoordinator.sync(
263-
todos: state.todos,
264-
displayOptions: state.displayOptions
265-
)
266259
}
267260
}
268261
}
@@ -292,15 +285,12 @@ private extension TodayViewModel {
292285
case .setDueDateVisibility(let visibility):
293286
state.displayOptions.dueDateVisibility = visibility
294287
updateTodayDisplayOptionsUseCase.execute(state.displayOptions)
295-
return [.syncTodayWidget]
296288
case .setFocusVisibility(let visibility):
297289
state.displayOptions.focusVisibility = visibility
298290
updateTodayDisplayOptionsUseCase.execute(state.displayOptions)
299-
return [.syncTodayWidget]
300291
case .resetDisplayOptions:
301292
state.displayOptions = .default
302293
updateTodayDisplayOptionsUseCase.execute(state.displayOptions)
303-
return [.syncTodayWidget]
304294
case .completeTodo(let item):
305295
return [.completeTodo(item)]
306296
case .togglePinned(let item):
@@ -325,7 +315,6 @@ private extension TodayViewModel {
325315
switch action {
326316
case .fetchTodos(let items):
327317
state.todos = items
328-
return [.syncTodayWidget]
329318
case .setLoading(let isLoading):
330319
state.isLoading = isLoading
331320
case .updateTodo(let item):
@@ -334,10 +323,8 @@ private extension TodayViewModel {
334323
} else {
335324
state.todos.append(item)
336325
}
337-
return [.syncTodayWidget]
338326
case .removeTodo(let todoId):
339327
state.todos.removeAll { $0.id == todoId }
340-
return [.syncTodayWidget]
341328
default:
342329
break
343330
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//
2+
// WidgetSnapshotPreferenceStore.swift
3+
// DevLog
4+
//
5+
// Created by opfic on 4/30/26.
6+
//
7+
8+
import Foundation
9+
10+
final class WidgetSnapshotPreferenceStore {
11+
private enum Key {
12+
static let heatmapActivityTypes = "Profile.heatmap.activityTypes"
13+
static let todayDueDateVisibility = "Today.dueDateVisibility"
14+
static let todayFocusVisibility = "Today.focusVisibility"
15+
}
16+
17+
private let userDefaults: UserDefaults
18+
19+
init(userDefaults: UserDefaults = .standard) {
20+
self.userDefaults = userDefaults
21+
}
22+
23+
func heatmapActivityTypes() -> [String] {
24+
userDefaults.stringArray(forKey: Key.heatmapActivityTypes) ?? []
25+
}
26+
27+
func setHeatmapActivityTypes(_ activityTypes: [String]) {
28+
userDefaults.set(activityTypes, forKey: Key.heatmapActivityTypes)
29+
}
30+
31+
func selectedActivityKinds() -> Set<ActivityKind> {
32+
let selectedActivityKinds = Set(
33+
heatmapActivityTypes().compactMap(ActivityKind.init(rawValue:))
34+
)
35+
let selectableActivityKinds: [ActivityKind] = [.created, .completed, .deleted]
36+
let normalizedActivityKinds = Set(
37+
selectableActivityKinds.filter { selectedActivityKinds.contains($0) }
38+
)
39+
40+
return normalizedActivityKinds.isEmpty ? Set(selectableActivityKinds) : normalizedActivityKinds
41+
}
42+
43+
func todayDisplayOptions() -> TodayDisplayOptions {
44+
let dueDateVisibilityRawValue = userDefaults.string(forKey: Key.todayDueDateVisibility)
45+
let focusVisibilityRawValue = userDefaults.string(forKey: Key.todayFocusVisibility)
46+
47+
return TodayDisplayOptions(
48+
dueDateVisibility: TodayDisplayOptions.DueDateVisibility(
49+
rawValue: dueDateVisibilityRawValue ?? ""
50+
) ?? .all,
51+
focusVisibility: TodayDisplayOptions.FocusVisibility(
52+
rawValue: focusVisibilityRawValue ?? ""
53+
) ?? .all
54+
)
55+
}
56+
57+
func setTodayDisplayOptions(_ options: TodayDisplayOptions) {
58+
userDefaults.set(options.dueDateVisibility.rawValue, forKey: Key.todayDueDateVisibility)
59+
userDefaults.set(options.focusVisibility.rawValue, forKey: Key.todayFocusVisibility)
60+
}
61+
}

0 commit comments

Comments
 (0)