Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Application/DevLogApp/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ let project = Project(
DevLogPackages.swiftLintPlugin,
],
settings: .devlog(
versionXcconfigPath: "Sources/App.xcconfig",
versionXcconfigPath: "Sources/Resource/App.xcconfig",
base: [
"ASSETCATALOG_COMPILER_APPICON_NAME": "AppIcon",
"CODE_SIGN_STYLE": "Automatic",
Expand Down
2 changes: 0 additions & 2 deletions Application/DevLogApp/Sources/App.xcconfig

This file was deleted.

2 changes: 2 additions & 0 deletions Application/DevLogApp/Sources/Resource/App.xcconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#include "../../../Shared/Version.xcconfig"
#include? "Config.xcconfig"
4 changes: 2 additions & 2 deletions Application/DevLogApp/Sources/Resource/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>APPSTORE_URL</key>
<string>$(APPSTORE_URL)</string>
<key>TESTFLIGHT_URL</key>
<string>$(TESTFLIGHT_URL)</string>
<key>APP_REDIRECT_URL</key>
<string>$(APP_REDIRECT_URL)</string>
<key>CFBundleDevelopmentRegion</key>
Expand Down
145 changes: 128 additions & 17 deletions Application/DevLogPresentation/Sources/Common/Component/Toast.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,131 @@
//

import SwiftUI
import DevLogDomain

extension View {
func toast<Label: View>(
isPresented: Binding<Bool>,
@MainActor
@Observable
final class ToastPresenter {
Comment thread
opficdev marked this conversation as resolved.
fileprivate static let presenter = ToastPresenter()

private(set) var item: ToastItem?

private init() { }

static var item: ToastItem? {
presenter.item
}

static func present(
message: String,
systemImage: String? = nil,
duration: TimeInterval = 2,
font: Font? = nil,
multilineTextAlignment: TextAlignment = .leading,
lineLimit: Int? = nil,
action: (() -> Void)? = nil,
onDismiss: (() -> Void)? = nil,
@ViewBuilder label: @escaping () -> Label
) -> some View {
self
onDismiss: (() -> Void)? = nil
) {
presenter.present(
ToastItem(
message: message,
systemImage: systemImage,
duration: duration,
font: font,
multilineTextAlignment: multilineTextAlignment,
lineLimit: lineLimit,
action: action,
onDismiss: onDismiss
)
)
}

static func reset() {
presenter.item = nil
}

private func present(_ item: ToastItem) {
dismissImmediately()
self.item = item
}

fileprivate func dismiss(itemId: UUID) {
guard let item,
item.id == itemId else { return }
self.item = nil
}

private func dismissImmediately() {
guard let item else { return }
self.item = nil
item.onDismiss?()
}
}

struct ToastItem: Identifiable {
let id = UUID()
let message: String
let systemImage: String?
let duration: TimeInterval
let font: Font?
let multilineTextAlignment: TextAlignment
let lineLimit: Int?
let action: (() -> Void)?
let onDismiss: (() -> Void)?
}

extension View {
func toastHost() -> some View {
modifier(ToastHostModifier())
}
}

private struct ToastHostModifier: ViewModifier {
private let toastPresenter = ToastPresenter.presenter

func body(content: Content) -> some View {
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .bottom) {
ToastOverlayView(
isPresented: isPresented,
duration: duration,
action: action,
onDismiss: onDismiss,
label: label
)
.padding(.horizontal, 12)
if let item = toastPresenter.item {
ToastOverlayView(
isPresented: Binding(
get: { toastPresenter.item?.id == item.id },
set: { isPresented in
if !isPresented {
toastPresenter.dismiss(itemId: item.id)
}
}
),
duration: item.duration,
action: item.action,
onDismiss: item.onDismiss
) {
ToastItemLabel(item: item)
}
.id(item.id)
.padding(.horizontal, 12)
}
}
}
}

private struct ToastItemLabel: View {
let item: ToastItem

var body: some View {
Group {
if let systemImage = item.systemImage {
Label(item.message, systemImage: systemImage)
} else {
Text(item.message)
}
}
.font(item.font)
.multilineTextAlignment(item.multilineTextAlignment)
.lineLimit(item.lineLimit)
}
}

private struct ToastOverlayView<Label: View>: View {
@Binding var isPresented: Bool
let duration: TimeInterval
Expand All @@ -41,6 +141,7 @@ private struct ToastOverlayView<Label: View>: View {
@State private var yOffset: CGFloat = 0
@State private var opacityValue: Double = 0
@State private var dismissWorkItem: DispatchWorkItem?
@State private var dismissCompletionWorkItem: DispatchWorkItem?
@State private var isTapped: Bool = false
@State private var isScheduled: Bool = false

Expand All @@ -65,6 +166,9 @@ private struct ToastOverlayView<Label: View>: View {
presentAnimated()
scheduleDismissIfNeeded()
}
.onDisappear {
cleanupPresentation()
}
.onTapGesture {
isTapped = true
dismissAnimated()
Expand All @@ -86,6 +190,8 @@ private struct ToastOverlayView<Label: View>: View {
private func resetForNewPresentation() {
dismissWorkItem?.cancel()
dismissWorkItem = nil
dismissCompletionWorkItem?.cancel()
dismissCompletionWorkItem = nil
isScheduled = false
isTapped = false
yOffset = 0
Expand All @@ -95,6 +201,8 @@ private struct ToastOverlayView<Label: View>: View {
private func cleanupPresentation() {
dismissWorkItem?.cancel()
dismissWorkItem = nil
dismissCompletionWorkItem?.cancel()
dismissCompletionWorkItem = nil
isScheduled = false
isTapped = false
yOffset = 0
Expand All @@ -115,13 +223,14 @@ private struct ToastOverlayView<Label: View>: View {
private func dismissAnimated() {
dismissWorkItem?.cancel()
dismissWorkItem = nil
dismissCompletionWorkItem?.cancel()

withAnimation(.easeInOut(duration: 0.2)) {
yOffset = 0
opacityValue = 0
}

DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
let workItem = DispatchWorkItem {
isPresented = false
isScheduled = false

Expand All @@ -130,6 +239,8 @@ private struct ToastOverlayView<Label: View>: View {
}
isTapped = false
}
dismissCompletionWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: workItem)
}
}

Expand Down
12 changes: 0 additions & 12 deletions Application/DevLogPresentation/Sources/Home/Home/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,6 @@ struct HomeView: View {
} message: {
Text(coordinator.viewModel.state.alertMessage)
}
.toast(
isPresented: Binding(
get: { coordinator.viewModel.state.showToast },
set: { coordinator.viewModel.send(.setToast(isPresented: $0)) }
),
duration: 5,
action: { coordinator.viewModel.send(.undoDeleteWebPage) }
) {
Label(coordinator.viewModel.state.toastMessage, systemImage: "arrow.uturn.left")
.font(.caption)
.multilineTextAlignment(.center)
}
.overlay {
if coordinator.viewModel.state.isAppending {
LoadingView()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,18 @@ final class HomeViewModel: Store {
var alertTitle: String = ""
var alertType: AlertType?
var alertMessage: String = ""
var showToast: Bool = false
var toastType: ToastType?
var toastMessage: String = ""
}

enum Action {
case fetchData
case networkStatusChanged(Bool)
case setPresentation(Presentation, Bool)
case setAlert(isPresented: Bool, type: AlertType? = nil)
case setToast(isPresented: Bool, type: ToastType? = nil)
case refreshWebPages
case setLoading(LoadingTarget, Bool)
case setWebPageHidden(URL, Bool)
case handleWebPageDeleteFailure(URL)
case finishDeleteWebPageToast(String)
case tapTodoCategory(TodoCategory)
case orderTodoCategory([TodoCategoryItem])
case setTodoCategory([TodoCategoryItem])
Expand Down Expand Up @@ -75,10 +72,6 @@ final class HomeViewModel: Store {
case error
}

enum ToastType {
case deleteWebPage
}

enum ModalType {
case todoEditor
case urlInputAlert
Expand Down Expand Up @@ -143,9 +136,9 @@ final class HomeViewModel: Store {
switch action {
case .networkStatusChanged(let isConnected):
state.isNetworkConnected = isConnected
case .fetchData, .setPresentation, .setAlert, .setToast, .refreshWebPages,
case .fetchData, .setPresentation, .setAlert, .refreshWebPages,
.tapTodoCategory, .orderTodoCategory, .updateWebPageURLInput,
.addWebPage, .deleteWebPage, .undoDeleteWebPage:
.addWebPage, .deleteWebPage, .undoDeleteWebPage, .finishDeleteWebPageToast:
effects = reduceByView(action, state: &state)

case .setLoading, .setWebPageHidden, .handleWebPageDeleteFailure, .setTodoCategory,
Expand Down Expand Up @@ -269,12 +262,6 @@ private extension HomeViewModel {
return [.showModalAfterDelay(.urlInputAlert)]
}
setAlert(&state, isPresented: presented, type: type)
case .setToast(let isPresented, let type):
setToast(&state, isPresented: isPresented, for: type)
if !isPresented {
state.webPages.removeAll { $0.isHidden }
deletedWebPageURLString = nil
}
case .tapTodoCategory(let category):
state.selectedTodoCategory = category
state.showContentPicker = false
Expand All @@ -294,9 +281,10 @@ private extension HomeViewModel {
return [.addWebPage(normalizedURL)]
case .deleteWebPage(let page):
if let index = state.webPages.firstIndex(where: { $0.id == page.id }) {
deletedWebPageURLString = page.url.absoluteString
let urlString = page.url.absoluteString
deletedWebPageURLString = urlString
state.webPages[index].isHidden = true
setToast(&state, isPresented: true, for: .deleteWebPage)
presentDeleteWebPageToast(urlString)
return [.deleteWebPage(page)]
}
case .undoDeleteWebPage:
Expand All @@ -308,6 +296,11 @@ private extension HomeViewModel {
}
self.deletedWebPageURLString = nil
return [.undoDeleteWebPage(deletedWebPageURLString)]
case .finishDeleteWebPageToast(let urlString):
state.webPages.removeAll { $0.url.absoluteString == urlString && $0.isHidden }
if deletedWebPageURLString == urlString {
deletedWebPageURLString = nil
}
default:
break
}
Expand Down Expand Up @@ -388,19 +381,20 @@ private extension HomeViewModel {
state.alertType = type
}

func setToast(
_ state: inout State,
isPresented: Bool,
for type: ToastType?
) {
switch type {
case .deleteWebPage:
state.toastMessage = String(localized: "common_undo")
case .none:
state.toastMessage = ""
}
state.showToast = isPresented
state.toastType = type
func presentDeleteWebPageToast(_ urlString: String) {
ToastPresenter.present(
message: String(localized: "common_undo"),
systemImage: "arrow.uturn.left",
duration: 5,
font: .caption,
multilineTextAlignment: .center,
action: { [weak self] in
self?.send(.undoDeleteWebPage)
},
onDismiss: { [weak self] in
self?.send(.finishDeleteWebPageToast(urlString))
}
)
}

func setLoading(
Expand Down
10 changes: 0 additions & 10 deletions Application/DevLogPresentation/Sources/Home/TodoListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,6 @@ struct TodoListView: View {
} message: {
Text(viewModel.state.alertMessage)
}
.toast(
isPresented: Binding(
get: { viewModel.state.showToast },
set: { viewModel.send(.setToast(isPresented: $0)) }
),
duration: 5,
action: { viewModel.send(.undoDelete) }
) {
Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left")
}
.navigationTitle(TodoCategoryItem(from: viewModel.category).localizedName)
.fullScreenCover(isPresented: Binding(
get: { viewModel.state.showEditor },
Expand Down
Loading
Loading