2022-12-30 18:20:54 +01:00
|
|
|
//
|
|
|
|
// https://mczachurski.dev
|
|
|
|
// Copyright © 2022 Marcin Czachurski and the repository contributors.
|
|
|
|
// Licensed under the MIT License.
|
|
|
|
//
|
|
|
|
|
|
|
|
import SwiftUI
|
|
|
|
|
2023-02-08 20:18:03 +01:00
|
|
|
private struct OffsetPreferenceKey: PreferenceKey {
|
|
|
|
static var defaultValue: CGFloat = .zero
|
|
|
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
|
|
|
|
}
|
|
|
|
|
2022-12-30 18:20:54 +01:00
|
|
|
struct HomeFeedView: View {
|
2023-01-01 18:13:36 +01:00
|
|
|
@Environment(\.managedObjectContext) private var viewContext
|
2023-02-08 20:18:03 +01:00
|
|
|
|
2022-12-31 16:31:05 +01:00
|
|
|
@EnvironmentObject var applicationState: ApplicationState
|
2023-01-23 18:01:27 +01:00
|
|
|
@EnvironmentObject var routerPath: RouterPath
|
2022-12-30 18:20:54 +01:00
|
|
|
|
2023-02-02 17:35:41 +01:00
|
|
|
@State private var allItemsLoaded = false
|
2023-02-01 18:40:28 +01:00
|
|
|
@State private var state: ViewState = .loading
|
2023-02-23 18:43:19 +01:00
|
|
|
@State private var taskId: UUID? = nil
|
2023-02-08 20:18:03 +01:00
|
|
|
|
|
|
|
@State private var opacity = 0.0
|
|
|
|
@State private var offset = -50.0
|
|
|
|
|
2023-01-11 13:16:43 +01:00
|
|
|
@FetchRequest var dbStatuses: FetchedResults<StatusData>
|
2022-12-30 18:20:54 +01:00
|
|
|
|
2023-02-24 07:30:52 +01:00
|
|
|
private let pullToRefreshViewHigh: CGFloat = 170
|
|
|
|
|
2023-01-11 13:16:43 +01:00
|
|
|
init(accountId: String) {
|
|
|
|
_dbStatuses = FetchRequest<StatusData>(
|
|
|
|
sortDescriptors: [SortDescriptor(\.id, order: .reverse)],
|
|
|
|
predicate: NSPredicate(format: "pixelfedAccount.id = %@", accountId))
|
|
|
|
}
|
2023-01-01 18:13:36 +01:00
|
|
|
|
2022-12-30 18:20:54 +01:00
|
|
|
var body: some View {
|
2023-02-01 18:40:28 +01:00
|
|
|
switch state {
|
|
|
|
case .loading:
|
|
|
|
LoadingIndicator()
|
|
|
|
.task {
|
|
|
|
await self.loadData()
|
2023-01-11 13:16:43 +01:00
|
|
|
}
|
2023-02-01 18:40:28 +01:00
|
|
|
case .loaded:
|
|
|
|
if self.dbStatuses.isEmpty {
|
|
|
|
NoDataView(imageSystemName: "photo.on.rectangle.angled", text: "Unfortunately, there are no photos here.")
|
|
|
|
} else {
|
2023-02-02 17:35:41 +01:00
|
|
|
self.timeline()
|
2022-12-30 18:20:54 +01:00
|
|
|
}
|
2023-02-01 18:40:28 +01:00
|
|
|
case .error(let error):
|
|
|
|
ErrorView(error: error) {
|
|
|
|
self.state = .loading
|
|
|
|
await self.loadData()
|
2022-12-30 18:20:54 +01:00
|
|
|
}
|
2023-02-01 18:40:28 +01:00
|
|
|
.padding()
|
2022-12-30 18:20:54 +01:00
|
|
|
}
|
2023-02-01 18:40:28 +01:00
|
|
|
}
|
|
|
|
|
2023-02-02 17:35:41 +01:00
|
|
|
@ViewBuilder
|
|
|
|
private func timeline() -> some View {
|
2023-02-07 10:20:24 +01:00
|
|
|
ZStack {
|
|
|
|
ScrollView {
|
2023-02-08 20:18:03 +01:00
|
|
|
// Offset reader for hiding top pill with amount of new photos.
|
|
|
|
self.offsetReader()
|
|
|
|
|
|
|
|
// VStack with all photos from database.
|
2023-02-07 10:20:24 +01:00
|
|
|
LazyVStack {
|
|
|
|
ForEach(dbStatuses, id: \.self) { item in
|
|
|
|
if self.shouldUpToDateBeVisible(statusId: item.id) {
|
|
|
|
self.upToDatePlaceholder()
|
|
|
|
}
|
|
|
|
|
|
|
|
ImageRow(statusData: item)
|
2023-02-02 17:35:41 +01:00
|
|
|
}
|
|
|
|
|
2023-02-07 10:20:24 +01:00
|
|
|
if allItemsLoaded == false {
|
|
|
|
LoadingIndicator()
|
|
|
|
.task {
|
|
|
|
do {
|
|
|
|
if let account = self.applicationState.account {
|
|
|
|
let newStatusesCount = try await HomeTimelineService.shared.loadOnBottom(for: account)
|
|
|
|
if newStatusesCount == 0 {
|
|
|
|
allItemsLoaded = true
|
|
|
|
}
|
2023-02-02 17:35:41 +01:00
|
|
|
}
|
2023-02-07 10:20:24 +01:00
|
|
|
} catch {
|
|
|
|
ErrorService.shared.handle(error, message: "Error during download statuses from server.", showToastr: !Task.isCancelled)
|
2023-02-02 17:35:41 +01:00
|
|
|
}
|
|
|
|
}
|
2023-02-07 10:20:24 +01:00
|
|
|
}
|
2023-02-02 17:35:41 +01:00
|
|
|
}
|
|
|
|
}
|
2023-02-08 20:18:03 +01:00
|
|
|
.coordinateSpace(name: "frameLayer")
|
|
|
|
.onPreferenceChange(OffsetPreferenceKey.self) {(offset) in
|
|
|
|
self.calculateOpacity(offset: offset)
|
2023-02-07 10:20:24 +01:00
|
|
|
}
|
2023-02-08 20:18:03 +01:00
|
|
|
|
2023-02-12 08:18:05 +01:00
|
|
|
self.newPhotosView()
|
2023-02-08 20:18:03 +01:00
|
|
|
.offset(y: self.offset)
|
|
|
|
.opacity(self.opacity)
|
2023-02-02 17:35:41 +01:00
|
|
|
}
|
|
|
|
.refreshable {
|
2023-02-23 17:12:35 +01:00
|
|
|
// This is workaround of cancellation task when other SwiftUI states are changed.
|
|
|
|
// When user is pull to refresh we are precalculating opacity of the amount of status indicator,
|
|
|
|
// and this causes that refreshable is canceled, issue:
|
|
|
|
// (https://stackoverflow.com/questions/74977787/why-is-async-task-cancelled-in-a-refreshable-modifier-on-a-scrollview-ios-16)
|
|
|
|
taskId = .init()
|
|
|
|
}
|
|
|
|
.task(id: self.taskId) {
|
2023-02-23 18:43:19 +01:00
|
|
|
// We have to run task only after refresing the list by the user.
|
|
|
|
guard self.taskId != nil else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-02-19 12:54:31 +01:00
|
|
|
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.3))
|
|
|
|
await self.refreshData()
|
|
|
|
HapticService.shared.fireHaptic(of: .dataRefresh(intensity: 0.7))
|
2023-02-23 19:18:41 +01:00
|
|
|
|
|
|
|
// Reset taskId to nil, this prevent refreshing when user click status details and come back to list.
|
|
|
|
self.taskId = nil
|
2023-02-07 10:20:24 +01:00
|
|
|
}
|
2023-02-08 20:18:03 +01:00
|
|
|
.onChange(of: self.applicationState.amountOfNewStatuses) { newValue in
|
|
|
|
self.calculateOffset()
|
|
|
|
}.onAppear {
|
|
|
|
self.calculateOffset()
|
2023-02-07 10:20:24 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func refreshData() async {
|
|
|
|
do {
|
|
|
|
if let account = self.applicationState.account {
|
|
|
|
if let lastSeenStatusId = try await HomeTimelineService.shared.loadOnTop(for: account) {
|
|
|
|
try await HomeTimelineService.shared.save(lastSeenStatusId: lastSeenStatusId, for: account)
|
2023-02-08 20:18:03 +01:00
|
|
|
|
2023-02-07 10:20:24 +01:00
|
|
|
self.applicationState.lastSeenStatusId = lastSeenStatusId
|
2023-02-19 19:43:41 +01:00
|
|
|
self.applicationState.amountOfNewStatuses = 0
|
2023-02-02 17:35:41 +01:00
|
|
|
}
|
|
|
|
}
|
2023-02-07 10:20:24 +01:00
|
|
|
} catch {
|
|
|
|
ErrorService.shared.handle(error, message: "Error during download statuses from server.", showToastr: !Task.isCancelled)
|
2023-02-02 17:35:41 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-02-01 18:40:28 +01:00
|
|
|
private func loadData() async {
|
|
|
|
do {
|
|
|
|
if let account = self.applicationState.account {
|
|
|
|
_ = try await HomeTimelineService.shared.loadOnTop(for: account)
|
|
|
|
}
|
|
|
|
|
2023-02-08 20:18:03 +01:00
|
|
|
self.applicationState.amountOfNewStatuses = 0
|
2023-02-01 18:40:28 +01:00
|
|
|
self.state = .loaded
|
|
|
|
} catch {
|
|
|
|
if !Task.isCancelled {
|
2023-02-21 22:41:20 +01:00
|
|
|
ErrorService.shared.handle(error, message: "Statuses not retrieved.", showToastr: true)
|
2023-02-01 18:40:28 +01:00
|
|
|
self.state = .error(error)
|
|
|
|
} else {
|
2023-02-21 22:41:20 +01:00
|
|
|
ErrorService.shared.handle(error, message: "Statuses not retrieved.", showToastr: false)
|
2022-12-30 18:20:54 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2023-01-31 12:20:49 +01:00
|
|
|
|
2023-02-08 20:18:03 +01:00
|
|
|
private func calculateOpacity(offset: CGFloat) {
|
|
|
|
if self.applicationState.amountOfNewStatuses == 0 {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// View is scrolled down.
|
|
|
|
if offset <= 0 {
|
|
|
|
self.opacity = 1.0
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-02-24 07:30:52 +01:00
|
|
|
if offset < self.pullToRefreshViewHigh {
|
2023-02-18 16:47:36 +01:00
|
|
|
// View is scrolled up (loader is visible).
|
|
|
|
self.opacity = 1.0 - min((offset / 50.0), 1.0)
|
|
|
|
} else {
|
|
|
|
// View is scrolled so high that we can hide amount of new statuses.
|
2023-02-13 16:10:16 +01:00
|
|
|
self.applicationState.amountOfNewStatuses = 0
|
2023-02-08 20:18:03 +01:00
|
|
|
self.hideNewStatusesView()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func calculateOffset() {
|
|
|
|
if self.applicationState.amountOfNewStatuses > 0 {
|
|
|
|
withAnimation(.easeIn) {
|
|
|
|
self.showNewStatusesView()
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
withAnimation(.easeOut) {
|
|
|
|
self.hideNewStatusesView()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func showNewStatusesView() {
|
|
|
|
self.offset = 0.0
|
|
|
|
self.opacity = 1.0
|
|
|
|
}
|
|
|
|
|
|
|
|
private func hideNewStatusesView() {
|
|
|
|
self.offset = -50.0
|
|
|
|
self.opacity = 0.0
|
|
|
|
}
|
|
|
|
|
2023-01-31 12:20:49 +01:00
|
|
|
private func shouldUpToDateBeVisible(statusId: String) -> Bool {
|
|
|
|
return self.applicationState.lastSeenStatusId != dbStatuses.first?.id && self.applicationState.lastSeenStatusId == statusId
|
|
|
|
}
|
|
|
|
|
2023-02-08 20:18:03 +01:00
|
|
|
@ViewBuilder
|
|
|
|
private func offsetReader() -> some View {
|
|
|
|
GeometryReader { proxy in
|
|
|
|
Color.clear
|
|
|
|
.preference(
|
|
|
|
key: OffsetPreferenceKey.self,
|
|
|
|
value: proxy.frame(in: .named("frameLayer")).minY
|
|
|
|
)
|
|
|
|
}
|
|
|
|
.frame(height: 0)
|
|
|
|
}
|
|
|
|
|
2023-01-31 12:20:49 +01:00
|
|
|
@ViewBuilder
|
|
|
|
private func upToDatePlaceholder() -> some View {
|
|
|
|
VStack(alignment: .center) {
|
|
|
|
Image(systemName: "checkmark.seal")
|
|
|
|
.resizable()
|
|
|
|
.frame(width: 64, height: 64)
|
|
|
|
.fontWeight(.ultraLight)
|
|
|
|
.foregroundColor(.accentColor.opacity(0.6))
|
|
|
|
Text("You're all caught up")
|
|
|
|
.font(.title2)
|
|
|
|
.fontWeight(.thin)
|
|
|
|
.foregroundColor(Color.mainTextColor.opacity(0.6))
|
|
|
|
}
|
|
|
|
.padding(.vertical, 8)
|
|
|
|
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width * 0.75)
|
|
|
|
}
|
2023-02-07 10:20:24 +01:00
|
|
|
|
|
|
|
@ViewBuilder
|
2023-02-12 08:18:05 +01:00
|
|
|
private func newPhotosView() -> some View {
|
2023-02-07 10:20:24 +01:00
|
|
|
VStack(alignment: .trailing, spacing: 4) {
|
|
|
|
HStack {
|
2023-02-08 20:18:03 +01:00
|
|
|
Image(systemName: "arrow.up")
|
|
|
|
Text("\(self.applicationState.amountOfNewStatuses) New \(self.applicationState.amountOfNewStatuses == 1 ? "Photo" : "Photos")")
|
2023-02-07 10:20:24 +01:00
|
|
|
}
|
2023-02-08 20:18:03 +01:00
|
|
|
.padding(12)
|
|
|
|
.font(.footnote)
|
|
|
|
.fontWeight(.light)
|
|
|
|
.foregroundColor(Color.white)
|
|
|
|
.background(.ultraThinMaterial)
|
|
|
|
.clipShape(Capsule())
|
2023-02-07 10:20:24 +01:00
|
|
|
|
|
|
|
Spacer()
|
|
|
|
}
|
|
|
|
.padding(.top, 4)
|
|
|
|
.padding(.trailing, 4)
|
|
|
|
}
|
2022-12-30 18:20:54 +01:00
|
|
|
}
|