mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #3141 from omnivore-app/feat/ios-multi-select
iOS multi select
This commit is contained in:
commit
e0fdb33008
8 changed files with 314 additions and 175 deletions
|
|
@ -1384,7 +1384,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.38.0;
|
||||
MARKETING_VERSION = 1.39.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
|
|
@ -1419,7 +1419,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.38.0;
|
||||
MARKETING_VERSION = 1.39.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -1474,7 +1474,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.38.0;
|
||||
MARKETING_VERSION = 1.39.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = Omnivore;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -1815,7 +1815,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.38.0;
|
||||
MARKETING_VERSION = 1.39.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = Omnivore;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
|
|||
|
|
@ -31,14 +31,19 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@State var settingsPresented = false
|
||||
@State var isListScrolled = false
|
||||
@State var listTitle = ""
|
||||
@State var isEditMode: EditMode = .inactive
|
||||
@State var showOpenAIVoices = false
|
||||
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
@AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = true
|
||||
@AppStorage(UserDefaultKey.shouldPromptCommunityModal.rawValue) var shouldPromptCommunityModal = true
|
||||
@AppStorage(UserDefaultKey.openAIPrimerDisplayed.rawValue) var openAIPrimerDisplayed = false
|
||||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
@State private var selection = Set<String>()
|
||||
|
||||
func loadItems(isRefresh: Bool) {
|
||||
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
|
||||
}
|
||||
|
|
@ -58,6 +63,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
listTitle: $listTitle,
|
||||
isListScrolled: $isListScrolled,
|
||||
prefersListLayout: $prefersListLayout,
|
||||
selection: $selection,
|
||||
viewModel: viewModel,
|
||||
showFeatureCards: showFeatureCards
|
||||
)
|
||||
|
|
@ -95,7 +101,15 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
FilterSelectorView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
// .navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(isPresented: $showOpenAIVoices) {
|
||||
OpenAIVoicesModal(audioController: audioController)
|
||||
}
|
||||
.onAppear {
|
||||
if !openAIPrimerDisplayed, !Voices.isOpenAIVoice(self.audioController.currentVoice) {
|
||||
showOpenAIVoices = true
|
||||
openAIPrimerDisplayed = true
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
toolbarItems
|
||||
}
|
||||
|
|
@ -129,21 +143,6 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
}
|
||||
}
|
||||
// .formSheet(isPresented: $viewModel.snoozePresented) {
|
||||
// SnoozeView(
|
||||
// snoozePresented: $viewModel.snoozePresented,
|
||||
// itemToSnoozeID: $viewModel.itemToSnoozeID
|
||||
// ) { snoozeParams in
|
||||
// Task {
|
||||
// await viewModel.snoozeUntil(
|
||||
// dataService: dataService,
|
||||
// linkId: snoozeParams.feedItemId,
|
||||
// until: snoozeParams.snoozeUntilDate,
|
||||
// successMessage: snoozeParams.successMessage
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
.fullScreenCover(isPresented: $searchPresented) {
|
||||
LibrarySearchView(homeFeedViewModel: self.viewModel)
|
||||
}
|
||||
|
|
@ -162,6 +161,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
loadItems(isRefresh: false)
|
||||
}
|
||||
}
|
||||
.environment(\.editMode, self.$isEditMode)
|
||||
}
|
||||
|
||||
var toolbarItems: some ToolbarContent {
|
||||
|
|
@ -216,13 +216,11 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
ToolbarItem(placement: .barTrailing) {
|
||||
if UIDevice.isIPhone {
|
||||
Menu(content: {
|
||||
// Button(action: {
|
||||
// // withAnimation {
|
||||
// viewModel.isInMultiSelectMode.toggle()
|
||||
// // }
|
||||
// }, label: {
|
||||
// Label(viewModel.isInMultiSelectMode ? "End Multiselect" : "Select Multiple", systemImage: "checkmark.circle")
|
||||
// })
|
||||
Button(action: {
|
||||
isEditMode = isEditMode == .inactive ? .active : .inactive
|
||||
}, label: {
|
||||
Text(isEditMode == .inactive ? "Select Multiple" : "End Multiselect")
|
||||
})
|
||||
Button(action: { addLinkPresented = true }, label: {
|
||||
Label("Add Link", systemImage: "plus.circle")
|
||||
})
|
||||
|
|
@ -238,15 +236,22 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
EmptyView()
|
||||
}
|
||||
}
|
||||
// if viewModel.isInMultiSelectMode {
|
||||
// ToolbarItemGroup(placement: .bottomBar) {
|
||||
// Button(action: {}, label: { Image(systemName: "archivebox") })
|
||||
// Button(action: {}, label: { Image(systemName: "trash") })
|
||||
// Button(action: {}, label: { Image.label })
|
||||
// Spacer()
|
||||
// Button(action: { viewModel.isInMultiSelectMode = false }, label: { Text("Cancel") })
|
||||
// }
|
||||
// }
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
if isEditMode == .active {
|
||||
Button(action: {
|
||||
viewModel.bulkAction(dataService: dataService, action: .archive, items: Array(selection))
|
||||
isEditMode = .inactive
|
||||
}, label: { Image(systemName: "archivebox") })
|
||||
Button(action: {
|
||||
viewModel.bulkAction(dataService: dataService, action: .delete, items: Array(selection))
|
||||
isEditMode = .inactive
|
||||
}, label: { Image(systemName: "trash") })
|
||||
Spacer()
|
||||
Text("\(selection.count) selected").font(.footnote)
|
||||
Spacer()
|
||||
Button(action: { isEditMode = .inactive }, label: { Text("Cancel") })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -258,6 +263,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@Binding var listTitle: String
|
||||
@Binding var isListScrolled: Bool
|
||||
@Binding var prefersListLayout: Bool
|
||||
@Binding var selection: Set<String>
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
let showFeatureCards: Bool
|
||||
|
|
@ -281,7 +287,14 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
|
||||
if prefersListLayout || !enableGrid {
|
||||
HomeFeedListView(listTitle: $listTitle, isListScrolled: $isListScrolled, prefersListLayout: $prefersListLayout, viewModel: viewModel, showFeatureCards: showFeatureCards)
|
||||
HomeFeedListView(
|
||||
listTitle: $listTitle,
|
||||
isListScrolled: $isListScrolled,
|
||||
prefersListLayout: $prefersListLayout,
|
||||
selection: $selection,
|
||||
viewModel: viewModel,
|
||||
showFeatureCards: showFeatureCards
|
||||
)
|
||||
} else {
|
||||
HomeFeedGridView(viewModel: viewModel, isListScrolled: $isListScrolled)
|
||||
}
|
||||
|
|
@ -329,6 +342,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@Binding var prefersListLayout: Bool
|
||||
@State private var showHideFeatureAlert = false
|
||||
|
||||
@Binding var selection: Set<String>
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
let showFeatureCards: Bool
|
||||
|
|
@ -540,7 +554,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
Spacer(minLength: 2)
|
||||
}
|
||||
|
||||
List {
|
||||
List(selection: $selection) {
|
||||
filtersHeader
|
||||
.listRowSeparator(.hidden, edges: .all)
|
||||
.listRowInsets(.init(top: 0, leading: horizontalInset, bottom: 0, trailing: horizontalInset))
|
||||
|
|
@ -562,7 +576,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
}
|
||||
|
||||
ForEach(viewModel.items) { item in
|
||||
ForEach(viewModel.items, id: \.self.unwrappedID) { item in
|
||||
FeedCardNavigationLink(
|
||||
item: item,
|
||||
isInMultiSelectMode: viewModel.isInMultiSelectMode,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ import Views
|
|||
|
||||
@Published var showLabelsSheet = false
|
||||
@Published var showFiltersModal = false
|
||||
@Published var showCommunityModal = false
|
||||
@Published var featureItems = [LinkedItem]()
|
||||
|
||||
@Published var listConfig: LibraryListConfig
|
||||
|
|
@ -368,6 +367,21 @@ import Views
|
|||
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0, anchorIndex: 0, force: true)
|
||||
}
|
||||
|
||||
func bulkAction(dataService: DataService, action: BulkAction, items: [String]) {
|
||||
if items.count < 1 {
|
||||
snackbar("No items selected")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
try await dataService.bulkAction(action: action, items: items)
|
||||
snackbar("Operation completed")
|
||||
} catch {
|
||||
snackbar("Error performing operation")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var queryContainsFilter: Bool {
|
||||
if searchTerm.contains("in:inbox") || searchTerm.contains("in:all") || searchTerm.contains("in:archive") {
|
||||
return true
|
||||
|
|
|
|||
143
apple/OmnivoreKit/Sources/App/Views/Home/OpenAIVoicesModal.swift
Normal file
143
apple/OmnivoreKit/Sources/App/Views/Home/OpenAIVoicesModal.swift
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// swiftlint:disable line_length
|
||||
|
||||
//
|
||||
// CommunityModal.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 12/7/22.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
struct OpenAIVoiceItem {
|
||||
let name: String
|
||||
let key: String
|
||||
}
|
||||
|
||||
public struct OpenAIVoicesModal: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let audioController: AudioController
|
||||
|
||||
let message: String = """
|
||||
We've added six new voices powered by OpenAI and enabled them for all users. If you are already using our Ultra Realistic voices, don't worry, trying these voices will not remove you from the ultra realistic beta.
|
||||
|
||||
[Tell your friends about Omnivore](https://omnivore.app)
|
||||
"""
|
||||
|
||||
@State var playbackSample: String?
|
||||
|
||||
let voices = [
|
||||
OpenAIVoiceItem(name: "Alloy", key: "openai-alloy"),
|
||||
OpenAIVoiceItem(name: "Echo", key: "openai-echo"),
|
||||
OpenAIVoiceItem(name: "Fable", key: "openai-fable"),
|
||||
OpenAIVoiceItem(name: "Onyx", key: "openai-onyx"),
|
||||
OpenAIVoiceItem(name: "Nova", key: "openai-nova"),
|
||||
OpenAIVoiceItem(name: "Shimmer", key: "openai-shimmer")
|
||||
]
|
||||
|
||||
var closeButton: some View {
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}, label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color.circleButtonBackground)
|
||||
.frame(width: 30, height: 30)
|
||||
|
||||
Image(systemName: "xmark")
|
||||
.resizable(resizingMode: Image.ResizingMode.stretch)
|
||||
.foregroundColor(Color.circleButtonForeground)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.font(Font.title.weight(.bold))
|
||||
.frame(width: 12, height: 12)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack {
|
||||
Text("New voices powered by OpenAI")
|
||||
.font(Font.system(size: 20, weight: .bold))
|
||||
Spacer()
|
||||
closeButton
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
List {
|
||||
Section {
|
||||
let parsedMessage = try? AttributedString(markdown: message,
|
||||
options: .init(interpretedSyntax: .inlineOnly))
|
||||
Text(parsedMessage ?? "")
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
.accentColor(.blue)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(voices, id: \.self.name) { voice in
|
||||
voiceRow(for: voice)
|
||||
}
|
||||
}
|
||||
}
|
||||
.environmentObject(audioController)
|
||||
}
|
||||
|
||||
func voiceRow(for voice: OpenAIVoiceItem) -> some View {
|
||||
Button(action: {
|
||||
if audioController.isPlayingSample(voice: voice.key) {
|
||||
playbackSample = nil
|
||||
audioController.stopVoiceSample()
|
||||
}
|
||||
playbackSample = voice.key
|
||||
audioController.currentVoice = voice.key
|
||||
audioController.playVoiceSample(voice: voice.key)
|
||||
Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { timer in
|
||||
let playing = audioController.isPlayingSample(voice: voice.key)
|
||||
if playing {
|
||||
playbackSample = voice.key
|
||||
} else if !playing {
|
||||
// If the playback sample is something else, its taken ownership
|
||||
// of the value so we just ignore it and shut down our timer.
|
||||
if playbackSample == voice.key {
|
||||
playbackSample = nil
|
||||
}
|
||||
timer.invalidate()
|
||||
}
|
||||
}
|
||||
}, label: {
|
||||
HStack {
|
||||
if playbackSample == voice.key {
|
||||
Image(systemName: "stop.circle")
|
||||
.font(.appTitleTwo)
|
||||
.padding(.trailing, 16)
|
||||
} else {
|
||||
Image(systemName: "play.circle")
|
||||
.font(.appTitleTwo)
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
Text(voice.name)
|
||||
Spacer()
|
||||
|
||||
if audioController.currentVoice == voice.key {
|
||||
if audioController.isPlaying, audioController.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}.contentShape(Rectangle())
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -14,9 +14,6 @@ import Views
|
|||
public final class RootViewModel: ObservableObject {
|
||||
let services = Services()
|
||||
|
||||
@Published public var showNewFeaturePrimer = false
|
||||
@AppStorage(UserDefaultKey.shouldShowNewFeaturePrimer.rawValue) var shouldShowNewFeaturePrimer = false
|
||||
|
||||
public init() {
|
||||
registerFonts()
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
//
|
||||
// BulkActionMutation.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 11/17/23.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public enum BulkAction {
|
||||
case delete
|
||||
case archive
|
||||
|
||||
var GQLType: Enums.BulkActionType {
|
||||
switch self {
|
||||
case BulkAction.archive:
|
||||
return Enums.BulkActionType.archive
|
||||
case BulkAction.delete:
|
||||
return Enums.BulkActionType.delete
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension DataService {
|
||||
func bulkAction(action: BulkAction, items: [String]) async throws {
|
||||
// If the item is still available locally, update its state
|
||||
backgroundContext.performAndWait {
|
||||
items.forEach { itemID in
|
||||
if let linkedItem = LinkedItem.lookup(byID: itemID, inContext: backgroundContext) {
|
||||
if action == .delete {
|
||||
linkedItem.state = "DELETED"
|
||||
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
} else {
|
||||
linkedItem.update(inContext: self.backgroundContext, newIsArchivedValue: true)
|
||||
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsUpdate.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
do {
|
||||
try backgroundContext.save()
|
||||
logger.debug("LinkedItem updated succesfully")
|
||||
} catch {
|
||||
backgroundContext.rollback()
|
||||
logger.debug("Failed to update LinkedItem: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// If we recovered locally, but failed to sync the undelete, that is OK, because
|
||||
// the item shouldn't be deleted server side.
|
||||
try await syncBulkAction(action: action, items: items)
|
||||
}
|
||||
|
||||
func syncBulkAction(action: BulkAction, items: [String]) async throws {
|
||||
enum MutationResult {
|
||||
case result(success: Bool)
|
||||
case error(errorMessage: String)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.BulkActionResult> {
|
||||
try $0.on(
|
||||
bulkActionError: .init { .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "Unknown Error") },
|
||||
bulkActionSuccess: .init { .result(success: try $0.success()) }
|
||||
)
|
||||
}
|
||||
|
||||
let query = "includes:\"\(items.joined(separator: ","))\""
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.bulkAction(
|
||||
action: action.GQLType,
|
||||
query: query,
|
||||
selection: selection
|
||||
)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(mutation, to: path, headers: headers) { queryResult in
|
||||
guard let payload = try? queryResult.get() else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "network error"))
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .result(success: success):
|
||||
if success {
|
||||
continuation.resume()
|
||||
} else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "Operation failed"))
|
||||
}
|
||||
case let .error(errorMessage: errorMessage):
|
||||
continuation.resume(throwing: BasicError.message(messageText: errorMessage))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,10 +26,9 @@ public enum UserDefaultKey: String {
|
|||
case recentSearchTerms
|
||||
case audioPlayerExpanded
|
||||
case themeName
|
||||
case shouldShowNewFeaturePrimer
|
||||
case openAIPrimerDisplayed
|
||||
case notificationsEnabled
|
||||
case deviceTokenID
|
||||
case shouldPromptCommunityModal
|
||||
case userWordsPerMinute
|
||||
case hideFeatureSection
|
||||
case justifyText
|
||||
|
|
|
|||
|
|
@ -1,129 +0,0 @@
|
|||
//
|
||||
// CommunityModal.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 12/7/22.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
|
||||
// swiftlint:disable:next line_length
|
||||
let tweetUrl = "https://twitter.com/intent/tweet?text=I%20recently%20started%20using%20@OmnivoreApp%20as%20a%20free,%20open-source%20read-it-later%20app.%20Check%20it%20out:%20https://omnivore.app"
|
||||
|
||||
public struct CommunityModal: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let message: String = """
|
||||
Thank you for being a member of the Omnivore Community.
|
||||
|
||||
Omnivore is a free and open-source project and relies on \
|
||||
help from our community to grow. Below are a few simple \
|
||||
things you can do to help us build a better Omnivore.
|
||||
|
||||
If you would like to financially assist Omnivore \
|
||||
please [contribute on Open Collective](https://opencollective.com/omnivore).
|
||||
"""
|
||||
|
||||
public init() {}
|
||||
|
||||
// var body: some View {
|
||||
// ZStack {
|
||||
// Image("Biz-card_2020")
|
||||
// .resizable()
|
||||
// .edgesIgnoringSafeArea(.all)
|
||||
// closeButton
|
||||
// }
|
||||
// }
|
||||
|
||||
var closeButton: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle")
|
||||
.padding(10)
|
||||
}
|
||||
}
|
||||
.padding(.top, 5)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
public var header: some View {
|
||||
VStack(spacing: 0) {
|
||||
Text(LocalText.communityHeadline)
|
||||
.font(.textToSpeechRead)
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
HStack {
|
||||
TextChip(text: "Help Wanted", color: Color.appBackground)
|
||||
.frame(alignment: .leading)
|
||||
TextChip(text: "Community", color: Color.green)
|
||||
.frame(alignment: .leading)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
let links = [
|
||||
(title: LocalText.communityTweet, url: tweetUrl),
|
||||
(title: LocalText.communityFollowTwitter, url: "https://twitter.com/omnivoreapp"),
|
||||
(title: LocalText.communityJoinDiscord, url: "https://discord.gg/h2z5rppzz9"),
|
||||
(title: LocalText.communityStarGithub, url: "https://github.com/omnivore-app/omnivore")
|
||||
]
|
||||
|
||||
var buttonLinks: some View {
|
||||
VStack(spacing: 15) {
|
||||
Button(action: {
|
||||
// swiftlint:disable:next line_length
|
||||
if let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
|
||||
SKStoreReviewController.requestReview(in: scene)
|
||||
}
|
||||
}, label: { Text(LocalText.communityAppstoreReview) })
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
ForEach(links, id: \.url) { link in
|
||||
if let url = URL(string: link.url) {
|
||||
Link(link.title, destination: url)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
|
||||
let parsedMessage = try? AttributedString(markdown: message,
|
||||
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))
|
||||
Text(parsedMessage ?? "")
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
.accentColor(.blue)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 16)
|
||||
|
||||
Spacer()
|
||||
|
||||
buttonLinks
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}, label: { Text(LocalText.dismissButton) })
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.padding(.bottom, 16)
|
||||
.frame(alignment: .bottom)
|
||||
}.padding()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Loading…
Reference in a new issue