Release 7.0.0 (#394)
* Bump version * Clean code (#393) * Mac os version (#395) * wip mac os version * Build on macos * WIP Webview * Update app icon Refacto routing * Move folder * Image list * Change badge update * Arrange entry view menu * Add refresh command * Add app Sandbox for macos + first build on macos * Fix #397 * Fix macos build * Try fix app icon * Rework toolbar entry * Fix logo * Add test on serverViewModel * Update refresh button * Clean file + clean Picker * Fix progress view * Fix delete entry on ipad fix #389 * Swiftformat * Add swipe to back fix #356 * enhance bottom button tap fix #400 * Add dismiss action in entry view fix #338 * Add sorting options only on id fix #71 * Add progress
@@ -1 +1 @@
|
||||
5.5
|
||||
5.8
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
#if DEBUG
|
||||
let args = ProcessInfo.processInfo.arguments
|
||||
|
||||
if args.contains("POPULATE_APPLICATION") {
|
||||
populateApplication()
|
||||
}
|
||||
#endif
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_: UIApplication) {
|
||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_: UIApplication) {}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "logo-icon-bg-white-lg.jpg",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "mac16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "mac32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "mac32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "mac64.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "mac128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "mac256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "mac256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "mac512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "mac512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "mac1024.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "logo-icon-black-no-bg-lg.png",
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "logo-icon-white-no-bg-lg.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,58 @@
|
||||
#if os(iOS)
|
||||
import CoreData
|
||||
import Factory
|
||||
import Foundation
|
||||
import SharedLib
|
||||
import UIKit
|
||||
|
||||
#if DEBUG
|
||||
// swiftlint:disable line_length
|
||||
extension AppDelegate {
|
||||
func populateApplication() {
|
||||
resetApplication()
|
||||
|
||||
let appState = Container.shared.appState()
|
||||
appState.registred = true
|
||||
WallabagUserDefaults.defaultMode = "allArticles"
|
||||
WallabagUserDefaults.badgeEnabled = false
|
||||
|
||||
let context = Container.shared.coreData().viewContext
|
||||
|
||||
let entry = Entry(context: context)
|
||||
entry.title = "Marc Zuckerberg devrait être passible d'une peine de prison pour mensonges répétés au sujet de la protection de la vie privée des utilisateurs Facebook, d'après le sénateur américain Ron Wyden"
|
||||
entry.url = "https://www.developpez.com/actu/275880/Marc-Zuckerberg-devrait-etre-passible-d-une-peine-de-prison-pour-mensonges-repetes-au-sujet-de-la-protection-de-la-vie-privee-des-utilisateurs-Facebook-d-apres-le-senateur-americain-Ron-Wyden/"
|
||||
entry.previewPicture = "https://www.developpez.com/images/logos/mark-zuckerberg.png"
|
||||
entry.content = """
|
||||
<img src="https://www.developpez.com/images/logos/mark-zuckerberg.png" class="c13" referrerpolicy="no-referrer" alt="image" /> La société Facebook est depuis quelque temps très critiquée sur la manière avec laquelle elle manipule les données de ses utilisateurs et les nombreux scandales comme celui de cambridge analytica, n’améliorent pas l’image de l’entreprise. D’ailleurs, après l'épisode de Cambridge Analytica, la société a reconnu plusieurs autres cas de manquements à la vie privée, dont celui d'admettre qu'elle avait mal géré les mots de passe d'utilisateurs sur Instagram. Bien que l’entreprise Facebook soit déjà sous le coup d’une <a href="https://www.developpez.com/actu/271222/La-FTC-inflige-une-amende-de-5-milliards-de-dollars-a-Facebook-et-apporte-des-clauses-qui-reduisent-considerablement-le-pouvoir-de-Mark-Zuckerberg/" target="_blank">amende de 5 milliards de dollars</a>, cela semble ne pas être suffisant pour certains qui estiment que les dirigeants devraient eux aussi être poursuivis en justice.
|
||||
<p>En effet, un projet de loi présenté en 2018 par le sénateur Ron Wyden, donne à la FTC le pouvoir de sévir plus sévèrement contre les entreprises qui violent la vie privée des consommateurs. Selon le projet de loi, les dirigeants pourraient être condamnés à 20 ans de prison et à une amende de 5 millions de dollars. Et c’est dans ce sens que le sénateur Ron Wyden s’est exprimé lors d’une récente Interview qui portait sur l'article 230 de ce projet de loi, sur l'impact de cette législation et sur les conséquences néfastes, notamment le fait que Facebook utilisait cette législation à son avantage.</p><p>Au cours de cette interview, il a déclaré : « Mark Zuckerberg a menti à plusieurs reprises au peuple américain au sujet de la vie privée. Je pense qu'il devrait être tenu personnellement pour responsable, ce qui devrait entraîner des amendes financières et laissez-moi souligner également la possibilité d'une peine de prison parce qu'il a blessé beaucoup de gens ». Wyden a aussi mentionné le fait qu’il existait un précédent dans ce genre de cas : dans les services financiers, si le PDG et les dirigeants mentent au sujet des données financières, ils peuvent être tenus personnellement pour responsables.</p>
|
||||
<div class="c14"><img src="https://www.developpez.net/forums/attachments/p501331d1/a/a/a" referrerpolicy="no-referrer" alt="image" /></div>
|
||||
<br />Wyden n'est pas le premier législateur à proposer d'envoyer des cadres d’entreprises en prison. La sénatrice du Massachusetts, Elizabeth Warren, une candidate à la présidence de 2020 a dévoilé au début de cette année la loi sur la responsabilité des dirigeants d'entreprise, qui étend la responsabilité pénale aux dirigeants de toute société générant un chiffre d'affaires annuel supérieur à 1 milliard de dollars si cette société était reconnue coupable d'un crime ou d'une autre infraction civile. Malgré les souhaits de Wyden de voir Zuckerberg finir en prison, cela ne se produira probablement pas. C’est l’avis de Tim Gleason, un professeur de l’Université de l’Oregon, qui estime que les chances de Zuckerberg de faire face à une action criminelle sont plutôt minces.
|
||||
<p>Pendant ce temps, plusieurs personnes pensent qu’emprisonner Zuckerberg ne réglera pas le problème, car il sera juste remplacé par quelqu'un d'autre qui fera pareil que lui. Ces personnes estiment qu’il serait peut-être plus judicieux de lui imposer une très grosse amende, qui servira à financer des œuvres sociales. Pour ces personnes, il est inutile de combattre ces milliardaires de cette façon, il faudrait plutôt utiliser leur cupidité contre eux.</p>
|
||||
<p>Source : <a href="https://www.wweek.com/news/2019/08/28/ron-wyden-doesnt-apologize-for-helping-build-the-internet-but-hes-interested-in-sending-mark-zuckerberg-to-prison/" target="_blank">Willamette Week</a></p>
|
||||
<p><strong>Et vous ?</strong></p>
|
||||
<p><img src="https://www.developpez.net/forums/images/smilies/fleche.gif" border="0" alt="" title=":fleche:" class="inlineimg" referrerpolicy="no-referrer" /> Qu’en pensez-vous ?<br /><img src="https://www.developpez.net/forums/images/smilies/fleche.gif" border="0" alt="" title=":fleche:" class="inlineimg" referrerpolicy="no-referrer" /> Pensez-vous que Zuckerberg sera finalement inquiété ?<br /><img src="https://www.developpez.net/forums/images/smilies/fleche.gif" border="0" alt="" title=":fleche:" class="inlineimg" referrerpolicy="no-referrer" /> Quelles mesures proposeriez-vous pour contraindre les entreprises à respecter la vie privée des utilisateurs ?</p>
|
||||
<p><strong>Voir aussi :</strong></p>
|
||||
<p><img src="https://www.developpez.net/forums/images/smilies/fleche.gif" border="0" alt="" title=":fleche:" class="inlineimg" referrerpolicy="no-referrer" /><a href="https://www.developpez.com/actu/256658/Les-actionnaires-de-Facebook-en-ont-assez-de-Marc-Zuckerberg-mais-ne-peuvent-rien-faire-contre-lui-en-voici-les-raisons/" target="_blank">Les actionnaires de Facebook en ont assez de Marc Zuckerberg, mais ne peuvent rien faire contre lui, En voici les raisons</a><br /><img src="https://www.developpez.net/forums/images/smilies/fleche.gif" border="0" alt="" title=":fleche:" class="inlineimg" referrerpolicy="no-referrer" /><a href="https://www.developpez.com/actu/258769/Mark-Zuckerberg-ainsi-que-d-autres-cadres-dirigeants-de-Facebook-sont-poursuivis-en-justice-pour-les-scandales-lies-a-la-vie-privee/" target="_blank">Mark Zuckerberg ainsi que d'autres cadres dirigeants de Facebook sont poursuivis en justice Pour les scandales liés à la vie privée</a><br /><img src="https://www.developpez.net/forums/images/smilies/fleche.gif" border="0" alt="" title=":fleche:" class="inlineimg" referrerpolicy="no-referrer" /><a href="https://www.developpez.com/actu/224572/Mark-Zuckerberg-devrait-vendre-jusqu-a-13-Md-d-actions-Facebook-d-ici-mars-2019-pour-financer-les-actions-philanthropiques-de-sa-fondation-CZI/" target="_blank">Mark Zuckerberg devrait vendre jusqu'à 13 Md$ d'actions Facebook d'ici mars 2019 Pour financer les actions philanthropiques de sa fondation CZI</a></p>
|
||||
"""
|
||||
}
|
||||
|
||||
private func resetApplication() {
|
||||
do {
|
||||
// MARK: CoreData
|
||||
|
||||
let context = Container.shared.coreData().viewContext
|
||||
let operations = NSBatchDeleteRequest(fetchRequest: Entry.fetchRequest())
|
||||
try context.execute(operations)
|
||||
|
||||
let accounts = NSBatchDeleteRequest(fetchRequest: Tag.fetchRequest())
|
||||
try context.execute(accounts)
|
||||
|
||||
try context.save()
|
||||
} catch {
|
||||
print("error resetting app")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func addSwipeToBack(action: @escaping () -> Void) -> some View {
|
||||
gesture(
|
||||
DragGesture()
|
||||
.onEnded { gesture in
|
||||
if gesture.startLocation.x < 50, gesture.translation.width > 80 {
|
||||
action()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ struct AboutView: View {
|
||||
Spacer()
|
||||
Text("Made by Maxime Marinel @bourvill")
|
||||
}
|
||||
.navigationTitle("About")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Factory
|
||||
import SwiftUI
|
||||
|
||||
struct AddEntryView: View {
|
||||
@@ -6,7 +7,9 @@ struct AddEntryView: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
TextField("Url", text: $model.url)
|
||||
#if os(iOS)
|
||||
.autocapitalization(.none)
|
||||
#endif
|
||||
.disableAutocorrection(true)
|
||||
HStack {
|
||||
Button(model.submitting ? "Submitting..." : "Submit") {
|
||||
@@ -17,11 +20,12 @@ struct AddEntryView: View {
|
||||
Text("Great! Entry was added")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add entry")
|
||||
}
|
||||
}
|
||||
|
||||
private class AddEntryModel: ObservableObject {
|
||||
@Injector var session: WallabagSession
|
||||
@Injected(\.wallabagSession) private var session
|
||||
|
||||
@Published var url: String = ""
|
||||
@Published var submitting: Bool = false
|
||||
@@ -0,0 +1,43 @@
|
||||
import CoreData
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct EntriesListView: View {
|
||||
@Environment(\.managedObjectContext) var context: NSManagedObjectContext
|
||||
@EnvironmentObject var appSync: AppSync
|
||||
@FetchRequest var entries: FetchedResults<Entry>
|
||||
|
||||
init(predicate: NSPredicate, entriesSortAscending: Bool) {
|
||||
_entries = FetchRequest(
|
||||
entity: Entry.entity(),
|
||||
sortDescriptors: [NSSortDescriptor(key: "id", ascending: entriesSortAscending)],
|
||||
predicate: predicate, animation: nil
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(entries) { entry in
|
||||
NavigationLink(value: RoutePath.entry(entry)) {
|
||||
EntryRowView(entry: entry)
|
||||
.contentShape(Rectangle())
|
||||
.contextMenu {
|
||||
ArchiveEntryButton(entry: entry)
|
||||
StarEntryButton(entry: entry)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.swipeActions(allowsFullSwipe: false, content: {
|
||||
ArchiveEntryButton(entry: entry)
|
||||
.tint(.blue)
|
||||
.labelStyle(.iconOnly)
|
||||
StarEntryButton(entry: entry)
|
||||
.tint(.orange)
|
||||
.labelStyle(.iconOnly)
|
||||
})
|
||||
}
|
||||
}
|
||||
.refreshable { appSync.requestSync() }
|
||||
.listStyle(.inset)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Combine
|
||||
import CoreData
|
||||
import SwiftUI
|
||||
|
||||
struct EntriesView: View {
|
||||
@EnvironmentObject var router: Router
|
||||
@EnvironmentObject var appState: AppState
|
||||
@StateObject var searchViewModel = SearchViewModel()
|
||||
@State var entriesSortAscending = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
#if os(iOS)
|
||||
PasteBoardView()
|
||||
#endif
|
||||
SearchView(searchViewModel: searchViewModel)
|
||||
EntriesListView(predicate: searchViewModel.predicate, entriesSortAscending: entriesSortAscending)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack {
|
||||
Button(action: {
|
||||
entriesSortAscending.toggle()
|
||||
}, label: {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
.rotationEffect(.degrees(entriesSortAscending ? 180 : 0))
|
||||
})
|
||||
RefreshButton()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Menu(content: {
|
||||
Button(action: {
|
||||
router.path.append(RoutePath.addEntry)
|
||||
}, label: {
|
||||
Label("Add entry", systemImage: "tray.and.arrow.down")
|
||||
})
|
||||
Button(action: {
|
||||
router.path.append(RoutePath.about)
|
||||
}, label: {
|
||||
Label("About", systemImage: "questionmark")
|
||||
})
|
||||
Button(action: {
|
||||
router.path.append(RoutePath.tips)
|
||||
}, label: {
|
||||
Label("Don", systemImage: "heart")
|
||||
})
|
||||
Divider()
|
||||
Button(action: {
|
||||
router.path.append(RoutePath.setting)
|
||||
}, label: {
|
||||
Label("Setting", systemImage: "gear")
|
||||
})
|
||||
Divider()
|
||||
Button(role: .destructive, action: {
|
||||
appState.logout()
|
||||
}, label: {
|
||||
Label("Logout", systemImage: "person")
|
||||
}).foregroundColor(.red)
|
||||
}, label: {
|
||||
Label("Menu", systemImage: "list.bullet")
|
||||
})
|
||||
}
|
||||
}
|
||||
.navigationTitle("Entries")
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct ArticleListView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
EntriesView()
|
||||
#if os(iOS)
|
||||
.environmentObject(PasteBoardViewModel())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,4 +1,6 @@
|
||||
import Factory
|
||||
import HTMLEntities
|
||||
import SharedLib
|
||||
import SwiftUI
|
||||
|
||||
struct EntryRowView: View {
|
||||
@@ -35,7 +37,8 @@ struct EntryRowView: View {
|
||||
|
||||
struct ArticleRowView_Previews: PreviewProvider {
|
||||
static var entry: Entry {
|
||||
let entry = Entry(context: CoreData.shared.viewContext)
|
||||
let coreData = Container.shared.coreData()
|
||||
let entry = Entry(context: coreData.viewContext)
|
||||
entry.title = "Marc Zuckerberg devrait être passible d'une peine de prison pour mensonges répétés au sujet de la protection de la vie privée des utilisateurs Facebook, d'après le sénateur américain Ron Wyden"
|
||||
|
||||
return entry
|
||||
@@ -0,0 +1,135 @@
|
||||
import CoreData
|
||||
import Factory
|
||||
import HTMLEntities
|
||||
import SwiftUI
|
||||
|
||||
struct EntryView: View {
|
||||
@Environment(\.managedObjectContext) var context: NSManagedObjectContext
|
||||
@Environment(\.openURL) var openURL
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject var appSync: AppSync
|
||||
#if os(iOS)
|
||||
@EnvironmentObject var player: PlayerPublisher
|
||||
#endif
|
||||
@ObservedObject var entry: Entry
|
||||
@State var showTag: Bool = false
|
||||
@State private var showDeleteConfirm = false
|
||||
@State private var progress = 0.0
|
||||
|
||||
#if os(iOS)
|
||||
let toolbarPlacement: ToolbarItemPlacement = .bottomBar
|
||||
#else
|
||||
let toolbarPlacement: ToolbarItemPlacement = .primaryAction
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text(entry.title?.htmlUnescape() ?? "Entry")
|
||||
.font(.title)
|
||||
.fontWeight(.black)
|
||||
.lineLimit(2)
|
||||
.padding(.horizontal)
|
||||
ProgressView(value: progress, total: 1)
|
||||
WebView(entry: entry, progress: $progress)
|
||||
}
|
||||
.addSwipeToBack {
|
||||
dismiss()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: toolbarPlacement) {
|
||||
HStack {
|
||||
FontSizeSelectorView()
|
||||
.buttonStyle(.plain)
|
||||
#if os(iOS)
|
||||
Spacer()
|
||||
#endif
|
||||
Menu(content: {
|
||||
bottomBarButton
|
||||
}, label: {
|
||||
Label("Entry option", systemImage: "filemenu.and.selection")
|
||||
.foregroundColor(.primary)
|
||||
.labelStyle(.iconOnly)
|
||||
})
|
||||
.accessibilityLabel("Entry option")
|
||||
.frame(width: 28, height: 28)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.actionSheet(isPresented: $showDeleteConfirm) {
|
||||
ActionSheet(
|
||||
title: Text("Confirm delete?"),
|
||||
buttons: [
|
||||
.destructive(Text("Delete")) {
|
||||
context.delete(entry)
|
||||
dismiss()
|
||||
},
|
||||
.cancel(),
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showTag) {
|
||||
TagListFor(tagsForEntry: TagsForEntryPublisher(entry: entry))
|
||||
.environment(\.managedObjectContext, context)
|
||||
}
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var bottomBarButton: some View {
|
||||
Button(role: .destructive, action: {
|
||||
showDeleteConfirm = true
|
||||
}, label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
})
|
||||
Divider()
|
||||
Button(action: {
|
||||
openURL(entry.url!.url!)
|
||||
}, label: {
|
||||
Label("Open in Safari", systemImage: "safari")
|
||||
})
|
||||
Button(action: {
|
||||
showTag.toggle()
|
||||
}, label: {
|
||||
Label("Tag", systemImage: showTag ? "tag.fill" : "tag")
|
||||
})
|
||||
Button(action: {
|
||||
appSync.refresh(entry: entry)
|
||||
}, label: {
|
||||
Label("Refresh", systemImage: "arrow.counterclockwise")
|
||||
})
|
||||
StarEntryButton(entry: entry, showText: true)
|
||||
#if os(iOS)
|
||||
.hapticNotification(.success)
|
||||
#endif
|
||||
ArchiveEntryButton(entry: entry, showText: true) {
|
||||
dismiss()
|
||||
}
|
||||
#if os(iOS)
|
||||
.hapticNotification(.success)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
Button(action: {
|
||||
player.load(entry)
|
||||
}, label: {
|
||||
Label("Load entry", systemImage: "music.note")
|
||||
})
|
||||
.accessibilityHint("Load entry in text-to-speech player")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct EntryView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let coreData = Container.shared.coreData()
|
||||
EntryView(entry: Entry(context: coreData.viewContext))
|
||||
#if os(iOS)
|
||||
.environmentObject(PlayerPublisher())
|
||||
#endif
|
||||
.environment(\.managedObjectContext, coreData.viewContext)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
struct EntryPicture: View {
|
||||
@StateObject var imagePublisher = ImageDownloaderPublisher()
|
||||
|
||||
var url: String?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image = imagePublisher.image {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} else {
|
||||
Image(systemName: "book")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
}
|
||||
}.task {
|
||||
await imagePublisher.loadImage(url: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
struct EntryPicture: View {
|
||||
var url: String?
|
||||
|
||||
var body: some View {
|
||||
if let url {
|
||||
AsyncImage(url: URL(string: url)) { image in
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} placeholder: {
|
||||
placeholder
|
||||
}
|
||||
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
Image(systemName: "book")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,72 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
actor ImageCache {
|
||||
static var shared = ImageCache()
|
||||
|
||||
private var memoryCache: NSCache<NSString, UIImage> = {
|
||||
let cache = NSCache<NSString, UIImage>()
|
||||
// set limit?
|
||||
return cache
|
||||
}()
|
||||
|
||||
private init() {}
|
||||
|
||||
private func clearMemoryCache(_: Notification?) {
|
||||
memoryCache.removeAllObjects()
|
||||
}
|
||||
|
||||
subscript(name: String) -> UIImage? {
|
||||
get {
|
||||
get(name: name)
|
||||
}
|
||||
set {
|
||||
guard let image = newValue else { return }
|
||||
set(image, for: name)
|
||||
}
|
||||
}
|
||||
|
||||
func set(_ image: UIImage, for name: String) {
|
||||
memoryCache.setObject(image, forKey: name as NSString)
|
||||
|
||||
let url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
let filename = url.appendingPathComponent("\(name.md5).png")
|
||||
|
||||
try? image.pngData()?.write(to: filename)
|
||||
}
|
||||
|
||||
func get(name: String) -> UIImage? {
|
||||
if let imageCacheMemory = memoryCache.object(forKey: name.NSString) {
|
||||
return imageCacheMemory
|
||||
}
|
||||
|
||||
let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
let imageCacheDir = UIImage(contentsOfFile: URL(fileURLWithPath: dir.absoluteString).appendingPathComponent("\(name.md5).png").path)
|
||||
|
||||
if imageCacheDir != nil {
|
||||
memoryCache.setObject(imageCacheDir!, forKey: name.NSString)
|
||||
|
||||
return imageCacheDir
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func purge() {
|
||||
clearMemoryCache(nil)
|
||||
|
||||
let url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
do {
|
||||
let files = try FileManager.default.contentsOfDirectory(atPath: url.path)
|
||||
try files.forEach {
|
||||
try FileManager.default.removeItem(atPath: url.appendingPathComponent($0).path)
|
||||
}
|
||||
} catch {
|
||||
print("Error in cache purge")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,36 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
final class ImageDownloader {
|
||||
private var cacheStore = ImageCache.shared
|
||||
|
||||
private var dispatchQueue = DispatchQueue(label: "fr.district-web.wallabag.image-downloader", qos: .background)
|
||||
|
||||
func loadImage(url: URL) async -> UIImage? {
|
||||
if let imageCache = await cacheStore[url.absoluteString] {
|
||||
return imageCache
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.allowsConstrainedNetworkAccess = false
|
||||
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
guard let image = UIImage(data: data) else { return nil }
|
||||
|
||||
await cacheStore.set(image, for: url.absoluteString)
|
||||
|
||||
return image
|
||||
} catch {}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
final class ImageDownloader {}
|
||||
#endif
|
||||
@@ -0,0 +1,17 @@
|
||||
#if os(iOS)
|
||||
import Combine
|
||||
import Factory
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
final class ImageDownloaderPublisher: ObservableObject {
|
||||
@Injected(\.imageDownloader) private var imageDownloader
|
||||
@Published var image: UIImage?
|
||||
|
||||
@MainActor
|
||||
func loadImage(url: String?) async {
|
||||
guard let url = url?.url else { return }
|
||||
image = await imageDownloader.loadImage(url: url)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -2,10 +2,19 @@ import SwiftUI
|
||||
|
||||
struct ArchiveEntryButton: View {
|
||||
@ObservedObject var entry: Entry
|
||||
var showText: Bool = true
|
||||
let showText: Bool
|
||||
let action: (() -> Void)?
|
||||
|
||||
init(entry: Entry, showText: Bool = true, action: (() -> Void)? = nil) {
|
||||
self.entry = entry
|
||||
self.showText = showText
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
self.entry.toggleArchive()
|
||||
entry.toggleArchive()
|
||||
action?()
|
||||
}, label: {
|
||||
if showText {
|
||||
Label(
|
||||
@@ -6,7 +6,7 @@ struct DeleteEntryButton: View {
|
||||
var showText: Bool = true
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
self.showConfirm = true
|
||||
showConfirm = true
|
||||
}, label: {
|
||||
if showText {
|
||||
Text("Delete")
|
||||
@@ -9,7 +9,7 @@ struct FontSizeSelectorView: View {
|
||||
HStack {
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
self.showSelector = !self.showSelector
|
||||
showSelector = !showSelector
|
||||
}
|
||||
}, label: {
|
||||
Image(systemName: "textformat.size")
|
||||
@@ -5,7 +5,7 @@ struct StarEntryButton: View {
|
||||
var showText: Bool = true
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
self.entry.toggleStarred()
|
||||
entry.toggleStarred()
|
||||
}, label: {
|
||||
if showText {
|
||||
Label(
|
||||
@@ -0,0 +1,191 @@
|
||||
import CoreData
|
||||
import SafariServices
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
#if os(iOS)
|
||||
struct WebView: UIViewRepresentable {
|
||||
var entry: Entry
|
||||
private(set) var wkWebView = WKWebView(frame: .zero)
|
||||
@EnvironmentObject var appSetting: AppSetting
|
||||
@Binding var progress: Double
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self, appSetting: appSetting)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, WKNavigationDelegate, UIScrollViewDelegate {
|
||||
@CoreDataViewContext var context: NSManagedObjectContext
|
||||
var appSetting: AppSetting
|
||||
|
||||
private var webView: WebView
|
||||
|
||||
init(_ webView: WebView, appSetting: AppSetting) {
|
||||
self.webView = webView
|
||||
self.appSetting = appSetting
|
||||
super.init()
|
||||
}
|
||||
|
||||
func webViewToLastPosition() {
|
||||
DispatchQueue.main.async {
|
||||
self.webView.wkWebView.scrollView.setContentOffset(CGPoint(x: 0.0, y: self.webView.entry.screenPositionForWebView), animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
|
||||
webViewToLastPosition()
|
||||
webView.fontSizePercent(appSetting.webFontSizePercent)
|
||||
}
|
||||
|
||||
func webView(_: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard let urlTarget = navigationAction.request.url else {
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
let urlAbsolute = urlTarget.absoluteString
|
||||
|
||||
if urlAbsolute.hasPrefix(Bundle.main.bundleURL.absoluteString) || urlAbsolute == "about:blank" {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
|
||||
if navigationAction.targetFrame?.isMainFrame == false {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
|
||||
let safariController = SFSafariViewController(url: urlTarget)
|
||||
safariController.modalPresentationStyle = .overFullScreen
|
||||
|
||||
UIApplication.shared.open(urlTarget, options: [:], completionHandler: nil)
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
webView.progress = scrollView.contentOffset.y / (scrollView.contentSize.height - scrollView.bounds.height)
|
||||
}
|
||||
|
||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
context.perform {
|
||||
self.webView.entry.screenPosition = Float(scrollView.contentOffset.y)
|
||||
try? self.context.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
wkWebView.navigationDelegate = context.coordinator
|
||||
wkWebView.scrollView.delegate = context.coordinator
|
||||
wkWebView.load(content: entry.content, justify: false)
|
||||
|
||||
return wkWebView
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context _: Context) {
|
||||
webView.fontSizePercent(appSetting.webFontSizePercent)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
struct WebView: NSViewRepresentable {
|
||||
var entry: Entry
|
||||
private(set) var wkWebView = WKWebView(frame: .zero)
|
||||
@EnvironmentObject var appSetting: AppSetting
|
||||
|
||||
func makeNSView(context _: Context) -> WKWebView {
|
||||
wkWebView.load(content: entry.content, justify: false)
|
||||
|
||||
return wkWebView
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: WKWebView, context _: Context) {
|
||||
nsView.load(content: entry.content, justify: false)
|
||||
nsView.fontSizePercent(appSetting.webFontSizePercent)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self, appSetting: appSetting)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, WKNavigationDelegate {
|
||||
@CoreDataViewContext var context: NSManagedObjectContext
|
||||
var appSetting: AppSetting
|
||||
|
||||
private var webView: WebView
|
||||
|
||||
init(_ webView: WebView, appSetting: AppSetting) {
|
||||
self.webView = webView
|
||||
self.appSetting = appSetting
|
||||
super.init()
|
||||
}
|
||||
|
||||
func webViewToLastPosition() {
|
||||
/* DispatchQueue.main.async {
|
||||
self.webView.wkWebView.scrollView.setContentOffset(CGPoint(x: 0.0, y: self.webView.entry.screenPositionForWebView), animated: true)
|
||||
}*/
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
|
||||
webViewToLastPosition()
|
||||
webView.fontSizePercent(appSetting.webFontSizePercent)
|
||||
}
|
||||
|
||||
func webView(_: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard let urlTarget = navigationAction.request.url else {
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
let urlAbsolute = urlTarget.absoluteString
|
||||
|
||||
if urlAbsolute.hasPrefix(Bundle.main.bundleURL.absoluteString) || urlAbsolute == "about:blank" {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
|
||||
if navigationAction.targetFrame?.isMainFrame == false {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
|
||||
/* let safariController = SFSafariViewController(url: urlTarget)
|
||||
safariController.modalPresentationStyle = .overFullScreen
|
||||
|
||||
UIApplication.shared.open(urlTarget, options: [:], completionHandler: nil)
|
||||
*/
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
|
||||
/*
|
||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
context.perform {
|
||||
self.webView.entry.screenPosition = Float(scrollView.contentOffset.y)
|
||||
try? self.context.save()
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
struct WebView_Previews: PreviewProvider {
|
||||
static var entry: Entry = {
|
||||
let entry = Entry()
|
||||
entry.title = "Test"
|
||||
entry.content = "<p>Nice Content</p>"
|
||||
return entry
|
||||
}()
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
WebView(
|
||||
entry: entry, progress: .constant(0.5)
|
||||
).colorScheme(.light)
|
||||
WebView(
|
||||
entry: entry, progress: .constant(0.5)
|
||||
).colorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
class ErrorViewModel: ObservableObject {
|
||||
static var shared = ErrorViewModel()
|
||||
|
||||
final class ErrorViewModel: ObservableObject {
|
||||
private var resetAfter: Double
|
||||
|
||||
init(_ resetAfter: Double = 10) {
|
||||
@@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
struct HapticNotificationButtonStyle: ButtonStyle {
|
||||
let feedbackType: UINotificationFeedbackGenerator.FeedbackType
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
if configuration.isPressed {
|
||||
let generator = UINotificationFeedbackGenerator()
|
||||
generator.notificationOccurred(feedbackType)
|
||||
}
|
||||
return configuration.label
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,9 @@
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
extension View {
|
||||
func hapticNotification(_ feedbackType: UINotificationFeedbackGenerator.FeedbackType) -> some View {
|
||||
buttonStyle(HapticNotificationButtonStyle(feedbackType: feedbackType))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,47 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct MainView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@EnvironmentObject var router: Router
|
||||
|
||||
var body: some View {
|
||||
if appState.registred {
|
||||
mainView
|
||||
} else {
|
||||
RegistrationView()
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
var mainView: some View {
|
||||
NavigationStack(path: $router.path) {
|
||||
EntriesView()
|
||||
.appRouting()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
var mainView: some View {
|
||||
NavigationSplitView {
|
||||
EntriesView()
|
||||
.appRouting()
|
||||
} detail: {
|
||||
Text("Choose one entry")
|
||||
}.toolbar {
|
||||
ToolbarItem {
|
||||
RefreshButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct MainView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Text("nothing")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
struct PasteBoardView: View {
|
||||
@StateObject var pasteBoardViewModel = PasteBoardViewModel()
|
||||
|
||||
var body: some View {
|
||||
if pasteBoardViewModel.showPasteBoardView {
|
||||
HStack(alignment: .center) {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
VStack {
|
||||
Text("New url in pasteboard detected")
|
||||
.font(.headline)
|
||||
Text(pasteBoardViewModel.pasteBoardUrl)
|
||||
.lineLimit(1)
|
||||
HStack {
|
||||
Button(action: {
|
||||
pasteBoardViewModel.addUrl()
|
||||
}, label: {
|
||||
Text("Add")
|
||||
})
|
||||
Button(action: {
|
||||
pasteBoardViewModel.hide()
|
||||
}, label: {
|
||||
Text("Cancel")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PasteBoardView_Previews: PreviewProvider {
|
||||
static var publisher: PasteBoardViewModel = {
|
||||
let pub = PasteBoardViewModel()
|
||||
pub.pasteBoardUrl = "http://wallabag-with-a-long-url.org"
|
||||
return pub
|
||||
}()
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
PasteBoardView().previewLayout(.sizeThatFits).environmentObject(publisher)
|
||||
PasteBoardView().previewLayout(.fixed(width: 250, height: 60)).environmentObject(publisher)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,51 @@
|
||||
#if os(iOS)
|
||||
import Combine
|
||||
import Factory
|
||||
import Foundation
|
||||
import SharedLib
|
||||
import UIKit
|
||||
|
||||
class PasteBoardViewModel: ObservableObject {
|
||||
@Published var showPasteBoardView: Bool = false {
|
||||
willSet {
|
||||
if newValue {
|
||||
if let newPasteBoardUrl = UIPasteboard.general.url {
|
||||
WallabagUserDefaults.previousPasteBoardUrl = newPasteBoardUrl.absoluteString
|
||||
pasteBoardUrl = newPasteBoardUrl.absoluteString
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var pasteBoardUrl: String = ""
|
||||
@Injected(\.wallabagSession) var session
|
||||
|
||||
private var cancellableNotification: AnyCancellable?
|
||||
|
||||
init() {
|
||||
cancellableNotification = NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
|
||||
.map { _ -> Bool in
|
||||
guard let pasteBoardUrl = UIPasteboard.general.url,
|
||||
pasteBoardUrl.absoluteString != WallabagUserDefaults.previousPasteBoardUrl
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
.assign(to: \.showPasteBoardView, on: self)
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancellableNotification?.cancel()
|
||||
}
|
||||
|
||||
func addUrl() {
|
||||
session.addEntry(url: pasteBoardUrl) {}
|
||||
showPasteBoardView = false
|
||||
}
|
||||
|
||||
func hide() {
|
||||
showPasteBoardView = false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,81 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
import MediaPlayer
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
final class PlayerPublisher: ObservableObject {
|
||||
static var shared = PlayerPublisher()
|
||||
private var speecher = AVSpeechSynthesizer()
|
||||
private var utterance: AVSpeechUtterance?
|
||||
|
||||
@Published var podcast: Podcast?
|
||||
@Published var showPlayer: Bool = false
|
||||
@Published private(set) var isPlaying = false {
|
||||
willSet {
|
||||
if newValue {
|
||||
play()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
MPRemoteCommandCenter.shared().previousTrackCommand.isEnabled = false
|
||||
MPRemoteCommandCenter.shared().nextTrackCommand.isEnabled = false
|
||||
MPRemoteCommandCenter.shared().playCommand.addTarget { _ in
|
||||
self.play()
|
||||
return .success
|
||||
}
|
||||
MPRemoteCommandCenter.shared().pauseCommand.addTarget { _ in
|
||||
self.pause()
|
||||
return .success
|
||||
}
|
||||
}
|
||||
|
||||
func load(_ entry: Entry) {
|
||||
isPlaying = false
|
||||
showPlayer = true
|
||||
podcast = Podcast(id: entry.id, title: entry.title ?? "Title", content: entry.content?.withoutHTML ?? "", picture: entry.previewPicture)
|
||||
utterance = AVSpeechUtterance(string: podcast!.content)
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = [
|
||||
MPMediaItemPropertyTitle: entry.title,
|
||||
MPMediaItemPropertyArtist: "Wallabag",
|
||||
]
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback)
|
||||
}
|
||||
|
||||
func togglePlaying() {
|
||||
isPlaying = !isPlaying
|
||||
}
|
||||
|
||||
func play() {
|
||||
guard let utterance else { return }
|
||||
if !speecher.isSpeaking {
|
||||
speecher.speak(utterance)
|
||||
} else {
|
||||
if speecher.isPaused {
|
||||
speecher.continueSpeaking()
|
||||
} else {
|
||||
speecher.pauseSpeaking(at: .word)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pause() {
|
||||
speecher.pauseSpeaking(at: .word)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
isPlaying = false
|
||||
speecher.stopSpeaking(at: .immediate)
|
||||
}
|
||||
|
||||
func togglePlayer() {
|
||||
showPlayer.toggle()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,76 @@
|
||||
import Factory
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
struct PlayerView: View {
|
||||
@EnvironmentObject var playerPublisher: PlayerPublisher
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if playerPublisher.podcast != nil {
|
||||
playerPublisher.podcast.map { podcast in
|
||||
VStack {
|
||||
EntryPicture(url: podcast.picture).frame(width: 100, alignment: .center)
|
||||
Text(podcast.title)
|
||||
.padding(4)
|
||||
HStack {
|
||||
Button(action: {
|
||||
playerPublisher.togglePlaying()
|
||||
}, label: {
|
||||
Image(systemName: playerPublisher.isPlaying ? "pause.circle" : "play.circle")
|
||||
}).font(.system(size: 30))
|
||||
Button(action: {
|
||||
playerPublisher.stop()
|
||||
}, label: {
|
||||
Image(systemName: "stop.circle")
|
||||
}).font(.system(size: 30))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}.padding()
|
||||
}
|
||||
} else {
|
||||
VStack {
|
||||
EntryPicture(url: nil).frame(width: 100, alignment: .center)
|
||||
Text("Select one entry")
|
||||
.padding(4)
|
||||
HStack {
|
||||
Button(action: {}, label: {
|
||||
Image(systemName: "play.circle")
|
||||
.font(.system(size: 30))
|
||||
}).disabled(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 200, height: 200, alignment: .center)
|
||||
.background(Color.primary.colorInvert())
|
||||
.cornerRadius(6)
|
||||
.shadow(radius: 10)
|
||||
.gesture(DragGesture().onEnded { swipe in
|
||||
if swipe.startLocation.x < swipe.location.x {
|
||||
playerPublisher.showPlayer = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerView_Previews: PreviewProvider {
|
||||
static var player: PlayerPublisher = {
|
||||
let coreData = Container.shared.coreData()
|
||||
let player = PlayerPublisher()
|
||||
let entry = Entry(context: coreData.viewContext)
|
||||
player.load(entry)
|
||||
return player
|
||||
}()
|
||||
|
||||
static var previews: some View {
|
||||
PlayerView()
|
||||
.environmentObject(PlayerPublisher())
|
||||
.previewLayout(.sizeThatFits)
|
||||
|
||||
PlayerView()
|
||||
.environmentObject(player)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -9,19 +9,26 @@ struct ClientIdClientSecretView: View {
|
||||
Section(header: Text("Client id")) {
|
||||
TextField("Client id", text: $clientIdSecretViewModel.clientId)
|
||||
.disableAutocorrection(true)
|
||||
#if os(iOS)
|
||||
.autocapitalization(.none)
|
||||
#endif
|
||||
}
|
||||
Section(header: Text("Client secret")) {
|
||||
TextField("Client secret", text: $clientIdSecretViewModel.clientSecret)
|
||||
.disableAutocorrection(true)
|
||||
#if os(iOS)
|
||||
.autocapitalization(.none)
|
||||
#endif
|
||||
}
|
||||
NavigationLink("Next", destination: LoginView()).disabled(!clientIdSecretViewModel.isValid)
|
||||
}.navigationBarTitle("Client id & secret")
|
||||
.navigationBarItems(trailing:
|
||||
Link(destination: Bundle.infoForKey("DOCUMENTATION_URL")!.url!) {
|
||||
Text("Open documentation")
|
||||
})
|
||||
}
|
||||
#if os(iOS)
|
||||
.navigationBarTitle("Client id & secret")
|
||||
.navigationBarItems(trailing:
|
||||
Link(destination: Bundle.infoForKey("DOCUMENTATION_URL")!.url!) {
|
||||
Text("Open documentation")
|
||||
})
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import SharedLib
|
||||
import SwiftUI
|
||||
|
||||
class ClientIdSecretViewModel: ObservableObject {
|
||||
@@ -15,8 +16,8 @@ class ClientIdSecretViewModel: ObservableObject {
|
||||
clientSecret = WallabagUserDefaults.clientSecret
|
||||
|
||||
cancellable = Publishers.CombineLatest($clientId, $clientSecret).sink { [unowned self] clientId, clientSecret in
|
||||
self.isValid = !clientId.isEmpty && !clientSecret.isEmpty
|
||||
if self.isValid {
|
||||
isValid = !clientId.isEmpty && !clientSecret.isEmpty
|
||||
if isValid {
|
||||
WallabagUserDefaults.clientId = clientId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
WallabagUserDefaults.clientSecret = clientSecret.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
@@ -7,14 +7,16 @@ struct LoginView: View {
|
||||
Form {
|
||||
Section(header: Text("Login")) {
|
||||
TextField("Login", text: $loginViewModel.login)
|
||||
#if os(iOS)
|
||||
.disableAutocorrection(true)
|
||||
.autocapitalization(.none)
|
||||
#endif
|
||||
}
|
||||
Section(header: Text("Password")) {
|
||||
SecureField("Password", text: $loginViewModel.password)
|
||||
}
|
||||
Button("Login") {
|
||||
self.loginViewModel.tryLogin()
|
||||
loginViewModel.tryLogin()
|
||||
}.disabled(!loginViewModel.isValid)
|
||||
loginViewModel.error.map { error in
|
||||
VStack {
|
||||
@@ -22,11 +24,14 @@ struct LoginView: View {
|
||||
Link("Report issue", destination: "https://github.com/wallabag/ios-app/issues")
|
||||
}
|
||||
}
|
||||
}.navigationBarTitle("Login & Password")
|
||||
.navigationBarItems(trailing:
|
||||
Link(destination: Bundle.infoForKey("DOCUMENTATION_URL")!.url!) {
|
||||
Text("Open documentation")
|
||||
})
|
||||
}
|
||||
#if os(iOS)
|
||||
.navigationBarTitle("Login & Password")
|
||||
.navigationBarItems(trailing:
|
||||
Link(destination: Bundle.infoForKey("DOCUMENTATION_URL")!.url!) {
|
||||
Text("Open documentation")
|
||||
})
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
import Combine
|
||||
import Factory
|
||||
import Foundation
|
||||
import SharedLib
|
||||
|
||||
class LoginViewModel: ObservableObject {
|
||||
@Injected(\.appState) private var appState
|
||||
@Injected(\.wallabagSession) private var session
|
||||
@Injected(\.router) private var router
|
||||
|
||||
@Published var login: String = ""
|
||||
@Published var password: String = ""
|
||||
@Published var error: String?
|
||||
|
||||
@Injector var appState: AppState
|
||||
@Injector var router: Router
|
||||
|
||||
private(set) var isValid: Bool = false
|
||||
private var cancellable = Set<AnyCancellable>()
|
||||
|
||||
init() {
|
||||
login = WallabagUserDefaults.login
|
||||
Publishers.CombineLatest($login, $password).sink { [unowned self] login, password in
|
||||
self.isValid = !login.isEmpty && !password.isEmpty
|
||||
isValid = !login.isEmpty && !password.isEmpty
|
||||
}.store(in: &cancellable)
|
||||
|
||||
appState.session.$state.receive(on: DispatchQueue.main).sink { [unowned self] state in
|
||||
session.$state.receive(on: DispatchQueue.main).sink { [unowned self] state in
|
||||
switch state {
|
||||
case let .error(reason):
|
||||
self.error = reason
|
||||
error = reason
|
||||
case .connected:
|
||||
self.appState.registred = true
|
||||
self.router.load(.entries)
|
||||
appState.registred = true
|
||||
case .unknown, .connecting, .offline:
|
||||
break
|
||||
}
|
||||
@@ -35,8 +37,8 @@ class LoginViewModel: ObservableObject {
|
||||
error = nil
|
||||
WallabagUserDefaults.login = login
|
||||
WallabagUserDefaults.password = password
|
||||
appState.session.kit.host = WallabagUserDefaults.host
|
||||
appState.session.requestSession(
|
||||
session.kit.host = WallabagUserDefaults.host
|
||||
session.requestSession(
|
||||
clientId: WallabagUserDefaults.clientId,
|
||||
clientSecret: WallabagUserDefaults.clientSecret,
|
||||
username: login,
|
||||
@@ -2,7 +2,7 @@ import SwiftUI
|
||||
|
||||
struct RegistrationView: View {
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
VStack {
|
||||
Image("logo")
|
||||
.resizable()
|
||||
@@ -11,9 +11,11 @@ struct RegistrationView: View {
|
||||
.font(.title)
|
||||
NavigationLink("Log in", destination: ServerView())
|
||||
.buttonStyle(.borderedProminent)
|
||||
.buttonBorderShape(.capsule)
|
||||
}.navigationBarHidden(true)
|
||||
}.navigationViewStyle(StackNavigationViewStyle())
|
||||
}
|
||||
#if os(iOS)
|
||||
.navigationBarHidden(true)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,16 +8,21 @@ struct ServerView: View {
|
||||
Section(header: Text("Server")) {
|
||||
TextField("https://your-instance.domain", text: $serverViewModel.url)
|
||||
.disableAutocorrection(true)
|
||||
#if os(iOS)
|
||||
.autocapitalization(.none)
|
||||
#endif
|
||||
}
|
||||
NavigationLink(destination: ClientIdClientSecretView()) {
|
||||
Text("Next")
|
||||
}.disabled(!serverViewModel.isValid)
|
||||
}.navigationBarTitle("Server")
|
||||
.navigationBarItems(trailing:
|
||||
Link(destination: Bundle.infoForKey("DOCUMENTATION_URL")!.url!) {
|
||||
Text("Open documentation")
|
||||
})
|
||||
}
|
||||
#if os(iOS)
|
||||
.navigationBarTitle("Server")
|
||||
.navigationBarItems(trailing:
|
||||
Link(destination: Bundle.infoForKey("DOCUMENTATION_URL")!.url!) {
|
||||
Text("Open documentation")
|
||||
})
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import SharedLib
|
||||
import SwiftUI
|
||||
|
||||
class ServerViewModel: ObservableObject {
|
||||
final class ServerViewModel: ObservableObject {
|
||||
@Published private(set) var isValid: Bool = false
|
||||
|
||||
@Published var url: String = ""
|
||||
|
||||
private var cancellable: AnyCancellable?
|
||||
@@ -12,8 +12,8 @@ class ServerViewModel: ObservableObject {
|
||||
init() {
|
||||
url = WallabagUserDefaults.host
|
||||
cancellable = $url.sink { [unowned self] url in
|
||||
self.isValid = self.validateServer(host: url)
|
||||
if self.isValid {
|
||||
isValid = validateServer(host: url)
|
||||
if isValid {
|
||||
WallabagUserDefaults.host = url
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum RoutePath: Hashable {
|
||||
case registration
|
||||
case addEntry
|
||||
case entry(Entry)
|
||||
case tips
|
||||
case about
|
||||
case setting
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// RouteSwiftUIExtension.swift
|
||||
// wallabag (iOS)
|
||||
//
|
||||
// Created by maxime marinel on 13/03/2023.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func appRouting() -> some View {
|
||||
navigationDestination(for: RoutePath.self) { route in
|
||||
switch route {
|
||||
case .addEntry:
|
||||
AddEntryView()
|
||||
case let .entry(entry):
|
||||
EntryView(entry: entry)
|
||||
case .about:
|
||||
AboutView()
|
||||
case .tips:
|
||||
TipView()
|
||||
case .setting:
|
||||
SettingView()
|
||||
default:
|
||||
Text("test")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
final class Router: ObservableObject {
|
||||
@Published var path: [RoutePath] = []
|
||||
}
|
||||
@@ -17,21 +17,24 @@ struct SearchView: View {
|
||||
HStack {
|
||||
if showSearchBar {
|
||||
TextField("Search", text: $searchViewModel.search)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disableAutocorrection(true)
|
||||
#if os(iOS)
|
||||
.autocapitalization(.none)
|
||||
#endif
|
||||
.padding(.leading)
|
||||
} else {
|
||||
RetrieveModePicker(filter: $searchViewModel.retrieveMode)
|
||||
}
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
self.showSearchBar = !self.showSearchBar
|
||||
showSearchBar = !showSearchBar
|
||||
}
|
||||
}, label: {
|
||||
Image(systemName: self.showSearchBar ? "list.bullet.below.rectangle" : "magnifyingglass")
|
||||
Image(systemName: showSearchBar ? "list.bullet.below.rectangle" : "magnifyingglass")
|
||||
.padding(.trailing)
|
||||
}).buttonStyle(PlainButtonStyle())
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import SharedLib
|
||||
|
||||
final class AppSetting: ObservableObject {
|
||||
@Published var webFontSizePercent: Double
|
||||
@@ -0,0 +1,39 @@
|
||||
import SharedLib
|
||||
import SwiftUI
|
||||
|
||||
struct SettingView: View {
|
||||
@AppStorage("showImageInList") var showImageInList: Bool = true
|
||||
@AppStorage("justifyArticle") var justifyArticle: Bool = true
|
||||
@AppStorage("badge") var badge: Bool = true
|
||||
@AppStorage("defaultMode") var defaultMode: String = RetrieveMode.allArticles.rawValue
|
||||
@AppStorage("itemPerPageDuringSync") var itemPerPageDuringSync: Int = 50
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Entries list") {
|
||||
Toggle("Show image in list", isOn: $showImageInList)
|
||||
Picker("Default mode", selection: $defaultMode) {
|
||||
ForEach(RetrieveMode.allCases, id: \.rawValue) {
|
||||
Text($0.rawValue).tag($0.settingCase)
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("Badge") {
|
||||
Toggle("Show badge", isOn: $badge)
|
||||
}
|
||||
Section("Entry") {
|
||||
Toggle("Justify entry", isOn: $justifyArticle)
|
||||
}
|
||||
Section("Sync") {
|
||||
Stepper("Item per page during sync \(itemPerPageDuringSync)", value: $itemPerPageDuringSync, in: 20 ... 200)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Setting")
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SettingView()
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
import Combine
|
||||
import CoreData
|
||||
import Factory
|
||||
import Foundation
|
||||
import SharedLib
|
||||
import WallabagKit
|
||||
|
||||
class AppSync: ObservableObject {
|
||||
static var shared = AppSync()
|
||||
@Injector var session: WallabagSession
|
||||
@Injector var errorViewModel: ErrorViewModel
|
||||
final class AppSync: ObservableObject {
|
||||
@Injected(\.wallabagSession) private var session
|
||||
@Injected(\.errorHandler) private var errorViewModel
|
||||
@Injected(\.coreData) private var coreData
|
||||
@CoreDataViewContext var coreDataContext: NSManagedObjectContext
|
||||
|
||||
@Published private(set) var inProgress = false
|
||||
@Published private(set) var progress: Float = 0.0
|
||||
|
||||
private var backgroundContext: NSManagedObjectContext = {
|
||||
let context = CoreData.shared.persistentContainer.newBackgroundContext()
|
||||
private lazy var backgroundContext: NSManagedObjectContext = {
|
||||
let context = coreData.persistentContainer.newBackgroundContext()
|
||||
context.mergePolicy = NSOverwriteMergePolicy
|
||||
return context
|
||||
}()
|
||||
@@ -1,13 +1,14 @@
|
||||
import Combine
|
||||
import CoreData
|
||||
import Factory
|
||||
import Foundation
|
||||
import SharedLib
|
||||
|
||||
class CoreDataSync {
|
||||
private var objectsDidChangeCancellable: AnyCancellable?
|
||||
|
||||
@Injector var appSync: AppSync
|
||||
@Injector var wallabagSession: WallabagSession
|
||||
@Injected(\.appSync) private var appSync
|
||||
@Injected(\.wallabagSession) var wallabagSession
|
||||
|
||||
init() {
|
||||
objectsDidChangeCancellable = NotificationCenter.default
|
||||
@@ -26,11 +27,9 @@ class CoreDataSync {
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable force_cast
|
||||
private func deletedObjects(_ deletedObjects: Set<NSManagedObject>) {
|
||||
deletedObjects
|
||||
.filter { $0 is Entry }
|
||||
.map { entry -> Entry in entry as! Entry }
|
||||
.compactMap { $0 as? Entry }
|
||||
.forEach { entry in
|
||||
self.wallabagSession.delete(entry: entry)
|
||||
}
|
||||
@@ -47,7 +46,7 @@ class CoreDataSync {
|
||||
changedValues.removeValue(forKey: "tags")
|
||||
if changedValues.count > 0 {
|
||||
logger.debug("Push update entry \(entry.id) to remote")
|
||||
self.wallabagSession.update(
|
||||
wallabagSession.update(
|
||||
entry,
|
||||
parameters:
|
||||
[
|
||||
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RefreshButton: View {
|
||||
@EnvironmentObject var appSync: AppSync
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if appSync.inProgress {
|
||||
ProgressView(value: appSync.progress, total: 100)
|
||||
#if os(iOS)
|
||||
.progressViewStyle(.linear)
|
||||
#else
|
||||
.progressViewStyle(.circular)
|
||||
#endif
|
||||
} else {
|
||||
Button(
|
||||
action: appSync.requestSync,
|
||||
label: {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
.frame(width: 34, height: 34, alignment: .center)
|
||||
}
|
||||
)
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Refresh")
|
||||
.accessibilityHint("Refres entries from server")
|
||||
.keyboardShortcut("r", modifiers: .command)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RefreshButton_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
RefreshButton().environmentObject(AppSync())
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,12 @@ struct TagListFor: View {
|
||||
HStack {
|
||||
TextField("New tag", text: $tagLabel)
|
||||
Button(action: {
|
||||
self.tagsForEntry.add(tag: self.tagLabel)
|
||||
self.tagLabel = ""
|
||||
tagsForEntry.add(tag: tagLabel)
|
||||
tagLabel = ""
|
||||
}, label: { Text("Add") })
|
||||
}.padding(.horizontal)
|
||||
List(tagsForEntry.tags) { tag in
|
||||
TagRow(tag: tag, tagsForEntry: self.tagsForEntry)
|
||||
TagRow(tag: tag, tagsForEntry: tagsForEntry)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,10 @@ struct TagRow: View {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}.onTapGesture {
|
||||
if self.tag.isChecked {
|
||||
self.tagsForEntry.delete(tag: self.tag)
|
||||
if tag.isChecked {
|
||||
tagsForEntry.delete(tag: tag)
|
||||
} else {
|
||||
self.tagsForEntry.add(tag: self.tag)
|
||||
tagsForEntry.add(tag: tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
import Combine
|
||||
import CoreData
|
||||
import Factory
|
||||
import Foundation
|
||||
|
||||
// swiftlint:disable all
|
||||
// swiftlint:disable:next all
|
||||
class TagsForEntryPublisher: ObservableObject {
|
||||
@Injected(\.wallabagSession) private var session
|
||||
|
||||
var objectWillChange = PassthroughSubject<Void, Never>()
|
||||
|
||||
var tags: [Tag]
|
||||
var entry: Entry
|
||||
|
||||
@CoreDataViewContext var coreDataContext: NSManagedObjectContext
|
||||
@Injector var appState: AppState
|
||||
|
||||
init(entry: Entry) {
|
||||
self.entry = entry
|
||||
tags = try! CoreData.shared.viewContext.fetch(Tag.fetchRequestSorted())
|
||||
tags = (try? Container.shared.coreData().viewContext.fetch(Tag.fetchRequestSorted())) ?? []
|
||||
|
||||
tags.filter { tag in
|
||||
entry.tags.contains(tag)
|
||||
@@ -27,13 +29,13 @@ class TagsForEntryPublisher: ObservableObject {
|
||||
}
|
||||
|
||||
func add(tag: String) {
|
||||
appState.session.add(tag: tag, for: entry)
|
||||
session.add(tag: tag, for: entry)
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
func delete(tag: Tag) {
|
||||
tag.isChecked = false
|
||||
tag.objectWillChange.send()
|
||||
appState.session.delete(tag: tag, for: entry)
|
||||
session.delete(tag: tag, for: entry)
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,11 @@ struct TipView: View {
|
||||
HStack {
|
||||
Spacer()
|
||||
if tipViewModel.canMakePayments {
|
||||
if self.tipViewModel.tipProduct != nil {
|
||||
if tipViewModel.tipProduct != nil {
|
||||
Button(
|
||||
action: { Task { await purchase() } },
|
||||
label: {
|
||||
self.tipViewModel.tipProduct.map { product in
|
||||
tipViewModel.tipProduct.map { product in
|
||||
HStack {
|
||||
Text(product.displayName)
|
||||
Text(product.displayPrice)
|
||||
@@ -42,7 +42,9 @@ struct TipView: View {
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
}.padding()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Don")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -11,7 +11,7 @@ final class TipViewModel: ObservableObject {
|
||||
init() {
|
||||
canMakePayments = SKPaymentQueue.canMakePayments()
|
||||
if canMakePayments {
|
||||
Task {
|
||||
Task { @MainActor in
|
||||
let product = try? await requestProduct()
|
||||
tipProduct = product?.first
|
||||
}
|
||||
@@ -20,13 +20,12 @@ final class TipViewModel: ObservableObject {
|
||||
taskHandle = listenForTransactions()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func requestProduct() async throws -> [Product] {
|
||||
try await Product.products(for: ["tips1"])
|
||||
}
|
||||
|
||||
func listenForTransactions() -> Task<Void, Error> {
|
||||
Task.detached {
|
||||
Task {
|
||||
for await result in Transaction.updates {
|
||||
do {
|
||||
let transaction = try self.checkVerified(result)
|
||||
@@ -19,7 +19,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>6.2.1</string>
|
||||
<string>7.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>319</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
@@ -50,5 +50,7 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.utilities</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,10 +1,12 @@
|
||||
import Combine
|
||||
import Factory
|
||||
import Foundation
|
||||
import SharedLib
|
||||
import SwiftUI
|
||||
import WallabagKit
|
||||
|
||||
class AppState: NSObject, ObservableObject {
|
||||
static var shared = AppState()
|
||||
final class AppState: NSObject, ObservableObject {
|
||||
@Injected(\.wallabagSession) private var session
|
||||
|
||||
@Published var registred: Bool = false {
|
||||
didSet {
|
||||
@@ -12,9 +14,6 @@ class AppState: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
@Injector var session: WallabagSession
|
||||
@Injector var router: Router
|
||||
|
||||
@AppStorage("readingSpeed") var readingSpeed: Double = 200
|
||||
|
||||
override init() {
|
||||
@@ -35,7 +34,6 @@ class AppState: NSObject, ObservableObject {
|
||||
logger.debug("Logout called")
|
||||
registred = false
|
||||
session.state = .unknown
|
||||
router.load(.registration)
|
||||
}
|
||||
|
||||
/// Fetch user config from server
|
||||
@@ -43,7 +41,7 @@ class AppState: NSObject, ObservableObject {
|
||||
private func fetchConfig() {
|
||||
logger.info("Fetch user config")
|
||||
session.config { [weak self] config in
|
||||
guard let config = config else { return }
|
||||
guard let config else { return }
|
||||
logger.debug("User config available")
|
||||
DispatchQueue.main.async {
|
||||
self?.readingSpeed = config.readingSpeed
|
||||
@@ -2,10 +2,6 @@ import CoreData
|
||||
import Foundation
|
||||
|
||||
final class CoreData {
|
||||
static let shared = CoreData()
|
||||
|
||||
private init() {}
|
||||
|
||||
lazy var persistentContainer: NSPersistentContainer = {
|
||||
let container = NSPersistentContainer(name: "wallabagStore")
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import Factory
|
||||
import Foundation
|
||||
import SharedLib
|
||||
import WallabagKit
|
||||
|
||||
extension Container {
|
||||
var appState: Factory<AppState> {
|
||||
Factory(self) {
|
||||
AppState()
|
||||
}.scope(.singleton)
|
||||
}
|
||||
|
||||
var router: Factory<Router> {
|
||||
Factory(self) {
|
||||
Router()
|
||||
}.scope(.singleton)
|
||||
}
|
||||
|
||||
var errorHandler: Factory<ErrorViewModel> {
|
||||
Factory(self) {
|
||||
ErrorViewModel()
|
||||
}.scope(.singleton)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
var playerPublisher: Factory<PlayerPublisher> {
|
||||
Factory(self) {
|
||||
PlayerPublisher()
|
||||
}.scope(.singleton)
|
||||
}
|
||||
#endif
|
||||
|
||||
var appSync: Factory<AppSync> {
|
||||
Factory(self) {
|
||||
AppSync()
|
||||
}.scope(.singleton)
|
||||
}
|
||||
|
||||
var wallabagSession: Factory<WallabagSession> {
|
||||
Factory(self) {
|
||||
WallabagSession()
|
||||
}.scope(.singleton)
|
||||
}
|
||||
|
||||
var imageDownloader: Factory<ImageDownloader> {
|
||||
Factory(self) {
|
||||
ImageDownloader()
|
||||
}.scope(.singleton)
|
||||
}
|
||||
|
||||
var coreDataSync: Factory<CoreDataSync> {
|
||||
Factory(self) {
|
||||
CoreDataSync()
|
||||
}.scope(.singleton)
|
||||
}
|
||||
|
||||
var appSetting: Factory<AppSetting> {
|
||||
Factory(self) {
|
||||
AppSetting()
|
||||
}.scope(.singleton)
|
||||
}
|
||||
|
||||
var coreData: Factory<CoreData> {
|
||||
Factory(self) {
|
||||
CoreData()
|
||||
}.scope(.singleton)
|
||||
}
|
||||
|
||||
var wallabagKit: Factory<WallabagKit> {
|
||||
Factory(self) {
|
||||
let kit = WallabagKit(host: WallabagUserDefaults.host)
|
||||
kit.clientId = WallabagUserDefaults.clientId
|
||||
kit.clientSecret = WallabagUserDefaults.clientSecret
|
||||
kit.username = WallabagUserDefaults.login
|
||||
kit.password = WallabagUserDefaults.password
|
||||
kit.accessToken = WallabagUserDefaults.accessToken
|
||||
kit.refreshToken = WallabagUserDefaults.refreshToken
|
||||
|
||||
return kit
|
||||
}.scope(.singleton)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import Combine
|
||||
import CoreData
|
||||
import Factory
|
||||
import Foundation
|
||||
import SharedLib
|
||||
import WallabagKit
|
||||
|
||||
class WallabagSession: ObservableObject {
|
||||
@@ -13,7 +15,7 @@ class WallabagSession: ObservableObject {
|
||||
}
|
||||
|
||||
@Published var state: State = .unknown
|
||||
@Injector var kit: WallabagKit
|
||||
@Injected(\.wallabagKit) var kit
|
||||
@CoreDataViewContext var coreDataContext: NSManagedObjectContext
|
||||
private var cancellable = Set<AnyCancellable>()
|
||||
|
||||
@@ -38,7 +40,7 @@ class WallabagSession: ObservableObject {
|
||||
}
|
||||
}
|
||||
}, receiveValue: { token in
|
||||
guard let token = token else { self.state = .unknown; return }
|
||||
guard let token else { self.state = .unknown; return }
|
||||
WallabagUserDefaults.refreshToken = token.refreshToken
|
||||
WallabagUserDefaults.accessToken = token.accessToken
|
||||
self.kit.accessToken = token.accessToken
|
||||
@@ -51,7 +53,7 @@ class WallabagSession: ObservableObject {
|
||||
kit.send(to: WallabagEntryEndpoint.add(url: url))
|
||||
.catch { _ in Empty<WallabagEntry, Never>() }
|
||||
.sink { [unowned self] (wallabagEntry: WallabagEntry) in
|
||||
let entry = Entry(context: self.coreDataContext)
|
||||
let entry = Entry(context: coreDataContext)
|
||||
entry.hydrate(from: wallabagEntry)
|
||||
completion()
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import CoreData
|
||||
import Factory
|
||||
import Foundation
|
||||
|
||||
@propertyWrapper
|
||||
struct CoreDataViewContext {
|
||||
@Injected(\Container.coreData) private var coreData
|
||||
|
||||
var wrappedValue: NSManagedObjectContext {
|
||||
CoreData.shared.viewContext
|
||||
coreData.viewContext
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Factory
|
||||
import Foundation
|
||||
import os
|
||||
import SharedLib
|
||||
import SwiftUI
|
||||
|
||||
let logger = Logger(subsystem: "fr.district-web.wallabag", category: "main")
|
||||
|
||||
@main
|
||||
struct WallabagApp: App {
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
#if os(iOS)
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
#endif
|
||||
|
||||
@Injected(\.appState) private var appState
|
||||
@Injected(\.router) private var router
|
||||
#if os(iOS)
|
||||
@Injected(\.playerPublisher) private var playerPublisher
|
||||
#endif
|
||||
@Injected(\.errorHandler) private var errorHandler
|
||||
@Injected(\.appSync) private var appSync
|
||||
@Injected(\.coreDataSync) private var coreDataSync
|
||||
@Injected(\.appSetting) private var appSetting
|
||||
@Injected(\.coreData) private var coreData
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
MainView()
|
||||
.environmentObject(appState)
|
||||
#if os(iOS)
|
||||
.environmentObject(playerPublisher)
|
||||
#endif
|
||||
.environmentObject(router)
|
||||
.environmentObject(errorHandler)
|
||||
.environmentObject(appSync)
|
||||
.environmentObject(appSetting)
|
||||
.environment(\.managedObjectContext, coreData.viewContext)
|
||||
}.onChange(of: scenePhase) { state in
|
||||
if state == .active {
|
||||
appState.initSession()
|
||||
#if os(iOS)
|
||||
requestNotificationAuthorization()
|
||||
#endif
|
||||
}
|
||||
|
||||
if state == .background {
|
||||
coreData.saveContext()
|
||||
updateBadge()
|
||||
}
|
||||
}
|
||||
.commands {
|
||||
CommandGroup(after: .newItem) {
|
||||
Button("Refresh entries") {
|
||||
appSync.requestSync()
|
||||
}
|
||||
.keyboardShortcut("r", modifiers: .command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateBadge() {
|
||||
if !WallabagUserDefaults.badgeEnabled {
|
||||
setBadgeNumber(0)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let fetchRequest = Entry.fetchRequestSorted()
|
||||
fetchRequest.predicate = RetrieveMode(fromCase: WallabagUserDefaults.defaultMode).predicate()
|
||||
let entries = try coreData.viewContext.fetch(fetchRequest)
|
||||
setBadgeNumber(entries.count)
|
||||
} catch {
|
||||
fatalError(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func setBadgeNumber(_ number: Int) {
|
||||
#if os(iOS)
|
||||
UIApplication.shared.applicationIconBadgeNumber = number
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func requestNotificationAuthorization() {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.requestAuthorization(options: [.badge]) { _, _ in }
|
||||
}
|
||||
#endif
|
||||
}
|
||||