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
This commit is contained in:
Maxime Marinel
2023-10-03 09:29:24 +02:00
committed by GitHub
parent 235cb13286
commit b59b9e0408
219 changed files with 2032 additions and 4421 deletions
+1 -1
View File
@@ -1 +1 @@
5.5
5.8
+25
View File
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

+22
View File
@@ -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

+58
View File
@@ -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, naméliorent pas limage de lentreprise. Dailleurs, 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 lentreprise Facebook soit déjà sous le coup dune <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 cest dans ce sens que le sénateur Ron Wyden sest exprimé lors dune 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 quil 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 dentreprises 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. Cest lavis de Tim Gleason, un professeur de lUniversité de lOregon, qui estime que les chances de Zuckerberg de faire face à une action criminelle sont plutôt minces.
<p>Pendant ce temps, plusieurs personnes pensent quemprisonner 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 quil 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" /> Quen 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
+43
View File
@@ -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)
}
}
+78
View File
@@ -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
+135
View File
@@ -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(
+191
View File
@@ -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
+47
View File
@@ -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
+81
View File
@@ -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
+76
View File
@@ -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
}
}
+11
View File
@@ -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")
}
}
}
}
+7
View File
@@ -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
+39
View File
@@ -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:
[
+36
View File
@@ -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)
+3 -1
View File
@@ -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")
+82
View File
@@ -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
}
}
+90
View File
@@ -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
}

Some files were not shown because too many files have changed in this diff Show More