Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
144 changes: 127 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,130 @@
//

import SwiftUI
import DevLogDomain

extension View {
func toast<Label: View>(
isPresented: Binding<Bool>,
@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 +140,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 +165,9 @@ private struct ToastOverlayView<Label: View>: View {
presentAnimated()
scheduleDismissIfNeeded()
}
.onDisappear {
cleanupPresentation()
}
.onTapGesture {
isTapped = true
dismissAnimated()
Expand All @@ -86,6 +189,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 +200,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 +222,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 +238,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